/** * cli:publish-api-contract — build.ts * * Pure builders: endpoints + DTO shapes in, contract artefacts out. Keeping * every artefact derived from the SAME endpoint list is what makes the OpenAPI * document, the Postman collection and the French guide impossible to disagree. */ import type { ContractDiff, ContractEndpoint, DtoField } from './types.js' /** C# → OpenAPI. Unknown types degrade to `string`, never to nothing. */ export function openApiType(csType: string): Record { const nullable = csType.trim().endsWith('?') const bare = csType.trim().replace(/\?$/, '') const base: Record = /^Guid$/i.test(bare) ? { type: 'string', format: 'uuid' } : /^(int|long|short)$/i.test(bare) ? { type: 'integer' } : /^(decimal|double|float)$/i.test(bare) ? { type: 'number' } : /^bool(ean)?$/i.test(bare) ? { type: 'boolean' } : /^(DateTime|DateOnly|DateTimeOffset)$/i.test(bare) ? { type: 'string', format: 'date-time' } : /^byte\[\]$/i.test(bare) ? { type: 'string', format: 'byte' } : /^(List|IReadOnlyList|IEnumerable|ICollection)<(.+)>$/i.test(bare) ? { type: 'array', items: openApiType(/^(?:List|IReadOnlyList|IEnumerable|ICollection)<(.+)>$/i.exec(bare)![1]) } : { type: 'string' } return nullable ? { ...base, nullable: true } : base } export function schemaOf(fields: DtoField[]): Record { const properties: Record = {} const required: string[] = [] for (const f of fields) { properties[camel(f.name)] = openApiType(f.csType) if (!f.csType.trim().endsWith('?')) required.push(camel(f.name)) } return required.length > 0 ? { type: 'object', properties, required } : { type: 'object', properties } } function camel(name: string): string { return name.charAt(0).toLowerCase() + name.slice(1) } const TENANT_PARAM = { name: 'tenantId', in: 'query', required: true, schema: { type: 'string', format: 'uuid' }, description: "Tenant the call addresses. MANDATORY: an external application carries no ambient tenant, and the platform validates this value against the application's tenant binding and the grant's tenant whitelist.", } export function buildOpenApi( endpoints: ContractEndpoint[], dtos: Map, opts: { title: string; version: string; baseUrl: string }, ): Record { const paths: Record = {} const schemas: Record = { ProblemDetails: { type: 'object', properties: { title: { type: 'string' }, detail: { type: 'string', nullable: true }, status: { type: 'integer' }, }, }, } const errorResponses = { '400': { description: 'Missing or invalid tenantId', content: json('#/components/schemas/ProblemDetails') }, '401': { description: 'Token missing, expired or revoked', content: json('#/components/schemas/ProblemDetails') }, '403': { description: 'route_blocked (path outside the whitelist), access_denied (no grant), tenant_not_allowed / tenant_bound_app_scope_denied (tenant out of scope), or permission_mismatch', content: json('#/components/schemas/ProblemDetails'), }, '404': { description: 'endpoint_not_found (endpoint not catalogued or inactive) or resource not found', content: json('#/components/schemas/ProblemDetails') }, '429': { description: 'Rate limit exceeded — honour the Retry-After header' }, } for (const ep of endpoints) { const listSchema = ep.listDto && dtos.has(ep.listDto) ? registerSchema(schemas, ep.listDto, dtos) : null const detailSchema = ep.detailDto && dtos.has(ep.detailDto) ? registerSchema(schemas, ep.detailDto, dtos) : null const createSchema = ep.createDto && dtos.has(ep.createDto) ? registerSchema(schemas, ep.createDto, dtos) : null const updateSchema = ep.updateDto && dtos.has(ep.updateDto) ? registerSchema(schemas, ep.updateDto, dtos) : null const item: Record = {} if (ep.verbs.includes('GET')) { const pagedName = `Paginated${ep.entity}List` if (listSchema) { schemas[pagedName] = { type: 'object', properties: { items: { type: 'array', items: { $ref: listSchema } }, page: { type: 'integer' }, pageSize: { type: 'integer' }, totalCount: { type: 'integer' }, totalPages: { type: 'integer' }, hasMore: { type: 'boolean' }, }, } } item.get = { summary: `Export ${ep.entity}`, operationId: `export_${ep.code.replace(/-/g, '_')}`, parameters: [ TENANT_PARAM, { name: 'page', in: 'query', schema: { type: 'integer', default: 1 } }, { name: 'pageSize', in: 'query', schema: { type: 'integer', default: 100, maximum: ep.maxPageSize ?? 1000 }, description: `Clamped server-side to ${ep.maxPageSize ?? 1000}.`, }, { name: 'search', in: 'query', schema: { type: 'string' } }, ], responses: { '200': { description: 'Paginated page', content: listSchema ? json(`#/components/schemas/${pagedName}`) : undefined, }, ...errorResponses, }, } } if (ep.verbs.includes('POST')) { item.post = { summary: `Create a ${ep.entity}`, operationId: `create_${ep.code.replace(/-/g, '_')}`, parameters: [TENANT_PARAM], requestBody: createSchema ? { required: true, content: json(createSchema) } : undefined, responses: { '201': { description: 'Created — the body is the new id' }, '409': { description: 'A row with the same natural key already exists. Retry-safe by construction: the unique index answers 409 rather than duplicating.', content: json('#/components/schemas/ProblemDetails'), }, ...errorResponses, }, } } paths[ep.route] = item if (ep.verbs.includes('GET') && detailSchema) { paths[`${ep.route}/{id}`] = { get: { summary: `Read one ${ep.entity}`, operationId: `get_${ep.code.replace(/-/g, '_')}`, parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, TENANT_PARAM, ], responses: { '200': { description: ep.entity, content: json(detailSchema) }, ...errorResponses }, }, } } if (ep.verbs.includes('PUT') || ep.verbs.includes('DELETE')) { const detail: Record = {} if (ep.verbs.includes('PUT')) { detail.put = { summary: `Update a ${ep.entity}`, operationId: `update_${ep.code.replace(/-/g, '_')}`, parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, TENANT_PARAM, ], requestBody: updateSchema ? { required: true, content: json(updateSchema) } : undefined, responses: { '204': { description: 'Updated' }, ...errorResponses }, } } if (ep.verbs.includes('DELETE')) { detail.delete = { summary: `Delete a ${ep.entity}`, operationId: `delete_${ep.code.replace(/-/g, '_')}`, parameters: [ { name: 'id', in: 'path', required: true, schema: { type: 'string', format: 'uuid' } }, TENANT_PARAM, ], responses: { '204': { description: 'Deleted' }, ...errorResponses }, } } paths[`${ep.route}/{id}`] = { ...(paths[`${ep.route}/{id}`] as object ?? {}), ...detail } } } return { openapi: '3.0.3', info: { title: opts.title, version: opts.version, description: 'Machine-to-machine API. Obtain a token with a JWT HS256 client assertion on POST /api/auth/external-app/token, then send it as a Bearer token. Every call must name its tenant with ?tenantId=.', }, servers: [{ url: opts.baseUrl }], security: [{ bearerAuth: [] }], components: { securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT', description: 'Token obtained from POST /api/auth/external-app/token. Permissions are FROZEN into the token at exchange time and cached server-side: a new grant only takes effect at the next renewal.', }, }, schemas, }, paths, } } function json(ref: string): Record { return { 'application/json': { schema: ref.startsWith('#') ? { $ref: ref } : { $ref: ref } } } } function registerSchema(schemas: Record, dto: string, dtos: Map): string { schemas[dto] = schemaOf(dtos.get(dto) ?? []) return `#/components/schemas/${dto}` } export function buildPostman( endpoints: ContractEndpoint[], opts: { title: string; baseUrl: string }, ): Record { const items = endpoints.flatMap(ep => ep.verbs.map(verb => ({ name: `${verb} ${ep.code}`, request: { method: verb, header: [ { key: 'Authorization', value: 'Bearer {{access_token}}' }, { key: 'Content-Type', value: 'application/json' }, ], url: { raw: `{{base_url}}${ep.route}?tenantId={{tenant_id}}`, host: ['{{base_url}}'], path: ep.route.split('/').filter(Boolean), query: [{ key: 'tenantId', value: '{{tenant_id}}' }], }, description: `Requires ${ep.requiredPermission}. Rate limit ${ep.rateLimitPerMinute ?? '—'}/min.`, }, })), ) return { info: { name: opts.title, schema: 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json', }, variable: [ { key: 'base_url', value: opts.baseUrl }, { key: 'client_id', value: '' }, { key: 'client_secret', value: '' }, { key: 'tenant_id', value: '' }, { key: 'access_token', value: '' }, ], item: [ { name: '00 — Obtain a token', request: { method: 'POST', header: [{ key: 'Content-Type', value: 'application/json' }], url: { raw: '{{base_url}}/api/auth/external-app/token', host: ['{{base_url}}'], path: ['api', 'auth', 'external-app', 'token'] }, body: { mode: 'raw', raw: JSON.stringify({ assertion: '{{assertion}}' }, null, 2) }, description: 'The assertion is a JWT signed HS256 with the client secret: sub = clientId, exp ≤ iat + 5 min. See the integration guide for the signing snippet.', }, }, ...items, ], } } export function buildGuide(endpoints: ContractEndpoint[], opts: { title: string; version: string }): string { const lines: string[] = [] lines.push(`# ${opts.title} — guide d'intégration (${opts.version})`, '') lines.push("Cette API est destinée à un système tiers, en machine-to-machine. Aucune session utilisateur n'est impliquée.", '') lines.push('## 1. Obtenir un jeton', '') lines.push('1. Forgez une **assertion JWT signée HS256** avec votre `clientSecret` :') lines.push(' - `sub` = votre `clientId`, `exp` ≤ `iat` + 5 minutes.') lines.push('2. `POST /api/auth/external-app/token` avec `{ "assertion": "" }`.') lines.push('3. Réutilisez le jeton reçu en `Authorization: Bearer ` jusqu\'à son expiration.', '') lines.push('> Les permissions sont **figées dans le jeton** au moment de l\'échange et mises en cache côté serveur.') lines.push('> Un droit ajouté ne prend effet qu\'au renouvellement du jeton.', '') lines.push('## 2. Règles valables sur tous les appels', '') lines.push('- `?tenantId=` est **obligatoire** : un appelant machine ne porte aucun tenant implicite.') lines.push('- La pagination est côté serveur (`page`, `pageSize`), et `pageSize` est plafonné par le serveur.') lines.push("- Les erreurs sont toujours au format `ProblemDetails` — un seul gestionnaire d'erreurs suffit.") lines.push('- Un `429` s\'accompagne d\'un `Retry-After` : respectez-le, le quota est par application **et par endpoint**.') lines.push('- `Idempotency-Key` **ne fonctionne pas** sur cette surface. La sécurité de rejeu vient de la clé naturelle :') lines.push(' un POST rejoué répond `409` au lieu de créer un doublon.', '') lines.push('## 3. Endpoints', '') lines.push('| Code | Route | Verbes | Permission | Quota/min |') lines.push('|---|---|---|---|---|') for (const ep of endpoints) { lines.push(`| \`${ep.code}\` | \`${ep.route}\` | ${ep.verbs.join(', ')} | \`${ep.requiredPermission}\` | ${ep.rateLimitPerMinute ?? '—'} |`) } lines.push('') lines.push('Chaque code est accordé **séparément** : recevoir la lecture ne donne pas l\'écriture.', '') lines.push('## 4. Codes d\'erreur propres à cette surface', '') lines.push('| Code | Signification |') lines.push('|---|---|') lines.push('| `route_blocked` | Le chemin appelé n\'est pas ouvert aux applications externes. |') lines.push('| `endpoint_not_found` | L\'endpoint n\'est pas (ou plus) au catalogue. |') lines.push('| `access_denied` | Votre application n\'a pas d\'accord actif sur cet endpoint. |') lines.push('| `tenant_not_allowed` | Le tenant demandé n\'est pas dans votre liste autorisée. |') lines.push('| `tenant_bound_app_scope_denied` | Votre application est liée à un tenant unique. |') lines.push('| `permission_mismatch` | L\'accord existe mais la permission attendue manque — signalez-le, cela ne se corrige pas de votre côté. |') return lines.join('\n') } /** * What changed for a third party already holding the previous contract. * Anything they must react to is BREAKING; the rest is additive. */ export function diffContracts( previous: Record | null, next: Record, ): ContractDiff { if (previous === null) return { additive: [], breaking: [] } const prevPaths = Object.keys((previous.paths ?? {}) as Record) const nextPaths = Object.keys((next.paths ?? {}) as Record) const additive: string[] = [] const breaking: string[] = [] for (const p of nextPaths) if (!prevPaths.includes(p)) additive.push(`new path ${p}`) for (const p of prevPaths) if (!nextPaths.includes(p)) breaking.push(`path removed: ${p}`) for (const p of prevPaths.filter(x => nextPaths.includes(x))) { const prevItem = ((previous.paths as Record>)[p]) ?? {} const nextItem = ((next.paths as Record>)[p]) ?? {} for (const verb of Object.keys(prevItem)) { if (!(verb in nextItem)) breaking.push(`operation removed: ${verb.toUpperCase()} ${p}`) } for (const verb of Object.keys(nextItem)) { if (!(verb in prevItem)) additive.push(`new operation ${verb.toUpperCase()} ${p}`) } } const prevSchemas = ((previous.components as Record>)?.schemas ?? {}) as Record }> const nextSchemas = ((next.components as Record>)?.schemas ?? {}) as Record }> for (const [name, schema] of Object.entries(prevSchemas)) { const after = nextSchemas[name] if (after === undefined) { breaking.push(`schema removed: ${name}`) continue } for (const prop of Object.keys(schema.properties ?? {})) { if (!(prop in (after.properties ?? {}))) breaking.push(`field removed: ${name}.${prop}`) } for (const prop of Object.keys(after.properties ?? {})) { if (!(prop in (schema.properties ?? {}))) additive.push(`new field ${name}.${prop}`) } } return { additive, breaking } }