Add custom API documentation page at /api/doc

- ApiDocController: serves doc page + JSON spec at /api/doc/spec.json
- Custom brutal design template matching site aesthetic
- 6 sections: Auth, Events, Orders, Scanner, Billets/Stock, Export
- Each endpoint shows: method badge (colored), path, summary, description
- Auth headers: ETicket-Email + ETicket-JWT displayed prominently
- Parameters table with type, required, default values
- Request body with JSON example and field types
- Response body with JSON example
- Status codes with colored badges (green/yellow/red)
- Rate limiting section with X-RateLimit headers
- Table of contents with anchor links
- Standard response format: {success, data, error}
- No external dependencies (no Swagger/NelmioApiDoc)
- Fully customizable via PHP spec array

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-03-23 18:51:46 +01:00
parent 4e49553c3d
commit 9c5c1b6da5
2 changed files with 629 additions and 0 deletions

View File

@@ -0,0 +1,375 @@
<?php
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
class ApiDocController extends AbstractController
{
#[Route('/api/doc', name: 'app_api_doc', methods: ['GET'])]
public function index(): Response
{
return $this->render('api/doc.html.twig', [
'sections' => $this->getApiSpec(),
]);
}
#[Route('/api/doc/spec.json', name: 'app_api_doc_json', methods: ['GET'])]
public function spec(): Response
{
return $this->json($this->getApiSpec());
}
/**
* @return list<array<string, mixed>>
*/
private function getApiSpec(): array
{
return [
[
'name' => 'Authentification',
'description' => 'Toutes les routes API necessitent les headers d\'authentification.',
'endpoints' => [
[
'method' => 'POST',
'path' => '/api/auth/login',
'summary' => 'Obtenir un token JWT',
'description' => 'Authentifie un organisateur et retourne un token JWT valable 24h.',
'headers' => [],
'params' => [],
'request' => [
'email' => ['type' => 'string', 'required' => true, 'example' => 'orga@example.com'],
'password' => ['type' => 'string', 'required' => true, 'example' => '********'],
],
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"token": "eyJ...", "expiresAt": "2026-03-24T12:00:00+00:00"}'],
],
'statuses' => [
200 => 'Token genere avec succes',
401 => 'Identifiants invalides',
429 => 'Trop de tentatives',
],
],
],
],
[
'name' => 'Evenements',
'description' => 'Gestion des evenements de l\'organisateur authentifie.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/events',
'summary' => 'Liste des evenements',
'description' => 'Retourne tous les evenements de l\'organisateur authentifie.',
'headers' => $this->authHeaders(),
'params' => [
'page' => ['type' => 'int', 'required' => false, 'default' => 1, 'description' => 'Page courante'],
'limit' => ['type' => 'int', 'required' => false, 'default' => 20, 'description' => 'Nombre par page (max 100)'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'array', 'example' => '[{"id": 1, "title": "Brocante 2026", "startAt": "2026-08-01T10:00:00", "endAt": "2026-08-01T18:00:00", "address": "1 rue", "city": "Paris", "isOnline": true, "isSecret": false}]'],
'meta' => ['type' => 'object', 'example' => '{"page": 1, "limit": 20, "total": 5}'],
],
'statuses' => [
200 => 'Liste retournee',
401 => 'Non authentifie',
],
],
[
'method' => 'GET',
'path' => '/api/events/{id}',
'summary' => 'Detail d\'un evenement',
'description' => 'Retourne un evenement avec ses categories et billets.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"id": 1, "title": "Brocante", "categories": [{"id": 1, "name": "General", "billets": [{"id": 1, "name": "Entree", "priceHT": 1500, "quantity": 100, "sold": 42, "type": "billet"}]}]}'],
],
'statuses' => [
200 => 'Evenement retourne',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
404 => 'Evenement introuvable',
],
],
[
'method' => 'GET',
'path' => '/api/events/{id}/stats',
'summary' => 'Statistiques d\'un evenement',
'description' => 'CA, nombre de commandes, billets vendus, billets scannes.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"totalHT": 4200.50, "nbOrders": 28, "nbTicketsSold": 42, "nbTicketsScanned": 35}'],
],
'statuses' => [
200 => 'Stats retournees',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
404 => 'Evenement introuvable',
],
],
],
],
[
'name' => 'Commandes',
'description' => 'Consultation des commandes d\'un evenement.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/events/{id}/orders',
'summary' => 'Liste des commandes',
'description' => 'Retourne les commandes d\'un evenement avec filtrage optionnel par statut.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
'status' => ['type' => 'string', 'required' => false, 'description' => 'Filtrer par statut : pending, paid, cancelled, refunded, partially_refunded'],
'page' => ['type' => 'int', 'required' => false, 'default' => 1, 'description' => 'Page courante'],
'limit' => ['type' => 'int', 'required' => false, 'default' => 20, 'description' => 'Nombre par page (max 100)'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'array', 'example' => '[{"orderNumber": "2026-03-23-1", "status": "paid", "firstName": "Jean", "lastName": "Dupont", "email": "jean@test.fr", "totalHT": 15.00, "paidAt": "2026-03-23T14:30:00", "items": [{"billetName": "Entree", "quantity": 1, "unitPriceHT": 15.00}]}]'],
'meta' => ['type' => 'object', 'example' => '{"page": 1, "limit": 20, "total": 28}'],
],
'statuses' => [
200 => 'Commandes retournees',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
404 => 'Evenement introuvable',
],
],
[
'method' => 'GET',
'path' => '/api/orders/{orderNumber}',
'summary' => 'Detail d\'une commande',
'description' => 'Retourne une commande avec ses items et tickets generes.',
'headers' => $this->authHeaders(),
'params' => [
'orderNumber' => ['type' => 'string', 'required' => true, 'description' => 'Numero de commande'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"orderNumber": "2026-03-23-1", "status": "paid", "firstName": "Jean", "lastName": "Dupont", "email": "jean@test.fr", "totalHT": 15.00, "items": [...], "tickets": [{"reference": "ETICKET-XXXX-XXXX", "state": "valid", "firstScannedAt": null}]}'],
],
'statuses' => [
200 => 'Commande retournee',
401 => 'Non authentifie',
403 => 'Commande non accessible',
404 => 'Commande introuvable',
],
],
],
],
[
'name' => 'Scanner',
'description' => 'Endpoints pour l\'application mobile de scan de billets.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/events/{id}/tickets',
'summary' => 'Liste des billets generes',
'description' => 'Retourne tous les billets generes pour un evenement.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
'page' => ['type' => 'int', 'required' => false, 'default' => 1, 'description' => 'Page courante'],
'limit' => ['type' => 'int', 'required' => false, 'default' => 50, 'description' => 'Nombre par page (max 100)'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'array', 'example' => '[{"reference": "ETICKET-XXXX-XXXX", "billetName": "Entree", "state": "valid", "isInvitation": false, "firstScannedAt": null, "buyerName": "Jean Dupont"}]'],
],
'statuses' => [
200 => 'Billets retournes',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
],
],
[
'method' => 'POST',
'path' => '/api/scan',
'summary' => 'Scanner un billet',
'description' => 'Decode le QR code, verifie la reference et l\'etat du billet, marque le billet comme scanne. Gere la sortie definitive si activee.',
'headers' => $this->authHeaders(),
'params' => [],
'request' => [
'reference' => ['type' => 'string', 'required' => true, 'example' => 'ETICKET-XXXX-XXXX-XXXX'],
],
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"reference": "ETICKET-XXXX-XXXX", "state": "valid", "billetName": "Entree", "buyerFirstName": "Jean", "buyerLastName": "Dupont", "isInvitation": false, "firstScannedAt": "2026-03-23T14:30:00", "hasDefinedExit": true}'],
],
'statuses' => [
200 => 'Billet scanne avec succes',
400 => 'Reference invalide ou billet deja scanne',
401 => 'Non authentifie',
404 => 'Billet introuvable',
],
],
[
'method' => 'POST',
'path' => '/api/scan/verify',
'summary' => 'Verifier un billet (lecture seule)',
'description' => 'Verifie l\'etat d\'un billet sans le marquer comme scanne.',
'headers' => $this->authHeaders(),
'params' => [],
'request' => [
'reference' => ['type' => 'string', 'required' => true, 'example' => 'ETICKET-XXXX-XXXX-XXXX'],
],
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"reference": "ETICKET-XXXX-XXXX", "state": "valid", "billetName": "Entree", "buyerFirstName": "Jean", "buyerLastName": "Dupont"}'],
],
'statuses' => [
200 => 'Billet valide',
401 => 'Non authentifie',
404 => 'Billet introuvable',
],
],
[
'method' => 'GET',
'path' => '/api/events/{id}/scan-stats',
'summary' => 'Stats de scan temps reel',
'description' => 'Nombre de billets scannes, restants, invalides et dernier scan.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"scanned": 35, "remaining": 7, "invalid": 2, "lastScanAt": "2026-03-23T16:45:00"}'],
],
'statuses' => [
200 => 'Stats retournees',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
],
],
],
],
[
'name' => 'Billets & Stock',
'description' => 'Gestion des billets et du stock.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/events/{id}/billets',
'summary' => 'Liste des billets avec stock',
'description' => 'Retourne les billets d\'un evenement avec quantite disponible et vendue.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'array', 'example' => '[{"id": 1, "name": "Entree", "priceHT": 1500, "quantity": 100, "sold": 42, "type": "billet", "isGeneratedBillet": true}]'],
],
'statuses' => [
200 => 'Billets retournes',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
],
],
[
'method' => 'PATCH',
'path' => '/api/billets/{id}/stock',
'summary' => 'Modifier le stock d\'un billet',
'description' => 'Met a jour la quantite disponible d\'un billet. Null = illimite.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID du billet'],
],
'request' => [
'quantity' => ['type' => 'int|null', 'required' => true, 'example' => '150'],
],
'response' => [
'success' => ['type' => 'bool', 'example' => true],
'data' => ['type' => 'object', 'example' => '{"id": 1, "name": "Entree", "quantity": 150}'],
],
'statuses' => [
200 => 'Stock mis a jour',
400 => 'Donnees invalides',
401 => 'Non authentifie',
403 => 'Billet non accessible',
404 => 'Billet introuvable',
],
],
],
],
[
'name' => 'Export',
'description' => 'Export de donnees en CSV.',
'endpoints' => [
[
'method' => 'GET',
'path' => '/api/events/{id}/export/orders.csv',
'summary' => 'Export CSV des commandes',
'description' => 'Telecharge un fichier CSV avec toutes les commandes payees de l\'evenement.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'file' => ['type' => 'text/csv', 'example' => 'Commande;Date;Acheteur;Email;Billets;Total HT'],
],
'statuses' => [
200 => 'Fichier CSV retourne',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
],
],
[
'method' => 'GET',
'path' => '/api/events/{id}/export/tickets.csv',
'summary' => 'Export CSV des billets scannes',
'description' => 'Telecharge un fichier CSV avec tous les billets generes et leur etat de scan.',
'headers' => $this->authHeaders(),
'params' => [
'id' => ['type' => 'int', 'required' => true, 'description' => 'ID de l\'evenement'],
],
'request' => null,
'response' => [
'file' => ['type' => 'text/csv', 'example' => 'Reference;Billet;Acheteur;Etat;Scanne le'],
],
'statuses' => [
200 => 'Fichier CSV retourne',
401 => 'Non authentifie',
403 => 'Evenement non accessible',
],
],
],
],
];
}
/**
* @return list<array{name: string, description: string, required: bool}>
*/
private function authHeaders(): array
{
return [
['name' => 'ETicket-Email', 'description' => 'Email de l\'organisateur', 'required' => true],
['name' => 'ETicket-JWT', 'description' => 'Token JWT obtenu via /api/auth/login', 'required' => true],
];
}
}

