Skip to content

Commit 13b8f91

Browse files
authored
fix: avro parser now handles record reuse in definitions
1 parent 9d1f4b9 commit 13b8f91

File tree

7 files changed

+217
-13
lines changed

7 files changed

+217
-13
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
"scripts": {
77
"test": "jest",
88
"release": "semantic-release",
9-
"lint": "eslint --max-warnings 0 --config .eslintrc.yaml .",
9+
"lint": "eslint --max-warnings 1 --config .eslintrc.yaml .",
1010
"generate:assets": "echo 'No additional assets need to be generated at the moment'",
1111
"bump:version": "npm --no-git-tag-version --allow-same-version version $VERSION"
1212
},

tests/asyncapi-avro-111-1.9.0.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
asyncapi: 2.0.0
2+
info:
3+
title: My API
4+
version: '1.0.0'
5+
channels:
6+
mychannel:
7+
publish:
8+
message:
9+
schemaFormat: application/vnd.apache.avro;version=1.9.0
10+
payload:
11+
$ref: 'schemas/issue-111-testcase.avsc'

tests/asyncapi-avro-113-1.9.0.yaml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
asyncapi: 2.0.0
2+
info:
3+
title: My API
4+
version: '1.0.0'
5+
channels:
6+
mychannel:
7+
publish:
8+
message:
9+
schemaFormat: application/vnd.apache.avro;version=1.9.0
10+
payload:
11+
$ref: 'schemas/issue-113-testcase.avsc'

