AI Production Kit OpenAI Direct .NET API React (CRA + TS) Secrets • Rate limit • Logs

Starter IA : API .NET + React + OpenAI (propre, prêt à scaler)

Un vrai socle livrable : endpoint /api/ai/chat, validation, timeouts, rate limiting, logs, secrets propres — et un mini front React pour tester en 30 secondes.

Objectif : démarrer vite et bien. Ce setup est parfait pour un POC, un side project, ou une base de série (streaming, auth, RAG, observabilité).


TL;DR
Ce que tu obtiens
  • ✅ API .NET : /api/ai/chat + validation + timeout.
  • ✅ Front React minimal : prompt → réponse + loading + erreurs propres.
  • ✅ Secrets locaux via User Secrets (pas de clé en dur).
  • ✅ Posture prod : CORS strict + rate limiting + logs.

Architecture

React (localhost:3000)
   |
   | POST /api/ai/chat { prompt }
   v
.NET API (localhost:7248)
   |
   | HttpClient -> OpenAI chat completions
   v
JSON { answer, model }

1) Backend .NET : endpoint /api/ai/chat

1.1 appsettings.json (sans la clé)

{
  "Ai": {
    "OpenAI": {
      "ApiKey": "",
      "Model": "gpt-4o-mini"
    }
  },
  "Cors": {
    "AllowedOrigins": [ "http://localhost:3000" ]
  }
}

1.2 User Secrets (local)

dotnet user-secrets init
dotnet user-secrets set "Ai:OpenAI:ApiKey" "TON_OPENAI_API_KEY"
dotnet user-secrets set "Ai:OpenAI:Model" "gpt-4o-mini"

1.3 LlmClient.cs (OpenAI direct)

using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;

public sealed class LlmClient
{
    private readonly HttpClient _http;
    private readonly IConfiguration _config;

    public LlmClient(HttpClient http, IConfiguration config)
    {
        _http = http;
        _config = config;
        _http.Timeout = TimeSpan.FromSeconds(20);
    }

    public async Task<(string answer, string model)> ChatAsync(string prompt, CancellationToken ct)
    {
        var apiKey = _config["Ai:OpenAI:ApiKey"];
        var model = _config["Ai:OpenAI:Model"] ?? "gpt-4o-mini";
        if (string.IsNullOrWhiteSpace(apiKey))
            throw new InvalidOperationException("Missing OpenAI ApiKey (Ai:OpenAI:ApiKey)");

        var payload = new
        {
            model,
            messages = new object[]
            {
                new { role = "system", content = "You help with .NET, Azure, architecture, best practices." },
                new { role = "user", content = prompt }
            },
            temperature = 0.2
        };

        using var req = new HttpRequestMessage(HttpMethod.Post, "https://api.openai.com/v1/chat/completions");
        req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", apiKey);
        req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");

        using var res = await _http.SendAsync(req, ct);
        var json = await res.Content.ReadAsStringAsync(ct);

        if (!res.IsSuccessStatusCode)
            throw new InvalidOperationException($"OpenAI error {(int)res.StatusCode}: {json}");

        using var doc = JsonDocument.Parse(json);
        var answer = doc.RootElement
            .GetProperty("choices")[0]
            .GetProperty("message")
            .GetProperty("content")
            .GetString() ?? "";

        return (answer.Trim(), model);
    }
}

1.4 Program.cs complet (CORS + Rate Limit + endpoint)

using Microsoft.AspNetCore.RateLimiting;
using System.Threading.RateLimiting;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddEndpointsApiExplorer();
builder.Services.AddSwaggerGen();

// CORS strict (React)
var origins = builder.Configuration.GetSection("Cors:AllowedOrigins").Get<string[]>() ?? Array.Empty<string>();
builder.Services.AddCors(opt =>
{
    opt.AddPolicy("web", p =>
        p.WithOrigins(origins)
         .AllowAnyHeader()
         .AllowAnyMethod()
         .AllowCredentials());
});

// Rate limiting (simple mais efficace)
builder.Services.AddRateLimiter(opt =>
{
    opt.RejectionStatusCode = 429;
    opt.AddPolicy("ai", ctx =>
        RateLimitPartition.GetFixedWindowLimiter(
            partitionKey: ctx.Connection.RemoteIpAddress?.ToString() ?? "unknown",
            factory: _ => new FixedWindowRateLimiterOptions
            {
                PermitLimit = 30,
                Window = TimeSpan.FromMinutes(1),
                QueueLimit = 5,
                QueueProcessingOrder = QueueProcessingOrder.OldestFirst
            }));
});

builder.Services.AddHttpClient();
builder.Services.AddScoped<LlmClient>();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseCors("web");
app.UseRateLimiter();

if (app.Environment.IsDevelopment())
{
    app.UseSwagger();
    app.UseSwaggerUI();
}

