mirror of
https://github.com/siop-spelev/siop2.git
synced 2026-08-08 12:41:54 +00:00
- FileStorage : putObject/getObjectStream/removeObject, bucket créé au démarrage — MinIO confiné à son implémentation (règle ESLint intacte) - documents : upload multipart (PDF/JPG/PNG, 20 Mo max, rattachement appareil OU OT requis, permission d'édition sur la CIBLE), liste filtrable, téléchargement STREAMÉ par l'API (MinIO jamais exposé), suppression ; e2e : octets téléchargés identiques aux octets envoyés - analytics : GET /analytics/summary dérivé du réel — coûts/mois (mouvements + main-d'œuvre figés), pannes par organe (bilans codés), taux de préventif, durée moyenne de résolution, top équipements - générateur OpenAPI : query params, multipart, réponse binaire (71 ops) - CI : service MinIO (bitnami) sur les jobs api et e2e - test de régression du tri « Interventions récentes » rendu déterministe (positions absolues instables sous 12 suites parallèles) ; 58 tests, 6 runs complets consécutifs verts Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
130 lines
4.0 KiB
TypeScript
130 lines
4.0 KiB
TypeScript
/**
|
||
* Règle d'or (ADR-001) : Zod → OpenAPI → clients typés.
|
||
* Génère docs/openapi.json (COMMITTÉE) depuis src/contract.ts.
|
||
* Usage : pnpm --filter @siop/shared contract (raccourci racine : pnpm contract)
|
||
* La CI (ci-contract, R0.12) échoue si le fichier committé diffère de la génération.
|
||
*/
|
||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||
import { dirname, resolve } from 'node:path';
|
||
import { z } from 'zod';
|
||
import { API_CONTRACT } from '../src/contract';
|
||
|
||
const OUT = resolve(__dirname, '../../../docs/openapi.json');
|
||
|
||
const schemas: Record<string, unknown> = {};
|
||
|
||
function register(name: string, schema: z.ZodType): { $ref: string } {
|
||
if (!(name in schemas)) {
|
||
schemas[name] = z.toJSONSchema(schema, { target: 'draft-2020-12' });
|
||
}
|
||
return { $ref: `#/components/schemas/${name}` };
|
||
}
|
||
|
||
const paths: Record<string, Record<string, unknown>> = {};
|
||
|
||
for (const op of API_CONTRACT) {
|
||
const responses: Record<string, unknown> = {};
|
||
for (const [status, res] of Object.entries(op.responses)) {
|
||
responses[status] = {
|
||
description: res.description,
|
||
...(res.schema && res.name
|
||
? { content: { 'application/json': { schema: register(res.name, res.schema) } } }
|
||
: {}),
|
||
};
|
||
}
|
||
paths[op.path] = {
|
||
...(paths[op.path] ?? {}),
|
||
[op.method]: {
|
||
operationId: op.operationId,
|
||
summary: op.summary,
|
||
tags: op.tags,
|
||
...(op.pathParams?.length || op.queryParams?.length
|
||
? {
|
||
parameters: [
|
||
...(op.pathParams ?? []).map((name) => ({
|
||
name,
|
||
in: 'path',
|
||
required: true,
|
||
// Convention : « id » / «…Id » sont des UUID, le reste est libre
|
||
schema:
|
||
name === 'id' || name.endsWith('Id')
|
||
? { type: 'string', format: 'uuid' }
|
||
: { type: 'string' },
|
||
})),
|
||
...(op.queryParams ?? []).map((q) => ({
|
||
name: q.name,
|
||
in: 'query',
|
||
required: q.required ?? false,
|
||
schema: { type: 'string' },
|
||
})),
|
||
],
|
||
}
|
||
: {}),
|
||
...(op.isPublic ? {} : { security: [{ bearerAuth: [] }] }),
|
||
...(op.demoOnly
|
||
? {
|
||
'x-demo-only': true,
|
||
description:
|
||
'ADR-002 : cette route est absente (404) quand DEMO_MODE n’est pas actif.',
|
||
}
|
||
: {}),
|
||
...(op.request
|
||
? {
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
'application/json': { schema: register(op.request.name, op.request.schema) },
|
||
},
|
||
},
|
||
}
|
||
: {}),
|
||
...(op.multipartFields
|
||
? {
|
||
requestBody: {
|
||
required: true,
|
||
content: {
|
||
'multipart/form-data': {
|
||
schema: {
|
||
type: 'object',
|
||
properties: Object.fromEntries(
|
||
Object.entries(op.multipartFields).map(([name, sorte]) => [
|
||
name,
|
||
sorte === 'file'
|
||
? { type: 'string', format: 'binary' }
|
||
: { type: 'string' },
|
||
]),
|
||
),
|
||
required: ['file'],
|
||
},
|
||
},
|
||
},
|
||
},
|
||
}
|
||
: {}),
|
||
responses,
|
||
},
|
||
};
|
||
}
|
||
|
||
const doc = {
|
||
openapi: '3.1.0',
|
||
info: {
|
||
title: 'SIOP V2 API',
|
||
version: '0.1.0',
|
||
description:
|
||
'GMAO ascenseurs — contrat R0 (auth, démo-login ADR-002, profil, santé). ' +
|
||
'Généré depuis packages/shared/src/contract.ts — NE PAS ÉDITER À LA MAIN.',
|
||
},
|
||
paths,
|
||
components: {
|
||
schemas,
|
||
securitySchemes: {
|
||
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||
},
|
||
},
|
||
};
|
||
|
||
mkdirSync(dirname(OUT), { recursive: true });
|
||
writeFileSync(OUT, JSON.stringify(doc, null, 2) + '\n');
|
||
console.log(`OpenAPI générée : ${OUT} (${API_CONTRACT.length} opérations)`);
|