From 196cf9cafd4697c7e21cb87114a5f21599cdba35 Mon Sep 17 00:00:00 2001 From: Anugrah Date: Wed, 28 Oct 2026 20:54:53 +0530 Subject: [PATCH] fix(openapi): write decorators under an x- extension key OpenApiVisitor copied the JSON Schema definitions unchanged, including the $decorators key on type and property schemas. Open API 3.0 schema objects only allow extension keys that start with x-, so redocly rejected the output for any decorated model. Rename the key to x-concerto-decorators in the Open API output, and run the model-base and agreement verification cases that this unblocks. JSON Schema output is unchanged. Closes #299 Signed-off-by: Anugrah --- lib/codegen/fromcto/openapi/openapivisitor.js | 21 ++++++++++ test/codegen/__snapshots__/codegen.js.snap | 20 +++++----- .../codegen/fromcto/openapi/openapivisitor.js | 40 +++++++++++++++++++ test/verification/cases.js | 4 +- 4 files changed, 72 insertions(+), 13 deletions(-) diff --git a/lib/codegen/fromcto/openapi/openapivisitor.js b/lib/codegen/fromcto/openapi/openapivisitor.js index 6cc3dbdd..7729bef6 100644 --- a/lib/codegen/fromcto/openapi/openapivisitor.js +++ b/lib/codegen/fromcto/openapi/openapivisitor.js @@ -69,6 +69,7 @@ class OpenApiVisitor { refRoot: '#/components/schemas', }; const jsonSchema = modelManager.accept(visitor, childParameters); + this.renameDecorators(jsonSchema.definitions); // Visit all of the files in the model manager. const title = parameters?.openApiTitle ?? 'Generated Open API from Concerto Models'; @@ -100,6 +101,26 @@ class OpenApiVisitor { return result; } + /** + * Renames the $decorators key that JSONSchemaVisitor adds to type and + * property schemas, because Open API 3.0 schema objects only allow + * extension keys that start with x-. + * @param {Object} definitions - the JSON schema definitions, changed in place + * @private + */ + renameDecorators(definitions) { + const rename = (schema) => { + if (schema.$decorators) { + schema['x-concerto-decorators'] = schema.$decorators; + delete schema.$decorators; + } + }; + Object.values(definitions).forEach((schema) => { + rename(schema); + Object.values(schema.properties ?? {}).forEach(rename); + }); + } + /** * Visitor design pattern * @param {ModelFile} modelFile - the object being visited diff --git a/test/codegen/__snapshots__/codegen.js.snap b/test/codegen/__snapshots__/codegen.js.snap index ab85f8de..60349303 100644 --- a/test/codegen/__snapshots__/codegen.js.snap +++ b/test/codegen/__snapshots__/codegen.js.snap @@ -4361,7 +4361,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "isPii", "category" ], - "$decorators": { + "x-concerto-decorators": { "example": [ "@pii(true, GeneralCategory)" ], @@ -4383,7 +4383,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio }, "name": { "type": "string", - "$decorators": { + "x-concerto-decorators": { "pii": [ false, { @@ -4414,7 +4414,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "name": { "type": "string", "pattern": "abc.*", - "$decorators": { + "x-concerto-decorators": { "description": [ "The name of the company" ] @@ -4497,7 +4497,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "name", "headquarters" ], - "$decorators": { + "x-concerto-decorators": { "description": [ "A company" ] @@ -4514,7 +4514,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "Engineering", "Design" ], - "$decorators": { + "x-concerto-decorators": { "category": [ { "type": "Identifier", @@ -4547,7 +4547,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "$class", "serialNumber" ], - "$decorators": { + "x-concerto-decorators": { "resource": [] } }, @@ -4558,7 +4558,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "Apple", "Microsoft" ], - "$decorators": { + "x-concerto-decorators": { "test": [ "one" ] @@ -4654,7 +4654,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "dob", "nextOfKin" ], - "$decorators": { + "x-concerto-decorators": { "resource": [] } }, @@ -4767,7 +4767,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "dob", "nextOfKin" ], - "$decorators": { + "x-concerto-decorators": { "category": [ { "type": "Identifier", @@ -4794,7 +4794,7 @@ exports[`codegen #formats check we can convert all formats from namespace versio "manager": { "type": "string", "description": "The identifier of an instance of org.acme.hr@1.0.0.Manager", - "$decorators": { + "x-concerto-decorators": { "level": [ { "type": "Identifier", diff --git a/test/codegen/fromcto/openapi/openapivisitor.js b/test/codegen/fromcto/openapi/openapivisitor.js index d75d56d0..3057831c 100644 --- a/test/codegen/fromcto/openapi/openapivisitor.js +++ b/test/codegen/fromcto/openapi/openapivisitor.js @@ -57,6 +57,32 @@ concept Person identified by email { } `; +const MODEL_DECORATED = ` +namespace test@1.0.0 + +@Term("Colour") +enum Color { + o RED + o GREEN +} + +concept Address { + o String city +} + +@resource +@Term("Person") +concept Person identified by email { + o String email + @Term("Age in years") + o Integer age + @Term("Home") + o Address home + @Term("Friends") + --> Person[] friends +} +`; + describe('OpenApi (samples)', function () { describe('samples', () => { @@ -84,6 +110,20 @@ describe('OpenApi (samples)', function () { const spec = modelManager.accept(visitor); spec.paths.should.have.property('/peeps'); }); + it('should write decorators under an x- extension key', () => { + const modelManager = new ModelManager(); + modelManager.addCTOModel( MODEL_DECORATED ); + const visitor = new OpenApiVisitor(); + const spec = modelManager.accept(visitor); + const schemas = spec.components.schemas; + const person = schemas['test@1.0.0.Person']; + expect(schemas['test@1.0.0.Color']['x-concerto-decorators']).deep.equal({ Term: ['Colour'] }); + expect(person['x-concerto-decorators']).deep.equal({ resource: [], Term: ['Person'] }); + expect(person.properties.age['x-concerto-decorators']).deep.equal({ Term: ['Age in years'] }); + expect(person.properties.home['x-concerto-decorators']).deep.equal({ Term: ['Home'] }); + expect(person.properties.friends['x-concerto-decorators']).deep.equal({ Term: ['Friends'] }); + JSON.stringify(spec).should.not.contain('$decorators'); + }); it('should throw an error when an unrecognised type is supplied', () => { let thing = 'Something of unrecognised type'; const visitor = new OpenApiVisitor(); diff --git a/test/verification/cases.js b/test/verification/cases.js index 7e31ee96..f9f6b409 100644 --- a/test/verification/cases.js +++ b/test/verification/cases.js @@ -61,7 +61,7 @@ const CASES = [ graphql: 'map types emit invalid GraphQL SDL (key/value field syntax)', rust: 'map declarations reference scalar key/value types (e.g. SSN, Time) that the Rust visitor does not emit, so cargo check fails', avro: 'imported types are referenced by short name (e.g. Category from org.acme.hr.base), so avro-tools cannot resolve them', - openapi: 'JSON Schema $decorators and invalid schema nesting fail OpenAPI struct validation', + openapi: 'Map schema nesting (schema property) fails OpenAPI struct validation', golang: 'GoLangVisitor emits bare package-name imports (e.g. import "concerto_decorator_1_0_0") instead of Go module-relative import paths, so any cross-namespace model fails go build', }, }, @@ -86,7 +86,6 @@ const CASES = [ name: 'model-base', files: ['model-base.cto'], skip: { - openapi: 'JSON Schema $decorators fail OpenAPI struct validation', golang: 'GoLangVisitor emits bare package-name imports (e.g. import "concerto_decorator_1_0_0") instead of Go module-relative import paths, so any cross-namespace model fails go build', }, }, @@ -94,7 +93,6 @@ const CASES = [ name: 'agreement', files: ['agreement.cto'], skip: { - openapi: 'JSON Schema $decorators fail OpenAPI struct validation', golang: 'GoLangVisitor emits bare package-name imports (e.g. import "concerto_decorator_1_0_0") instead of Go module-relative import paths, so any cross-namespace model fails go build', }, },