Add Avro serialization and schema management support
- Add schema server implementation - Add schema client abstraction - Add schema client implementation for own schema registry server - Add schema client supporting Confluent schema registry - Add Avro-based message converter supporting a static schema resource - Add Avro-based message converter with schema evolution support, via schema registry client. - On serialization, the converter register writer schemas with the schema registry server and augment the content type of outbound message with schema information. On deserialization, the reading converter will fetch the schema from the server if not available locally. Use class information if schema is not specified In the case of SpecificRecord and Reflective readers/writers, the class information can be used instead Make subtype prefix configurable and shorten the subject - Subtype prefix is now configurable and subject is the lowercase schema name - Enhance/correct javadoc Refine AbstractAvroMessageConverter - distinguish between writer and reader schema when reader is created Add schema registry and schema registry client docs
This commit is contained in:
committed by
Marius Bogoevici
parent
8dd22ebca0
commit
4422b21438
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema;
|
||||
|
||||
/**
|
||||
* @author Vinicius Carvalho
|
||||
*/
|
||||
public class SchemaNotFoundException extends RuntimeException {
|
||||
|
||||
public SchemaNotFoundException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema;
|
||||
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* References a schema through its subject and version.
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class SchemaReference {
|
||||
|
||||
private String subject;
|
||||
|
||||
private int version;
|
||||
|
||||
private String format;
|
||||
|
||||
public SchemaReference(String subject, int version, String format) {
|
||||
Assert.hasText(subject, "cannot be empty");
|
||||
Assert.isTrue(version > 0, "must be a positive integer");
|
||||
Assert.hasText(format, "cannot be empty");
|
||||
this.subject = subject;
|
||||
this.version = version;
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
public String getSubject() {
|
||||
return this.subject;
|
||||
}
|
||||
|
||||
public void setSubject(String subject) {
|
||||
Assert.hasText(subject, "cannot be empty");
|
||||
this.subject = subject;
|
||||
}
|
||||
|
||||
public int getVersion() {
|
||||
return this.version;
|
||||
}
|
||||
|
||||
public void setVersion(int version) {
|
||||
Assert.isTrue(version > 0, "must be a positive integer");
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public String getFormat() {
|
||||
return this.format;
|
||||
}
|
||||
|
||||
public void setFormat(String format) {
|
||||
Assert.hasText(format, "cannot be empty");
|
||||
this.format = format;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) {
|
||||
return true;
|
||||
}
|
||||
if (o == null || getClass() != o.getClass()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
SchemaReference that = (SchemaReference) o;
|
||||
|
||||
if (this.version != that.version) {
|
||||
return false;
|
||||
}
|
||||
if (!this.subject.equals(that.subject)) {
|
||||
return false;
|
||||
}
|
||||
return this.format.equals(that.format);
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
int result = this.subject.hashCode();
|
||||
result = 31 * result + this.version;
|
||||
result = 31 * result + this.format.hashCode();
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "SchemaReference{" +
|
||||
"subject='" + this.subject + '\'' +
|
||||
", version=" + this.version +
|
||||
", format='" + this.format + '\'' +
|
||||
'}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class SchemaRegistrationResponse {
|
||||
|
||||
private long id;
|
||||
|
||||
private SchemaReference schemaReference;
|
||||
|
||||
public long getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public void setId(long id) {
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
public SchemaReference getSchemaReference() {
|
||||
return this.schemaReference;
|
||||
}
|
||||
|
||||
public void setSchemaReference(SchemaReference schemaReference) {
|
||||
this.schemaReference = schemaReference;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.avro;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.ByteBuffer;
|
||||
import java.util.Collection;
|
||||
|
||||
import org.apache.avro.Schema;
|
||||
import org.apache.avro.generic.GenericDatumReader;
|
||||
import org.apache.avro.generic.GenericDatumWriter;
|
||||
import org.apache.avro.generic.GenericRecord;
|
||||
import org.apache.avro.io.DatumReader;
|
||||
import org.apache.avro.io.DatumWriter;
|
||||
import org.apache.avro.io.Decoder;
|
||||
import org.apache.avro.io.DecoderFactory;
|
||||
import org.apache.avro.io.Encoder;
|
||||
import org.apache.avro.io.EncoderFactory;
|
||||
import org.apache.avro.reflect.ReflectDatumReader;
|
||||
import org.apache.avro.reflect.ReflectDatumWriter;
|
||||
import org.apache.avro.specific.SpecificDatumReader;
|
||||
import org.apache.avro.specific.SpecificDatumWriter;
|
||||
import org.apache.avro.specific.SpecificRecord;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.MessageHeaders;
|
||||
import org.springframework.messaging.converter.AbstractMessageConverter;
|
||||
import org.springframework.messaging.converter.MessageConversionException;
|
||||
import org.springframework.util.MimeType;
|
||||
|
||||
/**
|
||||
* Base class for Apache Avro {@link org.springframework.messaging.converter.MessageConverter} implementations.
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public abstract class AbstractAvroMessageConverter extends AbstractMessageConverter {
|
||||
|
||||
|
||||
protected AbstractAvroMessageConverter(MimeType supportedMimeType) {
|
||||
super(supportedMimeType);
|
||||
}
|
||||
|
||||
protected AbstractAvroMessageConverter(Collection<MimeType> supportedMimeTypes) {
|
||||
super(supportedMimeTypes);
|
||||
}
|
||||
|
||||
protected static Schema parseSchema(Resource r) throws IOException {
|
||||
return new Schema.Parser().parse(r.getInputStream());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean canConvertFrom(Message<?> message, Class<?> targetClass) {
|
||||
return super.canConvertFrom(message, targetClass) && (message.getPayload() instanceof byte[]);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object convertFromInternal(Message<?> message, Class<?> targetClass, Object conversionHint) {
|
||||
Object result = null;
|
||||
try {
|
||||
byte[] payload = (byte[]) message.getPayload();
|
||||
ByteBuffer buf = ByteBuffer.wrap(payload);
|
||||
MimeType mimeType = getContentTypeResolver().resolve(message.getHeaders());
|
||||
if (mimeType == null) {
|
||||
if (conversionHint instanceof MimeType) {
|
||||
mimeType = (MimeType) conversionHint;
|
||||
}
|
||||
else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
buf.get(payload);
|
||||
Schema writerSchema = resolveWriterSchemaForDeserialization(mimeType);
|
||||
Schema readerSchema = resolveReaderSchemaForDeserialization(targetClass);
|
||||
DatumReader<Object> reader = getDatumReader((Class<Object>) targetClass, readerSchema, writerSchema);
|
||||
Decoder decoder = DecoderFactory.get().binaryDecoder(payload, null);
|
||||
result = reader.read(null, decoder);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new MessageConversionException(message, "Failed to read payload", e);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private DatumWriter<Object> getDatumWriter(Class<Object> type, Schema schema) {
|
||||
DatumWriter<Object> writer;
|
||||
this.logger.debug("Finding correct DatumWriter for type " + type.getName());
|
||||
if (SpecificRecord.class.isAssignableFrom(type)) {
|
||||
if (schema != null) {
|
||||
writer = new SpecificDatumWriter<>(schema);
|
||||
}
|
||||
else {
|
||||
writer = new SpecificDatumWriter<>(type);
|
||||
}
|
||||
}
|
||||
else if (GenericRecord.class.isAssignableFrom(type)) {
|
||||
writer = new GenericDatumWriter<>(schema);
|
||||
}
|
||||
else {
|
||||
if (schema != null) {
|
||||
writer = new ReflectDatumWriter<>(schema);
|
||||
}
|
||||
else {
|
||||
writer = new ReflectDatumWriter<>(type);
|
||||
}
|
||||
}
|
||||
return writer;
|
||||
}
|
||||
|
||||
protected DatumReader<Object> getDatumReader(Class<Object> type, Schema schema, Schema writerSchema) {
|
||||
DatumReader<Object> reader = null;
|
||||
if (SpecificRecord.class.isAssignableFrom(type)) {
|
||||
if (schema != null) {
|
||||
if (writerSchema != null) {
|
||||
reader = new SpecificDatumReader<>(writerSchema, schema);
|
||||
}
|
||||
else {
|
||||
reader = new SpecificDatumReader<>(schema);
|
||||
}
|
||||
}
|
||||
else {
|
||||
reader = new SpecificDatumReader<>(type);
|
||||
if (writerSchema != null) {
|
||||
reader.setSchema(writerSchema);
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (GenericRecord.class.isAssignableFrom(type)) {
|
||||
if (schema != null) {
|
||||
if (writerSchema != null) {
|
||||
reader = new GenericDatumReader<>(writerSchema, schema);
|
||||
}
|
||||
else {
|
||||
reader = new GenericDatumReader<>(schema);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
reader = new ReflectDatumReader(type);
|
||||
if (writerSchema != null) {
|
||||
reader.setSchema(writerSchema);
|
||||
}
|
||||
}
|
||||
if (reader == null) {
|
||||
throw new MessageConversionException(
|
||||
"No schema can be inferred from type " + type
|
||||
.getName() + " and no schema has been explicitly configured.");
|
||||
}
|
||||
return reader;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Object convertToInternal(Object payload, MessageHeaders headers, Object conversionHint) {
|
||||
ByteArrayOutputStream baos = new ByteArrayOutputStream();
|
||||
try {
|
||||
MimeType hintedContentType = null;
|
||||
if (conversionHint instanceof MimeType) {
|
||||
hintedContentType = (MimeType) conversionHint;
|
||||
}
|
||||
Schema schema = resolveSchemaForWriting(payload, headers, hintedContentType);
|
||||
DatumWriter<Object> writer = getDatumWriter((Class<Object>) payload.getClass(), schema);
|
||||
Encoder encoder = EncoderFactory.get().binaryEncoder(baos, null);
|
||||
writer.write(payload, encoder);
|
||||
encoder.flush();
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new MessageConversionException("Failed to write payload", e);
|
||||
}
|
||||
return baos.toByteArray();
|
||||
}
|
||||
|
||||
protected abstract Schema resolveSchemaForWriting(Object payload, MessageHeaders headers,
|
||||
MimeType hintedContentType);
|
||||
|
||||
protected abstract Schema resolveWriterSchemaForDeserialization(MimeType mimeType);
|
||||
|
||||
protected abstract Schema resolveReaderSchemaForDeserialization(Class<?> targetClass);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.avro;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.cloud.stream.binder.StringConvertingContentTypeResolver;
|
||||
import org.springframework.cloud.stream.schema.client.SchemaRegistryClient;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
* @author Vinicius Carvalho
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnClass(name = "org.apache.avro.Schema")
|
||||
@ConditionalOnProperty(value = "spring.cloud.stream.schemaRegistryClient.enabled", matchIfMissing = true)
|
||||
@ConditionalOnBean(type = "org.springframework.cloud.stream.schema.client.SchemaRegistryClient")
|
||||
@EnableConfigurationProperties(AvroMessageConverterProperties.class)
|
||||
public class AvroMessageConverterAutoConfiguration {
|
||||
|
||||
@Autowired
|
||||
private AvroMessageConverterProperties avroMessageConverterProperties;
|
||||
|
||||
@Bean
|
||||
public AvroSchemaRegistryClientMessageConverter avroSchemaMessageConverter(
|
||||
SchemaRegistryClient schemaRegistryClient) {
|
||||
AvroSchemaRegistryClientMessageConverter
|
||||
avroSchemaRegistryClientMessageConverter = new AvroSchemaRegistryClientMessageConverter(
|
||||
schemaRegistryClient);
|
||||
avroSchemaRegistryClientMessageConverter.setDynamicSchemaGenerationEnabled(
|
||||
this.avroMessageConverterProperties.isDynamicSchemaGenerationEnabled());
|
||||
avroSchemaRegistryClientMessageConverter.setContentTypeResolver(new StringConvertingContentTypeResolver());
|
||||
if (this.avroMessageConverterProperties.getReaderSchema() != null) {
|
||||
avroSchemaRegistryClientMessageConverter.setReaderSchema(
|
||||
this.avroMessageConverterProperties.getReaderSchema());
|
||||
}
|
||||
if (!ObjectUtils.isEmpty(this.avroMessageConverterProperties.getSchemaLocations())) {
|
||||
avroSchemaRegistryClientMessageConverter.setSchemaLocations(
|
||||
this.avroMessageConverterProperties.getSchemaLocations());
|
||||
}
|
||||
avroSchemaRegistryClientMessageConverter.setPrefix(this.avroMessageConverterProperties.getPrefix());
|
||||
return avroSchemaRegistryClientMessageConverter;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.avro;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.util.Assert;
|
||||
|
||||
/**
|
||||
* @author Vinicius Carvalho
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "spring.cloud.stream.schema.avro")
|
||||
public class AvroMessageConverterProperties {
|
||||
|
||||
private boolean dynamicSchemaGenerationEnabled;
|
||||
|
||||
private Resource readerSchema;
|
||||
|
||||
private Resource[] schemaLocations;
|
||||
|
||||
private String prefix = "vnd";
|
||||
|
||||
public Resource getReaderSchema() {
|
||||
return this.readerSchema;
|
||||
}
|
||||
|
||||
public void setReaderSchema(Resource readerSchema) {
|
||||
Assert.notNull(readerSchema, "cannot be null");
|
||||
this.readerSchema = readerSchema;
|
||||
}
|
||||
|
||||
public Resource[] getSchemaLocations() {
|
||||
return this.schemaLocations;
|
||||
}
|
||||
|
||||
public void setSchemaLocations(Resource[] schemaLocations) {
|
||||
Assert.notEmpty(schemaLocations, "cannot be null");
|
||||
this.schemaLocations = schemaLocations;
|
||||
}
|
||||
|
||||
public boolean isDynamicSchemaGenerationEnabled() {
|
||||
return this.dynamicSchemaGenerationEnabled;
|
||||
}
|
||||
|
||||
public void setDynamicSchemaGenerationEnabled(boolean dynamicSchemaGenerationEnabled) {
|
||||
this.dynamicSchemaGenerationEnabled = dynamicSchemaGenerationEnabled;
|
||||
}
|
||||
|
||||
public String getPrefix() {
|
||||
return this.prefix;
|
||||
}
|
||||
|
||||
public void setPrefix(String prefix) {
|
||||
this.prefix = prefix;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.avro;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Collection;
|
||||
|
||||
import org.apache.avro.Schema;
|
||||
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.messaging.MessageHeaders;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.MimeType;
|
||||
|
||||
/**
|
||||
* A {@link org.springframework.messaging.converter.MessageConverter}
|
||||
* using Apache Avro.
|
||||
* The schema for serializing and deserializing will be automatically inferred
|
||||
* from the class for {@link org.apache.avro.specific.SpecificRecord} and regular
|
||||
* classes, unless a specific schema is set, case in which that schema will be used
|
||||
* instead.
|
||||
* For converting to {@link org.apache.avro.generic.GenericRecord} targets,
|
||||
* a schema must be set.s
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
|
||||
public class AvroSchemaMessageConverter extends AbstractAvroMessageConverter {
|
||||
|
||||
private Schema schema;
|
||||
|
||||
/**
|
||||
* Create a {@link AvroSchemaMessageConverter}.
|
||||
* Uses the default {@link MimeType} of {@code "application/avro"}.
|
||||
*/
|
||||
public AvroSchemaMessageConverter() {
|
||||
super(new MimeType("application", "avro"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link AvroSchemaMessageConverter}.
|
||||
* The converter will be used for the provided {@link MimeType}.
|
||||
*/
|
||||
public AvroSchemaMessageConverter(MimeType supportedMimeType) {
|
||||
super(supportedMimeType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link AvroSchemaMessageConverter}.
|
||||
* The converter will be used for the provided {@link MimeType}s.
|
||||
* @param supportedMimeTypes the mime types supported by this converter
|
||||
*/
|
||||
public AvroSchemaMessageConverter(Collection<MimeType> supportedMimeTypes) {
|
||||
super(supportedMimeTypes);
|
||||
}
|
||||
|
||||
public Schema getSchema() {
|
||||
return this.schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Apache Avro schema to be used by this converter.
|
||||
* @param schema schema to be used by this converter
|
||||
*/
|
||||
public void setSchema(Schema schema) {
|
||||
Assert.notNull(schema, "schema cannot be null");
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
/**
|
||||
* The location of the Apache Avro schema to be used by this converter.
|
||||
* @param schemaLocation the location of the schema used by this converter.
|
||||
*/
|
||||
public void setSchemaLocation(Resource schemaLocation) {
|
||||
Assert.notNull(schemaLocation, "schema cannot be null");
|
||||
try {
|
||||
this.schema = parseSchema(schemaLocation);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new IllegalStateException("Schema cannot be parsed:", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean supports(Class<?> clazz) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Schema resolveWriterSchemaForDeserialization(MimeType mimeType) {
|
||||
return this.schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Schema resolveReaderSchemaForDeserialization(Class<?> targetClass) {
|
||||
return this.schema;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Schema resolveSchemaForWriting(Object payload, MessageHeaders headers,
|
||||
MimeType hintedContentType) {
|
||||
return this.schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.avro;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import org.apache.avro.Schema;
|
||||
import org.apache.avro.generic.GenericContainer;
|
||||
import org.apache.avro.reflect.ReflectData;
|
||||
|
||||
import org.springframework.beans.factory.BeanInitializationException;
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.cloud.stream.schema.SchemaNotFoundException;
|
||||
import org.springframework.cloud.stream.schema.SchemaReference;
|
||||
import org.springframework.cloud.stream.schema.SchemaRegistrationResponse;
|
||||
import org.springframework.cloud.stream.schema.client.SchemaRegistryClient;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.integration.support.MutableMessageHeaders;
|
||||
import org.springframework.messaging.MessageHeaders;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.MimeType;
|
||||
import org.springframework.util.ObjectUtils;
|
||||
|
||||
/**
|
||||
* A {@link org.springframework.messaging.converter.MessageConverter}
|
||||
* for Apache Avro, with the ability to publish and retrieve schemas
|
||||
* stored in a schema server, allowing for schema evolution in applications.
|
||||
* The supported content types are in the form `application/*+avro`.
|
||||
*
|
||||
* During the conversion to a message, the converter will set the 'contentType'
|
||||
* header to 'application/[prefix].[subject].v[version]+avro', where:
|
||||
*
|
||||
* <li>
|
||||
* <ul><i>prefix</i> is a configurable prefix (default 'vnd');</ul>
|
||||
* <ul><i>subject</i> is a subject derived from the type of the outgoing object - typically the class name;</ul>
|
||||
* <ul><i>version</i> is the schema version for the given subject;</ul>
|
||||
* </li>
|
||||
*
|
||||
* When converting from a message, the converter will parse the content-type
|
||||
* and use it to fetch and cache the writer schema using the provided
|
||||
* {@link SchemaRegistryClient}.
|
||||
* @author Marius Bogoevici
|
||||
* @author Vinicius Carvalho
|
||||
*/
|
||||
public class AvroSchemaRegistryClientMessageConverter extends AbstractAvroMessageConverter implements InitializingBean {
|
||||
|
||||
public static final String AVRO_FORMAT = "avro";
|
||||
|
||||
public static final Pattern PREFIX_VALIDATION_PATTERN = Pattern.compile("[\\p{Alnum}]");
|
||||
|
||||
private Pattern versionedSchema;
|
||||
|
||||
private boolean dynamicSchemaGenerationEnabled;
|
||||
|
||||
private Map<String, Schema> localSchemaMap = new HashMap<>();
|
||||
|
||||
private Schema readerSchema;
|
||||
|
||||
private Resource[] schemaLocations;
|
||||
|
||||
private SchemaRegistryClient schemaRegistryClient;
|
||||
|
||||
private String prefix = "vnd";
|
||||
|
||||
/**
|
||||
* Creates a new instance, configuring it with a {@link SchemaRegistryClient}.
|
||||
* @param schemaRegistryClient the {@link SchemaRegistryClient} used to interact with the schema registry server.
|
||||
*/
|
||||
public AvroSchemaRegistryClientMessageConverter(SchemaRegistryClient schemaRegistryClient) {
|
||||
super(Arrays.asList(new MimeType("application", "*+avro")));
|
||||
Assert.notNull(schemaRegistryClient, "cannot be null");
|
||||
this.schemaRegistryClient = schemaRegistryClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allows the converter to generate and register schemas automatically.
|
||||
* If set to false, it only allows the converter to use pre-registered schemas.
|
||||
* Default 'true'.
|
||||
* @param dynamicSchemaGenerationEnabled true if dynamic schema generation is enabled
|
||||
*/
|
||||
public void setDynamicSchemaGenerationEnabled(boolean dynamicSchemaGenerationEnabled) {
|
||||
this.dynamicSchemaGenerationEnabled = dynamicSchemaGenerationEnabled;
|
||||
}
|
||||
|
||||
public boolean isDynamicSchemaGenerationEnabled() {
|
||||
return this.dynamicSchemaGenerationEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* A set of locations where the converter can load schemas from.
|
||||
* Schemas provided at these locations will be registered automatically.
|
||||
*
|
||||
* @param schemaLocations
|
||||
*/
|
||||
public void setSchemaLocations(Resource[] schemaLocations) {
|
||||
Assert.notEmpty(schemaLocations, "cannot be empty");
|
||||
this.schemaLocations = schemaLocations;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the prefix to be used in the publised subtype. Default 'vnd'.
|
||||
* @param prefix
|
||||
*/
|
||||
public void setPrefix(String prefix) {
|
||||
Assert.hasText(prefix, "Prefix cannot be empty");
|
||||
Assert.isTrue(!PREFIX_VALIDATION_PATTERN.matcher(this.prefix).matches(), "Invalid prefix:" + this.prefix);
|
||||
this.prefix = prefix;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void afterPropertiesSet() throws Exception {
|
||||
this.versionedSchema = Pattern.compile(
|
||||
"application/" + this.prefix + "\\.([\\p{Alnum}\\$\\.]+)\\.v(\\p{Digit}+)\\+avro");
|
||||
if (!ObjectUtils.isEmpty(this.schemaLocations)) {
|
||||
this.logger.info("Scanning avro schema resources on classpath");
|
||||
if (this.logger.isInfoEnabled()) {
|
||||
this.logger.info("Parsing" + this.schemaLocations.length);
|
||||
}
|
||||
for (Resource schemaLocation : this.schemaLocations) {
|
||||
try {
|
||||
Schema schema = parseSchema(schemaLocation);
|
||||
if (this.logger.isInfoEnabled()) {
|
||||
this.logger.info("Resource " + schemaLocation.getFilename() + " parsed into schema " + schema
|
||||
.getNamespace() + "." + schema.getName());
|
||||
}
|
||||
this.schemaRegistryClient.register(toSubject(schema), AVRO_FORMAT, schema.toString(true));
|
||||
if (this.logger.isInfoEnabled()) {
|
||||
this.logger.info("Schema " + schema.getName() + " registered with id " + schema);
|
||||
}
|
||||
this.localSchemaMap.put(schema.getNamespace() + "." + schema.getName(), schema);
|
||||
}
|
||||
catch (IOException e) {
|
||||
if (this.logger.isWarnEnabled()) {
|
||||
this.logger.warn("Failed to parse schema at " + schemaLocation.getFilename(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected String toSubject(Schema schema) {
|
||||
return schema.getName().toLowerCase();
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean supports(Class<?> clazz) {
|
||||
// we support all types
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean supportsMimeType(MessageHeaders headers) {
|
||||
if (super.supportsMimeType(headers)) {
|
||||
return true;
|
||||
}
|
||||
MimeType mimeType = getContentTypeResolver().resolve(headers);
|
||||
return MimeType.valueOf("application/*+avro").includes(mimeType);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Schema resolveSchemaForWriting(Object payload, MessageHeaders headers,
|
||||
MimeType hintedContentType) {
|
||||
Schema schema;
|
||||
SchemaReference schemaReference = extractSchemaReference(hintedContentType);
|
||||
// the mimeType does not contain a schema reference
|
||||
if (schemaReference == null) {
|
||||
schema = extractSchemaForWriting(payload);
|
||||
SchemaRegistrationResponse schemaRegistrationResponse = this.schemaRegistryClient.register(
|
||||
toSubject(schema), AVRO_FORMAT, schema.toString(true));
|
||||
schemaReference = schemaRegistrationResponse.getSchemaReference();
|
||||
}
|
||||
else {
|
||||
Schema.Parser parser = new Schema.Parser();
|
||||
String schemaContents = this.schemaRegistryClient.fetch(schemaReference);
|
||||
schema = parser.parse(schemaContents);
|
||||
}
|
||||
if (headers instanceof MutableMessageHeaders) {
|
||||
headers.put(MessageHeaders.CONTENT_TYPE,
|
||||
"application/vnd." + schemaReference.getSubject() + ".v" + schemaReference
|
||||
.getVersion() + "+avro");
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
private SchemaReference extractSchemaReference(MimeType mimeType) {
|
||||
SchemaReference schemaReference = null;
|
||||
Matcher schemaMatcher = this.versionedSchema.matcher(mimeType.toString());
|
||||
if (schemaMatcher.find()) {
|
||||
String subject = schemaMatcher.group(1);
|
||||
Integer version = Integer.parseInt(schemaMatcher.group(2));
|
||||
schemaReference = new SchemaReference(subject, version, AVRO_FORMAT);
|
||||
}
|
||||
return schemaReference;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Schema resolveWriterSchemaForDeserialization(MimeType mimeType) {
|
||||
if (this.readerSchema == null) {
|
||||
Schema schema = null;
|
||||
SchemaReference schemaReference = extractSchemaReference(mimeType);
|
||||
if (schemaReference != null) {
|
||||
String schemaContent = this.schemaRegistryClient.fetch(schemaReference);
|
||||
schema = new Schema.Parser().parse(schemaContent);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
else {
|
||||
return this.readerSchema;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Schema resolveReaderSchemaForDeserialization(Class<?> targetClass) {
|
||||
return this.readerSchema;
|
||||
}
|
||||
|
||||
public void setReaderSchema(Resource readerSchema) {
|
||||
Assert.notNull(readerSchema, "cannot be null");
|
||||
try {
|
||||
this.readerSchema = parseSchema(readerSchema);
|
||||
}
|
||||
catch (IOException e) {
|
||||
throw new BeanInitializationException("Cannot initialize reader schema", e);
|
||||
}
|
||||
}
|
||||
|
||||
private Schema extractSchemaForWriting(Object payload) {
|
||||
Schema schema = null;
|
||||
if (this.logger.isDebugEnabled()) {
|
||||
this.logger.debug("Obtaining schema for class " + payload.getClass());
|
||||
}
|
||||
if (GenericContainer.class.isAssignableFrom(payload.getClass())) {
|
||||
schema = ((GenericContainer) payload).getSchema();
|
||||
if (this.logger.isDebugEnabled()) {
|
||||
this.logger.debug("Avro type detected, using schema from object");
|
||||
}
|
||||
}
|
||||
else {
|
||||
schema = this.localSchemaMap.get(payload.getClass().getName());
|
||||
if (schema == null) {
|
||||
if (!isDynamicSchemaGenerationEnabled()) {
|
||||
throw new SchemaNotFoundException(
|
||||
String.format("No schema found in the local cache for %s, and dynamic schema generation " +
|
||||
"is not enabled", payload.getClass()));
|
||||
}
|
||||
else {
|
||||
schema = ReflectData.get().getSchema(payload.getClass());
|
||||
this.schemaRegistryClient.register(toSubject(schema), AVRO_FORMAT, schema.toString(true));
|
||||
}
|
||||
this.localSchemaMap.put(payload.getClass().getName(), schema);
|
||||
}
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.client;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import org.springframework.cloud.stream.schema.SchemaReference;
|
||||
import org.springframework.cloud.stream.schema.SchemaRegistrationResponse;
|
||||
import org.springframework.http.HttpEntity;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* @author Vinicius Carvalho
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class ConfluentSchemaRegistryClient implements SchemaRegistryClient {
|
||||
|
||||
private RestTemplate template;
|
||||
|
||||
private String endpoint = "http://localhost:8081";
|
||||
|
||||
private ObjectMapper mapper;
|
||||
|
||||
public ConfluentSchemaRegistryClient() {
|
||||
this.template = new RestTemplate();
|
||||
this.mapper = new ObjectMapper();
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaRegistrationResponse register(String subject, String format, String schema) {
|
||||
Assert.isTrue("avro".equals(format), "Only Avro is supported");
|
||||
String path = String.format("/subjects/%s/versions", subject);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.put("Accept",
|
||||
Arrays.asList("application/vnd.schemaregistry.v1+json", "application/vnd.schemaregistry+json",
|
||||
"application/json"));
|
||||
headers.add("Content-Type", "application/json");
|
||||
Integer id = null;
|
||||
try {
|
||||
String payload = this.mapper.writeValueAsString(Collections.singletonMap("schema", schema));
|
||||
HttpEntity<String> request = new HttpEntity<>(payload, headers);
|
||||
ResponseEntity<Map> response = this.template.exchange(this.endpoint + path, HttpMethod.POST, request,
|
||||
Map.class);
|
||||
id = (Integer) response.getBody().get("id");
|
||||
}
|
||||
catch (JsonProcessingException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
SchemaRegistrationResponse schemaRegistrationResponse = new SchemaRegistrationResponse();
|
||||
schemaRegistrationResponse.setId(id);
|
||||
schemaRegistrationResponse.setSchemaReference(new SchemaReference(subject, id, "avro"));
|
||||
return schemaRegistrationResponse;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fetch(SchemaReference schemaReference) {
|
||||
String path = String.format("/schemas/ids/%d", schemaReference.getVersion());
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.put("Accept",
|
||||
Arrays.asList("application/vnd.schemaregistry.v1+json", "application/vnd.schemaregistry+json",
|
||||
"application/json"));
|
||||
headers.add("Content-Type", "application/vnd.schemaregistry.v1+json");
|
||||
HttpEntity<String> request = new HttpEntity<>("", headers);
|
||||
ResponseEntity<Map> response = this.template.exchange(this.endpoint + path, HttpMethod.GET, request, Map
|
||||
.class);
|
||||
return (String) response.getBody().get("schema");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fetch(Integer id) {
|
||||
String path = String.format("/schemas/ids/%d", id);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.put("Accept",
|
||||
Arrays.asList("application/vnd.schemaregistry.v1+json", "application/vnd.schemaregistry+json",
|
||||
"application/json"));
|
||||
headers.add("Content-Type", "application/vnd.schemaregistry.v1+json");
|
||||
HttpEntity<String> request = new HttpEntity<>("", headers);
|
||||
ResponseEntity<Map> response = this.template.exchange(this.endpoint + path, HttpMethod.GET, request, Map
|
||||
.class);
|
||||
return (String) response.getBody().get("schema");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.client;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.cloud.stream.schema.SchemaReference;
|
||||
import org.springframework.cloud.stream.schema.SchemaRegistrationResponse;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class DefaultSchemaRegistryClient implements SchemaRegistryClient {
|
||||
|
||||
|
||||
private RestTemplate template;
|
||||
|
||||
private String endpoint = "http://localhost:8990";
|
||||
|
||||
public DefaultSchemaRegistryClient() {
|
||||
this.template = new RestTemplate();
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
Assert.hasText(endpoint, "cannot be empty");
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SchemaRegistrationResponse register(String subject, String format, String schema) {
|
||||
Map<String, String> requestBody = new HashMap<>();
|
||||
requestBody.put("subject", subject);
|
||||
requestBody.put("format", format);
|
||||
requestBody.put("definition", schema);
|
||||
ResponseEntity<Map> responseEntity = this.template.postForEntity(this.endpoint, requestBody, Map.class);
|
||||
if (responseEntity.getStatusCode().is2xxSuccessful()) {
|
||||
SchemaRegistrationResponse registrationResponse = new SchemaRegistrationResponse();
|
||||
Map<String, Object> responseBody = (Map<String, Object>) responseEntity.getBody();
|
||||
registrationResponse.setId((Integer) responseBody.get("id"));
|
||||
registrationResponse.setSchemaReference(
|
||||
new SchemaReference(subject, (Integer) responseBody.get("version"),
|
||||
responseBody.get("format").toString()));
|
||||
return registrationResponse;
|
||||
}
|
||||
throw new RuntimeException("Failed to register schema: " + responseEntity.toString());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fetch(SchemaReference schemaReference) {
|
||||
ResponseEntity<Map> responseEntity = this.template.getForEntity(
|
||||
this.endpoint + "/" + schemaReference.getSubject() + "/" + schemaReference
|
||||
.getFormat() + "/v" + schemaReference
|
||||
.getVersion(), Map.class);
|
||||
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
|
||||
throw new RuntimeException("Failed to fetch schema: " + responseEntity.toString());
|
||||
}
|
||||
return (String) responseEntity.getBody().get("definition");
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fetch(Integer id) {
|
||||
ResponseEntity<Map> responseEntity = this.template.getForEntity(
|
||||
this.endpoint + "/schemas/" + id, Map.class);
|
||||
if (!responseEntity.getStatusCode().is2xxSuccessful()) {
|
||||
throw new RuntimeException("Failed to fetch schema: " + responseEntity.toString());
|
||||
}
|
||||
return (String) responseEntity.getBody().get("definition");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.client;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Inherited;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.cloud.stream.schema.client.config.SchemaRegistryClientConfiguration;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE})
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@Inherited
|
||||
@Configuration
|
||||
@Import(SchemaRegistryClientConfiguration.class)
|
||||
public @interface EnableSchemaRegistryClient {
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.client;
|
||||
|
||||
import org.springframework.cloud.stream.schema.SchemaReference;
|
||||
import org.springframework.cloud.stream.schema.SchemaRegistrationResponse;
|
||||
|
||||
/**
|
||||
* @author Vinicius Carvalho
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public interface SchemaRegistryClient {
|
||||
|
||||
/**
|
||||
* Registers a schema with the remote repository returning the unique identifier associated with this schema.
|
||||
* @param subject the full name of the schema
|
||||
* @param schema
|
||||
* @return a {@link SchemaRegistrationResponse} representing the result of the operation
|
||||
*/
|
||||
SchemaRegistrationResponse register(String subject, String format, String schema);
|
||||
|
||||
/**
|
||||
* Retrieves a schema by its reference (subject and version).
|
||||
* @param schemaReference a {@link SchemaReference} used to identify the target schema.
|
||||
* @return
|
||||
*/
|
||||
String fetch(SchemaReference schemaReference);
|
||||
|
||||
/**
|
||||
* Retrieves a schema by its identifier.
|
||||
* @param id the id of the target schema.
|
||||
* @return
|
||||
*/
|
||||
String fetch(Integer id);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.client.config;
|
||||
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.cloud.stream.schema.client.DefaultSchemaRegistryClient;
|
||||
import org.springframework.cloud.stream.schema.client.SchemaRegistryClient;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
@Configuration
|
||||
@EnableConfigurationProperties(SchemaRegistryClientProperties.class)
|
||||
public class SchemaRegistryClientConfiguration {
|
||||
|
||||
@Bean
|
||||
public SchemaRegistryClient schemaRegistryClient(SchemaRegistryClientProperties schemaRegistryClientProperties) {
|
||||
DefaultSchemaRegistryClient defaultSchemaRegistryClient = new DefaultSchemaRegistryClient();
|
||||
if (StringUtils.hasText(schemaRegistryClientProperties.getEndpoint())) {
|
||||
defaultSchemaRegistryClient.setEndpoint(schemaRegistryClientProperties.getEndpoint());
|
||||
}
|
||||
return defaultSchemaRegistryClient;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.stream.schema.client.config;
|
||||
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "spring.cloud.stream.schemaRegistryClient")
|
||||
public class SchemaRegistryClientProperties {
|
||||
|
||||
private String endpoint;
|
||||
|
||||
public String getEndpoint() {
|
||||
return this.endpoint;
|
||||
}
|
||||
|
||||
public void setEndpoint(String endpoint) {
|
||||
this.endpoint = endpoint;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
||||
org.springframework.cloud.stream.schema.avro.AvroMessageConverterAutoConfiguration
|
||||
@@ -0,0 +1,252 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.schema.avro;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.cloud.stream.annotation.EnableBinding;
|
||||
import org.springframework.cloud.stream.annotation.StreamListener;
|
||||
import org.springframework.cloud.stream.messaging.Sink;
|
||||
import org.springframework.cloud.stream.messaging.Source;
|
||||
import org.springframework.cloud.stream.schema.avro.AvroSchemaMessageConverter;
|
||||
import org.springframework.cloud.stream.schema.client.SchemaRegistryClient;
|
||||
import org.springframework.cloud.stream.test.binder.MessageCollector;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.converter.MessageConverter;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
import org.springframework.util.MimeType;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class AvroSchemaMessageConverterTests {
|
||||
|
||||
static StubSchemaRegistryClient stubSchemaRegistryClient = new StubSchemaRegistryClient();
|
||||
|
||||
@Test
|
||||
public void testSendMessageWithLocation() throws Exception {
|
||||
ConfigurableApplicationContext sourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--schemaLocation=classpath:schemas/users_v1.schema",
|
||||
"--spring.cloud.stream.schemaRegistryClient.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=avro/bytes");
|
||||
Source source = sourceContext.getBean(Source.class);
|
||||
User1 firstOutboundFoo = new User1();
|
||||
firstOutboundFoo.setName("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build());
|
||||
MessageCollector sourceMessageCollector = sourceContext.getBean(MessageCollector.class);
|
||||
Message<?> outboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext barSourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--schemaLocation=classpath:schemas/users_v1.schema",
|
||||
"--spring.cloud.stream.schemaRegistryClient.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=avro/bytes");
|
||||
Source barSource = barSourceContext.getBean(Source.class);
|
||||
User2 firstOutboundUser2 = new User2();
|
||||
firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundUser2.setFavoritePlace("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundUser2.setName("foo" + UUID.randomUUID().toString());
|
||||
barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build());
|
||||
MessageCollector barSourceMessageCollector = barSourceContext.getBean(MessageCollector.class);
|
||||
Message<?> barOutboundMessage = barSourceMessageCollector.forChannel(barSource.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
assertThat(barOutboundMessage).isNotNull();
|
||||
|
||||
|
||||
User2 secondUser2OutboundPojo = new User2();
|
||||
secondUser2OutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
secondUser2OutboundPojo.setFavoritePlace("foo" + UUID.randomUUID().toString());
|
||||
secondUser2OutboundPojo.setName("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(secondUser2OutboundPojo).build());
|
||||
Message<?> secondBarOutboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext sinkContext = SpringApplication.run(AvroSinkApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.schemaRegistryClient.enabled=false",
|
||||
"--schemaLocation=classpath:schemas/users_v1.schema");
|
||||
Sink sink = sinkContext.getBean(Sink.class);
|
||||
sink.input().send(outboundMessage);
|
||||
sink.input().send(barOutboundMessage);
|
||||
sink.input().send(secondBarOutboundMessage);
|
||||
List<User1> receivedUsers = sinkContext.getBean(AvroSinkApplication.class).receivedUsers;
|
||||
assertThat(receivedUsers).hasSize(3);
|
||||
assertThat(receivedUsers.get(0)).isNotSameAs(firstOutboundFoo);
|
||||
assertThat(receivedUsers.get(0).getFavoriteColor()).isEqualTo(firstOutboundFoo.getFavoriteColor());
|
||||
assertThat(receivedUsers.get(0).getName()).isEqualTo(firstOutboundFoo.getName());
|
||||
|
||||
assertThat(receivedUsers.get(1)).isNotSameAs(firstOutboundUser2);
|
||||
assertThat(receivedUsers.get(1).getFavoriteColor()).isEqualTo(firstOutboundUser2.getFavoriteColor());
|
||||
assertThat(receivedUsers.get(1).getName()).isEqualTo(firstOutboundUser2.getName());
|
||||
|
||||
assertThat(receivedUsers.get(2)).isNotSameAs(secondUser2OutboundPojo);
|
||||
assertThat(receivedUsers.get(2).getFavoriteColor()).isEqualTo(secondUser2OutboundPojo.getFavoriteColor());
|
||||
assertThat(receivedUsers.get(2).getName()).isEqualTo(secondUser2OutboundPojo.getName());
|
||||
|
||||
sourceContext.close();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSendMessageWithoutLocation() throws Exception {
|
||||
ConfigurableApplicationContext sourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.schemaRegistryClient.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=avro/bytes");
|
||||
Source source = sourceContext.getBean(Source.class);
|
||||
User1 firstOutboundFoo = new User1();
|
||||
firstOutboundFoo.setName("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build());
|
||||
MessageCollector sourceMessageCollector = sourceContext.getBean(MessageCollector.class);
|
||||
Message<?> outboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext barSourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.schemaRegistryClient.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=avro/bytes");
|
||||
Source barSource = barSourceContext.getBean(Source.class);
|
||||
User2 firstOutboundUser2 = new User2();
|
||||
firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundUser2.setFavoritePlace("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundUser2.setName("foo" + UUID.randomUUID().toString());
|
||||
barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build());
|
||||
MessageCollector barSourceMessageCollector = barSourceContext.getBean(MessageCollector.class);
|
||||
Message<?> barOutboundMessage = barSourceMessageCollector.forChannel(barSource.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
assertThat(barOutboundMessage).isNotNull();
|
||||
|
||||
|
||||
User2 secondUser2OutboundPojo = new User2();
|
||||
secondUser2OutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
secondUser2OutboundPojo.setFavoritePlace("foo" + UUID.randomUUID().toString());
|
||||
secondUser2OutboundPojo.setName("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(secondUser2OutboundPojo).build());
|
||||
Message<?> secondBarOutboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext sinkContext = SpringApplication.run(AvroSinkApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.schemaRegistryClient.enabled=false");
|
||||
Sink sink = sinkContext.getBean(Sink.class);
|
||||
sink.input().send(outboundMessage);
|
||||
sink.input().send(barOutboundMessage);
|
||||
sink.input().send(secondBarOutboundMessage);
|
||||
List<User1> receivedUsers = sinkContext.getBean(AvroSinkApplication.class).receivedUsers;
|
||||
assertThat(receivedUsers).hasSize(3);
|
||||
assertThat(receivedUsers.get(0)).isNotSameAs(firstOutboundFoo);
|
||||
assertThat(receivedUsers.get(0).getFavoriteColor()).isEqualTo(firstOutboundFoo.getFavoriteColor());
|
||||
assertThat(receivedUsers.get(0).getName()).isEqualTo(firstOutboundFoo.getName());
|
||||
|
||||
assertThat(receivedUsers.get(1)).isNotSameAs(firstOutboundUser2);
|
||||
assertThat(receivedUsers.get(1).getFavoriteColor()).isEqualTo(firstOutboundUser2.getFavoriteColor());
|
||||
assertThat(receivedUsers.get(1).getName()).isEqualTo(firstOutboundUser2.getName());
|
||||
|
||||
assertThat(receivedUsers.get(2)).isNotSameAs(secondUser2OutboundPojo);
|
||||
assertThat(receivedUsers.get(2).getFavoriteColor()).isEqualTo(secondUser2OutboundPojo.getFavoriteColor());
|
||||
assertThat(receivedUsers.get(2).getName()).isEqualTo(secondUser2OutboundPojo.getName());
|
||||
|
||||
sourceContext.close();
|
||||
}
|
||||
|
||||
|
||||
@EnableBinding(Source.class)
|
||||
@EnableAutoConfiguration
|
||||
@ConfigurationProperties
|
||||
public static class AvroSourceApplication {
|
||||
|
||||
@Bean
|
||||
public SchemaRegistryClient schemaRegistryClient() {
|
||||
return stubSchemaRegistryClient;
|
||||
}
|
||||
|
||||
private Resource schemaLocation;
|
||||
|
||||
public void setSchemaLocation(Resource schemaLocation) {
|
||||
this.schemaLocation = schemaLocation;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MessageConverter userMessageConverter() throws IOException {
|
||||
AvroSchemaMessageConverter avroSchemaMessageConverter = new AvroSchemaMessageConverter(
|
||||
MimeType.valueOf("avro/bytes"));
|
||||
if (schemaLocation != null) {
|
||||
avroSchemaMessageConverter.setSchemaLocation(schemaLocation);
|
||||
}
|
||||
return avroSchemaMessageConverter;
|
||||
}
|
||||
}
|
||||
|
||||
@EnableBinding(Sink.class)
|
||||
@EnableAutoConfiguration
|
||||
@ConfigurationProperties
|
||||
public static class AvroSinkApplication {
|
||||
|
||||
public List<User1> receivedUsers = new ArrayList<>();
|
||||
|
||||
@StreamListener(Sink.INPUT)
|
||||
public void listen(User1 user) {
|
||||
receivedUsers.add(user);
|
||||
}
|
||||
|
||||
private Resource schemaLocation;
|
||||
|
||||
public void setSchemaLocation(Resource schemaLocation) {
|
||||
this.schemaLocation = schemaLocation;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public MessageConverter userMessageConverter() throws IOException {
|
||||
AvroSchemaMessageConverter avroSchemaMessageConverter = new AvroSchemaMessageConverter(
|
||||
MimeType.valueOf("avro/bytes"));
|
||||
if (schemaLocation != null) {
|
||||
avroSchemaMessageConverter.setSchemaLocation(schemaLocation);
|
||||
}
|
||||
return avroSchemaMessageConverter;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.schema.avro;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.cloud.stream.annotation.EnableBinding;
|
||||
import org.springframework.cloud.stream.annotation.StreamListener;
|
||||
import org.springframework.cloud.stream.messaging.Sink;
|
||||
import org.springframework.cloud.stream.messaging.Source;
|
||||
import org.springframework.cloud.stream.schema.client.EnableSchemaRegistryClient;
|
||||
import org.springframework.cloud.stream.schema.client.SchemaRegistryClient;
|
||||
import org.springframework.cloud.stream.schema.server.SchemaRegistryServerApplication;
|
||||
import org.springframework.cloud.stream.test.binder.MessageCollector;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class AvroSchemaRegistryClientMessageConverterTests {
|
||||
|
||||
static SchemaRegistryClient stubSchemaRegistryClient = new StubSchemaRegistryClient();
|
||||
|
||||
@Test
|
||||
public void testSendMessage() throws Exception {
|
||||
|
||||
ConfigurableApplicationContext schemaRegistryServerContext = SpringApplication.run(
|
||||
SchemaRegistryServerApplication.class);
|
||||
|
||||
ConfigurableApplicationContext sourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=application/*+avro",
|
||||
"--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true");
|
||||
Source source = sourceContext.getBean(Source.class);
|
||||
User1 firstOutboundFoo = new User1();
|
||||
firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundFoo.setName("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build());
|
||||
MessageCollector sourceMessageCollector = sourceContext.getBean(MessageCollector.class);
|
||||
Message<?> outboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext barSourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=application/vnd.user1.v1+avro",
|
||||
"--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true");
|
||||
Source barSource = barSourceContext.getBean(Source.class);
|
||||
User2 firstOutboundUser2 = new User2();
|
||||
firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundUser2.setName("foo" + UUID.randomUUID().toString());
|
||||
barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build());
|
||||
MessageCollector barSourceMessageCollector = barSourceContext.getBean(MessageCollector.class);
|
||||
Message<?> barOutboundMessage = barSourceMessageCollector.forChannel(barSource.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
assertThat(barOutboundMessage).isNotNull();
|
||||
|
||||
|
||||
User2 secondBarOutboundPojo = new User2();
|
||||
secondBarOutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
secondBarOutboundPojo.setName("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(secondBarOutboundPojo).build());
|
||||
Message<?> secondBarOutboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext sinkContext = SpringApplication.run(AvroSinkApplication.class,
|
||||
"--server.port=0", "--spring.jmx.enabled=false");
|
||||
Sink sink = sinkContext.getBean(Sink.class);
|
||||
sink.input().send(outboundMessage);
|
||||
sink.input().send(barOutboundMessage);
|
||||
sink.input().send(secondBarOutboundMessage);
|
||||
List<User2> receivedPojos = sinkContext.getBean(AvroSinkApplication.class).receivedPojos;
|
||||
assertThat(receivedPojos).hasSize(3);
|
||||
assertThat(receivedPojos.get(0)).isNotSameAs(firstOutboundFoo);
|
||||
assertThat(receivedPojos.get(0).getFavoriteColor()).isEqualTo(firstOutboundFoo.getFavoriteColor());
|
||||
assertThat(receivedPojos.get(0).getName()).isEqualTo(firstOutboundFoo.getName());
|
||||
assertThat(receivedPojos.get(0).getFavoritePlace()).isEqualTo("NYC");
|
||||
|
||||
assertThat(receivedPojos.get(1)).isNotSameAs(firstOutboundUser2);
|
||||
assertThat(receivedPojos.get(1).getFavoriteColor()).isEqualTo(firstOutboundUser2.getFavoriteColor());
|
||||
assertThat(receivedPojos.get(1).getName()).isEqualTo(firstOutboundUser2.getName());
|
||||
assertThat(receivedPojos.get(1).getFavoritePlace()).isEqualTo("NYC");
|
||||
|
||||
|
||||
assertThat(receivedPojos.get(2)).isNotSameAs(secondBarOutboundPojo);
|
||||
assertThat(receivedPojos.get(2).getFavoriteColor()).isEqualTo(secondBarOutboundPojo.getFavoriteColor());
|
||||
assertThat(receivedPojos.get(2).getName()).isEqualTo(secondBarOutboundPojo.getName());
|
||||
assertThat(receivedPojos.get(2).getFavoritePlace()).isEqualTo(secondBarOutboundPojo.getFavoritePlace());
|
||||
|
||||
sinkContext.close();
|
||||
barSourceContext.close();
|
||||
sourceContext.close();
|
||||
schemaRegistryServerContext.close();
|
||||
}
|
||||
|
||||
@EnableBinding(Source.class)
|
||||
@EnableAutoConfiguration
|
||||
@EnableSchemaRegistryClient
|
||||
public static class AvroSourceApplication {
|
||||
|
||||
}
|
||||
|
||||
@EnableBinding(Sink.class)
|
||||
@EnableAutoConfiguration
|
||||
@EnableSchemaRegistryClient
|
||||
public static class AvroSinkApplication {
|
||||
|
||||
public List<User2> receivedPojos = new ArrayList<>();
|
||||
|
||||
@StreamListener(Sink.INPUT)
|
||||
public void listen(User2 fooPojo) {
|
||||
receivedPojos.add(fooPojo);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.schema.avro;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.cloud.stream.annotation.EnableBinding;
|
||||
import org.springframework.cloud.stream.annotation.StreamListener;
|
||||
import org.springframework.cloud.stream.messaging.Sink;
|
||||
import org.springframework.cloud.stream.messaging.Source;
|
||||
import org.springframework.cloud.stream.schema.client.SchemaRegistryClient;
|
||||
import org.springframework.cloud.stream.test.binder.MessageCollector;
|
||||
import org.springframework.context.ConfigurableApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.messaging.Message;
|
||||
import org.springframework.messaging.support.MessageBuilder;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class AvroStubSchemaRegistryClientMessageConverterTests {
|
||||
|
||||
static SchemaRegistryClient stubSchemaRegistryClient = new StubSchemaRegistryClient();
|
||||
|
||||
@Test
|
||||
public void testSendMessage() throws Exception {
|
||||
ConfigurableApplicationContext sourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=application/*+avro",
|
||||
"--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true");
|
||||
Source source = sourceContext.getBean(Source.class);
|
||||
User1 firstOutboundFoo = new User1();
|
||||
firstOutboundFoo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundFoo.setName("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(firstOutboundFoo).build());
|
||||
MessageCollector sourceMessageCollector = sourceContext.getBean(MessageCollector.class);
|
||||
Message<?> outboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext barSourceContext = SpringApplication.run(AvroSourceApplication.class,
|
||||
"--server.port=0",
|
||||
"--spring.jmx.enabled=false",
|
||||
"--spring.cloud.stream.bindings.output.contentType=application/vnd.user1.v1+avro",
|
||||
"--spring.cloud.stream.schema.avro.dynamicSchemaGenerationEnabled=true");
|
||||
Source barSource = barSourceContext.getBean(Source.class);
|
||||
User2 firstOutboundUser2 = new User2();
|
||||
firstOutboundUser2.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
firstOutboundUser2.setName("foo" + UUID.randomUUID().toString());
|
||||
barSource.output().send(MessageBuilder.withPayload(firstOutboundUser2).build());
|
||||
MessageCollector barSourceMessageCollector = barSourceContext.getBean(MessageCollector.class);
|
||||
Message<?> barOutboundMessage = barSourceMessageCollector.forChannel(barSource.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
assertThat(barOutboundMessage).isNotNull();
|
||||
|
||||
|
||||
User2 secondBarOutboundPojo = new User2();
|
||||
secondBarOutboundPojo.setFavoriteColor("foo" + UUID.randomUUID().toString());
|
||||
secondBarOutboundPojo.setName("foo" + UUID.randomUUID().toString());
|
||||
source.output().send(MessageBuilder.withPayload(secondBarOutboundPojo).build());
|
||||
Message<?> secondBarOutboundMessage = sourceMessageCollector.forChannel(source.output()).poll(1000,
|
||||
TimeUnit.MILLISECONDS);
|
||||
|
||||
|
||||
ConfigurableApplicationContext sinkContext = SpringApplication.run(AvroSinkApplication.class,
|
||||
"--server.port=0", "--spring.jmx.enabled=false");
|
||||
Sink sink = sinkContext.getBean(Sink.class);
|
||||
sink.input().send(outboundMessage);
|
||||
sink.input().send(barOutboundMessage);
|
||||
sink.input().send(secondBarOutboundMessage);
|
||||
List<User2> receivedPojos = sinkContext.getBean(AvroSinkApplication.class).receivedPojos;
|
||||
assertThat(receivedPojos).hasSize(3);
|
||||
assertThat(receivedPojos.get(0)).isNotSameAs(firstOutboundFoo);
|
||||
assertThat(receivedPojos.get(0).getFavoriteColor()).isEqualTo(firstOutboundFoo.getFavoriteColor());
|
||||
assertThat(receivedPojos.get(0).getName()).isEqualTo(firstOutboundFoo.getName());
|
||||
assertThat(receivedPojos.get(0).getFavoritePlace()).isEqualTo("NYC");
|
||||
|
||||
assertThat(receivedPojos.get(1)).isNotSameAs(firstOutboundUser2);
|
||||
assertThat(receivedPojos.get(1).getFavoriteColor()).isEqualTo(firstOutboundUser2.getFavoriteColor());
|
||||
assertThat(receivedPojos.get(1).getName()).isEqualTo(firstOutboundUser2.getName());
|
||||
assertThat(receivedPojos.get(1).getFavoritePlace()).isEqualTo("NYC");
|
||||
|
||||
|
||||
assertThat(receivedPojos.get(2)).isNotSameAs(secondBarOutboundPojo);
|
||||
assertThat(receivedPojos.get(2).getFavoriteColor()).isEqualTo(secondBarOutboundPojo.getFavoriteColor());
|
||||
assertThat(receivedPojos.get(2).getName()).isEqualTo(secondBarOutboundPojo.getName());
|
||||
assertThat(receivedPojos.get(2).getFavoritePlace()).isEqualTo(secondBarOutboundPojo.getFavoritePlace());
|
||||
|
||||
sourceContext.close();
|
||||
}
|
||||
|
||||
@EnableBinding(Source.class)
|
||||
@EnableAutoConfiguration
|
||||
public static class AvroSourceApplication {
|
||||
|
||||
@Bean
|
||||
public SchemaRegistryClient schemaRegistryClient() {
|
||||
return stubSchemaRegistryClient;
|
||||
}
|
||||
}
|
||||
|
||||
@EnableBinding(Sink.class)
|
||||
@EnableAutoConfiguration
|
||||
public static class AvroSinkApplication {
|
||||
|
||||
public List<User2> receivedPojos = new ArrayList<>();
|
||||
|
||||
@StreamListener(Sink.INPUT)
|
||||
public void listen(User2 fooPojo) {
|
||||
receivedPojos.add(fooPojo);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public SchemaRegistryClient schemaRegistryClient() {
|
||||
return stubSchemaRegistryClient;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.schema.avro;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
import org.springframework.cloud.stream.schema.SchemaNotFoundException;
|
||||
import org.springframework.cloud.stream.schema.SchemaReference;
|
||||
import org.springframework.cloud.stream.schema.SchemaRegistrationResponse;
|
||||
import org.springframework.cloud.stream.schema.avro.AvroSchemaRegistryClientMessageConverter;
|
||||
import org.springframework.cloud.stream.schema.client.SchemaRegistryClient;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class StubSchemaRegistryClient implements SchemaRegistryClient {
|
||||
|
||||
private final AtomicInteger index = new AtomicInteger(0);
|
||||
|
||||
private final Map<Integer, String> schemasById = new HashMap<>();
|
||||
|
||||
private final Map<String, Map<Integer, SchemaWithId>> storedSchemas = new HashMap<>();
|
||||
|
||||
@Override
|
||||
public SchemaRegistrationResponse register(String subject, String format, String schema) {
|
||||
if (!this.storedSchemas.containsKey(subject)) {
|
||||
this.storedSchemas.put(subject, new TreeMap<Integer, SchemaWithId>());
|
||||
}
|
||||
Map<Integer, SchemaWithId> schemaVersions = this.storedSchemas.get(subject);
|
||||
for (Map.Entry<Integer, SchemaWithId> integerSchemaEntry : schemaVersions.entrySet()) {
|
||||
|
||||
if (integerSchemaEntry.getValue().getSchema().equals(schema)) {
|
||||
SchemaRegistrationResponse schemaRegistrationResponse = new SchemaRegistrationResponse();
|
||||
schemaRegistrationResponse.setId(integerSchemaEntry.getValue().getId());
|
||||
schemaRegistrationResponse.setSchemaReference(
|
||||
new SchemaReference(subject, integerSchemaEntry.getKey(),
|
||||
AvroSchemaRegistryClientMessageConverter.AVRO_FORMAT));
|
||||
return schemaRegistrationResponse;
|
||||
}
|
||||
}
|
||||
int nextVersion = schemaVersions.size() + 1;
|
||||
int id = this.index.incrementAndGet();
|
||||
schemaVersions.put(nextVersion, new SchemaWithId(id, schema));
|
||||
SchemaRegistrationResponse schemaRegistrationResponse = new SchemaRegistrationResponse();
|
||||
schemaRegistrationResponse.setId(this.index.getAndIncrement());
|
||||
schemaRegistrationResponse.setSchemaReference(
|
||||
new SchemaReference(subject, nextVersion, AvroSchemaRegistryClientMessageConverter.AVRO_FORMAT));
|
||||
this.schemasById.put(id, schema);
|
||||
return schemaRegistrationResponse;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fetch(SchemaReference schemaReference) {
|
||||
if (!AvroSchemaRegistryClientMessageConverter.AVRO_FORMAT.equals(schemaReference.getFormat())) {
|
||||
throw new IllegalArgumentException("Only 'avro' is supported by this client");
|
||||
}
|
||||
if (!this.storedSchemas.containsKey(schemaReference.getSubject())) {
|
||||
throw new SchemaNotFoundException("Not found: " + schemaReference);
|
||||
}
|
||||
if (!this.storedSchemas.get(schemaReference.getSubject()).containsKey(schemaReference.getVersion())) {
|
||||
throw new SchemaNotFoundException("Not found: " + schemaReference);
|
||||
}
|
||||
return this.storedSchemas.get(schemaReference.getSubject()).get(schemaReference.getVersion()).getSchema();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String fetch(Integer id) {
|
||||
return this.schemasById.get(id);
|
||||
}
|
||||
|
||||
static class SchemaWithId {
|
||||
|
||||
int id;
|
||||
|
||||
String schema;
|
||||
|
||||
SchemaWithId(int id, String schema) {
|
||||
this.id = id;
|
||||
this.schema = schema;
|
||||
}
|
||||
|
||||
public int getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
public String getSchema() {
|
||||
return this.schema;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.schema.avro;
|
||||
|
||||
import org.apache.avro.reflect.Nullable;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class User1 {
|
||||
|
||||
@Nullable
|
||||
private String name;
|
||||
|
||||
private int favoriteNumber;
|
||||
|
||||
@Nullable
|
||||
private String favoriteColor;
|
||||
|
||||
public String getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getFavoriteNumber() {
|
||||
return this.favoriteNumber;
|
||||
}
|
||||
|
||||
public void setFavoriteNumber(int favoriteNumber) {
|
||||
this.favoriteNumber = favoriteNumber;
|
||||
}
|
||||
|
||||
public String getFavoriteColor() {
|
||||
return this.favoriteColor;
|
||||
}
|
||||
|
||||
public void setFavoriteColor(String favoriteColor) {
|
||||
this.favoriteColor = favoriteColor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
/*
|
||||
* Copyright 2016 the original author or authors.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package org.springframework.cloud.schema.avro;
|
||||
|
||||
import org.apache.avro.reflect.AvroDefault;
|
||||
import org.apache.avro.reflect.Nullable;
|
||||
|
||||
/**
|
||||
* @author Marius Bogoevici
|
||||
*/
|
||||
public class User2 {
|
||||
|
||||
@Nullable
|
||||
private String name;
|
||||
|
||||
private int favoriteNumber;
|
||||
|
||||
@Nullable
|
||||
private String favoriteColor;
|
||||
|
||||
@AvroDefault("\"NYC\"")
|
||||
private String favoritePlace = "Boston";
|
||||
|
||||
public String getName() {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
public void setName(String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public int getFavoriteNumber() {
|
||||
return this.favoriteNumber;
|
||||
}
|
||||
|
||||
public void setFavoriteNumber(int favoriteNumber) {
|
||||
this.favoriteNumber = favoriteNumber;
|
||||
}
|
||||
|
||||
public String getFavoriteColor() {
|
||||
return this.favoriteColor;
|
||||
}
|
||||
|
||||
public void setFavoriteColor(String favoriteColor) {
|
||||
this.favoriteColor = favoriteColor;
|
||||
}
|
||||
|
||||
public String getFavoritePlace() {
|
||||
return this.favoritePlace;
|
||||
}
|
||||
|
||||
public void setFavoritePlace(String favoritePlace) {
|
||||
this.favoritePlace = favoritePlace;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"namespace":"org.springframework.cloud.stream.samples",
|
||||
"name": "Status",
|
||||
"type" : "record",
|
||||
"fields": [
|
||||
{"name": "id", "type": "string"},
|
||||
{"name": "text", "type": "string"},
|
||||
{"name": "timestamp", "type": "long"}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"namespace": "example.avro",
|
||||
"type": "record",
|
||||
"name": "User",
|
||||
"fields": [
|
||||
{"name": "name", "type": "string"},
|
||||
{"name": "favoriteNumber", "type": ["int", "null"]},
|
||||
{"name": "favoriteColor", "type": ["string", "null"]}
|
||||
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"namespace": "example.avro",
|
||||
"type": "record",
|
||||
"name": "User",
|
||||
"fields": [
|
||||
{"name": "name", "type": "string"},
|
||||
{"name": "favoriteNumber", "type": ["int", "null"]},
|
||||
{"name": "favoriteColor", "type": ["string", "null"]},
|
||||
{"name": "favoritePlace", "type": ["string","null"], "default" : "NYC"}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user