From c67018313fd1c86a3237a455cb372d1c468e1ea1 Mon Sep 17 00:00:00 2001 From: Ludovic Dussart Date: Fri, 1 Oct 2021 13:52:23 +0200 Subject: [PATCH] feat: support required and validator attributes in avro files - supports of required fields: required fields are field without default value or without a union containing the 'null' element - brings supports for additional attributes in avro file. See the README.MD for the list of supported attributes - also add the format precision for the double and float type by setting the format attribute in the json definition Closes #17,#18 --- .gitignore | 3 +- README.md | 24 ++ ...capi-avro-1.9.0-additional-attributes.yaml | 11 + tests/parse.test.js | 18 +- .../Person-1.9.0-additional-attributes.avsc | 30 +++ tests/to-json-schema.test.js | 245 ++++++++++++++---- to-json-schema.js | 66 ++++- 7 files changed, 331 insertions(+), 66 deletions(-) create mode 100644 tests/asyncapi-avro-1.9.0-additional-attributes.yaml create mode 100644 tests/schemas/Person-1.9.0-additional-attributes.avsc diff --git a/.gitignore b/.gitignore index b512c09d..178ad9ca 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ -node_modules \ No newline at end of file +node_modules +.idea/* diff --git a/README.md b/README.md index e15fec1e..af938c98 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,10 @@ await parser.parse(asyncapiWithAvro) ## Features +### Support of required attributes + +We assume that required fields are fields with no default value and without the `"null"` union element. + ### Support for extra attributes on top of Avro specification Additional attributes not defined in the [Avro Specification](https://avro.apache.org/docs/current/spec.html) are permitted and are treated as a metadata by the specification. To improve human readability of generated AsyncAPI documentation and to leverage more features from the JSON schema we included support for the extra attributes that can be added into Avro document. @@ -138,6 +142,24 @@ Additional attributes not defined in the [Avro Specification](https://avro.apach - `example` - Can be used to define the example value from the business domain of given field. Value will be propagated into [examples attribute](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.9.5) of JSON schema and therefore will be picked for the generated "Example of payload" when using some AsyncAPI documentation generator. +For Number instances: + +- `multipleOf` - Can be used to define [the multipleOf value of a numeric instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.1). The `multipleOf` MUST be a number, strictly greater than 0. +- `maximum` - Can be used to define [the maximum value of a numeric instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.2). +- `exclusiveMaximum` - Can be used to define [the exclusiveMaximum value of a numeric instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.3). +- `minimum` - Can be used to define [the minimum value of a numeric instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.4). +- `exclusiveMinimum` - Can be used to define [the exclusiveMinimum value of a numeric instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.2.5). + +For String instances: +- `maxLength` - Can be used to define [the maxLength value of a string instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.3.1). The value of this keyword MUST be a non-negative integer. +- `minLength` - Can be used to define [the minLength value of a string instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.3.2). The value of this keyword MUST be a non-negative integer. +- `pattern` - Can be used to define [the pattern value of a string instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.3.3). + +For Array instances: +- `maxItems` - Can be used to define [the maxItems value of a string instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.4.1). The value of this keyword MUST be a non-negative integer. +- `minItems` - Can be used to define [the minItems value of a string instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.4.2). The value of this keyword MUST be a non-negative integer. +- `uniqueItems` - Can be used to define [the uniqueItems value of a string instance](https://json-schema.org/draft/2020-12/json-schema-validation.html#rfc.section.6.4.3). The value of this keyword MUST be a boolean. + ### Support for names and namespaces If, at the top level of the Avro schema, the 'name' attribute is defined, it will be copied to the corresponding JSON schema's 'x-parser-schema-id' attribute. If the Avro schema also has the 'namespace' attribute defined, then that schema's fully qualified name will be put into that attribute. The fully qualified name is defined by the namespace, followed by a dot, followed by the name. @@ -152,6 +174,8 @@ If no name attribute is present, the 'x-parser-schema-id' will have a generated JSON numbers ([RFC 4627, section 2.4](http://tools.ietf.org/html/rfc4627)) don't define any limit to the scale and/or precision of numbers. That said, we can enforce limits on `int` and `long` but we can't enforce them on `float` and `double` because they can't accurately be represented on JSON Schema. +> Since we support extra attributes on field, you can set `minimum` and `maximum` attribute on float and double types to display number limits. + ### Hardcoded key and secret This is not a limitation of this package per se but of the [JSON Reference RFC](https://tools.ietf.org/html/draft-pbryan-zyp-json-ref-03). So far, you can only hardcode the values for `key` and `secret` on the `$ref` URL. diff --git a/tests/asyncapi-avro-1.9.0-additional-attributes.yaml b/tests/asyncapi-avro-1.9.0-additional-attributes.yaml new file mode 100644 index 00000000..3aff57ce --- /dev/null +++ b/tests/asyncapi-avro-1.9.0-additional-attributes.yaml @@ -0,0 +1,11 @@ +asyncapi: 2.1.0 +info: + title: My API + version: '1.0.0' +channels: + mychannel: + publish: + message: + schemaFormat: application/vnd.apache.avro;version=1.9.0 + payload: + $ref: 'schemas/Person-1.9.0-additional-attributes.avsc' diff --git a/tests/parse.test.js b/tests/parse.test.js index 048ef5e4..d23a4a31 100644 --- a/tests/parse.test.js +++ b/tests/parse.test.js @@ -4,16 +4,20 @@ const avroSchemaParser = require('..'); const parser = require('@asyncapi/parser'); const inputWithAvro182 = fs.readFileSync(path.resolve(__dirname, './asyncapi-avro-1.8.2.yaml'), 'utf8'); -const outputWithAvro182 = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"x-parser-schema-id":""},"address":{"type":"object","properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":""},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.8.2","x-parser-original-payload":{"type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"]}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; +const outputWithAvro182 = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","required":["name","favoriteProgrammingLanguage","address"],"properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"x-parser-schema-id":""},"address":{"type":"object","required":["zipcode"],"properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""}},"x-parser-schema-id":""},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.8.2","x-parser-original-payload":{"type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"]}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; const inputWithAvro190 = fs.readFileSync(path.resolve(__dirname, './asyncapi-avro-1.9.0.yaml'), 'utf8'); -const outputWithAvro190 = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123],"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS","x-parser-schema-id":""},"address":{"type":"object","properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""},"someid":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"Person"},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.9.0","x-parser-original-payload":{"name":"Person","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; +const outputWithAvro190 = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","required":["name","favoriteProgrammingLanguage","address","someid"],"properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123],"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS","x-parser-schema-id":""},"address":{"type":"object","required":["zipcode"],"properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""},"someid":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"Person"},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.9.0","x-parser-original-payload":{"name":"Person","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; const inputWithAvro190WithNamespace = fs.readFileSync(path.resolve(__dirname, './asyncapi-avro-1.9.0-namespace.yaml'), 'utf8'); -const outputWithAvro190ithNamespace = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123],"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS","x-parser-schema-id":""},"address":{"type":"object","properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""},"someid":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"com.company.Person"},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.9.0","x-parser-original-payload":{"name":"Person","namespace":"com.company","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; +const outputWithAvro190WithNamespace = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","required":["name","favoriteProgrammingLanguage","address","someid"],"properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123],"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS","x-parser-schema-id":""},"address":{"type":"object","required":["zipcode"],"properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""},"someid":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"com.company.Person"},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.9.0","x-parser-original-payload":{"name":"Person","namespace":"com.company","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; const inputWithAvro190WithBindings = fs.readFileSync(path.resolve(__dirname, './asyncapi-avro-1.9.0-bindings.yaml'), 'utf8'); -const outputWithAvro190WithBindings = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123],"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS","x-parser-schema-id":""},"address":{"type":"object","properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""},"someid":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"com.company.Person"},"bindings":{"kafka":{"key":{"type":"object","properties":{"name":{"type":"string","examples":["Donkey"]},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123]},{"type":"null"}],"default":null},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS"},"address":{"type":"object","properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003]}}},"someid":{"type":"string"}},"x-parser-schema-id":"com.company.Person"}},"mqtt":{"x-test":{"type":"string"}}},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.9.0","x-parser-original-payload":{"name":"Person","namespace":"com.company","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"x-parser-original-bindings-kafka-key":{"name":"Person","namespace":"com.company","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; +const outputWithAvro190WithBindings = '{"asyncapi":"2.0.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","required":["name","favoriteProgrammingLanguage","address","someid"],"properties":{"name":{"type":"string","examples":["Donkey"],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123],"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS","x-parser-schema-id":""},"address":{"type":"object","required":["zipcode"],"properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""}},"x-parser-schema-id":""},"someid":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"com.company.Person"},"bindings":{"kafka":{"key":{"type":"object","required":["name","favoriteProgrammingLanguage","address","someid"],"properties":{"name":{"type":"string","examples":["Donkey"]},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123]},{"type":"null"}],"default":null},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS"},"address":{"type":"object","required":["zipcode"],"properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003]}}},"someid":{"type":"string"}},"x-parser-schema-id":"com.company.Person"}},"mqtt":{"x-test":{"type":"string"}}},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.9.0","x-parser-original-payload":{"name":"Person","namespace":"com.company","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"x-parser-original-bindings-kafka-key":{"name":"Person","namespace":"com.company","type":"record","fields":[{"name":"name","type":"string","example":"Donkey"},{"name":"age","type":["null","int"],"default":null,"example":"123"},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":"53003"}]}},{"name":"someid","type":"uuid"}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.0.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; + +const inputWithAvroAdditionalAttributes = fs.readFileSync(path.resolve(__dirname, './asyncapi-avro-1.9.0-additional-attributes.yaml'), 'utf8'); +const outputWithAvroAdditionalAttributes = '{"asyncapi":"2.1.0","info":{"title":"My API","version":"1.0.0"},"channels":{"mychannel":{"publish":{"message":{"payload":{"type":"object","required":["name","serialNo","favoriteProgrammingLanguage","certifications","address","weight","height","someid"],"properties":{"name":{"type":"string","examples":["Donkey"],"minLength":0,"x-parser-schema-id":""},"serialNo":{"type":"string","minLength":0,"maxLength":50,"x-parser-schema-id":""},"email":{"oneOf":[{"type":"string","examples":["donkey@asyncapi.com"],"pattern":"^[\\\\w-\\\\.]+@([\\\\w-]+\\\\.)+[\\\\w-]{2,4}$","x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"x-parser-schema-id":""},"age":{"oneOf":[{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[123],"exclusiveMinimum":0,"exclusiveMaximum":200,"x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"default":null,"x-parser-schema-id":""},"favoriteProgrammingLanguage":{"type":"string","enum":["JS","Java","Go","Rust","C"],"default":"JS","x-parser-schema-id":""},"certifications":{"type":"array","items":{"type":"string","x-parser-schema-id":""},"minItems":1,"maxItems":500,"uniqueItems":true,"x-parser-schema-id":""},"address":{"type":"object","required":["zipcode"],"properties":{"zipcode":{"type":"integer","minimum":-2147483648,"maximum":2147483647,"examples":[53003],"x-parser-schema-id":""},"country":{"oneOf":[{"type":"string","x-parser-schema-id":""},{"type":"null","x-parser-schema-id":""}],"x-parser-schema-id":""}},"x-parser-schema-id":""},"weight":{"type":"number","format":"float","examples":["65.0"],"minimum":0,"maximum":500,"x-parser-schema-id":""},"height":{"type":"number","format":"double","examples":["1.85"],"minimum":0,"maximum":3,"x-parser-schema-id":""},"someid":{"type":"string","x-parser-schema-id":""}},"x-parser-schema-id":"com.company.Person"},"x-parser-original-schema-format":"application/vnd.apache.avro;version=1.9.0","x-parser-original-payload":{"name":"Person","namespace":"com.company","type":"record","fields":[{"name":"name","type":"string","example":"Donkey","minLength":0},{"name":"serialNo","type":"string","minLength":0,"maxLength":50},{"name":"email","type":["null","string"],"example":"donkey@asyncapi.com","pattern":"^[\\\\w-\\\\.]+@([\\\\w-]+\\\\.)+[\\\\w-]{2,4}$"},{"name":"age","type":["null","int"],"default":null,"example":"123","exclusiveMinimum":0,"exclusiveMaximum":200},{"name":"favoriteProgrammingLanguage","type":{"name":"ProgrammingLanguage","type":"enum","symbols":["JS","Java","Go","Rust","C"],"default":"JS"}},{"name":"certifications","type":{"type":"array","items":"string","minItems":1,"maxItems":500,"uniqueItems":true}},{"name":"address","type":{"name":"Address","type":"record","fields":[{"name":"zipcode","type":"int","example":53003},{"name":"country","type":["null","string"]}]}},{"name":"weight","type":"float","example":"65.0","minimum":0,"maximum":500},{"name":"height","type":"double","example":"1.85","minimum":0,"maximum":3},{"name":"someid","type":"uuid"}]},"schemaFormat":"application/vnd.aai.asyncapi;version=2.1.0","x-parser-message-parsed":true,"x-parser-message-name":""}}}},"x-parser-spec-parsed":true}'; + parser.registerSchemaParser(avroSchemaParser); @@ -28,10 +32,14 @@ describe('parse()', function() { }); it('should parse Avro schema 1.9.0 with a namespace', async function() { const result = await parser.parse(inputWithAvro190WithNamespace, { path: __filename }); - expect(JSON.stringify(result.json())).toEqual(outputWithAvro190ithNamespace); + expect(JSON.stringify(result.json())).toEqual(outputWithAvro190WithNamespace); }); it('should parse Avro schema in kafka bindings', async function() { const result = await parser.parse(inputWithAvro190WithBindings, { path: __filename }); expect(JSON.stringify(result.json())).toEqual(outputWithAvro190WithBindings); }); + it('should handle additional Avro attributes like', async function() { + const result = await parser.parse(inputWithAvroAdditionalAttributes, { path: __filename }); + expect(JSON.stringify(result.json())).toEqual(outputWithAvroAdditionalAttributes); + }); }); diff --git a/tests/schemas/Person-1.9.0-additional-attributes.avsc b/tests/schemas/Person-1.9.0-additional-attributes.avsc new file mode 100644 index 00000000..f81c88d7 --- /dev/null +++ b/tests/schemas/Person-1.9.0-additional-attributes.avsc @@ -0,0 +1,30 @@ +{ + "name": "Person", + "namespace": "com.company", + "type": "record", + "fields": [ + {"name": "name", "type": "string", "example": "Donkey", "minLength": 0}, + {"name": "serialNo", "type": "string", "minLength": 0, "maxLength": 50}, + {"name": "email", "type": ["null","string"], "example": "donkey@asyncapi.com", "pattern": "^[\\w-\\.]+@([\\w-]+\\.)+[\\w-]{2,4}$"}, + {"name": "age", "type": ["null", "int"], "default": null, "example": "123", "exclusiveMinimum": 0, exclusiveMaximum: 200}, + { + "name": "favoriteProgrammingLanguage", + "type": {"name": "ProgrammingLanguage", "type": "enum", "symbols": ["JS", "Java", "Go", "Rust", "C"], "default": "JS"} + }, + {"name": "certifications", "type": {"type": "array", "items": "string", "minItems": 1, "maxItems": 500, "uniqueItems": true}}, + { + "name": "address", + "type": { + "name": "Address", + "type": "record", + "fields": [ + {"name": "zipcode", "type": "int", "example": 53003}, + {"name": "country", "type": ["null", "string"]} + ] + } + }, + {"name": "weight", "type": "float", "example": "65.0", "minimum": 0, "maximum": 500}, + {"name": "height", "type": "double", "example": "1.85", "minimum": 0, "maximum": 3.0}, + {"name": "someid", "type": "uuid"} + ] +} diff --git a/tests/to-json-schema.test.js b/tests/to-json-schema.test.js index 0abbbfee..1df73285 100644 --- a/tests/to-json-schema.test.js +++ b/tests/to-json-schema.test.js @@ -1,4 +1,4 @@ -const { avroToJsonSchema } = require('../to-json-schema'); +const {avroToJsonSchema} = require('../to-json-schema'); const BYTES_PATTERN = '^[\u0000-\u00ff]*$'; const INT_MIN = Math.pow(-2, 31); @@ -8,89 +8,103 @@ const LONG_MAX = Math.pow(2, 63) - 1; describe('avroToJsonSchema()', function () { it('transforms null values', async function () { - const result = await avroToJsonSchema({ type: 'null' }); - expect(result).toEqual({ type: 'null' }); + const result = await avroToJsonSchema({type: 'null'}); + expect(result).toEqual({type: 'null'}); }); - + it('transforms boolean values', async function () { - const result = await avroToJsonSchema({ type: 'boolean' }); - expect(result).toEqual({ type: 'boolean' }); + const result = await avroToJsonSchema({type: 'boolean'}); + expect(result).toEqual({type: 'boolean'}); }); - + it('transforms int values', async function () { - const result = await avroToJsonSchema({ type: 'int' }); - expect(result).toEqual({ type: 'integer', minimum: INT_MIN, maximum: INT_MAX }); + const result = await avroToJsonSchema({type: 'int'}); + expect(result).toEqual({type: 'integer', minimum: INT_MIN, maximum: INT_MAX}); }); - + it('transforms long values', async function () { - const result = await avroToJsonSchema({ type: 'long' }); - expect(result).toEqual({ type: 'integer', minimum: LONG_MIN, maximum: LONG_MAX }); + const result = await avroToJsonSchema({type: 'long'}); + expect(result).toEqual({type: 'integer', minimum: LONG_MIN, maximum: LONG_MAX}); }); - + it('transforms float values', async function () { - const result = await avroToJsonSchema({ type: 'float' }); - expect(result).toEqual({ type: 'number' }); + const result = await avroToJsonSchema({type: 'float'}); + expect(result).toEqual({type: 'number', format: 'float'}); }); - + it('transforms double values', async function () { - const result = await avroToJsonSchema({ type: 'double' }); - expect(result).toEqual({ type: 'number' }); + const result = await avroToJsonSchema({type: 'double'}); + expect(result).toEqual({type: 'number', format: 'double'}); }); - + it('transforms bytes values', async function () { - const result = await avroToJsonSchema({ type: 'bytes' }); - expect(result).toEqual({ type: 'string', pattern: BYTES_PATTERN }); + const result = await avroToJsonSchema({type: 'bytes'}); + expect(result).toEqual({type: 'string', pattern: BYTES_PATTERN}); }); - + it('transforms string values', async function () { - const result = await avroToJsonSchema({ type: 'string' }); - expect(result).toEqual({ type: 'string' }); + const result = await avroToJsonSchema({type: 'string'}); + expect(result).toEqual({type: 'string'}); }); it('transforms uuid values', async function () { - const result = await avroToJsonSchema({ type: 'uuid' }); - expect(result).toEqual({ type: 'string' }); + const result = await avroToJsonSchema({type: 'uuid'}); + expect(result).toEqual({type: 'string'}); }); - + it('transforms fixed values', async function () { - const result = await avroToJsonSchema({ type: 'fixed', size: 5 }); - expect(result).toEqual({ type: 'string', pattern: BYTES_PATTERN, minLength: 5, maxLength: 5 }); + const result = await avroToJsonSchema({type: 'fixed', size: 5}); + expect(result).toEqual({type: 'string', pattern: BYTES_PATTERN, minLength: 5, maxLength: 5}); }); - + it('transforms union values', async function () { const result = await avroToJsonSchema(['null', 'int']); - expect(result).toEqual({ oneOf: [{ type: 'integer', minimum: INT_MIN, maximum: INT_MAX }, { type: 'null' }] }); + expect(result).toEqual({oneOf: [{type: 'integer', minimum: INT_MIN, maximum: INT_MAX}, {type: 'null'}]}); }); - + it('transforms map values', async function () { - const result = await avroToJsonSchema({ type: 'map', values: 'long' }); - expect(result).toEqual({ type: 'object', additionalProperties: { type: 'integer', minimum: LONG_MIN, maximum: LONG_MAX } }); + const result = await avroToJsonSchema({type: 'map', values: 'long'}); + expect(result).toEqual({ + type: 'object', + additionalProperties: {type: 'integer', minimum: LONG_MIN, maximum: LONG_MAX} + }); }); - + it('transforms array values', async function () { - const result = await avroToJsonSchema({ type: 'array', items: 'long' }); - expect(result).toEqual({ type: 'array', items: { type: 'integer', minimum: LONG_MIN, maximum: LONG_MAX } }); + const result = await avroToJsonSchema({type: 'array', items: 'long'}); + expect(result).toEqual({type: 'array', items: {type: 'integer', minimum: LONG_MIN, maximum: LONG_MAX}}); }); - + it('transforms enum values', async function () { - const result = await avroToJsonSchema({ type: 'enum', doc: 'My test enum', symbols: ['one', 'two', 'three'], default: 'one' }); - expect(result).toEqual({ type: 'string', enum: ['one', 'two', 'three'], default: 'one', description: 'My test enum' }); + const result = await avroToJsonSchema({ + type: 'enum', + doc: 'My test enum', + symbols: ['one', 'two', 'three'], + default: 'one' + }); + expect(result).toEqual({ + type: 'string', + enum: ['one', 'two', 'three'], + default: 'one', + description: 'My test enum' + }); }); - + it('transforms record values', async function () { const result = await avroToJsonSchema({ type: 'record', doc: 'My test record', name: 'MyName', fields: [ - { name: 'key1', type: 'long', doc: 'Key1 docs' }, - { name: 'key2', type: 'string', default: 'value2', doc: 'Key2 docs' }, + {name: 'key1', type: 'long', doc: 'Key1 docs'}, + {name: 'key2', type: 'string', default: 'value2', doc: 'Key2 docs'}, ] }); expect(result).toEqual({ type: 'object', 'x-parser-schema-id': 'MyName', description: 'My test record', + required: ['key1'], properties: { key1: { type: 'integer', @@ -109,20 +123,143 @@ describe('avroToJsonSchema()', function () { it('assigns default values correctly in types and fields', async function () { expect( - await avroToJsonSchema({ type: 'int', default: 1 }) - ).toEqual({ type: 'integer', minimum: INT_MIN, maximum: INT_MAX, default: 1 }); - + await avroToJsonSchema({type: 'int', default: 1}) + ).toEqual({type: 'integer', minimum: INT_MIN, maximum: INT_MAX, default: 1}); + expect( - await avroToJsonSchema({ type: 'record', fields: [{ name: 'field1', type: 'string', default: 'AsyncAPI rocks!' }] }) - ).toEqual({ type: 'object', properties: { field1: { type: 'string', default: 'AsyncAPI rocks!' } } }); + await avroToJsonSchema({type: 'record', fields: [{name: 'field1', type: 'string', default: 'AsyncAPI rocks!'}]}) + ).toEqual({ + type: 'object', + properties: {field1: {type: 'string', default: 'AsyncAPI rocks!'}} + }); }); - + it('treats array Avro documents as unions', async function () { expect( - await avroToJsonSchema([{ type: 'int', default: 1 }, 'string']) - ).toEqual({ oneOf: [ - { type: 'integer', minimum: INT_MIN, maximum: INT_MAX, default: 1 }, - { type: 'string' }, - ] }); + await avroToJsonSchema([{type: 'int', default: 1}, 'string']) + ).toEqual({ + oneOf: [ + {type: 'integer', minimum: INT_MIN, maximum: INT_MAX, default: 1}, + {type: 'string'}, + ] + }); + }); +}); + +describe('supportExampleAttribute', function () { + it('transforms example on union values', async function () { + const result = await avroToJsonSchema({ + type: 'record', + name: 'MyName', + fields: [ + {name: 'example', type: ['null', 'int'], example: 3} + ] + }); + expect(result).toEqual({ + type: 'object', + 'x-parser-schema-id': 'MyName', + properties: { + example: { + oneOf: [{ + type: 'integer', + minimum: INT_MIN, + maximum: INT_MAX, + examples: [3] + }, { + type: 'null' + } + ] + } + } + }); + }); +}); + +describe('requiredAttributesMapping()', function () { + it('support required fields', async function () { + const result = await avroToJsonSchema({ + type: 'record', + doc: 'My test record', + name: 'MyName', + fields: [ + {name: 'required1', type: ['int', 'long']}, + {name: 'required2', type: ['long']}, + {name: 'notRequiredBecauseDefault', type: 'string', default: 'value2'}, + {name: 'notRequiredBecauseUnionWithNull', type: ['null', 'string']}, + ] + }); + expect(result).toMatchObject({ + type: 'object', + 'x-parser-schema-id': 'MyName', + description: 'My test record', + required: ['required1', 'required2'] + }); + }); +}); + +describe('additionalAttributesMapping()', function () { + it('support minimum and maximum for float', async function () { + const result = await avroToJsonSchema({type: 'float', minimum: 0, maximum: 10}); + expect(result).toEqual({type: 'number', format: 'float', minimum: 0, maximum: 10}); + }); + + it('support exclusiveMinimum and exclusiveMaximum for float', async function () { + const result = await avroToJsonSchema({type: 'float', exclusiveMinimum: 0, exclusiveMaximum: 10}); + expect(result).toEqual({type: 'number', format: 'float', exclusiveMinimum: 0, exclusiveMaximum: 10}); + }); + + it('support minimum and maximum for double', async function () { + const result = await avroToJsonSchema({type: 'double', minimum: 0, maximum: 10}); + expect(result).toEqual({type: 'number', format: 'double', minimum: 0, maximum: 10}); + }); + + it('support exclusiveMinimum and exclusiveMaximum for double', async function () { + const result = await avroToJsonSchema({type: 'double', exclusiveMinimum: 0, exclusiveMaximum: 10}); + expect(result).toMatchObject({type: 'number', format: 'double', exclusiveMinimum: 0, exclusiveMaximum: 10}); + }); + + it('support minimum and maximum for int', async function () { + const result = await avroToJsonSchema({type: 'int', minimum: 0, maximum: 10}); + expect(result).toEqual({type: 'integer', minimum: 0, maximum: 10}); + }); + + it('support exclusiveMinimum and exclusiveMaximum for int', async function () { + const result = await avroToJsonSchema({type: 'int', exclusiveMinimum: 0, exclusiveMaximum: 10}); + expect(result).toMatchObject({type: 'integer', exclusiveMinimum: 0, exclusiveMaximum: 10}); + }); + + it('support minimum and maximum for long', async function () { + const result = await avroToJsonSchema({type: 'long', minimum: 0, maximum: 10}); + expect(result).toEqual({type: 'integer', minimum: 0, maximum: 10}); + }); + + it('support exclusiveMinimum and exclusiveMaximum for long', async function () { + const result = await avroToJsonSchema({type: 'long', exclusiveMinimum: 0, exclusiveMaximum: 10}); + expect(result).toMatchObject({type: 'integer', exclusiveMinimum: 0, exclusiveMaximum: 10}); + }); + + it('support pattern, minLength and maxLength for string', async function () { + const result = await avroToJsonSchema({type: 'string', pattern: '$pattern^', minLength: 1, maxLength: 10}); + expect(result).toEqual({type: 'string', pattern: '$pattern^', minLength: 1, maxLength: 10}); + }); + + it('handle non-negative integer value for minLength and maxLength', async function () { + const result = await avroToJsonSchema({type: 'string', minLength: -1, maxLength: -110}); + expect(result).toEqual({type: 'string'}); + }); + + it('support pattern, minLength and maxLength for fixed', async function () { + const result = await avroToJsonSchema({type: 'fixed', pattern: '$pattern^', minLength: 1, maxLength: 10}); + expect(result).toEqual({type: 'string', pattern: '$pattern^', minLength: 1, maxLength: 10}); + }); + + it('support minItems and maxItems for array', async function () { + const result = await avroToJsonSchema({type: 'array', items: 'long', minItems: 0, maxItems: 10}); + expect(result).toMatchObject({type: 'array', items: {type: 'integer'}, minItems: 0, maxItems: 10}); + }); + + it('support uniqueItems for array', async function () { + const result = await avroToJsonSchema({type: 'array', items: 'long', uniqueItems: true}); + expect(result).toMatchObject({type: 'array', items: {type: 'integer'}, uniqueItems: true}); }); }); diff --git a/to-json-schema.js b/to-json-schema.js index f3e35a88..d2996e97 100644 --- a/to-json-schema.js +++ b/to-json-schema.js @@ -45,17 +45,35 @@ function getFullyQualifiedName(avroDefinition) { return name; } -const exampleAttributeMapping = (typeInput, example, jsonSchemaInput) => { +/** + * Enrich the parent's required attribute with the required record attributes + * @param fieldDefinition the actual field definition + * @param parentJsonSchema the parent json schema which contains the required property to enrich + * @param haveDefaultValue we assure that a required field does not have a default value + */ +const requiredAttributesMapping = (fieldDefinition, parentJsonSchema, haveDefaultValue) => { + const isUnionWithNull = Array.isArray(fieldDefinition.type) && fieldDefinition.type.includes('null'); + + // we assume that a union type without null and a field without default value is required + if (!isUnionWithNull && !haveDefaultValue) { + parentJsonSchema.required = parentJsonSchema.required || []; + parentJsonSchema.required.push(fieldDefinition.name); + } +}; + +function extractNonNullableTypeIfNeeded(typeInput, jsonSchemaInput) { let type = typeInput; let jsonSchema = jsonSchemaInput; - // Map example to first non-null type if (Array.isArray(typeInput) && typeInput.length > 0) { const pickSecondType = typeInput.length > 1 && typeInput[0] === 'null'; type = typeInput[+pickSecondType]; jsonSchema = jsonSchema.oneOf[0]; } + return {type, jsonSchema}; +} +const exampleAttributeMapping = (type, example, jsonSchema) => { if (example === undefined || jsonSchema.examples || Array.isArray(type)) return; switch (type) { @@ -70,6 +88,38 @@ const exampleAttributeMapping = (typeInput, example, jsonSchemaInput) => { } }; +const additionalAttributesMapping = (typeInput, avroDefinition, jsonSchemaInput) => { + const __ret = extractNonNullableTypeIfNeeded(typeInput, jsonSchemaInput); + const type = __ret.type; + const jsonSchema = __ret.jsonSchema; + + exampleAttributeMapping(type, avroDefinition.example, jsonSchema); + + switch (type) { + case 'int': + case 'long': + case 'float': + case 'double': + if (avroDefinition.minimum !== undefined) jsonSchema.minimum = avroDefinition.minimum; + if (avroDefinition.multipleOf !== undefined && avroDefinition.multipleOf > 0) jsonSchema.multipleOf = avroDefinition.multipleOf; + if (avroDefinition.maximum !== undefined) jsonSchema.maximum = avroDefinition.maximum; + if (avroDefinition.exclusiveMinimum !== undefined) jsonSchema.exclusiveMinimum = avroDefinition.exclusiveMinimum; + if (avroDefinition.exclusiveMaximum !== undefined) jsonSchema.exclusiveMaximum = avroDefinition.exclusiveMaximum; + break; + case 'string': + case 'fixed': + if (avroDefinition.pattern) jsonSchema.pattern = avroDefinition.pattern; + if (avroDefinition.minLength !== undefined && avroDefinition.minLength > -1) jsonSchema.minLength = avroDefinition.minLength; + if (avroDefinition.maxLength !== undefined && avroDefinition.maxLength > -1) jsonSchema.maxLength = avroDefinition.maxLength; + break; + case 'array': + if (avroDefinition.minItems !== undefined && avroDefinition.minItems > -1) jsonSchema.minItems = avroDefinition.minItems; + if (avroDefinition.maxItems !== undefined && avroDefinition.maxItems > -1) jsonSchema.maxItems = avroDefinition.maxItems; + if (avroDefinition.uniqueItems !== undefined) jsonSchema.uniqueItems = avroDefinition.uniqueItems; + break; + } +}; + async function convertAvroToJsonSchema(avroDefinition, isTopLevel) { const jsonSchema = {}; const isUnion = Array.isArray(avroDefinition); @@ -84,7 +134,6 @@ async function convertAvroToJsonSchema(avroDefinition, isTopLevel) { // To prefer non-null values in the examples skip null definition here and push it as the last element after loop if (defType === 'null') nullDef = def; else jsonSchema.oneOf.push(def); } - if (nullDef) jsonSchema.oneOf.push(nullDef); return jsonSchema; @@ -121,13 +170,18 @@ async function convertAvroToJsonSchema(avroDefinition, isTopLevel) { case 'enum': jsonSchema.enum = avroDefinition.symbols; break; + case 'float': + case 'double': + jsonSchema.format = type; + break; case 'record': const propsMap = new Map(); for (const field of avroDefinition.fields) { const def = await convertAvroToJsonSchema(field.type, false); + requiredAttributesMapping(field, jsonSchema, field.default !== undefined); commonAttributesMapping(field, def, false); - exampleAttributeMapping(field.type, field.example, def); + additionalAttributesMapping(field.type, field, def); propsMap.set(field.name, def); } @@ -136,11 +190,11 @@ async function convertAvroToJsonSchema(avroDefinition, isTopLevel) { } commonAttributesMapping(avroDefinition, jsonSchema, isTopLevel); - exampleAttributeMapping(type, avroDefinition.example, jsonSchema); + additionalAttributesMapping(type, avroDefinition, jsonSchema); return jsonSchema; } module.exports.avroToJsonSchema = async function avroToJsonSchema(avroDefinition) { return convertAvroToJsonSchema(avroDefinition, true); -}; \ No newline at end of file +};