diff --git a/json-schema-annotations/src/main/java/io/micronaut/jsonschema/GeneratedFromSchema.java b/json-schema-annotations/src/main/java/io/micronaut/jsonschema/GeneratedFromSchema.java new file mode 100644 index 00000000..ea95435d --- /dev/null +++ b/json-schema-annotations/src/main/java/io/micronaut/jsonschema/GeneratedFromSchema.java @@ -0,0 +1,40 @@ +/* + * Copyright 2017-2024 original 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 + * + * https://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 io.micronaut.jsonschema; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * An annotation that signals that the object was generated from json schema. + * + * @since 1.5.0 + * @author Elif Kurtay + */ +@Retention(RetentionPolicy.RUNTIME) +@Target({ ElementType.TYPE }) +public @interface GeneratedFromSchema { + + /** + * The title of the JSON schema file used to generate the object. + * + * @return The fileName + */ + String fileName() default ""; + +} diff --git a/json-schema-generator/src/main/java/io/micronaut/jsonschema/generator/SourceGenerator.java b/json-schema-generator/src/main/java/io/micronaut/jsonschema/generator/SourceGenerator.java index 08491e6c..1ae5f9f0 100644 --- a/json-schema-generator/src/main/java/io/micronaut/jsonschema/generator/SourceGenerator.java +++ b/json-schema-generator/src/main/java/io/micronaut/jsonschema/generator/SourceGenerator.java @@ -18,6 +18,7 @@ import io.micronaut.core.annotation.Internal; import io.micronaut.inject.processing.ProcessingException; import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.jsonschema.GeneratedFromSchema; import io.micronaut.jsonschema.generator.aggregator.AnnotationsAggregator; import io.micronaut.jsonschema.generator.loaders.FileLoader; import io.micronaut.jsonschema.generator.utils.GeneratorContext; @@ -303,7 +304,9 @@ private File generateFromSchema(Schema jsonSchema, Path outputPath, String packa public EnumDef buildEnum(Schema jsonSchema, String builderClassName) { EnumDef.EnumDefBuilder enumBuilder = EnumDef.builder(builderClassName) .addModifiers(Modifier.PUBLIC) - .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)); + .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)) + .addAnnotation(getGeneratedFromSchemaAnn()); + boolean isComplexEnum = false; LinkedHashMap cases = new LinkedHashMap<>(); LinkedHashMap enumValues = new LinkedHashMap<>(); @@ -378,7 +381,8 @@ public EnumDef buildEnum(Schema jsonSchema, String builderClassName) { private RecordDef buildRecord(Schema jsonSchema, String builderClassName) { RecordDef.RecordDefBuilder objectBuilder = RecordDef.builder(builderClassName) .addModifiers(Modifier.PUBLIC) - .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)); + .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)) + .addAnnotation(getGeneratedFromSchemaAnn()); addFields(jsonSchema, objectBuilder); return objectBuilder.build(); @@ -387,7 +391,8 @@ private RecordDef buildRecord(Schema jsonSchema, String builderClassName) { private ClassDef buildClass(Schema jsonSchema, String builderClassName) { ClassDef.ClassDefBuilder objectBuilder = ClassDef.builder(builderClassName) .addModifiers(Modifier.PUBLIC) - .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)); + .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)) + .addAnnotation(getGeneratedFromSchemaAnn()); if (context.hasDefinition(inputFileName + "/superClass")) { var superClass = context.getDefinitionType(inputFileName + "/superClass"); @@ -421,7 +426,8 @@ private ClassDef buildClass(Schema jsonSchema, String builderClassName) { private InterfaceDef buildInterface(Schema jsonSchema, String builderClassName) { InterfaceDef.InterfaceDefBuilder objectBuilder = InterfaceDef.builder(builderClassName) .addModifiers(Modifier.PUBLIC) - .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)); + .addAnnotation(ClassTypeDef.of(SERDEABLE_ANN)) + .addAnnotation(getGeneratedFromSchemaAnn()); if (jsonSchema.hasDiscriminator()) { // top level interface addDiscriminatorAnnotations(jsonSchema, objectBuilder); @@ -632,4 +638,8 @@ public static String getOutputPackageName() { public static VisitorContext.Language getLanguage() { return language; } + + private static AnnotationDef getGeneratedFromSchemaAnn() { + return AnnotationDef.builder(ClassTypeDef.of(GeneratedFromSchema.class)).addMember("fileName", inputFileName).build(); + } } diff --git a/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/AbstractGeneratorSpec.groovy b/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/AbstractGeneratorSpec.groovy index 97f56c32..9ab43fb6 100644 --- a/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/AbstractGeneratorSpec.groovy +++ b/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/AbstractGeneratorSpec.groovy @@ -16,6 +16,7 @@ class AbstractGeneratorSpec extends Specification { TypeDeclaration generateType(String className, String jsonSchema) { SourceGenerator generator = new SourceGenerator("java") + SourceGenerator.setInputFileName("test.schema.json") Path outputPath = new File("output").toPath() // Define the base output path String packageName = "com.example.project"; // Example package name diff --git a/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/SimpleGeneratorSpec.groovy b/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/SimpleGeneratorSpec.groovy index 11b9e0ee..21ad7759 100644 --- a/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/SimpleGeneratorSpec.groovy +++ b/json-schema-generator/src/test/groovy/io/micronaut/jsonschema/generator/SimpleGeneratorSpec.groovy @@ -27,6 +27,9 @@ class SimpleGeneratorSpec extends AbstractGeneratorSpec { then: content == """ @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public enum Status { ACTIVE("active"), @@ -118,6 +121,9 @@ class SimpleGeneratorSpec extends AbstractGeneratorSpec { then: content == """ @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public record Llama( @NotNull @Min(0) int age, @NotNull @Size(min = 1) String name, @@ -159,6 +165,9 @@ class SimpleGeneratorSpec extends AbstractGeneratorSpec { then: content == """ @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public record Llama( @Min(0) int age, Llama name, @@ -207,20 +216,32 @@ class SimpleGeneratorSpec extends AbstractGeneratorSpec { then: content == """ @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public record Default( @Min(0) int age, Defaults defaults ) { @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public record Defaults( Run run ) { @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public record Run( Shell shell, @JsonProperty("working-directory") @Pattern(regexp = "^[a-zA-Z]*") String workingDirectory ) { @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public enum Shell { BASH("bash"), @@ -296,6 +317,9 @@ class SimpleGeneratorSpec extends AbstractGeneratorSpec { then: content == """ @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public class Llama2 { /** * The age @@ -395,6 +419,9 @@ class SimpleGeneratorSpec extends AbstractGeneratorSpec { then: content == """ @Serdeable + @GeneratedFromSchema( + fileName = "test.schema.json" + ) public record Llama3( @NotNull @Size(min = 1) String name, @NotNull String foo, diff --git a/json-schema-registry/build.gradle b/json-schema-registry/build.gradle new file mode 100644 index 00000000..f9db4ccc --- /dev/null +++ b/json-schema-registry/build.gradle @@ -0,0 +1,54 @@ +import io.micronaut.build.internal.generator.BeanGeneratorTask + +plugins { + id("io.micronaut.build.internal.json-schema-module") + id("io.micronaut.build.internal.test-suite-generator-java") +} + +dependencies { + compileOnly(mn.micronaut.core.processor) + + implementation(projects.micronautJsonSchemaAnnotations) + implementation(mn.micronaut.http) + implementation(mn.micronaut.http.client) + implementation(mn.micronaut.jackson.databind) + implementation(mnSerde.micronaut.serde.jackson) + implementation(mnValidation.validation) + + annotationProcessor(mn.micronaut.http.validation) + annotationProcessor(mnSerde.micronaut.serde.processor) + + testAnnotationProcessor(mn.micronaut.inject.java) + testImplementation(mn.micronaut.inject) + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mnTest.micronaut.test.junit5) + testImplementation(libs.junit.jupiter.api) + testRuntimeOnly(libs.junit.jupiter.engine) +} + + +tasks.test { + useJUnitPlatform() +} + +def animalGenerator = tasks.register("generateAnimals", BeanGeneratorTask) { + language = "java" + classpath.from(configurations.beanGenerator) + jsonFile.convention(layout.projectDirectory.file("src/test/resources/animal.schema.json")) + outputDirectory.convention(layout.buildDirectory.dir("generated/jsonSchema")) + packageName.convention("io.micronaut.jsonschema.generator.animals") +} + +tasks.named("generateAnimals") { +} + +sourceSets { + test { + java.srcDir('src/test/java') + java.srcDir(animalGenerator.map(BeanGeneratorTask::getGeneratedSourcesDirectory)) + } +} + +tasks.withType(Checkstyle).configureEach { + enabled = false +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryClient.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryClient.java new file mode 100644 index 00000000..b3f95a25 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryClient.java @@ -0,0 +1,313 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.Post; +import io.micronaut.http.annotation.Put; +import io.micronaut.http.client.annotation.Client; +import io.micronaut.jsonschema.registry.auth.SchemaRegistryAuth; +import io.micronaut.jsonschema.registry.model.*; +import jakarta.inject.Singleton; + +import java.util.List; + +/** + * A client for the Confluent Json Schema Registry. + * + *

Supports the operations defined in the + * SchemaJson Registry API. + * + *

The client is configured with the {@link SchemaRegistryConfig} file for its host address + * and authentication. the configuration parameters are expected to be configured in the application context. + * + * @author Elif Kurtay + * @since 1.5.0 + */ +@Requires(beans = SchemaRegistryConfig.class) +@Requires(property = SchemaRegistryConfig.HOST_URL) +@Client(value = "${" + SchemaRegistryConfig.HOST_URL + "}") +@SchemaRegistryAuth +@Singleton +public interface SchemaRegistryClient { + +// SCHEMA OPERATIONS --------------------------------------------------------------------------- + + /** + * Get the schema string identified by the input ID. + * + * @param id The unique identifier of the schema + * @return The schema string identified by the input ID + */ + @Get("/schemas/ids/{id}") + SchemaResponse getSchemaWithId(@PathVariable int id); + + /** + * Retrieves only the schema identified by the input ID. + * + * @param id The unique identifier of the schema + * @return SchemaJson identified by the ID + */ + @Get("/schemas/ids/{id}/schema") + String getSchemaStringWithId(@PathVariable int id); + + /** + * Get the subject-version pairs identified by the input ID. + * + * @param id The unique identifier of the schema + * @return The subject-version pairs + */ + @Get("/schemas/ids/{id}/versions") + List getSchemaVersionsWithId(@PathVariable int id); + + /** + * Get the schema types that are registered with SchemaJson Registry. + * + * @return The list of schema types + */ + @Get("/schemas/types") + List getSchemaTypes(); + + + // SUBJECTS ------------------------------------------------------------------------------------ + + /** + * Get the list of subjects that are registered with SchemaJson Registry. + * + * @return The list of subject names + */ + @Get("/subjects") + List getSubjects(); + + /** + * Get the list of versions of the schema registered under this subject. + * + * @param subject The subject (topic name, e.g., user-data) + * @return The list of versions + */ + @Get("/subjects/{subject}/versions") + List getSubjectVersions(@PathVariable String subject); + + /** + * Delete the subject and all its versions. + * + * @param subject The subject (topic name, e.g., user-data) + * @return The list of versions that were deleted + */ + @Delete("/subjects/{subject}") + List deleteSubject(@PathVariable String subject); + + /** + * Get a specific version of the schema registered under this subject. + * + * @param subject The subject (topic name, e.g., user-data) + * @param version The version number or 'latest' as a string + * @return The requested schema + */ + @Get("/subjects/{subject}/versions/{version}") + SubjectResponse getSubjectWithVersion(@PathVariable String subject, + @PathVariable String version); + + /** + * Get the schema string registered under this subject and version. + * + * @param subject The subject (topic name, e.g., user-data) + * @param version The version number or 'latest' as a string + * @return The requested schema string (unescaped) + */ + @Get("/subjects/{subject}/versions/{version}/schema") + String getSchemaWithSubjectAndVersion(@PathVariable String subject, + @PathVariable String version); + + /** + * Register a new schema under the specified subject. (Essentially, create a new schema.) + * If successfully registered, this returns the unique identifier of this schema in the registry. + * + * @param subject The subject (topic name, e.g., user-data) + * @param schemaBody The new schema wished to be registered in the form of {@link SubjectRequestBody} + * @return The globally unique identifier of the schema + */ + @Post("/subjects/{subject}/versions") + IdResponse registerNewVersion(@PathVariable String subject, + @Body SubjectRequestBody schemaBody); + + /** + * Checks if a schema has already been registered under the specified subject. + * + * @param subject Subject under which the schema will be registered + * @param schemaBody The new schema wished to be registered in the form of {@link SubjectRequestBody} + * @return the schema string along with its globally unique identifier, its version under this subject and the subject name. + */ + @Post("/subjects/{subject}") + SubjectResponse checkSubject(@PathVariable String subject, + @Body SubjectRequestBody schemaBody); + + /** + * Deletes a specific version of the schema registered under this subject. + * + * @param subject Subject under which the schema is registered + * @param version The version number or 'latest' as a string + * @return The version number that was deleted + */ + @Delete("/subjects/{subject}/versions/{version}") + int deleteSubjectVersion(@PathVariable String subject, + @PathVariable String version); + + /** + * Get the list of versions that reference this schema. + * + * @param subject Subject under which the schema is registered + * @param version The version number or 'latest' as a string + * @return The list of versions that reference this schema + */ + @Get("/subjects/{subject}/versions/{version}/referencedby") + List getSubjectVersionReferencedBy(@PathVariable String subject, + @PathVariable String version); + + /** + * Get the metadata for the specified subject. + * + * @param subject Subject under which the schema is registered + * @return The metadata for the specified subject + */ + @Get("/subjects/{subject}/metadata") + SubjectResponse getSubjectMetadata(@PathVariable String subject); + + // MODE ---------------------------------------------------------------------------------------- + + /** + * Get the global compatibility mode. + * + * @return The global compatibility mode + */ + @Get("/mode") + ModeResponse getMode(); + + /** + * Set the global compatibility mode. + * + * @param mode The new global compatibility mode + * @return The new global compatibility mode + */ + @Put("/mode") + ModeResponse setMode(@Body ModeResponse mode); + + /** + * Get the compatibility mode for the specified subject. + * + * @param subject Subject under which the schema is registered + * @return The compatibility mode for the specified subject + */ + @Get("/mode/{subject}") + ModeResponse getModeForSubject(@PathVariable String subject); + + /** + * Set the compatibility mode for the specified subject. + * + * @param subject Subject under which the schema is registered + * @param mode The new compatibility mode for the specified subject + * @return The new compatibility mode for the specified subject + */ + @Put("/mode/{subject}") + ModeResponse setModeForSubject(@PathVariable String subject, @Body ModeResponse mode); + + /** + * Delete the compatibility mode for the specified subject. + * + * @param subject Subject under which the schema is registered + * @return The compatibility mode that was deleted + */ + @Delete("/mode/{subject}") + ModeResponse deleteModeForSubject(@PathVariable String subject); + + // COMPATIBILITY --------------------------------------------------------------------------- + + /** + * Test input schema against a particular version of a subject's schema for compatibility. + * + * @param subject Subject under which the schema is registered + * @param version The version number or 'latest' as a string + * @param compatibilityRequest The new schema wished to be registered in the form of {@link SubjectRequestBody} + * @return Whether the new schema is compatible with the specified subject and version + */ + @Post("/compatibility/subjects/{subject}/versions/{version}") + CompatibilityResponse checkCompatibilityForSubjectVersion(@PathVariable String subject, + @PathVariable String version, + @Body SubjectRequestBody compatibilityRequest); + + /** + * Perform a compatibility check on the schema against one or more versions in the subject, + * depending on how the compatibility is set. + * + * @param subject Subject under which the schema is registered + * @param compatibilityRequest The new schema to be checked in the form of {@link SubjectRequestBody} + * @return Whether the new schema is compatible with the specified subject + */ + @Post("/compatibility/subjects/{subject}/versions") + CompatibilityResponse checkCompatibilityForSubject(@PathVariable String subject, + @Body SubjectRequestBody compatibilityRequest); + + // CONFIG ---------------------------------------------------------------------------------- + + /** + * Get the global configuration. + * + * @return The global configuration + */ + @Get("/config") + ConfigResponse getConfig(); + + /** + * Set the global configuration. + * + * @param config The new global configuration + * @return The new global configuration + */ + @Put("/config") + ConfigResponse setConfig(@Body ConfigResponse config); + + /** + * Get the configuration for the specified subject. + * + * @param subject Subject under which the schema is registered + * @return The configuration for the specified subject + */ + @Get("/config/{subject}") + ConfigResponse getConfigForSubject(@PathVariable String subject); + + /** + * Set the configuration for the specified subject. + * + * @param subject Subject under which the schema is registered + * @param config The new configuration for the specified subject + * @return The new configuration for the specified subject + */ + @Put("/config/{subject}") + ConfigResponse setConfigForSubject(@PathVariable String subject, @Body ConfigResponse config); + + /** + * Delete the configuration for the specified subject. + * + * @param subject Subject under which the schema is registered + * @return The configuration that was deleted + */ + @Delete("/config/{subject}") + ConfigResponse deleteConfigForSubject(@PathVariable String subject); +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryConfig.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryConfig.java new file mode 100644 index 00000000..24a6331e --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryConfig.java @@ -0,0 +1,116 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry; + +import io.micronaut.context.annotation.ConfigurationProperties; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotBlank; + +import static io.micronaut.jsonschema.registry.SchemaRegistryConfig.PREFIX; + +/** + * Configuration for the schema registry. + * + * @since 1.5.0 + * @author Elif Kurtay + */ +@ConfigurationProperties(PREFIX) +public final class SchemaRegistryConfig { + public static final String PREFIX = "schema.registry"; + public static final String HOST_URL = PREFIX + ".url"; + public static final String BASIC_AUTH_ENABLED = PREFIX + ".basicAuthEnabled"; + public static final String PUSH_TO_REGISTRY_ENABLED = PREFIX + ".pushToRegistryEnabled"; + + @NotBlank + private String url; + private String username; + private String password; + private boolean pushToRegistryEnabled = false; + private boolean basicAuthEnabled = false; + + public SchemaRegistryConfig() { + } + + /** + * @return The URL of the schema registry + */ + public @NotBlank String getUrl() { + return url; + } + + /** + * @param url The URL of the schema registry + */ + public void setUrl(@NotBlank String url) { + this.url = url; + } + + /** + * @return The username to authenticate with + */ + public String getUsername() { + return username; + } + + /** + * @param username The username to authenticate with + */ + public void setUsername(String username) { + this.username = username; + } + + /** + * @return The password to authenticate with + */ + public String getPassword() { + return password; + } + + /** + * @param password The password to authenticate with + */ + public void setPassword(String password) { + this.password = password; + } + + /** + * @return Whether to push to the registry + */ + public boolean isPushToRegistryEnabled() { + return pushToRegistryEnabled; + } + + /** + * @param pushToRegistryEnabled Whether to push to the registry + */ + public void setPushToRegistryEnabled(boolean pushToRegistryEnabled) { + this.pushToRegistryEnabled = pushToRegistryEnabled; + } + + /** + * @return Whether basic auth is enabled + */ + public boolean isBasicAuthEnabled() { + return basicAuthEnabled; + } + + /** + * @param basicAuthEnabled Whether basic auth will be enabled + */ + public void setBasicAuthEnabled(boolean basicAuthEnabled) { + this.basicAuthEnabled = basicAuthEnabled; + } +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryManager.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryManager.java new file mode 100644 index 00000000..2c687650 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/SchemaRegistryManager.java @@ -0,0 +1,109 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry; + +import io.micronaut.context.annotation.Context; +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.core.annotation.Nullable; +import io.micronaut.core.beans.BeanIntrospector; +import io.micronaut.core.io.ResourceLoader; +import io.micronaut.jsonschema.GeneratedFromSchema; +import io.micronaut.jsonschema.registry.model.SchemaType; +import io.micronaut.jsonschema.registry.model.SubjectRequestBody; +import jakarta.inject.Inject; + +import java.io.InputStream; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * A manager for the Confluent SchemaJson Registry Client. + * + * This class is responsible for keeping the registry updated. + * When the application starts, + * - it will check for generated files from a local schema, + * - check if the local schema is different from the one in the registry + * (schema filename and registry subject name needs to be the same), + * - if yes, will push the new local schemas to the registry. + * + * @author Elif Kurtay + * @since 1.5.0 + */ +@Requires(beans = SchemaRegistryClient.class) +@Requires(beans = SchemaRegistryConfig.class) +@Requires(beans = ResourceLoader.class) +@Context +public class SchemaRegistryManager { + SchemaRegistryClient client; + + @Inject + public SchemaRegistryManager(SchemaRegistryClient client, SchemaRegistryConfig config) { + this.client = client; + if (config.isPushToRegistryEnabled()) { + pushToRegistry(); + } + } + + private void pushToRegistry() { + // push all schemas to registry + Set uniqueFileNames = BeanIntrospector.SHARED.findIntrospections(GeneratedFromSchema.class) + .stream() + .map(i -> i.getAnnotation(GeneratedFromSchema.class).stringValue("fileName").orElse(null)) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + // for each unique filename, get schema from file + for (String filename: uniqueFileNames) { + String schemaString = readSchemaFromFile(filename); + if (schemaString == null) { + continue; + } + + var subjectName = getSubjectName(filename); + String responseSchemaString = client.getSchemaWithSubjectAndVersion(subjectName, "latest"); + // compare local vs registry, if different, push to registry + if (!schemaString.equals(responseSchemaString)) { + var response = client.registerNewVersion( + subjectName, + new SubjectRequestBody(schemaString, SchemaType.JSON, null, null, null)); + if (response.id() == -1) { + System.err.println("Error pushing schema to registry: " + filename); + } + } + } + } + + private @NonNull String getSubjectName(@NonNull String filename) { + return filename.substring( + filename.contains("/") ? filename.lastIndexOf('/') : 0, + filename.contains(".") ? filename.indexOf('.') : filename.length()); + } + + private @Nullable String readSchemaFromFile(@NonNull String filename) { + try (InputStream inputStream = getClass().getResourceAsStream("/" + filename)) { + // get local schema file + if (inputStream == null) { + System.err.println("Resource not found: " + filename); + return null; + } + return new String(inputStream.readAllBytes()); + } catch (Exception e) { + throw new RuntimeException("Error loading resource " + filename, e); + } + } +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/auth/SchemaRegistryAuth.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/auth/SchemaRegistryAuth.java new file mode 100644 index 00000000..7c87782c --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/auth/SchemaRegistryAuth.java @@ -0,0 +1,36 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.auth; + +import io.micronaut.http.annotation.FilterMatcher; + +import java.lang.annotation.Documented; +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.PARAMETER; +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * An annotation that can be applied to a client to indicate that basic auth should be used. + */ +@FilterMatcher +@Documented +@Retention(RUNTIME) +@Target({TYPE, PARAMETER}) +public @interface SchemaRegistryAuth { +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/auth/SchemaRegistryClientBasicAuthFilter.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/auth/SchemaRegistryClientBasicAuthFilter.java new file mode 100644 index 00000000..40d15773 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/auth/SchemaRegistryClientBasicAuthFilter.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.auth; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.http.MutableHttpRequest; +import io.micronaut.http.annotation.ClientFilter; +import io.micronaut.http.annotation.RequestFilter; +import io.micronaut.jsonschema.registry.SchemaRegistryConfig; +import jakarta.inject.Singleton; + +/** + * A client filter that adds basic auth to all requests. + * + * @since 1.4.0 + * @author Elif Kurtay + */ +@Requires(beans = SchemaRegistryConfig.class) +@Requires(property = SchemaRegistryConfig.BASIC_AUTH_ENABLED) +@SchemaRegistryAuth +@Singleton +@ClientFilter("/**") +public class SchemaRegistryClientBasicAuthFilter { + private final SchemaRegistryConfig config; + + public SchemaRegistryClientBasicAuthFilter(SchemaRegistryConfig config) { + this.config = config; + } + + /** + * Do basic auth on all requests. + * + * @param request The request + */ + @RequestFilter + public void doFilter(MutableHttpRequest request) { + if (config.isBasicAuthEnabled()) { + request.basicAuth(config.getUsername(), config.getPassword()); + } + } +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/CompatibilityLevel.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/CompatibilityLevel.java new file mode 100644 index 00000000..a6850c4a --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/CompatibilityLevel.java @@ -0,0 +1,33 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The compatibility levels of the schema registry. + * Defaults to BACKWARD. + */ +@Serdeable +public enum CompatibilityLevel { + NONE, + BACKWARD, + BACKWARD_TRANSITIVE, + FORWARD, + FORWARD_TRANSITIVE, + FULL, + FULL_TRANSITIVE +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/CompatibilityResponse.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/CompatibilityResponse.java new file mode 100644 index 00000000..01dca729 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/CompatibilityResponse.java @@ -0,0 +1,29 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The compatibility of the schema. + * + * @param is_compatible Whether the two schemas are compatible + */ +@Serdeable +public record CompatibilityResponse( + boolean is_compatible +) { +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/ConfigResponse.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/ConfigResponse.java new file mode 100644 index 00000000..cfcade6d --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/ConfigResponse.java @@ -0,0 +1,45 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import com.fasterxml.jackson.annotation.JsonAlias; +import io.micronaut.serde.annotation.Serdeable; + +/** + * The configuration of the registry. + * + * @param alias The alias of the registry + * @param normalize Whether to normalize the schema + * @param compatibility The compatibility level + * @param compatibilityGroup The compatibility group + * (used for compatibility checks) + * @param defaultMetadata The default metadata + * @param overrideMetadata The override metadata + * @param defaultRuleSet The default rule set + * @param overrideRuleSet The override rule set + */ +@Serdeable +public record ConfigResponse( + String alias, + boolean normalize, + @JsonAlias("compatibilityLevel") CompatibilityLevel compatibility, + String compatibilityGroup, + Object defaultMetadata, + Object overrideMetadata, + Object defaultRuleSet, + Object overrideRuleSet +) { +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/IdResponse.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/IdResponse.java new file mode 100644 index 00000000..63ad9eb4 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/IdResponse.java @@ -0,0 +1,27 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The ID of a schema. + * + * @param id The ID + */ +@Serdeable +public record IdResponse(int id) { +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/ModeResponse.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/ModeResponse.java new file mode 100644 index 00000000..e0ff87f1 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/ModeResponse.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The mode of the registry. + * + * @param mode The mode type + */ +@Serdeable +public record ModeResponse( + Mode mode +) { + /** + * The mode type. + */ + public enum Mode { + IMPORT, + READONLY, + READWRITE + } +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SchemaResponse.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SchemaResponse.java new file mode 100644 index 00000000..a6a60955 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SchemaResponse.java @@ -0,0 +1,29 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The JSON Schema in string. + * + * @param schema The schema string + */ +@Serdeable +public record SchemaResponse( + String schema +) { +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SchemaType.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SchemaType.java new file mode 100644 index 00000000..5becedf4 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SchemaType.java @@ -0,0 +1,28 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The Schema Types available on the registry. + */ +@Serdeable +public enum SchemaType { + AVRO, + JSON, + PROTOBUF +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectRequestBody.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectRequestBody.java new file mode 100644 index 00000000..fdcf8445 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectRequestBody.java @@ -0,0 +1,54 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * Represents a request body for a subject in the Schema Registry. + * + * @param schema The schema. + * @param schemaType The schema type. + * @param references The references. + * @param metadata The metadata. + * @param ruleSet The rule set. + */ +@Serdeable +public record SubjectRequestBody( + String schema, + SchemaType schemaType, + List references, + Map metadata, + Set ruleSet +) { + /** + * The reference type used in the schema registry. + * + * @param name The reference name. + * @param subject The subject name. + * @param version The subject version. + */ + @Serdeable + public record Reference( + String name, + String subject, + String version) { + } +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectResponse.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectResponse.java new file mode 100644 index 00000000..8ee18b89 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectResponse.java @@ -0,0 +1,37 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The subject details available at the registry. + * + * @param subject The subject name + * @param id The unique global ID of the schema + * @param version The version of the schema + * @param schemaType The type of the schema + * @param schema The schema string + */ +@Serdeable +public record SubjectResponse( + String subject, + int id, + int version, + SchemaType schemaType, + String schema +) { +} diff --git a/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectVersionResponse.java b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectVersionResponse.java new file mode 100644 index 00000000..cc0772e5 --- /dev/null +++ b/json-schema-registry/src/main/java/io/micronaut/jsonschema/registry/model/SubjectVersionResponse.java @@ -0,0 +1,28 @@ +/* + * Copyright 2017-2025 original 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 + * + * https://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 io.micronaut.jsonschema.registry.model; + +import io.micronaut.serde.annotation.Serdeable; + +/** + * The (subject,version) pairs available at the registry. + * + * @param subject The subject name + * @param version The version of the subject + */ +@Serdeable +public record SubjectVersionResponse(String subject, int version) { +} diff --git a/json-schema-registry/src/test/java/io/micronaut/jsonschema/registry/ClientTest.java b/json-schema-registry/src/test/java/io/micronaut/jsonschema/registry/ClientTest.java new file mode 100644 index 00000000..5c1215ec --- /dev/null +++ b/json-schema-registry/src/test/java/io/micronaut/jsonschema/registry/ClientTest.java @@ -0,0 +1,87 @@ +package io.micronaut.jsonschema.registry; + +import com.fasterxml.jackson.databind.json.JsonMapper; +import io.micronaut.context.annotation.Property; +import io.micronaut.core.io.ResourceLoader; +import io.micronaut.jsonschema.registry.model.SchemaType; +import io.micronaut.jsonschema.registry.model.SubjectRequestBody; +import io.micronaut.jsonschema.registry.model.SubjectResponse; +import io.micronaut.test.extensions.junit5.annotation.MicronautTest; +import jakarta.inject.Inject; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@MicronautTest +@Property(name = SchemaRegistryConfig.HOST_URL, value = "/test/") +@Property(name = "schema.registry.mock.service", value = "true") +public class ClientTest { + + @Inject + ResourceLoader resourceLoader; + @Inject + SchemaRegistryClient client; + + SubjectResponse exampleSubject; + + @Test + void testSchemaManager() { + SchemaRegistryConfig config = new SchemaRegistryConfig(); + config.setUrl("/test/"); + config.setPushToRegistryEnabled(true); + SchemaRegistryManager manager = new SchemaRegistryManager(client, config); + assertNotNull(manager); + + assertEquals(List.of("animal"), client.getSubjects()); + assertNotNull(client.getSubjectWithVersion("animal", "1")); + } + + @Test + void testAddNewSubject() throws IOException { + prepareTestData(); + // register new subject + var body = new SubjectRequestBody(exampleSubject.schema(), SchemaType.JSON, List.of(), Map.of(), Set.of()); + var response = client.registerNewVersion(exampleSubject.subject(), body); + + // check subject is correctly registered + assertEquals(2, response.id()); + assertEquals(1, client.getSubjects().size()); + assertEquals(List.of(1), client.getSubjectVersions(exampleSubject.subject())); + assertEquals(exampleSubject, client.getSubjectWithVersion(exampleSubject.subject(), "1")); + assertEquals(exampleSubject, client.getSubjectWithVersion(exampleSubject.subject(), "latest")); + assertEquals(exampleSubject, client.checkSubject(exampleSubject.subject(), body)); + assertEquals(1, client.getSubjects().size()); + + // delete non-existing version + assertEquals(-1, client.deleteSubjectVersion(exampleSubject.subject(), "2")); + + // delete subject + assertEquals(List.of(1), client.deleteSubject(exampleSubject.subject())); + assertEquals(0, client.getSubjects().size()); + } + + @Test + void testGetSubjects() { + var response = client.getSubjects(); + assertEquals(0, response.size()); + } + + public void prepareTestData() throws IOException { + JsonMapper jsonMapper = new JsonMapper(); + Optional expectedOptional = resourceLoader.getResourceAsStream("human.json"); + assertTrue(expectedOptional.isPresent()); + String expected = new String(expectedOptional.get().readAllBytes(), StandardCharsets.UTF_8); + expected = expected.replaceAll("\\s+", "").trim(); + exampleSubject = jsonMapper.readValue(expected, SubjectResponse.class); + } +} diff --git a/json-schema-registry/src/test/java/io/micronaut/jsonschema/registry/MockSchemaRegistryService.java b/json-schema-registry/src/test/java/io/micronaut/jsonschema/registry/MockSchemaRegistryService.java new file mode 100644 index 00000000..bb9ceed2 --- /dev/null +++ b/json-schema-registry/src/test/java/io/micronaut/jsonschema/registry/MockSchemaRegistryService.java @@ -0,0 +1,130 @@ +package io.micronaut.jsonschema.registry; + +import io.micronaut.context.annotation.Requires; +import io.micronaut.core.async.annotation.SingleResult; +import io.micronaut.http.MediaType; +import io.micronaut.http.annotation.Body; +import io.micronaut.http.annotation.Consumes; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Delete; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.annotation.PathVariable; +import io.micronaut.http.annotation.Post; +import io.micronaut.jsonschema.registry.model.IdResponse; +import io.micronaut.jsonschema.registry.model.SubjectRequestBody; +import io.micronaut.jsonschema.registry.model.SubjectResponse; + +import java.util.ArrayList; +import java.util.List; + +@Controller("/test") +@Consumes(MediaType.APPLICATION_JSON) +@Requires(property = "schema.registry.mock.service", value = "true") +public class MockSchemaRegistryService { + private final List subjects = new ArrayList<>(); + private int idCounter = 2; + + @Post("/subjects/{subject}/versions") + @SingleResult + IdResponse registerNewVersion(@PathVariable String subject, + @Body SubjectRequestBody schemaBody) { + // check if subject already exists and get its latest version + var existingSubject = getExistingLatestSubject(subject); + var version = 1; + if (existingSubject != null) { + if (existingSubject.schema().equals(schemaBody.schema())) { + return new IdResponse(existingSubject.id()); + } + version = existingSubject.version() + 1; + } + var newSubject = new SubjectResponse(subject, idCounter, version, schemaBody.schemaType(), schemaBody.schema()); + subjects.add(newSubject); + idCounter++; + return new IdResponse(newSubject.id()); + } + + @Get("/subjects") + @SingleResult + List getSubjects() { + return subjects.stream() + .map(SubjectResponse::subject) + .distinct() + .toList(); + } + + @Get("/subjects/{subject}/versions") + @SingleResult + List getSubjectVersions(@PathVariable String subject) { + return subjects.stream() + .filter(s -> s.subject().equals(subject)) + .map(SubjectResponse::version) + .toList(); + } + + @Delete("/subjects/{subject}") + @SingleResult + List deleteSubject(@PathVariable String subject) { + var deletedVersions = subjects.stream() + .filter(s -> s.subject().equals(subject)) + .map(SubjectResponse::version) + .toList(); + subjects.removeIf(s -> s.subject().equals(subject)); + return deletedVersions; + } + + @Get("/subjects/{subject}/versions/{version}") + @SingleResult + SubjectResponse getSubjectWithVersion(@PathVariable String subject, + @PathVariable String version) { + if (version.equals("latest")) { + return getExistingLatestSubject(subject); + } + return subjects.stream() + .filter(s -> s.subject().equals(subject) && s.version() == Integer.parseInt(version)) + .findFirst() + .orElse(null); + } + + @Get("/subjects/{subject}/versions/{version}/schema") + @SingleResult + String getSchemaWithSubjectAndVersion(@PathVariable String subject, + @PathVariable String version) { + var subjectResponse = getSubjectWithVersion(subject, version); + return subjectResponse != null ? subjectResponse.schema() : null; + } + + @Post("/subjects/{subject}") + @SingleResult + SubjectResponse checkSubject(@PathVariable String subject, + @Body SubjectRequestBody schemaBody) { + return subjects.stream() + .filter(s -> s.subject().equals(subject) && s.schema().equals(schemaBody.schema())) + .findFirst().orElse(null); + } + + @Delete("/subjects/{subject}/versions/{version}") + @SingleResult + int deleteSubjectVersion(@PathVariable String subject, + @PathVariable String version) { + if (version.equals("latest")) { + var latest = getExistingLatestSubject(subject); + if (latest != null) { + subjects.remove(latest); + return latest.version(); + } + return -1; + } + int versionInt = Integer.parseInt(version); + if (!subjects.removeIf(s -> s.subject().equals(subject) && s.version() == versionInt)) { + return -1; + } + return versionInt; + } + + private SubjectResponse getExistingLatestSubject(String subject) { + return subjects.stream() + .filter(s -> s.subject().equals(subject)) + .max((s1, s2) -> Integer.compare(s2.version(), s1.version())) + .orElse(null); + } +} diff --git a/json-schema-registry/src/test/resources/animal.schema.json b/json-schema-registry/src/test/resources/animal.schema.json new file mode 100644 index 00000000..adaebca8 --- /dev/null +++ b/json-schema-registry/src/test/resources/animal.schema.json @@ -0,0 +1,131 @@ +{ + "$schema":"https://json-schema.org/draft/2020-12/schema", + "$id":"https://example.com/schemas/animal.schema.json", + "title":"Animal", + "description":"An animal.", + "type":["object"], + "properties":{ + "id": { + "description": "Unique id for the animal.", + "$ref": "#/definitions/id" + }, + "birthdate":{ + "description":"The birthdate", + "$ref": "#/definitions/date" + }, + "name":{ + "description":"The name", + "type":"string", + "minLength":1 + } + }, + "discriminator": { + "propertyName": "resourceType", + "mapping": { + "Cat": "#/definitions/Cat", + "Dog": "#/definitions/Dog", + "Fish": "#/definitions/Fish", + "Human": "#/oneOf/Human" + } + }, + "oneOf": [{ + "$ref": "#/definitions/Cat" + },{ + "$ref": "#/definitions/Dog" + },{ + "$ref": "#/definitions/Fish" + }, { + "title": "Human", + "type": "object", + "properties": { + "resourceType": { + "const": "Human" + }, + "firstName": { + "type": "string" + }, + "lastName": { + "type": "string" + }, + "sport": { + "type": "string" + } + } + }], + "definitions": { + "id": { + "pattern": "^[A-Za-z0-9\\-\\.]{1,64}$", + "type": "string", + "description": "Any combination of letters, numerals, \"-\" and \".\", with a length limit of 64 characters. (This might be an integer, an unprefixed OID, UUID or any other identifier pattern that meets these constraints.) Ids are case-insensitive." + }, + "date": { + "pattern": "^([0-9]([0-9]([0-9][1-9]|[1-9]0)|[1-9]00)|[1-9]000)(-(0[1-9]|1[0-2])(-(0[1-9]|[1-2][0-9]|3[0-1]))?)?$", + "type": "string", + "description": "A date or partial date (e.g. just year or year + month). There is no UTC offset. The format is a union of the schema types gYear, gYearMonth and date. Dates SHALL be valid dates." + }, + "boolean": { + "pattern": "^true|false$", + "type": "boolean", + "description": "Value of \"true\" or \"false\"" + }, + "Cat": { + "description": "The definition of a cat.", + "properties": { + "resourceType": { + "description": "This is a Cat resource.", + "const": "Cat" + }, + "hasMate": { + "description": "True when the cat has found their mate.", + "$ref": "#/definitions/boolean" + } + }, + "type": ["object"], + "additionalProperties": false + }, + "Dog": { + "description": "The definition of a dog.", + "properties": { + "resourceType": { + "description": "This is a Dog resource.", + "const": "Dog" + }, + "hasMate": { + "description": "True when the dog has found their mate.", + "const": true + }, + "nickname" : { + "description": "A nickname of a good doggo.", + "type": "string" + }, + "enemies" : { + "description": "A list of the dog's cat enemies.", + "type": "array", + "items": { + "$ref": "#/definitions/Cat" + } + } + }, + "type": "object", + "additionalProperties": {"type": "string"} + }, + "Fish": { + "description": "The definition of a fish.", + "properties": { + "resourceType": { + "description": "This is a Fish resource.", + "const": "Fish" + }, + "friends" : { + "description": "A list of the fish's aquarium friends.", + "type": "array", + "items": { + "$ref": "#" + } + } + }, + "type": "object", + "additionalProperties": true + } + } +} diff --git a/json-schema-registry/src/test/resources/human.json b/json-schema-registry/src/test/resources/human.json new file mode 100644 index 00000000..ceeede55 --- /dev/null +++ b/json-schema-registry/src/test/resources/human.json @@ -0,0 +1,7 @@ +{ + "subject": "human", + "version": 1, + "id": 2, + "schemaType": "JSON", + "schema": "{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}" +} diff --git a/settings.gradle b/settings.gradle index 674b2df2..0ec0110b 100644 --- a/settings.gradle +++ b/settings.gradle @@ -26,6 +26,7 @@ include 'json-schema-annotations' include 'json-schema-common' include 'json-schema-processor' include 'json-schema-generator' +include 'json-schema-registry' include 'test-suite' include 'test-suite-generator-java' include 'test-suite-groovy' diff --git a/src/main/docs/guide/generator/example.adoc b/src/main/docs/guide/generator/example.adoc index 3a7201ab..05b53cb6 100644 --- a/src/main/docs/guide/generator/example.adoc +++ b/src/main/docs/guide/generator/example.adoc @@ -5,6 +5,13 @@ The following file is an example JSON schema describing an Animal interface whic include::test-suite-generator-java/src/test/resources/animal.schema.json[] ---- +And the following file shows the generated Java class for the Animal object: + +[source,java] +---- +include::test-suite-generator-java/build/generated/jsonSchema/src/main/java/io/micronaut/jsonschema/generator/animals/Animal.java[] +---- + The following example shows how the `JsonMapper` can map the JSON data to the best fitting/correct class in the inheritance relation even though only the interface is given to the function. snippet::io.micronaut.jsonschema.generator.animals.AnimalTest[project-base="test-suite-generator", tags=mapp, indent=0] diff --git a/src/main/docs/guide/introduction.adoc b/src/main/docs/guide/introduction.adoc index bc06fb06..b07856e5 100644 --- a/src/main/docs/guide/introduction.adoc +++ b/src/main/docs/guide/introduction.adoc @@ -1,4 +1,4 @@ link:https://json-schema.org/[JSON Schema] is a human-readable format for exchanging data that also enables JSON data consistency, validity and interoperability. -Micronaut JSON Schema assists transforming schemas to beans and beans to schemas in your applications. +Micronaut JSON Schema assists transforming schemas to beans and beans to schemas in your applications. In addition, it includes a convenient connection to https://docs.confluent.io/platform/current/schema-registry/index.html[Confluent Schema Registry] for schema management. diff --git a/src/main/docs/guide/registry.adoc b/src/main/docs/guide/registry.adoc new file mode 100644 index 00000000..c3b00689 --- /dev/null +++ b/src/main/docs/guide/registry.adoc @@ -0,0 +1,29 @@ +This section explain the connection to the https://docs.confluent.io/platform/current/schema-registry/index.html[Confluent Schema Registry] for schema management. + +The `SchemaRegistryConfig` class is used to configure the connection to the Confluent Schema Registry. The following properties are available: + +[cols="2,4", options="header"] +|=== +| Configuration Property | Explanation +| `SchemaRegistryConfig.HOST_URL` | This field is mandatory and specifies the URL of the Schema Registry. +| `SchemaRegistryConfig.BASIC_AUTH_ENABLED` | Whether the basic authentication is enabled. Default is `false`. +| `SchemaRegistryConfig.PUSH_TO_REGISTRY_ENABLED` | Whether new schemas should be automatically pushed to the registry. Default is `false`. +| `SchemaRegistryConfig.PREFIX + ".username"` | The username info needed if Basic Auth is enabled. +| `SchemaRegistryConfig.PREFIX + ".password"` | The password info needed if Basic Auth is enabled. +|=== + +The following example shows how to configure the connection to the Confluent Schema Registry: +[source,java] +---- +@Property(name = SchemaRegistryConfig.HOST_URL, value = "/test/") // The URL of your Schema Registry +public class ClientTest { + // Your code here +} +---- + +When the `SchemaRegistryConfig.PUSH_TO_REGISTRY_ENABLED` is set to `true`, the `SchemaRegistryManager` will automatically push new (or updated) schemas to the registry. If the schema already exists in the registry, the client will not push it again. However, there are some requirements: + +- The schema file must be available locally and in the `src/test/resources` directory. +- The schema file must be in the JSON Schema format. +- The schema file must have the same name as the subject name in the Confluent Schema Registry. For example, if the subject name is `test`, the schema file must be named `test.json` or `test.schema.json`. +- The schema must be used to generate sources from with the JSON Schema Generator module so that the generated files include the `@GeneratedFromSchema` annotation. diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 708a414f..3f88e6f8 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -22,6 +22,7 @@ generator: registration: Build-time Generation Configuration usage: Run-time Generation Configuration support: Supported JSON Schema Keywords and Limitations - example: Example Output of Generation + example: Example Usage of Generation +registry: Schema Registry with Confluent repository: Repository releaseHistory: Release History diff --git a/test-suite-generator-java/build.gradle b/test-suite-generator-java/build.gradle index 9b1bc290..12cc0719 100644 --- a/test-suite-generator-java/build.gradle +++ b/test-suite-generator-java/build.gradle @@ -31,7 +31,7 @@ dependencies { testRuntimeOnly(libs.junit.jupiter.engine) } -def beanGenerator = tasks.register("generateAnimals", BeanGeneratorTask) { +def animalGenerator = tasks.register("generateAnimals", BeanGeneratorTask) { language = "java" classpath.from(configurations.beanGenerator) jsonFile.convention(layout.projectDirectory.file("src/test/resources/animal.schema.json")) @@ -94,7 +94,7 @@ tasks.withType(Checkstyle).configureEach { sourceSets { test { java.srcDir('src/test/java') - java.srcDir(beanGenerator.map(BeanGeneratorTask::getGeneratedSourcesDirectory)) + java.srcDir(animalGenerator.map(BeanGeneratorTask::getGeneratedSourcesDirectory)) java.srcDir(foodGenerator.map(BeanGeneratorTask::getGeneratedSourcesDirectory)) java.srcDir(githubGenerator.map(BeanGeneratorTask::getGeneratedSourcesDirectory)) java.srcDir(wordPressGenerator.map(BeanGeneratorTask::getGeneratedSourcesDirectory))