/** * cli:publish-api-contract — execute.ts * * Collects the served surface with the SAME loaders the audit uses, so the * published contract cannot describe something `audit-dev-external-api` does * not see. Then writes the versioned bundle — unless the diff says a third * party's integration would break. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import path from 'node:path' import { findFiles, readText } from '../../../lib/fs.js' import { loadCatalogueRows, loadPublicControllers, } from '../../../development/audit-dev-external-api/cli/audit-dev-external-api/audit.js' import { buildGuide, buildOpenApi, buildPostman, diffContracts } from './build.js' import type { ContractEndpoint, DtoField, PublishApiContractInput, PublishReport, } from './types.js' const DTO_RECORD_RE = /public\s+(?:sealed\s+)?record\s+([A-Za-z0-9_]+Dto)\s*\(([^)]*)\)/gs /** * Parse the positional members of the generated DTO records. This is the whole * reason the contract is published from the client side: the platform's own * exporter only knows the catalogue metadata, so a generated client would have * no response shape at all. */ export function parseDtoRecords(source: string): Map { const out = new Map() const re = new RegExp(DTO_RECORD_RE.source, 'gs') let m: RegExpExecArray | null while ((m = re.exec(source)) !== null) { const members = m[2] .split(',') .map(s => s.trim()) .filter(Boolean) .map(part => { // `string? Numero = null` → type `string?`, name `Numero` const cleaned = part.split('=')[0].trim() const idx = cleaned.lastIndexOf(' ') if (idx === -1) return null return { csType: cleaned.slice(0, idx).trim(), name: cleaned.slice(idx + 1).trim() } }) .filter((f): f is DtoField => f !== null && /^[A-Za-z_]/.test(f.name)) out.set(m[1], members) } return out } async function loadDtos(backendPath: string): Promise> { const all = new Map() for (const abs of await findFiles('**/DTOs/**/*.cs', { cwd: backendPath })) { let source = '' try { source = (await readText(abs)) ?? '' } catch { continue } for (const [name, fields] of parseDtoRecords(source)) all.set(name, fields) } return all } export async function collectEndpoints( backendPath: string, applicationCode?: string, ): Promise { const controllers = await loadPublicControllers(backendPath) const rows = await loadCatalogueRows(backendPath) const rowByCode = new Map(rows.map(r => [r.code, r])) const out: ContractEndpoint[] = [] for (const c of controllers) { if (!c.code) continue if (applicationCode && !c.code.toLowerCase().startsWith(`${applicationCode.toLowerCase()}-`)) continue const row = rowByCode.get(c.code) // No catalogue row = unreachable for a third party. Publishing it would // advertise a 404; DEV-XAPI-002 is the place that failure is reported. if (!row) continue out.push({ code: c.code, route: c.classRoute ?? '', verbs: [...new Set(c.actions.map(a => a.verb))], requiredPermission: row.requiredPermission, accessType: row.accessType, entity: row.entityType, rateLimitPerMinute: row.rateLimitPerMinute, maxPageSize: row.maxPageSize, listDto: `${row.entityType}ListDto`, detailDto: `${row.entityType}DetailDto`, createDto: `Create${row.entityType}Dto`, updateDto: `Update${row.entityType}Dto`, }) } return out.sort((a, b) => a.code.localeCompare(b.code)) } export interface ExecuteResult { report: PublishReport warnings: string[] errors: string[] } export async function execute(spec: PublishApiContractInput): Promise { const projectPath = path.resolve(spec.projectPath) const backendPath = path.resolve(projectPath, spec.backendPath ?? 'src') const outDir = path.join(projectPath, '.smartstack', 'api-public', spec.version) const warnings: string[] = [] const errors: string[] = [] const endpoints = await collectEndpoints(backendPath, spec.applicationCode) const dtos = await loadDtos(backendPath) for (const ep of endpoints) { if (ep.listDto && !dtos.has(ep.listDto)) { warnings.push(`${ep.code}: ${ep.listDto} not found — the contract will describe the response without a schema, so a generated client gets an untyped payload.`) } } const title = spec.title ?? `${spec.applicationCode ?? 'SmartStack'} public API` const openapi = buildOpenApi(endpoints, dtos, { title, version: spec.version, baseUrl: spec.baseUrl }) const openapiPath = path.join(outDir, 'openapi.json') let previous: Record | null = null if (existsSync(openapiPath)) { try { previous = JSON.parse(readFileSync(openapiPath, 'utf8')) as Record } catch { warnings.push(`${openapiPath} is not readable JSON — treated as no previous contract, so the breaking-change gate cannot protect this publish.`) } } const diff = diffContracts(previous, openapi) if (diff.breaking.length > 0 && !spec.allowBreaking) { errors.push( `This publish would BREAK an integration already holding ${spec.version}: ${diff.breaking.join('; ')}. ` + `Publish under a new version instead (the third party cannot re-read a contract they already integrated), ` + `or pass allowBreaking: true if you have confirmed nobody consumes it yet.`, ) } const files: string[] = [] const wrote = spec.mode === 'write' && errors.length === 0 if (wrote) { mkdirSync(outDir, { recursive: true }) const artefacts: Array<[string, string]> = [ ['openapi.json', `${JSON.stringify(openapi, null, 2)}\n`], ['postman_collection.json', `${JSON.stringify(buildPostman(endpoints, { title, baseUrl: spec.baseUrl }), null, 2)}\n`], ['guide-integration.md', `${buildGuide(endpoints, { title, version: spec.version })}\n`], ] for (const [name, content] of artefacts) { const dest = path.join(outDir, name) writeFileSync(dest, content, 'utf8') files.push(path.relative(projectPath, dest).replace(/\\/g, '/')) } } return { report: { version: spec.version, endpointCount: endpoints.length, files, diff, hadPrevious: previous !== null, wrote, }, warnings, errors, } }