Files
Aleksandr Meshchryakov 18971d33ec
All checks were successful
Mostovik Backend CI/CD / Tests and lint (push) Successful in 3m55s
Mostovik Backend CI/CD / Build linux/amd64 release images (push) Successful in 3m43s
Mostovik Backend CI/CD / Deploy and verify internal main (push) Has been skipped
Mostovik Backend CI/CD / Deploy customer main (push) Has been skipped
Mostovik Backend CI/CD / Deploy dev (push) Successful in 1m45s
feat: complete published registry contracts and gated SRO ingestion
2026-09-14 17:01:02 +02:00

127 lines
6.9 KiB
JavaScript

/** 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`);
}