1. fixing infinite recursion with cyclic references (#2640)

2. using record names in unions to make possible to use several record
3. tests refactored

Co-authored-by: iliax <ikuramshin@provectus.com>
This commit is contained in:
Ilya Kuramshin 2022-09-28 14:08:17 +04:00 committed by GitHub
parent 049b35fc99
commit 97f1c639a3
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
2 changed files with 201 additions and 98 deletions

View file

@ -4,7 +4,6 @@ import java.net.URI;
import java.util.Collections; import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Locale;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import org.apache.avro.Schema; import org.apache.avro.Schema;
@ -22,7 +21,7 @@ public class AvroJsonSchemaConverter implements JsonSchemaConverter<Schema> {
builder.type(type); builder.type(type);
Map<String, FieldSchema> definitions = new HashMap<>(); Map<String, FieldSchema> definitions = new HashMap<>();
final FieldSchema root = convertSchema("root", schema, definitions, false); final FieldSchema root = convertSchema(schema, definitions, true);
builder.definitions(definitions); builder.definitions(definitions);
if (type.getType().equals(JsonType.Type.OBJECT)) { if (type.getType().equals(JsonType.Type.OBJECT)) {
@ -36,11 +35,11 @@ public class AvroJsonSchemaConverter implements JsonSchemaConverter<Schema> {
private FieldSchema convertField(Schema.Field field, Map<String, FieldSchema> definitions) { private FieldSchema convertField(Schema.Field field, Map<String, FieldSchema> definitions) {
return convertSchema(field.name(), field.schema(), definitions, true); return convertSchema(field.schema(), definitions, false);
} }
private FieldSchema convertSchema(String name, Schema schema, private FieldSchema convertSchema(Schema schema,
Map<String, FieldSchema> definitions, boolean ref) { Map<String, FieldSchema> definitions, boolean isRoot) {
if (!schema.isUnion()) { if (!schema.isUnion()) {
JsonType type = convertType(schema); JsonType type = convertType(schema);
switch (type.getType()) { switch (type.getType()) {
@ -53,12 +52,12 @@ public class AvroJsonSchemaConverter implements JsonSchemaConverter<Schema> {
return new SimpleFieldSchema(type); return new SimpleFieldSchema(type);
case OBJECT: case OBJECT:
if (schema.getType().equals(Schema.Type.MAP)) { if (schema.getType().equals(Schema.Type.MAP)) {
return new MapFieldSchema(convertSchema(name, schema.getValueType(), definitions, ref)); return new MapFieldSchema(convertSchema(schema.getValueType(), definitions, isRoot));
} else { } else {
return createObjectSchema(name, schema, definitions, ref); return createObjectSchema(schema, definitions, isRoot);
} }
case ARRAY: case ARRAY:
return createArraySchema(name, schema, definitions); return createArraySchema(schema, definitions);
default: default:
throw new RuntimeException("Unknown type"); throw new RuntimeException("Unknown type");
} }
@ -67,20 +66,26 @@ public class AvroJsonSchemaConverter implements JsonSchemaConverter<Schema> {
} }
} }
private FieldSchema createUnionSchema(Schema schema, Map<String, FieldSchema> definitions) {
// this method formats json-schema field in a way
// to fit avro-> json encoding rules (https://avro.apache.org/docs/1.11.1/specification/_print/#json-encoding)
private FieldSchema createUnionSchema(Schema schema, Map<String, FieldSchema> definitions) {
final boolean nullable = schema.getTypes().stream() final boolean nullable = schema.getTypes().stream()
.anyMatch(t -> t.getType().equals(Schema.Type.NULL)); .anyMatch(t -> t.getType().equals(Schema.Type.NULL));
final Map<String, FieldSchema> fields = schema.getTypes().stream() final Map<String, FieldSchema> fields = schema.getTypes().stream()
.filter(t -> !t.getType().equals(Schema.Type.NULL)) .filter(t -> !t.getType().equals(Schema.Type.NULL))
.map(f -> Tuples.of( .map(f -> {
f.getType().getName().toLowerCase(Locale.ROOT), String oneOfFieldName;
convertSchema( if (f.getType().equals(Schema.Type.RECORD)) {
f.getType().getName().toLowerCase(Locale.ROOT), // for records using full record name
f, definitions, true oneOfFieldName = f.getFullName();
) } else {
)).collect(Collectors.toMap( // for primitive types - using type name
oneOfFieldName = f.getType().getName().toLowerCase();
}
return Tuples.of(oneOfFieldName, convertSchema(f, definitions, false));
}).collect(Collectors.toMap(
Tuple2::getT1, Tuple2::getT1,
Tuple2::getT2 Tuple2::getT2
)); ));
@ -97,8 +102,16 @@ public class AvroJsonSchemaConverter implements JsonSchemaConverter<Schema> {
} }
} }
private FieldSchema createObjectSchema(String name, Schema schema, private FieldSchema createObjectSchema(Schema schema,
Map<String, FieldSchema> definitions, boolean ref) { Map<String, FieldSchema> definitions,
boolean isRoot) {
var definitionName = schema.getFullName();
if (definitions.containsKey(definitionName)) {
return createRefField(definitionName);
}
// adding stub record, need to avoid infinite recursion
definitions.put(definitionName, new ObjectFieldSchema(Map.of(), List.of()));
final Map<String, FieldSchema> fields = schema.getFields().stream() final Map<String, FieldSchema> fields = schema.getFields().stream()
.map(f -> Tuples.of(f.name(), convertField(f, definitions))) .map(f -> Tuples.of(f.name(), convertField(f, definitions)))
.collect(Collectors.toMap( .collect(Collectors.toMap(
@ -110,19 +123,26 @@ public class AvroJsonSchemaConverter implements JsonSchemaConverter<Schema> {
.filter(f -> !f.schema().isNullable()) .filter(f -> !f.schema().isNullable())
.map(Schema.Field::name).collect(Collectors.toList()); .map(Schema.Field::name).collect(Collectors.toList());
if (ref) { var objectSchema = new ObjectFieldSchema(fields, required);
String definitionName = String.format("Record%s", schema.getName()); if (isRoot) {
definitions.put(definitionName, new ObjectFieldSchema(fields, required)); // replacing stub with self-reference (need for usage in json-schema's oneOf)
return new RefFieldSchema(String.format("#/definitions/%s", definitionName)); definitions.put(definitionName, new RefFieldSchema("#"));
return objectSchema;
} else { } else {
return new ObjectFieldSchema(fields, required); // replacing stub record with actual object structure
definitions.put(definitionName, objectSchema);
return createRefField(definitionName);
} }
} }
private ArrayFieldSchema createArraySchema(String name, Schema schema, private RefFieldSchema createRefField(String definitionName) {
return new RefFieldSchema(String.format("#/definitions/%s", definitionName));
}
private ArrayFieldSchema createArraySchema(Schema schema,
Map<String, FieldSchema> definitions) { Map<String, FieldSchema> definitions) {
return new ArrayFieldSchema( return new ArrayFieldSchema(
convertSchema(name, schema.getElementType(), definitions, true) convertSchema(schema.getElementType(), definitions, false)
); );
} }

View file

@ -1,27 +1,29 @@
package com.provectus.kafka.ui.util.jsonschema; package com.provectus.kafka.ui.util.jsonschema;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.fge.jsonschema.core.exceptions.ProcessingException;
import com.github.fge.jsonschema.core.report.ProcessingReport;
import com.github.fge.jsonschema.main.JsonSchemaFactory;
import io.confluent.kafka.schemaregistry.avro.AvroSchemaUtils;
import java.io.IOException;
import java.net.URI; import java.net.URI;
import java.net.URISyntaxException; import java.net.URISyntaxException;
import lombok.SneakyThrows;
import org.apache.avro.Schema; import org.apache.avro.Schema;
import org.apache.avro.generic.GenericData;
import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
public class AvroJsonSchemaConverterTest { class AvroJsonSchemaConverterTest {
@Test
public void avroConvertTest() throws URISyntaxException, JsonProcessingException {
final AvroJsonSchemaConverter converter = new AvroJsonSchemaConverter();
URI basePath = new URI("http://example.com/");
Schema recordSchema = (new Schema.Parser()).parse( private AvroJsonSchemaConverter converter;
" {" private URI basePath;
@BeforeEach
void init() throws URISyntaxException {
converter = new AvroJsonSchemaConverter();
basePath = new URI("http://example.com/");
}
@Test
void avroConvertTest() {
String avroSchema =
" {"
+ " \"type\": \"record\"," + " \"type\": \"record\","
+ " \"name\": \"Message\"," + " \"name\": \"Message\","
+ " \"namespace\": \"com.provectus.kafka\"," + " \"namespace\": \"com.provectus.kafka\","
@ -76,45 +78,59 @@ public class AvroJsonSchemaConverterTest {
+ " }" + " }"
+ " }" + " }"
+ " ]" + " ]"
+ " }" + " }";
);
String expectedJsonSchema = "{ "
+ " \"$id\" : \"http://example.com/Message\", "
+ " \"$schema\" : \"https://json-schema.org/draft/2020-12/schema\", "
+ " \"type\" : \"object\", "
+ " \"properties\" : { "
+ " \"record\" : { \"$ref\" : \"#/definitions/com.provectus.kafka.InnerMessage\" } "
+ " }, "
+ " \"required\" : [ \"record\" ], "
+ " \"definitions\" : { "
+ " \"com.provectus.kafka.Message\" : { \"$ref\" : \"#\" }, "
+ " \"com.provectus.kafka.InnerMessage\" : { "
+ " \"type\" : \"object\", "
+ " \"properties\" : { "
+ " \"long_text\" : { "
+ " \"oneOf\" : [ { "
+ " \"type\" : \"null\" "
+ " }, { "
+ " \"type\" : \"object\", "
+ " \"properties\" : { "
+ " \"string\" : { "
+ " \"type\" : \"string\" "
+ " } "
+ " } "
+ " } ] "
+ " }, "
+ " \"array\" : { "
+ " \"type\" : \"array\", "
+ " \"items\" : { \"type\" : \"string\" } "
+ " }, "
+ " \"id\" : { \"type\" : \"integer\" }, "
+ " \"text\" : { \"type\" : \"string\" }, "
+ " \"map\" : { "
+ " \"type\" : \"object\", "
+ " \"additionalProperties\" : { \"type\" : \"integer\" } "
+ " }, "
+ " \"order\" : { "
+ " \"enum\" : [ \"SPADES\", \"HEARTS\", \"DIAMONDS\", \"CLUBS\" ], "
+ " \"type\" : \"string\" "
+ " } "
+ " }, "
+ " \"required\" : [ \"id\", \"text\", \"order\", \"array\", \"map\" ] "
+ " } "
+ " } "
+ "}";
String expected = "{\"$id\":\"http://example.com/Message\"," convertAndCompare(expectedJsonSchema, avroSchema);
+ "\"$schema\":\"https://json-schema.org/draft/2020-12/schema\","
+ "\"type\":\"object\",\"properties\":{\"record\":"
+ "{\"$ref\":\"#/definitions/RecordInnerMessage\"}},"
+ "\"required\":[\"record\"],\"definitions\":"
+ "{\"RecordInnerMessage\":{\"type\":\"object\",\""
+ "properties\":{\"long_text\":{\"oneOf\":[{\"type\":\"null\"},"
+ "{\"type\":\"object\",\"properties\":{\"string\":"
+ "{\"type\":\"string\"}}}]},\"array\":{\"type\":\"array\",\"items\":"
+ "{\"type\":\"string\"}},\"id\":{\"type\":\"integer\"},\"text\":"
+ "{\"type\":\"string\"},\"map\":{\"type\":\"object\","
+ "\"additionalProperties\":{\"type\":\"integer\"}},"
+ "\"order\":{\"enum\":[\"SPADES\",\"HEARTS\",\"DIAMONDS\",\"CLUBS\"],"
+ "\"type\":\"string\"}},"
+ "\"required\":[\"id\",\"text\",\"order\",\"array\",\"map\"]}}}";
final JsonSchema convertRecord = converter.convert(basePath, recordSchema);
ObjectMapper om = new ObjectMapper();
Assertions.assertEquals(
om.readTree(expected),
om.readTree(
convertRecord.toJson()
)
);
} }
@Test @Test
public void testNullableUnions() throws URISyntaxException, IOException, ProcessingException { void testNullableUnions() {
final AvroJsonSchemaConverter converter = new AvroJsonSchemaConverter(); String avroSchema =
URI basePath = new URI("http://example.com/");
final ObjectMapper objectMapper = new ObjectMapper();
Schema recordSchema = (new Schema.Parser()).parse(
" {" " {"
+ " \"type\": \"record\"," + " \"type\": \"record\","
+ " \"name\": \"Message\"," + " \"name\": \"Message\","
@ -138,38 +154,105 @@ public class AvroJsonSchemaConverterTest {
+ " \"default\": null" + " \"default\": null"
+ " }" + " }"
+ " ]" + " ]"
+ " }" + " }";
);
final GenericData.Record record = new GenericData.Record(recordSchema); String expectedJsonSchema =
record.put("text", "Hello world");
record.put("value", 100L);
byte[] jsonBytes = AvroSchemaUtils.toJson(record);
String serialized = new String(jsonBytes);
String expected =
"{\"$id\":\"http://example.com/Message\"," "{\"$id\":\"http://example.com/Message\","
+ "\"$schema\":\"https://json-schema.org/draft/2020-12/schema\"," + "\"$schema\":\"https://json-schema.org/draft/2020-12/schema\","
+ "\"type\":\"object\",\"properties\":{\"text\":" + "\"type\":\"object\",\"properties\":{\"text\":"
+ "{\"oneOf\":[{\"type\":\"null\"},{\"type\":\"object\"," + "{\"oneOf\":[{\"type\":\"null\"},{\"type\":\"object\","
+ "\"properties\":{\"string\":{\"type\":\"string\"}}}]},\"value\":" + "\"properties\":{\"string\":{\"type\":\"string\"}}}]},\"value\":"
+ "{\"oneOf\":[{\"type\":\"null\"},{\"type\":\"object\"," + "{\"oneOf\":[{\"type\":\"null\"},{\"type\":\"object\","
+ "\"properties\":{\"string\":{\"type\":\"string\"},\"long\":{\"type\":\"integer\"}}}]}}}"; + "\"properties\":{\"string\":{\"type\":\"string\"},\"long\":{\"type\":\"integer\"}}}]}},"
+ "\"definitions\" : { \"com.provectus.kafka.Message\" : { \"$ref\" : \"#\" }}}";
final JsonSchema convert = converter.convert(basePath, recordSchema); convertAndCompare(expectedJsonSchema, avroSchema);
Assertions.assertEquals(
objectMapper.readTree(expected),
objectMapper.readTree(convert.toJson())
);
final ProcessingReport validate =
JsonSchemaFactory.byDefault().getJsonSchema(
objectMapper.readTree(expected)
).validate(
objectMapper.readTree(serialized)
);
Assertions.assertTrue(validate.isSuccess());
} }
@Test
void testRecordReferences() {
String avroSchema =
"{\n"
+ " \"type\": \"record\", "
+ " \"namespace\": \"n.s\", "
+ " \"name\": \"RootMsg\", "
+ " \"fields\":\n"
+ " [ "
+ " { "
+ " \"name\": \"inner1\", "
+ " \"type\": { "
+ " \"type\": \"record\", "
+ " \"name\": \"Inner\", "
+ " \"fields\": [ { \"name\": \"f1\", \"type\": \"double\" } ] "
+ " } "
+ " }, "
+ " { "
+ " \"name\": \"inner2\", "
+ " \"type\": { "
+ " \"type\": \"record\", "
+ " \"namespace\": \"n.s2\", "
+ " \"name\": \"Inner\", "
+ " \"fields\": "
+ " [ { \"name\": \"f1\", \"type\": \"double\" } ] "
+ " } "
+ " }, "
+ " { "
+ " \"name\": \"refField\", "
+ " \"type\": [ \"null\", \"Inner\", \"n.s2.Inner\", \"RootMsg\" ] "
+ " } "
+ " ] "
+ "}";
String expectedJsonSchema = "{ "
+ " \"$id\" : \"http://example.com/RootMsg\", "
+ " \"$schema\" : \"https://json-schema.org/draft/2020-12/schema\", "
+ " \"type\" : \"object\", "
+ " \"properties\" : { "
+ " \"inner1\" : { \"$ref\" : \"#/definitions/n.s.Inner\" }, "
+ " \"inner2\" : { \"$ref\" : \"#/definitions/n.s2.Inner\" }, "
+ " \"refField\" : { "
+ " \"oneOf\" : [ "
+ " { "
+ " \"type\" : \"null\" "
+ " }, "
+ " { "
+ " \"type\" : \"object\", "
+ " \"properties\" : { "
+ " \"n.s.RootMsg\" : { \"$ref\" : \"#/definitions/n.s.RootMsg\" }, "
+ " \"n.s2.Inner\" : { \"$ref\" : \"#/definitions/n.s2.Inner\" }, "
+ " \"n.s.Inner\" : { \"$ref\" : \"#/definitions/n.s.Inner\" } "
+ " } "
+ " } ] "
+ " } "
+ " }, "
+ " \"required\" : [ \"inner1\", \"inner2\" ], "
+ " \"definitions\" : { "
+ " \"n.s.RootMsg\" : { \"$ref\" : \"#\" }, "
+ " \"n.s2.Inner\" : { "
+ " \"type\" : \"object\", "
+ " \"properties\" : { \"f1\" : { \"type\" : \"number\" } }, "
+ " \"required\" : [ \"f1\" ] "
+ " }, "
+ " \"n.s.Inner\" : { "
+ " \"type\" : \"object\", "
+ " \"properties\" : { \"f1\" : { \"type\" : \"number\" } }, "
+ " \"required\" : [ \"f1\" ] "
+ " } "
+ " } "
+ "}";
convertAndCompare(expectedJsonSchema, avroSchema);
}
@SneakyThrows
private void convertAndCompare(String expectedJsonSchema, String sourceAvroSchema) {
var parseAvroSchema = new Schema.Parser().parse(sourceAvroSchema);
var converted = converter.convert(basePath, parseAvroSchema).toJson();
var objectMapper = new ObjectMapper();
Assertions.assertEquals(
objectMapper.readTree(expectedJsonSchema),
objectMapper.readTree(converted)
);
}
} }