254
templates/api/doc.html.twig Normal file
View File

@@ -0,0 +1,254 @@
{% extends 'base.html.twig' %}
{% block title %}API Documentation - E-Ticket{% endblock %}
{% block description %}Documentation de l'API E-Ticket pour les organisateurs et l'application scanner.{% endblock %}
{% block body %}
<div class="bg-[#fbfbfb] min-h-screen">
<section class="relative bg-gray-900 text-white px-4 pt-20 pb-16 border-b-8 border-[#fabf04]">
<div class="absolute inset-0 opacity-[0.05] pointer-events-none select-none overflow-hidden">
<span class="text-[8rem] md:text-[16rem] font-black uppercase leading-none block -rotate-12 translate-y-10 font-mono">{API}</span>
</div>
<div class="max-w-5xl mx-auto relative z-10">
<div class="inline-block px-3 py-1 border-2 border-[#fabf04] bg-[#fabf04] text-gray-900 text-[10px] font-black uppercase tracking-widest mb-4">v1.0</div>
<h1 class="text-4xl md:text-6xl font-black uppercase tracking-tighter leading-[0.85] mb-4">API E-Ticket</h1>
<p class="text-lg font-bold text-gray-300 max-w-2xl">Documentation complete de l'API REST pour les organisateurs. Gestion des evenements, commandes, billets et scan.</p>
<div class="mt-8 flex flex-wrap gap-4">
<div class="border-2 border-gray-700 bg-gray-800 px-4 py-3">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Base URL</p>
<p class="font-mono font-bold text-sm text-[#fabf04]">https://ticket.e-cosplay.fr/api</p>
</div>
<div class="border-2 border-gray-700 bg-gray-800 px-4 py-3">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Format</p>
<p class="font-mono font-bold text-sm">JSON (application/json)</p>
</div>
<div class="border-2 border-gray-700 bg-gray-800 px-4 py-3">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-1">Authentification</p>
<p class="font-mono font-bold text-sm">ETicket-Email + ETicket-JWT</p>
</div>
</div>
</div>
</section>
<div class="max-w-5xl mx-auto px-4 py-12">
<nav class="card-brutal overflow-hidden mb-12">
<div class="bg-gray-900 text-white px-6 py-3">
<h2 class="text-[10px] font-black uppercase tracking-widest">Sommaire</h2>
</div>
<div class="p-6">
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-3">
{% for section in sections %}
<a href="#section-{{ loop.index }}" class="border-2 border-gray-900 px-4 py-3 font-black uppercase text-xs tracking-widest hover:bg-gray-900 hover:text-white transition-all">
{{ section.name }}
<span class="text-gray-400 ml-1">({{ section.endpoints|length }})</span>
</a>
{% endfor %}
</div>
</div>
</nav>
<div class="card-brutal overflow-hidden mb-12 border-[#fabf04]" style="border-color: #fabf04;">
<div class="bg-[#fabf04] text-gray-900 px-6 py-3">
<h2 class="text-[10px] font-black uppercase tracking-widest">Authentification</h2>
</div>
<div class="p-6">
<p class="font-bold text-sm text-gray-700 mb-4">Toutes les routes (sauf <span class="font-mono bg-gray-100 px-1">/api/auth/login</span>) necessitent deux headers :</p>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-900 text-white">
<th class="px-4 py-2 text-left font-black uppercase text-[10px] tracking-widest">Header</th>
<th class="px-4 py-2 text-left font-black uppercase text-[10px] tracking-widest">Description</th>
<th class="px-4 py-2 text-left font-black uppercase text-[10px] tracking-widest">Exemple</th>
</tr>
</thead>
<tbody>
<tr class="border-b border-gray-200">
<td class="px-4 py-3 font-mono font-bold text-indigo-600">ETicket-Email</td>
<td class="px-4 py-3 font-bold text-gray-600">Email de l'organisateur</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">orga@example.com</td>
</tr>
<tr>
<td class="px-4 py-3 font-mono font-bold text-indigo-600">ETicket-JWT</td>
<td class="px-4 py-3 font-bold text-gray-600">Token JWT (obtenu via /api/auth/login)</td>
<td class="px-4 py-3 font-mono text-xs text-gray-500">eyJhbGciOiJIUzI1NiIs...</td>
</tr>
</tbody>
</table>
</div>
<div class="mt-6 border-2 border-gray-900 bg-gray-900 text-white p-4">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-500 mb-2">Reponse standard</p>
<pre class="font-mono text-xs leading-relaxed overflow-x-auto"><code>{
"success": true,
"data": { ... },
"error": null
}</code></pre>
</div>
<div class="mt-4 border-2 border-red-800 bg-red-50 p-4">
<p class="text-[10px] font-black uppercase tracking-widest text-red-800 mb-2">Reponse erreur</p>
<pre class="font-mono text-xs leading-relaxed overflow-x-auto text-red-900"><code>{
"success": false,
"data": null,
"error": "Message d'erreur explicite"
}</code></pre>
</div>
</div>
</div>
{% for section in sections %}
<div id="section-{{ loop.index }}" class="mb-12 scroll-mt-8">
<div class="flex items-center gap-4 mb-6">
<h2 class="text-2xl font-black uppercase tracking-tighter">{{ section.name }}</h2>
<div class="flex-1 border-t-3 border-gray-900"></div>
</div>
{% if section.description %}
<p class="font-bold text-sm text-gray-500 mb-6">{{ section.description }}</p>
{% endif %}
{% for endpoint in section.endpoints %}
<div class="card-brutal overflow-hidden mb-6">
<div class="flex items-stretch">
{% set method_colors = {
'GET': 'bg-green-600',
'POST': 'bg-indigo-600',
'PATCH': 'bg-orange-500',
'DELETE': 'bg-red-600',
'PUT': 'bg-yellow-500'
} %}
<div class="{{ method_colors[endpoint.method] ?? 'bg-gray-600' }} text-white px-4 py-3 flex items-center min-w-[80px] justify-center">
<span class="font-black text-xs tracking-widest">{{ endpoint.method }}</span>
</div>
<div class="flex-1 bg-gray-900 text-white px-4 py-3 flex items-center">
<code class="font-mono font-bold text-sm">{{ endpoint.path }}</code>
</div>
</div>
<div class="p-6">
<h3 class="font-black uppercase text-sm tracking-tighter mb-1">{{ endpoint.summary }}</h3>
<p class="text-sm font-bold text-gray-500 mb-4">{{ endpoint.description }}</p>
{% if endpoint.headers|length > 0 %}
<div class="mb-4">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Headers requis</p>
<div class="flex flex-wrap gap-2">
{% for header in endpoint.headers %}
<span class="font-mono text-xs px-2 py-1 border-2 border-indigo-600 text-indigo-600 font-bold">{{ header.name }}</span>
{% endfor %}
</div>
</div>
{% endif %}
{% if endpoint.params|length > 0 %}
<div class="mb-4">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Parametres</p>
<div class="overflow-x-auto">
<table class="w-full text-xs">
<thead>
<tr class="bg-gray-100">
<th class="px-3 py-2 text-left font-black uppercase tracking-widest">Nom</th>
<th class="px-3 py-2 text-left font-black uppercase tracking-widest">Type</th>
<th class="px-3 py-2 text-left font-black uppercase tracking-widest">Requis</th>
<th class="px-3 py-2 text-left font-black uppercase tracking-widest">Description</th>
</tr>
</thead>
<tbody>
{% for name, param in endpoint.params %}
<tr class="border-b border-gray-100">
<td class="px-3 py-2 font-mono font-bold text-indigo-600">{{ name }}</td>
<td class="px-3 py-2 font-mono text-gray-500">{{ param.type }}</td>
<td class="px-3 py-2">
{% if param.required %}
<span class="text-red-600 font-black">oui</span>
{% else %}
<span class="text-gray-400">non{% if param.default is defined %} ({{ param.default }}){% endif %}</span>
{% endif %}
</td>
<td class="px-3 py-2 font-bold text-gray-600">{{ param.description }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
{% if endpoint.request %}
<div class="mb-4">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Body (JSON)</p>
<div class="border-2 border-gray-900 bg-gray-900 text-white p-4">
<pre class="font-mono text-xs leading-relaxed overflow-x-auto"><code>{
{% for name, field in endpoint.request %}
"{{ name }}": {{ field.example is defined ? '"' ~ field.example ~ '"' : '"..."' }}{{ not loop.last ? ',' : '' }} <span class="text-gray-500">// {{ field.type }}{% if field.required %} (requis){% endif %}</span>
{% endfor %}
}</code></pre>
</div>
</div>
{% endif %}
{% if endpoint.response %}
<div class="mb-4">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Reponse (200)</p>
<div class="border-2 border-green-800 bg-green-50 p-4">
<pre class="font-mono text-xs leading-relaxed overflow-x-auto text-green-900"><code>{
{% for name, field in endpoint.response %}
"{{ name }}": {{ field.example }} <span class="text-green-600">// {{ field.type }}</span>{{ not loop.last ? ',' : '' }}
{% endfor %}
}</code></pre>
</div>
</div>
{% endif %}
{% if endpoint.statuses|length > 0 %}
<div>
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Codes de reponse</p>
<div class="flex flex-wrap gap-2">
{% for code, desc in endpoint.statuses %}
{% set code_color = code < 300 ? 'border-green-600 text-green-700 bg-green-50' : (code < 400 ? 'border-yellow-500 text-yellow-700 bg-yellow-50' : (code < 500 ? 'border-red-600 text-red-700 bg-red-50' : 'border-gray-600 text-gray-700 bg-gray-50')) %}
<div class="border-2 {{ code_color }} px-3 py-1">
<span class="font-mono font-black text-xs">{{ code }}</span>
<span class="font-bold text-xs ml-1">{{ desc }}</span>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
{% endfor %}
</div>
{% endfor %}
<div class="card-brutal overflow-hidden">
<div class="bg-gray-900 text-white px-6 py-3">
<h2 class="text-[10px] font-black uppercase tracking-widest">Rate Limiting</h2>
</div>
<div class="p-6">
<p class="font-bold text-sm text-gray-700 mb-4">L'API est limitee a <span class="font-mono bg-gray-100 px-1 text-indigo-600">60 requetes par minute</span> par cle API. En cas de depassement, un code <span class="font-mono bg-red-50 px-1 text-red-600">429</span> est retourne.</p>
<div class="border-2 border-gray-200 p-4">
<p class="text-[10px] font-black uppercase tracking-widest text-gray-400 mb-2">Headers de rate limit</p>
<table class="w-full text-xs">
<tr class="border-b border-gray-100">
<td class="py-2 font-mono font-bold text-gray-600">X-RateLimit-Limit</td>
<td class="py-2 font-bold text-gray-500">Nombre max de requetes par fenetre</td>
</tr>
<tr class="border-b border-gray-100">
<td class="py-2 font-mono font-bold text-gray-600">X-RateLimit-Remaining</td>
<td class="py-2 font-bold text-gray-500">Requetes restantes dans la fenetre courante</td>
</tr>
<tr>
<td class="py-2 font-mono font-bold text-gray-600">Retry-After</td>
<td class="py-2 font-bold text-gray-500">Secondes avant la prochaine fenetre (si 429)</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}