/** Generate the canonical OAS3 document from actual DRF/Swagger definitions. */ import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import converter from 'swagger2openapi'; import validator from 'oas-validator'; const base = fileURLToPath(new URL('../..', import.meta.url)); const input = process.argv[2] || resolve(base, 'tools/openapi/swagger.json'); const output = resolve(base, 'src/core/openapi.json'); const check = process.argv.includes('--check'); const swagger = JSON.parse(readFileSync(input, 'utf8')); const converted = await converter.convertObj(swagger, { patch: true, warnOnly: false }); const document = converted.openapi; const schemas = document.components.schemas; const clone = (value) => structuredClone(value); const ref = (name) => ({ $ref: `#/components/schemas/${name}` }); // Relative URLs work through the deployed frontend proxy and the backend origin. delete document.servers; document.info.description += '\n\nCanonical OpenAPI 3 contract: /openapi.json. Legacy Swagger 2 remains available at /?format=openapi.'; const variants = [ ['BudgetRegistry', 'budget_process_registry', 'budget_ubpandnubp', 'budget_registry_organization', 'BudgetRegistryRecord'], ['SmeSupport', 'government_support', 'fns_sme_support_recipients', 'sme_support_measure', 'SmeSupportRecord'], ['SroMembership', 'sro_membership', 'sro_membership_check', 'sro_membership', 'SroMembership'], ]; const requiredRecordFields = [ 'uid', 'source_group', 'source', 'record_type', 'external_id', 'title', 'status', 'created_at', 'updated_at', 'organization', 'payload', ]; const listOperation = document.paths['/api/v2/organization-source-records/']?.get; if (!listOperation) throw new Error('Source records list operation missing'); const groupParameter = listOperation.parameters.find((parameter) => parameter.name === 'source_group'); const legacyGroups = groupParameter.schema.enum.filter((group) => !variants.some((variant) => variant[1] === group)); schemas.PublishedSourceRecordOrganization = clone(schemas.OrganizationSourceRecordOrganization); schemas.PublishedSourceRecordOrganization.required = ['uid', 'name', 'inn', 'ogrn', 'okpo']; for (const [name, property] of Object.entries(schemas.PublishedSourceRecordOrganization.properties)) { if (name !== 'uid' && name !== 'name') property.nullable = true; } schemas.SroSourceRecordOrganization = clone(schemas.PublishedSourceRecordOrganization); schemas.SroSourceRecordOrganization.required.push('full_name'); for (const field of schemas.SroSourceRecordOrganization.required) { delete schemas.SroSourceRecordOrganization.properties[field].nullable; } for (const [schemaName, kind] of [ ['OrganizationSourceRecordList', 'List'], ['OrganizationSourceRecord', 'Detail'], ]) { const common = clone(schemas[schemaName]); if (!common?.properties?.payload) throw new Error(`${schemaName} has no payload`); const legacyName = `Legacy${schemaName}`; schemas[legacyName] = clone(common); schemas[legacyName].properties.source_group = { type: 'string', enum: legacyGroups }; schemas[legacyName].required = [...new Set([...(common.required || []), 'source_group'])]; const oneOf = [ref(legacyName)]; const mapping = Object.fromEntries(legacyGroups.map((group) => [group, ref(legacyName).$ref])); for (const [prefix, group, source, recordType, payloadPrefix] of variants) { const payloadName = `${payloadPrefix}${kind}Payload`; if (!schemas[payloadName]) throw new Error(`Named payload missing: ${payloadName}`); const variantName = `${prefix}Record${kind}`; const schema = clone(common); schema.required = [...new Set([...(schema.required || []), ...requiredRecordFields])]; schema.properties.source_group = { type: 'string', enum: [group] }; schema.properties.source = { type: 'string', enum: [source] }; schema.properties.record_type = { type: 'string', enum: [recordType] }; schema.properties.status = { type: 'string', enum: { budget_process_registry: ['active', 'inactive', 'special', 'unknown'], government_support: ['published'], sro_membership: ['active', 'excluded'], }[group] }; schema.properties.payload = ref(payloadName); schema.properties.organization = ref(group === 'sro_membership' ? 'SroSourceRecordOrganization' : 'PublishedSourceRecordOrganization'); schemas[variantName] = schema; oneOf.push(ref(variantName)); mapping[group] = ref(variantName).$ref; } schemas[schemaName] = { oneOf, discriminator: { propertyName: 'source_group', mapping }, description: 'Published source record. source_group selects the variant; record_type and source are fixed within each registry variant.', }; } // Keep the generated enum name consumed by existing media/sanctions hooks, and // describe arbitrary validated comma-separated combinations without enumerating // factorially many permutations or pretending ordering is an array on the wire. const ordering = listOperation.parameters.find((parameter) => parameter.name === 'ordering'); const values = ordering.schema.enum.filter((value) => !value.includes(',')); const escapedValues = values.map((value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')); schemas.V2OrganizationSourceRecordsListOrdering = { type: 'string', enum: values }; schemas.SourceRecordOrderingSequence = { type: 'string', pattern: `^(?:${escapedValues.join('|')})(?:,(?:${escapedValues.join('|')}))+$`, description: 'Two or more allowlisted ordering fields, separated by commas without spaces.', }; ordering.schema = { anyOf: [ref('V2OrganizationSourceRecordsListOrdering'), ref('SourceRecordOrderingSequence')], }; // drf-yasg's read-only flags do not express response requiredness consistently. for (const name of ['OrganizationSourceRecordListResponse', 'SourceRecordMeta', 'SourceRecordPagination']) { schemas[name].required = Object.keys(schemas[name].properties); } schemas.OrganizationSourceRecordListResponse.properties.errors = { type: 'object', nullable: true, enum: [null] }; // Swagger's pagination inspector emits required:[] for legacy envelopes. Omitting // this empty constraint preserves its meaning and is valid in OpenAPI 3.0. function removeEmptyRequired(value) { if (!value || typeof value !== 'object') return; if (Array.isArray(value.required) && value.required.length === 0) delete value.required; for (const child of Object.values(value)) removeEmptyRequired(child); } removeEmptyRequired(document); const encoded = JSON.stringify(document, null, 2) + '\n'; await validator.validate(JSON.parse(encoded), { lint: false, validateSchema: true }); if (check) { if (readFileSync(output, 'utf8') !== encoded) throw new Error('Canonical openapi.json is stale; regenerate it from the current backend'); console.log('Canonical OpenAPI matches runtime schema'); } else { writeFileSync(output, encoded); console.log(`Generated ${Object.keys(document.paths).length} paths, ${Object.keys(schemas).length} schemas`); }