diff --git a/packages/concerto-cli/index.js b/packages/concerto-cli/index.js index 04a053d6f9..761cf79159 100755 --- a/packages/concerto-cli/index.js +++ b/packages/concerto-cli/index.js @@ -17,6 +17,7 @@ const Logger = require('@accordproject/concerto-util').Logger; const { glob } = require('glob'); +const fs = require('fs'); const Commands = require('./lib/commands'); require('yargs') @@ -307,22 +308,28 @@ require('yargs') }); }) .command('infer', 'generate a concerto model from a source schema', (yargs) => { - yargs.demandOption(['input', 'format', 'namespace', 'output']); + yargs.demandOption(['input', 'namespace']); yargs.option('input', { describe: 'path to the input file', type: 'string', }); yargs.option('output', { describe: 'path to the output file', - type: 'string' + type: 'string', }); yargs.option('format', { - describe: 'either `openapi` or `jsonSchema', + describe: 'either `openapi` or `jsonSchema`', + default: 'jsonSchema', type: 'string' }); yargs.option('namespace', { - describe: 'The namepspace for the output model', + describe: 'The namespace for the output model', + type: 'string', + }); + yargs.option('typeName', { + describe: 'The name of the root type', type: 'string', + default: 'Root' }); }, (argv) => { if (argv.verbose) { @@ -330,7 +337,12 @@ require('yargs') } try { - return Commands.inferConcertoSchema(argv.input, argv.output, argv.format, argv.namespace); + const cto = Commands.inferConcertoSchema(argv.input, argv.namespace, argv.typeName, argv.format, argv.output); + if (argv.output){ + fs.writeFileSync(argv.output, cto); + } else { + console.log(cto); + } } catch (err){ Logger.error(err); return; diff --git a/packages/concerto-cli/lib/commands.js b/packages/concerto-cli/lib/commands.js index 163a99c359..cfd5eb51b0 100644 --- a/packages/concerto-cli/lib/commands.js +++ b/packages/concerto-cli/lib/commands.js @@ -19,6 +19,7 @@ const fs = require('fs'); const path = require('path'); const semver = require('semver'); const toJsonSchema = require('@openapi-contrib/openapi-schema-to-json-schema'); +const migrate = require('json-schema-migrate'); const Logger = require('@accordproject/concerto-util').Logger; const FileWriter = require('@accordproject/concerto-util').FileWriter; @@ -563,21 +564,22 @@ class Commands { /** * Generate a Concerto model from another schema format * @param {string} input The source file. - * @param {string} output The target file. - * @param {string} format The source format * @param {string} namespace The namepspace for the output model + * @param {string} [typeName] The name of the root concept + * @param {string} [format] The source format + * @param {string} [output] The target file. + * + * @returns {string} a CTO string */ - static inferConcertoSchema(input, output, format, namespace) { + static inferConcertoSchema(input, namespace, typeName = 'Root', format = 'jsonSchema', output) { let schema = JSON.parse(fs.readFileSync(input, 'utf8')); if (format.toLowerCase() === 'openapi'){ - schema = toJsonSchema(schema); - fs.writeFileSync(`${output}.jsonschema.json`, JSON.stringify(schema, null, 2)); + const jsonSchema = toJsonSchema(schema); + migrate.draft2020(jsonSchema); + return CodeGen.InferFromJsonSchema(namespace, typeName, jsonSchema); } - const outputPath = output || `${input}.cto`; - const cto = CodeGen.InferFromJsonSchema(namespace, 'Model', schema); - Logger.info('Creating file: ' + outputPath); - fs.writeFileSync(outputPath, cto); + return CodeGen.InferFromJsonSchema(namespace, typeName, schema); } } diff --git a/packages/concerto-tools/lib/codegen/fromJsonSchema/cto/inferModel.js b/packages/concerto-tools/lib/codegen/fromJsonSchema/cto/inferModel.js index 217cedef58..59afe82f71 100644 --- a/packages/concerto-tools/lib/codegen/fromJsonSchema/cto/inferModel.js +++ b/packages/concerto-tools/lib/codegen/fromJsonSchema/cto/inferModel.js @@ -73,20 +73,25 @@ function parseIdUri(id) { /** * Infer a type name for a definition. Examines $id, title and parent declaration - * @param {*} definition the input object - * @param {*} context the processing context + * @param {object} definition - the input object + * @param {*} context - the processing context + * @param {boolean} skipDictionary - if true, this function will not use the dictionary help inference * @returns {string} A name for the definition * @private */ -function inferTypeName(definition, context) { +function inferTypeName(definition, context, skipDictionary) { + if (definition.$ref) { + return normalizeType(definition.$ref); + } const name = context.parents.peek(); const { type } = parseIdUri(definition.$id) || { type: definition.title || name }; - // TODO Resolve $ref definitions - // if (definition.$ref) {} - - return normalizeType(type); + if (skipDictionary || context.dictionary.has(normalizeType(type))){ + return normalizeType(type); + } + // We fallback to a stringified object representation. This is "untyped". + return 'String'; } /** @@ -107,12 +112,12 @@ function inferType(definition, context) { return parent; } - return inferTypeName(definition, context); + return inferTypeName(definition, context, false); } // TODO Also add local sub-schema definition if (definition.enum) { - return inferTypeName(definition, context); + return inferTypeName(definition, context, false); } if (definition.type) { @@ -121,10 +126,9 @@ function inferType(definition, context) { if (definition.format) { if (definition.format === 'date-time' || definition.format === 'date') { return 'DateTime'; - } else if (definition.format === 'decimal') { - return 'Double'; } else { - throw new Error(`Format '${definition.format}' in '${name}' is not supported`); + console.warn(`Format '${definition.format}' in '${name}' is not supported. It has been ignored.`); + return 'String'; } } return 'String'; @@ -137,7 +141,7 @@ function inferType(definition, context) { case 'array': return inferType(definition.items, context) + '[]'; case 'object': - return inferTypeName(definition, context); + return inferTypeName(definition, context, false); default: throw new Error(`Type keyword '${definition.type}' in '${name}' is not supported`); } @@ -145,9 +149,15 @@ function inferType(definition, context) { // Hack until we support union types. // https://github.com/accordproject/concerto/issues/292 - if (definition.anyOf){ + const alternative = definition.anyOf || definition.oneOf; + if (alternative){ + const keyword = definition.anyOf ? 'anyOf' : 'oneOf'; + console.warn( + `Keyword '${keyword}' in definition '${name}' is not fully supported. Defaulting to first alternative.` + ); + // Just choose the first item - return inferType(definition.anyOf[0], context); + return inferType(alternative[0], context); } throw new Error(`Unsupported definition: ${JSON.stringify(definition)}`); @@ -162,7 +172,7 @@ function inferType(definition, context) { function inferEnum(definition, context) { const { writer } = context; - writer.writeLine(0, `enum ${inferTypeName(definition, context)} {`); + writer.writeLine(0, `enum ${inferTypeName(definition, context, false)} {`); definition.enum.forEach((value) => { writer.writeLine( 1, @@ -251,9 +261,13 @@ function inferDeclaration(definition, context) { } else if (definition.type) { if (definition.type === 'object') { inferConcept(definition, context); + } else if (definition.type === 'array') { + console.warn( + `Type keyword 'array' in definition '${name}' is not supported. It has been ignored.` + ); } else { throw new Error( - `Type keyword '${definition.type}' in definition '${name}' not supported.` + `Type keyword '${definition.type}' in definition '${name}' is not supported.` ); } } else { @@ -263,7 +277,7 @@ function inferDeclaration(definition, context) { !key.startsWith('x-') // Ignore custom extensions ); console.warn( - `Keyword(s) '${badKeys.join('\', \'')}' in definition '${name}' not supported.` + `Keyword(s) '${badKeys.join('\', \'')}' in definition '${name}' are not supported.` ); } } @@ -298,18 +312,32 @@ function inferModelFile(defaultNamespace, defaultType, schema) { const context = { parents: new TypedStack(), writer: new Writer(), + dictionary: new Set(), }; context.writer.writeLine(0, `namespace ${namespace}`); context.writer.writeLine(0, ''); - // Add imports - // TODO we need some heuristic or metadata to identify Concerto dependencies rather than making assumptions - context.writer.writeLine(0, 'import org.accordproject.time.* from https://models.accordproject.org/time@0.2.0.cto'); - context.writer.writeLine(0, ''); - // Create definitions const defs = schema.definitions || schema.$defs || schema?.components?.schemas ||[]; + + // Build a dictionary + context.dictionary.add(defaultType); + if (schema.$id) { + context.dictionary.add(normalizeType(parseIdUri(schema.$id).type)); + } + Object.keys(defs).forEach((key) => { + context.parents.push(key); + const definition = defs[key]; + const typeName = inferTypeName(definition, context, true); + if (context.dictionary.has(typeName)){ + throw new Error(`Duplicate definition found for type '${typeName}'.`); + } + context.dictionary.add(typeName); + context.parents.pop(); + }); + + // Visit each declaration Object.keys(defs).forEach((key) => { context.parents.push(key); const definition = defs[key]; diff --git a/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/full.cto b/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/full.cto index 206a3ef13f..009f7213d6 100644 --- a/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/full.cto +++ b/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/full.cto @@ -1,7 +1,5 @@ namespace org.acme -import org.accordproject.time.* from https://models.accordproject.org/time@0.2.0.cto - concept Children { o String name o Integer age @@ -16,6 +14,8 @@ concept Children { o String[] emptyArray o Pet[] favoritePets o Stuff[] stuff + o String json optional + o Double alternation optional } enum Color { diff --git a/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/schema.json b/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/schema.json index c46fe93b83..d4df09dfdd 100644 --- a/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/schema.json +++ b/packages/concerto-tools/test/codegen/fromJsonSchema/cto/data/schema.json @@ -142,6 +142,16 @@ }, "required": ["$class", "sku", "price", "product"] } + }, + "json": { + "type": "object" + }, + "alternation": { + "oneOf": [ + { "type": "number" }, + { "type": "string" } + ] + } }, "required": [ diff --git a/packages/concerto-tools/test/codegen/fromJsonSchema/cto/inferModel.js b/packages/concerto-tools/test/codegen/fromJsonSchema/cto/inferModel.js index 986808ea7c..ca8b00a2e8 100644 --- a/packages/concerto-tools/test/codegen/fromJsonSchema/cto/inferModel.js +++ b/packages/concerto-tools/test/codegen/fromJsonSchema/cto/inferModel.js @@ -63,8 +63,6 @@ describe('inferModel', function () { }); cto.should.equal(`namespace org.acme -import org.accordproject.time.* from https://models.accordproject.org/time@0.2.0.cto - enum Root { o one o two @@ -86,13 +84,11 @@ enum Root { } } }); - // TODO This is not a valid CTO model, because we don't generate definitions for inline sub-schemas. + // TODO Generate definitions for inline sub-schemas. cto.should.equal(`namespace org.acme -import org.accordproject.time.* from https://models.accordproject.org/time@0.2.0.cto - concept Root { - o Xs[] xs optional + o String[] xs optional } `); @@ -112,8 +108,6 @@ concept Root { ); cto.should.equal(`namespace org.acme -import org.accordproject.time.* from https://models.accordproject.org/time@0.2.0.cto - concept Root { o String name optional o Root[] children optional @@ -127,8 +121,6 @@ concept Root { const cto = inferModel('org.acme', 'Root', schema); cto.should.equal(`namespace com.example -import org.accordproject.time.* from https://models.accordproject.org/time@0.2.0.cto - concept Veggie { o String veggieName o Boolean veggieLike @@ -147,8 +139,6 @@ concept Arrays { const cto = inferModel('org.acme', 'Root', schema); cto.should.equal(`namespace com.example -import org.accordproject.time.* from https://models.accordproject.org/time@0.2.0.cto - concept Geographical_location { o String name default="home" regex=/[\\w\\s]+/ optional o Double latitude @@ -190,23 +180,28 @@ concept Geographical_location { }).should.throw('\'additionalProperties\' are not supported in Concerto'); }); - it('should not generate when unsupported formats are used', async () => { - (function () { - inferModel('org.acme', 'Root', { - $schema: 'http://json-schema.org/draft-07/schema#', - definitions: { - Foo: { - type: 'object', - properties: { - email: { - type: 'string', - format: 'email' - } + it('should quietly accept unsupported formats', async () => { + const cto = inferModel('org.acme', 'Root', { + $schema: 'http://json-schema.org/draft-07/schema#', + definitions: { + Foo: { + type: 'object', + properties: { + email: { + type: 'string', + format: 'email' } } } - }); - }).should.throw('Format \'email\' in \'email\' is not supported'); + } + }); + cto.should.equal(`namespace org.acme + +concept Foo { + o String email optional +} + +`); }); it('should not generate when unsupported type keywords are used', async () => { @@ -219,7 +214,7 @@ concept Geographical_location { } } }); - }).should.throw('Type keyword \'null\' in definition \'Foo\' not supported.'); + }).should.throw('Type keyword \'null\' in definition \'Foo\' is not supported.'); }); it('should not generate when unsupported type keywords are used in an object', async () => { @@ -240,13 +235,36 @@ concept Geographical_location { }).should.throw('Type keyword \'null\' in \'email\' is not supported'); }); - it('should not generate when unsupported definitions', async () => { + it('should not generate when duplicate definitions are found', async () => { (function () { - inferModel('org.acme', 'Root', { - 'allOf': [ - { 'type': 'string' } - ] + inferModel('org.acme', 'Foo', { + $schema: 'http://json-schema.org/draft-07/schema#', + definitions: { + 'Foo': { + 'type': 'object', + }, + } }); - }).should.throw('Keyword(s) \'allOf\' in definition \'Root\' not supported.'); + }).should.throw('Duplicate definition found for type \'Foo\''); + }); + + it('should quietly accept array definitions', async () => { + const cto = inferModel('org.acme', 'Root', { + type: 'array' + }); + cto.should.equal(`namespace org.acme + +`); + }); + + it('should quietly accept unsupported definitions', async () => { + const cto = inferModel('org.acme', 'Root', { + 'allOf': [ + { 'type': 'string' } + ] + }); + cto.should.equal(`namespace org.acme + +`); }); -}); \ No newline at end of file +}); diff --git a/packages/concerto-tools/types/index.d.ts b/packages/concerto-tools/types/index.d.ts index 8b105baa20..d5ac31fccb 100644 --- a/packages/concerto-tools/types/index.d.ts +++ b/packages/concerto-tools/types/index.d.ts @@ -24,5 +24,6 @@ export var CodeGen: { mermaid: typeof import("./lib/codegen/fromcto/mermaid/mermaidvisitor"); markdown: typeof import("./lib/codegen/fromcto/markdown/markdownvisitor"); }; + InferFromJsonSchema: typeof import("./lib/codegen/fromJsonSchema/cto/inferModel"); }; export var version: any; diff --git a/packages/concerto-tools/types/lib/codegen/codegen.d.ts b/packages/concerto-tools/types/lib/codegen/codegen.d.ts index 0e3f9f2a4b..9e0108bc7e 100644 --- a/packages/concerto-tools/types/lib/codegen/codegen.d.ts +++ b/packages/concerto-tools/types/lib/codegen/codegen.d.ts @@ -10,6 +10,7 @@ import CSharpVisitor = require("./fromcto/csharp/csharpvisitor"); import ODataVisitor = require("./fromcto/odata/odatavisitor"); import MermaidVisitor = require("./fromcto/mermaid/mermaidvisitor"); import MarkdownVisitor = require("./fromcto/markdown/markdownvisitor"); +import InferFromJsonSchema = require("./fromJsonSchema/cto/inferModel"); export declare namespace formats { export { GoLangVisitor as golang }; export { JSONSchemaVisitor as jsonschema }; @@ -23,4 +24,4 @@ export declare namespace formats { export { MermaidVisitor as mermaid }; export { MarkdownVisitor as markdown }; } -export { AbstractPlugin, GoLangVisitor, JSONSchemaVisitor, XmlSchemaVisitor, PlantUMLVisitor, TypescriptVisitor, JavaVisitor, GraphQLVisitor, CSharpVisitor, ODataVisitor, MermaidVisitor, MarkdownVisitor }; +export { AbstractPlugin, GoLangVisitor, JSONSchemaVisitor, XmlSchemaVisitor, PlantUMLVisitor, TypescriptVisitor, JavaVisitor, GraphQLVisitor, CSharpVisitor, ODataVisitor, MermaidVisitor, MarkdownVisitor, InferFromJsonSchema }; diff --git a/packages/concerto-tools/types/lib/codegen/fromJsonSchema/cto/inferModel.d.ts b/packages/concerto-tools/types/lib/codegen/fromJsonSchema/cto/inferModel.d.ts new file mode 100644 index 0000000000..c6907cb2b1 --- /dev/null +++ b/packages/concerto-tools/types/lib/codegen/fromJsonSchema/cto/inferModel.d.ts @@ -0,0 +1,9 @@ +export = inferModelFile; +/** + * Infers a Concerto model from a JSON Schema. + * @param {string} defaultNamespace a fallback namespace to use for the model if it can't be infered + * @param {string} defaultType a fallback name for the root concept if it can't be infered + * @param {object} schema the input json object + * @returns {string} the Concerto model + */ +declare function inferModelFile(defaultNamespace: string, defaultType: string, schema: object): string;