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
127 lines
6.9 KiB
JavaScript
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`);
|
|
}
|