tests/parse.test.js

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
{
2+
"type": "record",
3+
"name": "ConnectionRequested",
4+
"namespace": "com.foo.connections",
5+
"doc": "An example schema to illustrate the issue",
6+
"fields": [
7+
{
8+
"name": "metadata",
9+
"type": {
10+
"type": "record",
11+
"name": "EventMetadata",
12+
"namespace": "com.foo",
13+
"doc": "Metadata to be associated with every published event",
14+
"fields": [
15+
{
16+
"name": "id",
17+
"type": {
18+
"type": "string",
19+
"logicalType": "uuid"
20+
},
21+
"doc": "Unique identifier for this specific event"
22+
},
23+
{
24+
"name": "timestamp",
25+
"type": {
26+
"type": "long",
27+
"logicalType": "timestamp-millis"
28+
},
29+
"doc": "Instant the event took place (not necessary when it was published)"
30+
},
31+
{
32+
"name": "correlation_id",
33+
"type": [
34+
"null",
35+
{
36+
"type": "string",
37+
"logicalType": "uuid"
38+
}
39+
],
40+
"doc": "id of the event that resulted in this\nevent being published (optional)",
41+
"default": null
42+
},
43+
{
44+
"name": "publisher_context",
45+
"type": [
46+
"null",
47+
{
48+
"type": "map",
49+
"values": {
50+
"type": "string",
51+
"avro.java.string": "String"
52+
},
53+
"avro.java.string": "String"
54+
}
55+
],
56+
"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",
57+
"default": null
58+
}
59+
]
60+
}
61+
},
62+
{
63+
"name": "auth_code",
64+
"type": {
65+
"type": "record",
66+
"name": "EncryptedString",
67+
"namespace": "com.foo",
68+
"doc": "A string that was encrypted with AES (using CTR mode), its key encrypted with RSA, and the nonce used for the encryption.",
69+
"fields": [
70+
{
71+
"name": "value",
72+
"type": "string",
73+
"doc": "A sequence of bytes that has been AES encrypted in CTR mode."
74+
},
75+
{
76+
"name": "nonce",
77+
"type": "string",
78+
"doc": "A nonce, used by the CTR encryption mode for our encrypted value. Not encrypted, not a secret."
79+
},
80+
{
81+
"name": "key",
82+
"type": "string",
83+
"doc": "An AES key, used to encrypt the value field, that has itself been encrypted using RSA."
84+
}
85+
]
86+
},
87+
"doc": "Encrypted auth_code received when user authorizes the app."
88+
},
89+
{
90+
"name": "refresh_token",
91+
"type": "com.foo.EncryptedString",
92+
"doc": "Encrypted refresh_token generated by using clientId and clientSecret."
93+
},
94+
{
95+
"name": "triggered_by",
96+
"type": {
97+
"type": "string",
98+
"logicalType": "uuid"
99+
},
100+
"doc": "ID of the user who triggered this event."
101+
}
102+
]
103+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
[
2+
{
3+
"type": "record",
4+
"name": "Address",
5+
"namespace": "com.example",
6+
"fields": [
7+
{
8+
"name": "streetaddress",
9+
"type": "string"
10+
},
11+
{
12+
"name": "city",
13+
"type": "string"
14+
}
15+
]
16+
},
17+
{
18+
"type": "record",
19+
"name": "Person",
20+
"namespace": "com.example",
21+
"fields": [
22+
{
23+
"name": "firstname",
24+
"type": "string"
25+
},
26+
{
27+
"name": "lastname",
28+
"type": "string"
29+
},
30+
{
31+
"name": "address",
32+
"type": "com.example.Address"
33+
}
34+
]
35+
}
36+
]

to-json-schema.js

Lines changed: 39 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -136,21 +136,39 @@ function validateAvroSchema(avroDefinition) {
136136
avsc.Type.forSchema(avroDefinition);
137137
}
138138

139-
async function convertAvroToJsonSchema(avroDefinition, isTopLevel) {
139+
/**
140+
* Cache the passed value under the given key. If the key is undefined the value will not be cached. This function
141+
* uses mutation of the passed cache object rather than a copy on write cache strategy.
142+
*
143+
* @param cache Map<String, JsonSchema> the cache to store the JsonSchema
144+
* @param key String | Undefined - the fully qualified name of an avro record
145+
* @param value JsonSchema - The json schema from the avro record
146+
*/
147+
function cacheAvroRecordDef(cache, key, value) {
148+
if (key) {
149+
cache[key] = value;
150+
}
151+
}
152+
153+
async function convertAvroToJsonSchema(avroDefinition, isTopLevel, recordCache = {}) {
140154
const jsonSchema = {};
141155
const isUnion = Array.isArray(avroDefinition);
142156

143-
validateAvroSchema(avroDefinition);
144-
145157
if (isUnion) {
146158
jsonSchema.oneOf = [];
147159
let nullDef = null;
148160
for (const avroDef of avroDefinition) {
149-
const def = await convertAvroToJsonSchema(avroDef, isTopLevel);
161+
const def = await convertAvroToJsonSchema(avroDef, isTopLevel, recordCache);
150162
// avroDef can be { type: 'int', default: 1 } and this is why avroDef.type has priority here
151163
const defType = avroDef.type || avroDef;
152164
// To prefer non-null values in the examples skip null definition here and push it as the last element after loop
153-
if (defType === 'null') nullDef = def; else jsonSchema.oneOf.push(def);
165+
if (defType === 'null') {
166+
nullDef = def;
167+
} else {
168+
jsonSchema.oneOf.push(def);
169+
const qualifiedName = getFullyQualifiedName(avroDef);
170+
cacheAvroRecordDef(recordCache, qualifiedName, def);
171+
}
154172
}
155173
if (nullDef) jsonSchema.oneOf.push(nullDef);
156174

@@ -195,13 +213,21 @@ async function convertAvroToJsonSchema(avroDefinition, isTopLevel) {
195213
case 'record':
196214
const propsMap = new Map();
197215
for (const field of avroDefinition.fields) {
198-
const def = await convertAvroToJsonSchema(field.type, false);
199-
200-
requiredAttributesMapping(field, jsonSchema, field.default !== undefined);
201-
commonAttributesMapping(field, def, false);
202-
additionalAttributesMapping(field.type, field, def);
203-
204-
propsMap.set(field.name, def);
216+
// If the type is a sub schema it will have been stored in the cache.
217+
if (recordCache[field.type]) {
218+
propsMap.set(field.name, recordCache[field.type]);
219+
} else {
220+
const def = await convertAvroToJsonSchema(field.type, false, recordCache);
221+
222+
requiredAttributesMapping(field, jsonSchema, field.default !== undefined);
223+
commonAttributesMapping(field, def, false);
224+
additionalAttributesMapping(field.type, field, def);
225+
226+
propsMap.set(field.name, def);
227+
// If there is a name for the sub record cache it under the name.
228+
const qualifiedFieldName = getFullyQualifiedName(field.type);
229+
cacheAvroRecordDef(recordCache, qualifiedFieldName, def);
230+
}
205231
}
206232
jsonSchema.properties = Object.fromEntries(propsMap.entries());
207233
break;
@@ -214,5 +240,6 @@ async function convertAvroToJsonSchema(avroDefinition, isTopLevel) {
214240
}
215241

216242
module.exports.avroToJsonSchema = async function avroToJsonSchema(avroDefinition) {
243+
validateAvroSchema(avroDefinition);
217244
return convertAvroToJsonSchema(avroDefinition, true);
218245
};

0 commit comments

Comments
 (0)