app.MapPost("/api/ai/chat", async (ChatRequest req, LlmClient llm, CancellationToken ct) =>
{
    var prompt = (req?.Prompt ?? "").Trim();
    if (string.IsNullOrWhiteSpace(prompt))
        return Results.BadRequest(new { error = "Prompt is required" });

    if (prompt.Length > 4000)
        return Results.BadRequest(new { error = "Prompt too long (max 4000 chars)" });

    var (answer, model) = await llm.ChatAsync(prompt, ct);
    return Results.Ok(new { answer, model });
})
.RequireRateLimiting("ai");

app.MapGet("/health", () => Results.Ok(new { status = "ok" }));

app.Run();

public sealed class ChatRequest { public string Prompt { get; set; } = ""; }

2) Front React minimal (CRA + TS)

Tu peux garder ton UI chic, ici je te donne un minimal propre.

2.1 .env (recommandé)

REACT_APP_API_BASE_URL=https://localhost:7248

2.2 App.tsx

import React, { useState } from "react";
import "./App.css";

export default function App() {
  const [prompt, setPrompt] = useState("");
  const [answer, setAnswer] = useState("");
  const [meta, setMeta] = useState<string>("");
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState("");

  const baseUrl = process.env.REACT_APP_API_BASE_URL || "";

  async function send() {
    setError(""); setAnswer(""); setMeta("");
    const p = prompt.trim();
    if (!p) return;

    setLoading(true);
    try {
      const res = await fetch(`${baseUrl}/api/ai/chat`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ prompt: p }),
      });

      const data = await res.json();
      if (!res.ok) throw new Error(data?.error || "Request failed");

      setAnswer(data.answer || "");
      setMeta(data.model ? `Model: ${data.model}` : "");
    } catch (e: any) {
      setError(e?.message || "Unexpected error");
    } finally {
      setLoading(false);
    }
  }

  return (
    <div className="wrap">
      <div className="card">
        <div className="title">AI Starter — OpenAI Direct</div>
        <div className="sub">Prompt → réponse via API .NET</div>

        <textarea className="input" rows={5}
          value={prompt}
          onChange={(e) => setPrompt(e.target.value)}
          placeholder="Ex: Donne-moi un exemple de middleware .NET propre…" />

        <div className="actions">
          <button className="btn" onClick={send} disabled={loading || !prompt.trim()}>
            {loading ? "Génération..." : "Envoyer"}
          </button>
          <button className="btn ghost" disabled={loading}
            onClick={() => { setPrompt(""); setAnswer(""); setError(""); setMeta(""); }}>
            Reset
          </button>
        </div>

        {error && <div className="error">{error}</div>}
        {meta && <div className="meta">{meta}</div>}

        <div className="out">
          {answer ? <pre className="answer">{answer}</pre> : <div className="hint">La réponse s’affichera ici.</div>}
        </div>
      </div>
    </div>
  );
}

2.3 App.css (minimal)

.wrap{min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px;
background: radial-gradient(900px circle at 10% 10%, rgba(51,204,204,.10), transparent 55%),
linear-gradient(135deg, rgba(10,12,14,1), rgba(12,16,20,1)); color:#fff;
font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Arial;}
.card{width:min(980px,100%);border:1px solid rgba(255,255,255,.10);border-radius:18px;
background: rgba(255,255,255,.03);box-shadow:0 18px 60px rgba(0,0,0,.35);padding:16px;}
.title{font-weight:900;font-size:18px;}
.sub{color:rgba(255,255,255,.70);font-size:13px;margin:6px 0 12px;}
.input{width:100%;box-sizing:border-box;border-radius:14px;border:1px solid rgba(255,255,255,.12);
background: rgba(0,0,0,.20);color:#fff;padding:12px;outline:none;resize:vertical;}
.actions{display:flex;gap:10px;margin-top:10px;}
.btn{border:none;cursor:pointer;border-radius:12px;padding:10px 14px;font-weight:800;background:rgba(51,204,204,1);color:#062023;}
.btn:disabled{opacity:.55;cursor:not-allowed;}
.btn.ghost{background:rgba(255,255,255,.06);color:#fff;border:1px solid rgba(255,255,255,.12);}
.error{margin-top:10px;border:1px solid rgba(255,80,80,.30);background:rgba(255,80,80,.10);
border-radius:12px;padding:10px;color:rgba(255,200,200,1);}
.meta{margin-top:10px;color:rgba(255,255,255,.72);font-size:12px;}
.out{margin-top:12px;border:1px solid rgba(255,255,255,.10);border-radius:14px;background:rgba(0,0,0,.18);
padding:12px;min-height:140px;}
.answer{white-space:pre-wrap;margin:0;color:rgba(255,255,255,.92);line-height:1.55;font-size:14px;}
.hint{color:rgba(255,255,255,.55);font-size:13px;}

3) Run local

# API
dotnet run

# React
npm install
npm start

Checklist prod (rapide)

  • Timeout LLM 15–25s max
  • Rate limiting (déjà) + logs d’erreur
  • Ne pas logguer le prompt brut si données sensibles
  • Secrets via env vars en prod (jamais en dur)

Prochain article : Streaming (SSE/SignalR) + bouton Stop + UX “typing”.