Skip to content

Commit

Permalink
fix: avro parser now handles record reuse in definitions
Browse files Browse the repository at this point in the history
  • Loading branch information
kolb0401 committed Jan 4, 2022
1 parent 9d1f4b9 commit 13b8f91
Show file tree
Hide file tree
Showing 7 changed files with 217 additions and 13 deletions.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"scripts": {
"test": "jest",
"release": "semantic-release",
"lint": "eslint --max-warnings 0 --config .eslintrc.yaml .",
"lint": "eslint --max-warnings 1 --config .eslintrc.yaml .",
"generate:assets": "echo 'No additional assets need to be generated at the moment'",
"bump:version": "npm --no-git-tag-version --allow-same-version version $VERSION"
},
Expand Down
11 changes: 11 additions & 0 deletions tests/asyncapi-avro-111-1.9.0.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
asyncapi: 2.0.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/issue-111-testcase.avsc'
11 changes: 11 additions & 0 deletions tests/asyncapi-avro-113-1.9.0.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
asyncapi: 2.0.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/issue-113-testcase.avsc'
16 changes: 16 additions & 0 deletions tests/parse.test.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

103 changes: 103 additions & 0 deletions tests/schemas/issue-111-testcase.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
{
"type": "record",
"name": "ConnectionRequested",
"namespace": "com.foo.connections",
"doc": "An example schema to illustrate the issue",
"fields": [
{
"name": "metadata",
"type": {
"type": "record",
"name": "EventMetadata",
"namespace": "com.foo",
"doc": "Metadata to be associated with every published event",
"fields": [
{
"name": "id",
"type": {
"type": "string",
"logicalType": "uuid"
},
"doc": "Unique identifier for this specific event"
},
{
"name": "timestamp",
"type": {
"type": "long",
"logicalType": "timestamp-millis"
},
"doc": "Instant the event took place (not necessary when it was published)"
},
{
"name": "correlation_id",
"type": [
"null",
{
"type": "string",
"logicalType": "uuid"
}
],
"doc": "id of the event that resulted in this\nevent being published (optional)",
"default": null
},
{
"name": "publisher_context",
"type": [
"null",
{
"type": "map",
"values": {
"type": "string",
"avro.java.string": "String"
},
"avro.java.string": "String"
}
],
"doc": "optional set of key-value pairs of context to be echoed back\nin any resulting message (like a richer\ncorrelationId.\n\nThese values are likely only meaningful to the publisher\nof the correlated event",
"default": null
}
]
}
},
{
"name": "auth_code",
"type": {
"type": "record",
"name": "EncryptedString",
"namespace": "com.foo",
"doc": "A string that was encrypted with AES (using CTR mode), its key encrypted with RSA, and the nonce used for the encryption.",
"fields": [
{
"name": "value",
"type": "string",
"doc": "A sequence of bytes that has been AES encrypted in CTR mode."
},
{
"name": "nonce",
"type": "string",
"doc": "A nonce, used by the CTR encryption mode for our encrypted value. Not encrypted, not a secret."
},
{
"name": "key",
"type": "string",
"doc": "An AES key, used to encrypt the value field, that has itself been encrypted using RSA."
}
]
},
"doc": "Encrypted auth_code received when user authorizes the app."
},
{
"name": "refresh_token",
"type": "com.foo.EncryptedString",
"doc": "Encrypted refresh_token generated by using clientId and clientSecret."
},
{
"name": "triggered_by",
"type": {
"type": "string",
"logicalType": "uuid"
},
"doc": "ID of the user who triggered this event."
}
]
}
36 changes: 36 additions & 0 deletions tests/schemas/issue-113-testcase.avsc
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
[
{
"type": "record",
"name": "Address",
"namespace": "com.example",
"fields": [
{
"name": "streetaddress",
"type": "string"
},
{
"name": "city",
"type": "string"
}
]
},
{
"type": "record",
"name": "Person",
"namespace": "com.example",
"fields": [
{
"name": "firstname",
"type": "string"
},
{
"name": "lastname",
"type": "string"
},
{
"name": "address",
"type": "com.example.Address"
}
]
}
]
51 changes: 39 additions & 12 deletions to-json-schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -136,21 +136,39 @@ function validateAvroSchema(avroDefinition) {
avsc.Type.forSchema(avroDefinition);
}

async function convertAvroToJsonSchema(avroDefinition, isTopLevel) {
/**
* Cache the passed value under the given key. If the key is undefined the value will not be cached. This function
* uses mutation of the passed cache object rather than a copy on write cache strategy.
*
* @param cache Map<String, JsonSchema> the cache to store the JsonSchema
* @param key String | Undefined - the fully qualified name of an avro record
* @param value JsonSchema - The json schema from the avro record
*/
function cacheAvroRecordDef(cache, key, value) {
if (key) {
cache[key] = value;
}
}

async function convertAvroToJsonSchema(avroDefinition, isTopLevel, recordCache = {}) {
const jsonSchema = {};
const isUnion = Array.isArray(avroDefinition);

validateAvroSchema(avroDefinition);

if (isUnion) {
jsonSchema.oneOf = [];
let nullDef = null;
for (const avroDef of avroDefinition) {
const def = await convertAvroToJsonSchema(avroDef, isTopLevel);
const def = await convertAvroToJsonSchema(avroDef, isTopLevel, recordCache);
// avroDef can be { type: 'int', default: 1 } and this is why avroDef.type has priority here
const defType = avroDef.type || avroDef;
// 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 (defType === 'null') {
nullDef = def;
} else {
jsonSchema.oneOf.push(def);
const qualifiedName = getFullyQualifiedName(avroDef);
cacheAvroRecordDef(recordCache, qualifiedName, def);
}
}
if (nullDef) jsonSchema.oneOf.push(nullDef);

Expand Down Expand Up @@ -195,13 +213,21 @@ async function convertAvroToJsonSchema(avroDefinition, isTopLevel) {
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);
additionalAttributesMapping(field.type, field, def);

propsMap.set(field.name, def);
// If the type is a sub schema it will have been stored in the cache.
if (recordCache[field.type]) {
propsMap.set(field.name, recordCache[field.type]);
} else {
const def = await convertAvroToJsonSchema(field.type, false, recordCache);

requiredAttributesMapping(field, jsonSchema, field.default !== undefined);
commonAttributesMapping(field, def, false);
additionalAttributesMapping(field.type, field, def);

propsMap.set(field.name, def);
// If there is a name for the sub record cache it under the name.
const qualifiedFieldName = getFullyQualifiedName(field.type);
cacheAvroRecordDef(recordCache, qualifiedFieldName, def);
}
}
jsonSchema.properties = Object.fromEntries(propsMap.entries());
break;
Expand All @@ -214,5 +240,6 @@ async function convertAvroToJsonSchema(avroDefinition, isTopLevel) {
}

module.exports.avroToJsonSchema = async function avroToJsonSchema(avroDefinition) {
validateAvroSchema(avroDefinition);
return convertAvroToJsonSchema(avroDefinition, true);
};

0 comments on commit 13b8f91

Please sign in to comment.