From 2008eb5d496e234a04d8d6e389fbe95b883056ff Mon Sep 17 00:00:00 2001 From: Andriy Dmytruk Date: Wed, 3 Apr 2024 14:24:50 -0400 Subject: [PATCH] Create an initial version of the JSON schema processors --- ...ut.build.internal.json-schema-base.gradle} | 0 ...t.build.internal.json-schema-module.gradle | 10 + ...ld.internal.project-template-module.gradle | 4 - config/checkstyle/suppressions.xml | 3 + gradle.properties | 10 +- gradle/libs.versions.toml | 33 +- json-schema-annotations/build.gradle | 17 + .../io/micronaut/jsonschema/JsonSchema.java | 51 ++ .../jsonschema/JsonSchemaConfiguration.java | 48 ++ json-schema-bom/build.gradle | 11 + json-schema-processor/build.gradle | 31 ++ .../JsonSchemaConfigurationVisitor.java | 93 ++++ .../jsonschema/visitor/JsonSchemaVisitor.java | 209 ++++++++ .../jsonschema/visitor/NameUtils.java | 49 ++ .../DocumentationInfoAggregator.java | 94 ++++ .../aggregator/JacksonInfoAggregator.java | 158 ++++++ .../aggregator/SchemaInfoAggregator.java | 42 ++ .../aggregator/ValidationInfoAggregator.java | 136 +++++ .../jsonschema/visitor/model/Schema.java | 465 ++++++++++++++++++ .../JsonSchemaMapperFactory.java | 48 ++ ...icronaut.inject.visitor.TypeElementVisitor | 2 + .../visitor/AbstractJsonSchemaSpec.groovy | 41 ++ .../JacksonJsonSchemaVisitorSpec.groovy | 133 +++++ .../visitor/JsonSchemaVisitorSpec.groovy | 357 ++++++++++++++ .../visitor/ReferenceSchemaVisitorSpec.groovy | 64 +++ .../src/test/resources/logback-test.xml | 17 + project-template-bom/build.gradle | 4 - project-template/build.gradle | 3 - settings.gradle | 14 +- src/main/docs/guide/configuration.adoc | 17 + src/main/docs/guide/introduction.adoc | 5 +- src/main/docs/guide/quickStart.adoc | 16 +- src/main/docs/guide/serving.adoc | 7 + src/main/docs/guide/supported-features.adoc | 36 ++ src/main/docs/guide/toc.yml | 6 + test-suite-groovy/build.gradle | 34 ++ .../test/AbstractValidationSpec.groovy | 47 ++ .../jsonschema/test/JsonSchemaConfig.groovy | 14 + .../io/micronaut/jsonschema/test/Llama.groovy | 28 ++ .../test/ObjectsValidationSpec.groovy | 50 ++ .../jsonschema/test/RWBlackbird.groovy | 27 + .../jsonschema/test/SchemaController.groovy | 31 ++ .../jsonschema/test/ServingSchemasSpec.groovy | 45 ++ test-suite-kotlin/build.gradle | 38 ++ .../test/AbstractValidationSpec.groovy | 47 ++ .../jsonschema/test/JsonSchemaConfig.groovy | 14 + .../test/ObjectsValidationSpec.groovy | 50 ++ .../jsonschema/test/ServingSchemasSpec.groovy | 45 ++ .../io/micronaut/jsonschema/test/Llama.kt | 24 + .../micronaut/jsonschema/test/RWBlackbird.kt | 23 + .../jsonschema/test/SchemaController.kt | 28 ++ test-suite/build.gradle | 31 ++ .../test/AbstractValidationSpec.groovy | 47 ++ .../test/ObjectsValidationSpec.groovy | 149 ++++++ .../jsonschema/test/ServingSchemasSpec.groovy | 45 ++ .../io/micronaut/jsonschema/test/Bird.java | 55 +++ .../jsonschema/test/JsonSchemaConfig.java | 14 + .../io/micronaut/jsonschema/test/Llama.java | 25 + .../io/micronaut/jsonschema/test/Possum.java | 41 ++ .../jsonschema/test/RWBlackbird.java | 24 + .../micronaut/jsonschema/test/Salamander.java | 126 +++++ .../jsonschema/test/SchemaController.java | 34 ++ 62 files changed, 3331 insertions(+), 39 deletions(-) rename buildSrc/src/main/groovy/{io.micronaut.build.internal.project-template-base.gradle => io.micronaut.build.internal.json-schema-base.gradle} (100%) create mode 100644 buildSrc/src/main/groovy/io.micronaut.build.internal.json-schema-module.gradle delete mode 100644 buildSrc/src/main/groovy/io.micronaut.build.internal.project-template-module.gradle create mode 100644 json-schema-annotations/build.gradle create mode 100644 json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchema.java create mode 100644 json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchemaConfiguration.java create mode 100644 json-schema-bom/build.gradle create mode 100644 json-schema-processor/build.gradle create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaConfigurationVisitor.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaVisitor.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/NameUtils.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/DocumentationInfoAggregator.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/JacksonInfoAggregator.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/SchemaInfoAggregator.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/ValidationInfoAggregator.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/model/Schema.java create mode 100644 json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/serialization/JsonSchemaMapperFactory.java create mode 100644 json-schema-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor create mode 100644 json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/AbstractJsonSchemaSpec.groovy create mode 100644 json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JacksonJsonSchemaVisitorSpec.groovy create mode 100644 json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JsonSchemaVisitorSpec.groovy create mode 100644 json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/ReferenceSchemaVisitorSpec.groovy create mode 100644 json-schema-processor/src/test/resources/logback-test.xml delete mode 100644 project-template-bom/build.gradle delete mode 100644 project-template/build.gradle create mode 100644 src/main/docs/guide/configuration.adoc create mode 100644 src/main/docs/guide/serving.adoc create mode 100644 src/main/docs/guide/supported-features.adoc create mode 100644 test-suite-groovy/build.gradle create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/Llama.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/RWBlackbird.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/SchemaController.groovy create mode 100644 test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy create mode 100644 test-suite-kotlin/build.gradle create mode 100644 test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy create mode 100644 test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy create mode 100644 test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy create mode 100644 test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/Llama.kt create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/RWBlackbird.kt create mode 100644 test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/SchemaController.kt create mode 100644 test-suite/build.gradle create mode 100644 test-suite/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy create mode 100644 test-suite/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy create mode 100644 test-suite/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy create mode 100644 test-suite/src/test/java/io/micronaut/jsonschema/test/Bird.java create mode 100644 test-suite/src/test/java/io/micronaut/jsonschema/test/JsonSchemaConfig.java create mode 100644 test-suite/src/test/java/io/micronaut/jsonschema/test/Llama.java create mode 100644 test-suite/src/test/java/io/micronaut/jsonschema/test/Possum.java create mode 100644 test-suite/src/test/java/io/micronaut/jsonschema/test/RWBlackbird.java create mode 100644 test-suite/src/test/java/io/micronaut/jsonschema/test/Salamander.java create mode 100644 test-suite/src/test/java/io/micronaut/jsonschema/test/SchemaController.java diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.project-template-base.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.json-schema-base.gradle similarity index 100% rename from buildSrc/src/main/groovy/io.micronaut.build.internal.project-template-base.gradle rename to buildSrc/src/main/groovy/io.micronaut.build.internal.json-schema-base.gradle diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.json-schema-module.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.json-schema-module.gradle new file mode 100644 index 00000000..ca5a40f2 --- /dev/null +++ b/buildSrc/src/main/groovy/io.micronaut.build.internal.json-schema-module.gradle @@ -0,0 +1,10 @@ +plugins { + id 'io.micronaut.build.internal.json-schema-base' + id "io.micronaut.build.internal.module" +} + +micronautBuild { + binaryCompatibility { + enabled.set(false) + } +} diff --git a/buildSrc/src/main/groovy/io.micronaut.build.internal.project-template-module.gradle b/buildSrc/src/main/groovy/io.micronaut.build.internal.project-template-module.gradle deleted file mode 100644 index 3fff0b48..00000000 --- a/buildSrc/src/main/groovy/io.micronaut.build.internal.project-template-module.gradle +++ /dev/null @@ -1,4 +0,0 @@ -plugins { - id 'io.micronaut.build.internal.project-template-base' - id "io.micronaut.build.internal.module" -} diff --git a/config/checkstyle/suppressions.xml b/config/checkstyle/suppressions.xml index 73f71b3a..57857404 100644 --- a/config/checkstyle/suppressions.xml +++ b/config/checkstyle/suppressions.xml @@ -9,4 +9,7 @@ + + + diff --git a/gradle.properties b/gradle.properties index 7fc259ea..52ac9404 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,10 +1,10 @@ projectVersion=1.0.0-SNAPSHOT -projectGroup=io.micronaut.project-template +projectGroup=io.micronaut.jsonschema -title=Micronaut project-template -projectDesc=TODO +title=Micronaut JSON schema +projectDesc=JSON schema support for Micronaut projectUrl=https://micronaut.io -githubSlug=micronaut-projects/micronaut-project-template -developers=Graeme Rocher +githubSlug=micronaut-projects/micronaut-json-schema +developers=Andriy Dmytruk org.gradle.caching=true org.gradle.jvmargs=-Xmx1g diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c0d6ab09..5a0fd7c4 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,33 +18,30 @@ [versions] micronaut = "4.3.12" micronaut-docs = "2.0.0" +micronaut-logging = "1.2.0" +micronaut-serde = "2.5.1" micronaut-test = "4.2.1" +micronaut-validation = "4.2.0" + groovy = "4.0.17" +json-schema-validator = "1.4.0" +kotlin = "1.9.23" +ksp = "1.9.23-1.0.19" spock = "2.3-groovy-4.0" -# Managed versions appear in the BOM -# managed-somelib = "1.0" -# managed-somebom = "1.1" - [libraries] -# Core micronaut-core = { module = 'io.micronaut:micronaut-core-bom', version.ref = 'micronaut' } +micronaut-logging = { module = "io.micronaut.logging:micronaut-logging-bom", version.ref = "micronaut-logging" } +micronaut-serde = { module = "io.micronaut.serde:micronaut-serde-bom", version.ref = "micronaut-serde" } +micronaut-test = { module = "io.micronaut.test:micronaut-test-bom", version.ref = "micronaut-test" } +micronaut-validation = { module = "io.micronaut.validation:micronaut-validation-bom", version.ref = "micronaut-validation" } -# -# Managed dependencies appear in the BOM -# -# managed-somelib = { module = "group:artifact", version.ref = "managed-somelib" } - -# -# Imported BOMs, also appearing in the generated BOM -# -# boms-somebom = { module = "com.foo:somebom", version.ref = "managed-somebom" } - -# Other libraries used by the project but non managed +json-schema-validator = { module = "com.networknt:json-schema-validator", version.ref = "json-schema-validator" } -# micronaut-bom = { module = "io.micronaut:micronaut-bom", version.ref = "micronaut" } -# jdoctor = { module = "me.champeau.jdoctor:jdoctor-core", version.ref="jdoctor" } [bundles] [plugins] +kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlin-ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +kotlin-kapt = { id = "org.jetbrains.kotlin.kapt", version.ref = "kotlin" } diff --git a/json-schema-annotations/build.gradle b/json-schema-annotations/build.gradle new file mode 100644 index 00000000..aa28119a --- /dev/null +++ b/json-schema-annotations/build.gradle @@ -0,0 +1,17 @@ +plugins { + id 'io.micronaut.build.internal.json-schema-module' +} + +dependencies { + +} + +configurations.configureEach { + all*.exclude group: "ch.qos.logback" +} + +test { + useJUnitPlatform() + + maxHeapSize = "1024m" +} diff --git a/json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchema.java b/json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchema.java new file mode 100644 index 00000000..67bd23d8 --- /dev/null +++ b/json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchema.java @@ -0,0 +1,51 @@ +/* + * 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; + +/** + * An annotation that signifies that json schema should be created for the object. + * The JSON schema will attempt to mimic the way this object would be serialized. + * + * @since 1.0.0 + * @author Andriy Dmytruk + */ +public @interface JsonSchema { + + /** + * The title of the JSON schema. + * By default, the class name will be used. + * + * @return The title + */ + String title() default ""; + + /** + * The description of the JSON schema. + * By default, javadoc of the object will be used. + * + * @return The description + */ + String description() default ""; + + /** + * The schema's relative or absolute URI. + * The default will create the URI based on class name and configured base URI. + * + * @return The URI + */ + String uri() default ""; + +} diff --git a/json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchemaConfiguration.java b/json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchemaConfiguration.java new file mode 100644 index 00000000..c75910b5 --- /dev/null +++ b/json-schema-annotations/src/main/java/io/micronaut/jsonschema/JsonSchemaConfiguration.java @@ -0,0 +1,48 @@ +/* + * 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; + +/** + * An annotation for globally configuring the JSON schema generation. + * + * @since 1.0.0 + * @author Andriy Dmytruk + */ +public @interface JsonSchemaConfiguration { + + /** + * The location where JSON schemas will be generated inside the build {@code META-INF/} directory. + * + * @return The output location + */ + String outputLocation() default "schemas"; + + /** + * The base URI to be used for schemas. + * + * @return The base URI + */ + String baseUri(); + + /** + * Whether to encode byte array as a JSON array. + * The default and preferred behavior is to encode it as a Base64 string. + * + * @return Whether to represent binary data as array + */ + boolean binaryAsArray() default false; + +} diff --git a/json-schema-bom/build.gradle b/json-schema-bom/build.gradle new file mode 100644 index 00000000..45d15c9b --- /dev/null +++ b/json-schema-bom/build.gradle @@ -0,0 +1,11 @@ +plugins { + id 'io.micronaut.build.internal.json-schema-base' + id "io.micronaut.build.internal.bom" +} + +micronautBuild { + binaryCompatibility { + enabled.set(false) + } +} + diff --git a/json-schema-processor/build.gradle b/json-schema-processor/build.gradle new file mode 100644 index 00000000..a7b0037d --- /dev/null +++ b/json-schema-processor/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'io.micronaut.build.internal.json-schema-module' +} + +dependencies { + compileOnly(mn.micronaut.core.processor) + + implementation(mn.micronaut.http) + + api(projects.micronautJsonSchemaAnnotations) + api(mn.jackson.databind) + + testImplementation(mnValidation.validation) + testImplementation(mn.micronaut.inject.kotlin.test) + testImplementation(mn.micronaut.inject.groovy.test) + testImplementation(mn.micronaut.inject.java.test) + testImplementation(mnLogging.logback.classic) + testImplementation(mnLogging.logback.core) + testImplementation(mnLogging.slf4j.api) + testImplementation(mnLogging.slf4j.simple) +} + +configurations.configureEach { + all*.exclude group: "ch.qos.logback" +} + +test { + useJUnitPlatform() + + maxHeapSize = "1024m" +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaConfigurationVisitor.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaConfigurationVisitor.java new file mode 100644 index 00000000..abdee365 --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaConfigurationVisitor.java @@ -0,0 +1,93 @@ +/* + * 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.visitor; + +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.visitor.TypeElementVisitor; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.jsonschema.JsonSchemaConfiguration; +import io.micronaut.jsonschema.visitor.model.Schema; + +import java.util.HashMap; +import java.util.Map; + +/** + * A visitor for reading the JSON schema configuration. + * It must be defined with a {@link JsonSchemaConfiguration} annotation on a bean. + * + * @since 1.0.0 + * @author Andriy Dmytruk + */ +@Internal +public final class JsonSchemaConfigurationVisitor implements TypeElementVisitor { + + public static final String JSON_SCHEMA_CONFIGURATION_PROPERTY = "io.micronaut.jsonschema.config"; + + @Override + public int getOrder() { + return 1; // Run before the JSON Schema visitor + } + + @Override + public @NonNull TypeElementVisitor.VisitorKind getVisitorKind() { + return VisitorKind.AGGREGATING; + } + + @Override + public void visitClass(ClassElement element, VisitorContext visitorContext) { + AnnotationValue annotation = element.getAnnotation(JsonSchemaConfiguration.class); + if (annotation != null) { + String outputLocation = annotation.stringValue("outputLocation") + .orElse(JsonSchemaContext.DEFAULT_OUTPUT_LOCATION); + String baseUri = annotation.getRequiredValue("baseUri", String.class); + if (baseUri.endsWith("/")) { + baseUri = baseUri.substring(0, baseUri.length() - 1); + } + boolean binaryAsArray = annotation.booleanValue("binaryAsArray") + .orElse(JsonSchemaContext.DEFAULT_BINARY_AS_ARRAY); + JsonSchemaContext context = new JsonSchemaContext(outputLocation, baseUri, binaryAsArray, new HashMap<>()); + visitorContext.put(JSON_SCHEMA_CONFIGURATION_PROPERTY, context); + } + } + + /** + * A configuration for the JSON schema. + * + * @param outputLocation The output location for schemas + * @param baseUrl The base URL of the schemas + * @param binaryAsArray Whether to represent byte arrays as arrays instead of base 64 string + * @param createdSchemasByType A cache of crated schemas + */ + public record JsonSchemaContext( + String outputLocation, + String baseUrl, + boolean binaryAsArray, + Map createdSchemasByType + ) { + public static final String DEFAULT_OUTPUT_LOCATION = "schemas"; + public static final boolean DEFAULT_BINARY_AS_ARRAY = false; + + public static JsonSchemaContext createDefault() { + return new JsonSchemaContext( + DEFAULT_OUTPUT_LOCATION, null, DEFAULT_BINARY_AS_ARRAY, new HashMap<>() + ); + } + } + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaVisitor.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaVisitor.java new file mode 100644 index 00000000..6007e429 --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/JsonSchemaVisitor.java @@ -0,0 +1,209 @@ +/* + * 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.visitor; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.core.annotation.Internal; +import io.micronaut.core.annotation.NonNull; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.ast.EnumElement; +import io.micronaut.inject.ast.PropertyElement; +import io.micronaut.inject.ast.TypedElement; +import io.micronaut.inject.visitor.TypeElementVisitor; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.inject.writer.GeneratedFile; +import io.micronaut.jsonschema.JsonSchema; +import io.micronaut.jsonschema.visitor.JsonSchemaConfigurationVisitor.JsonSchemaContext; +import io.micronaut.jsonschema.visitor.aggregator.DocumentationInfoAggregator; +import io.micronaut.jsonschema.visitor.aggregator.JacksonInfoAggregator; +import io.micronaut.jsonschema.visitor.aggregator.SchemaInfoAggregator; +import io.micronaut.jsonschema.visitor.aggregator.ValidationInfoAggregator; +import io.micronaut.jsonschema.visitor.model.Schema; +import io.micronaut.jsonschema.visitor.model.Schema.Type; +import io.micronaut.jsonschema.visitor.serialization.JsonSchemaMapperFactory; + +import java.io.IOException; +import java.io.Writer; +import java.net.URI; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static io.micronaut.jsonschema.visitor.JsonSchemaConfigurationVisitor.JSON_SCHEMA_CONFIGURATION_PROPERTY; + +/** + * A visitor for creating JSON schemas for beans. + * The bean must have a {@link JsonSchema} annotation. + * + * @since 1.0.0 + * @author Andriy Dmytruk + */ +@Internal +public final class JsonSchemaVisitor implements TypeElementVisitor { + + private static final List SCHEMA_INFO_AGGREGATORS = List.of( + new JacksonInfoAggregator(), + new ValidationInfoAggregator(), + new DocumentationInfoAggregator() + ); + + @Override + public @NonNull TypeElementVisitor.VisitorKind getVisitorKind() { + return VisitorKind.AGGREGATING; + } + + @Override + public void visitClass(ClassElement element, VisitorContext visitorContext) { + if (element.hasAnnotation(JsonSchema.class)) { + JsonSchemaContext context = visitorContext.get(JSON_SCHEMA_CONFIGURATION_PROPERTY, JsonSchemaContext.class, null); + if (context == null) { + context = JsonSchemaContext.createDefault(); + visitorContext.put(JSON_SCHEMA_CONFIGURATION_PROPERTY, context); + } + createTopLevelSchema(element, visitorContext, context); + } + } + + public static Schema createTopLevelSchema(TypedElement element, VisitorContext visitorContext, JsonSchemaContext context) { + Schema schema = context.createdSchemasByType().get(element.getGenericType().getName()); + if (schema != null) { + return schema; + } + schema = new Schema(); + + AnnotationValue schemaAnn = element.getGenericType().getDeclaredAnnotation(JsonSchema.class); + if (schemaAnn != null) { + schema.setTitle(schemaAnn.stringValue("title") + .orElse(element.getGenericType().getSimpleName().replace('$', '.'))); + schemaAnn.stringValue("description").ifPresent(schema::setDescription); + + String uri = schemaAnn.stringValue("uri") + .orElse("/" + NameUtils.camelCaseToKebabCase(schema.getTitle())); + if (!uri.contains("://")) { + if (context.baseUrl() != null) { + uri = context.baseUrl() + uri; + } else { + visitorContext.warn("The JSON schema for type " + element.getName() + + " does not have a resolvable URI", element); + } + } + + schema.set$id(uri); + schema.set$schema(Schema.SCHEMA_DRAFT_2022_12); + } + + setSchemaType(element, visitorContext, context, schema); + + for (SchemaInfoAggregator aggregator: SCHEMA_INFO_AGGREGATORS) { + schema = aggregator.addInfo(element, schema, visitorContext, context); + } + + if (schemaAnn != null) { + context.createdSchemasByType().put(element.getGenericType().getName(), schema); + writeSchema(schema, element.getGenericType(), visitorContext, context); + } + return schema; + } + + public static Schema createSchema(TypedElement element, VisitorContext visitorContext, JsonSchemaContext context) { + Schema topLevelSchema = createTopLevelSchema(element, visitorContext, context); + if (topLevelSchema.get$id() != null) { + return Schema.reference(topLevelSchema.get$id()); + } + return topLevelSchema; + } + + private static void setSchemaType(TypedElement element, VisitorContext visitorContext, JsonSchemaContext context, Schema schema) { + ClassElement type = element.getGenericType(); + if ((type.getName().equals("byte") || type.getName().equals("java.lang.Byte")) && type.isArray()) { + // By default, it is a base 64 encoded string + schema.addType(Type.STRING); + } else if (type.isAssignable(Map.class)) { + ClassElement valueType = type.getTypeArguments().get("V"); + if (valueType.getName().equals("java.lang.Object")) { + schema.addType(Type.OBJECT); + } else { + schema.addType(Type.OBJECT).setAdditionalProperties(createSchema(valueType, visitorContext, context)); + } + } else if (type.isAssignable(Set.class)) { + schema.addType(Type.ARRAY).setItems(createSchema(type.getTypeArguments().get("E"), visitorContext, context)); + } else if (type.isAssignable(Collection.class)) { + schema.addType(Type.ARRAY).setItems(createSchema(type.getTypeArguments().get("E"), visitorContext, context)); + } else if (!type.isPrimitive() && type.getRawClassElement() instanceof EnumElement enumElement) { + // Enum values must be camel case + schema.addType(Type.STRING) + .setEnumValues(enumElement.values().stream().map(v -> (Object) v).toList()); + } else { + switch (type.getName()) { + case "boolean", "java.lang.Boolean" -> schema.addType(Type.BOOLEAN); + case "int", "java.lang.Integer", "long", "java.lang.Long" -> schema.addType(Type.INTEGER); + case "java.math.BigDecimal", "float", "java.lang.Float", + "double", "java.lang.Double" -> schema.addType(Type.NUMBER); + case "java.lang.String" -> schema.addType(Type.STRING); + case "java.time.Instant" -> schema.addType(Type.STRING); + case "java.util.UUID" -> schema.addType(Type.STRING).setFormat("uuid"); + default -> setBeanSchemaProperties(type, visitorContext, context, schema); + } + } + } + + public static void setBeanSchemaProperties(ClassElement element, VisitorContext visitorContext, JsonSchemaContext context, Schema schema) { + schema.addType(Type.OBJECT); + if (schema.getTitle() == null) { + schema.setTitle(element.getSimpleName().replace('$', '.')); + } + context.createdSchemasByType().put(element.getGenericType().getName(), schema); + for (PropertyElement property: element.getBeanProperties()) { + schema.putProperty(property.getName(), createSchema(property, visitorContext, context)); + } + } + + public static void writeSchema(Schema schema, ClassElement originatingElement, VisitorContext visitorContext, JsonSchemaContext context) { + String fileName = getFileName(schema, context); + String path = context.outputLocation() + "/" + fileName + ".schema.json"; + GeneratedFile specFile = visitorContext.visitMetaInfFile(path, originatingElement).orElse(null); + if (specFile == null) { + visitorContext.warn("Unable to get [\" " + path + "\"] file to write JSON schema", null); + } else { + visitorContext.info("Generating JSON schema file: " + specFile.getName()); + try (Writer writer = specFile.openWriter()) { + ObjectMapper mapper = JsonSchemaMapperFactory.createMapper(); + mapper.writeValue(writer, schema); + } catch (IOException e) { + throw new RuntimeException("Failed writing JSON schema " + specFile.getName() + " file: " + e, e); + } + } + } + + private static String getFileName(Schema schema, JsonSchemaContext context) { + String id = schema.get$id(); + if (context.baseUrl() != null && id.startsWith(context.baseUrl())) { + id = id.substring(context.baseUrl().length()); + } else if (id.contains("://")) { + id = URI.create(id).getPath().substring(1); + if (id.startsWith(context.outputLocation())) { + id = id.substring(context.outputLocation().length()); + } + } + if (id.startsWith("/")) { + id = id.substring(1); + } + return id; + } + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/NameUtils.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/NameUtils.java new file mode 100644 index 00000000..73bd2ae1 --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/NameUtils.java @@ -0,0 +1,49 @@ +/* + * 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.visitor; + +/** + * A utility class for name conversions. + */ +public class NameUtils { + + /** + * Convert from camel case to kebab case. + * + * @param value The value + * @return The converted value + */ + public static String camelCaseToKebabCase(String value) { + StringBuilder result = new StringBuilder(); + boolean prevNewWord = true; + for (int i = 0; i < value.length(); ++i) { + if (value.charAt(i) == '.') { + continue; + } + if (Character.isUpperCase(value.charAt(i))) { + if (!prevNewWord) { + result.append("-"); + } + prevNewWord = true; + } else { + prevNewWord = false; + } + result.append(Character.toLowerCase(value.charAt(i))); + } + return result.toString(); + } + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/DocumentationInfoAggregator.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/DocumentationInfoAggregator.java new file mode 100644 index 00000000..89ad8bf3 --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/DocumentationInfoAggregator.java @@ -0,0 +1,94 @@ +/* + * 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.visitor.aggregator; + +import com.github.javaparser.StaticJavaParser; +import com.github.javaparser.javadoc.Javadoc; +import com.github.javaparser.javadoc.JavadocBlockTag; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.ast.PropertyElement; +import io.micronaut.inject.ast.TypedElement; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.jsonschema.visitor.JsonSchemaConfigurationVisitor.JsonSchemaContext; +import io.micronaut.jsonschema.visitor.model.Schema; + +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +/** + * An aggregator for adding information from the jackson serialization annotations. + */ +public class DocumentationInfoAggregator implements SchemaInfoAggregator { + + @Override + public Schema addInfo(TypedElement element, Schema schema, VisitorContext visitorContext, JsonSchemaContext context) { + addElementDoc(element, schema, visitorContext); + addRecordDocs(element.getGenericType(), schema, visitorContext); + return schema; + } + + /** + * Add description to element based on the javadoc. + */ + private void addElementDoc(TypedElement element, Schema schema, VisitorContext visitorContext) { + if ((element instanceof ClassElement) && element.getGenericType().isRecord()) { + return; + } + Optional documentation = element.getDocumentation(); + if (schema.getDescription() == null || !(element instanceof ClassElement)) { + documentation.ifPresent(schema::setDescription); + } + if (schema.getDescription() == null && documentation.isEmpty() && !(element instanceof ClassElement)) { + documentation = element.getGenericType().getDocumentation(); + documentation.ifPresent(schema::setDescription); + } + } + + /** + * Add record documentation. + * Description is added to properties based on javadoc {@code @param} blocks. + */ + private void addRecordDocs(ClassElement element, Schema schema, VisitorContext visitorContext) { + if (!element.isRecord()) { + return; + } + String javadocString = element.getDocumentation().orElse(null); + if (javadocString == null) { + return; + } + Javadoc javadoc = StaticJavaParser.parseJavadoc(javadocString); + if (schema.getDescription() == null && !javadoc.getDescription().isEmpty()) { + schema.setDescription(javadoc.getDescription().toText()); + } + Map propertiesDescription = new HashMap<>(); + for (JavadocBlockTag block : javadoc.getBlockTags()) { + if (block.getType() == JavadocBlockTag.Type.PARAM) { + block.getName().ifPresent(name -> propertiesDescription.put(name, block.getContent().toText())); + } + } + + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + for (PropertyElement property: element.getBeanProperties()) { + Schema propertySchema = schema.getProperties().get(property.getName()); + if (propertySchema != null && propertiesDescription.containsKey(property.getName())) { + propertySchema.setDescription(propertiesDescription.get(property.getName())); + } + } + } + } + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/JacksonInfoAggregator.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/JacksonInfoAggregator.java new file mode 100644 index 00000000..6e17644f --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/JacksonInfoAggregator.java @@ -0,0 +1,158 @@ +/* + * 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.visitor.aggregator; + +import com.fasterxml.jackson.annotation.JsonClassDescription; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonIncludeProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; +import com.fasterxml.jackson.annotation.JsonUnwrapped; +import io.micronaut.core.annotation.AnnotationValue; +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.ast.PropertyElement; +import io.micronaut.inject.ast.TypedElement; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.jsonschema.visitor.JsonSchemaConfigurationVisitor.JsonSchemaContext; +import io.micronaut.jsonschema.visitor.JsonSchemaVisitor; +import io.micronaut.jsonschema.visitor.model.Schema; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * An aggregator for adding information from the jackson serialization annotations. + */ +public class JacksonInfoAggregator implements SchemaInfoAggregator { + + @Override + public Schema addInfo(TypedElement element, Schema schema, VisitorContext visitorContext, JsonSchemaContext context) { + ClassElement type = element.getGenericType(); + + addSubtypeInfo(type, schema, visitorContext, context); + addPropertyInfo(type, schema, visitorContext); + + return schema; + } + + private void addPropertyInfo(ClassElement element, Schema schema, VisitorContext visitorContext) { + if (element.hasAnnotation(JsonClassDescription.class)) { + schema.setDescription(element.stringValue(JsonClassDescription.class).orElse(null)); + } + + Set includeProperties = null; + Set ignoreProperties = null; + if (element.hasAnnotation(JsonIncludeProperties.class)) { + includeProperties = Arrays.stream(element.stringValues(JsonIncludeProperties.class)) + .collect(Collectors.toSet()); + } + if (element.hasAnnotation(JsonIgnoreProperties.class)) { + ignoreProperties = Arrays.stream(element.stringValues(JsonIgnoreProperties.class)) + .collect(Collectors.toSet()); + } + + if (schema.getProperties() != null && !schema.getProperties().isEmpty()) { + for (PropertyElement property: element.getBeanProperties()) { + Schema propertySchema = schema.getProperties().get(property.getName()); + if (propertySchema == null) { + continue; + } + String name = property.stringValue(JsonProperty.class).orElse(property.getName()); + if (property.hasAnnotation(JsonIgnore.class) + || (ignoreProperties != null && ignoreProperties.contains(name)) + ) { + schema.getProperties().remove(property.getName()); + continue; + } + if (includeProperties != null && !includeProperties.contains(name)) { + if (!element.hasAnnotation(JsonInclude.class)) { + schema.getProperties().remove(property.getName()); + continue; + } + } + + element.stringValue(JsonPropertyDescription.class) + .ifPresent(schema::setDescription); + if (property.hasAnnotation(JsonUnwrapped.class)) { + schema.getProperties().remove(property.getName()); + schema.getProperties().putAll(propertySchema.getProperties()); + } else { + property.stringValue(JsonProperty.class).ifPresent(newName -> { + schema.getProperties().remove(property.getName()); + schema.putProperty(newName, propertySchema); + }); + } + } + } + } + + private void addSubtypeInfo(ClassElement element, Schema schema, VisitorContext visitorContext, JsonSchemaContext context) { + AnnotationValue subTypesAnn = element.getAnnotation(JsonSubTypes.class); + AnnotationValue typeInfoAnn = element.getAnnotation(JsonTypeInfo.class); + if (subTypesAnn == null || typeInfoAnn == null) { + return; + } + JsonTypeInfo.Id id = typeInfoAnn.enumValue("use", JsonTypeInfo.Id.class).orElse(Id.NAME); + String discriminatorName = typeInfoAnn.stringValue("property") + .orElse(id.getDefaultPropertyName()); + + for (AnnotationValue subTypeAnn: subTypesAnn.getAnnotations("value", JsonSubTypes.Type.class)) { + ClassElement subType = subTypeAnn.stringValue() + .flatMap(visitorContext::getClassElement).orElse(null); + if (subType != null) { + Schema subTypeSchema = JsonSchemaVisitor.createSchema(subType, visitorContext, context); + schema.addOneOf(subTypeSchema); + + if (discriminatorName != null) { + String discriminatorValue = null; + if (id == Id.MINIMAL_CLASS) { + discriminatorValue = getMinimalClassName(element.getPackageName(), subType.getName()); + } else if (id == Id.NAME) { + if (subTypeAnn.stringValues("names").length != 0) { + subTypeSchema.putProperty(discriminatorName, Schema.string().setEnumValues( + Arrays.stream(subTypeAnn.stringValues("names")).map(v -> (Object) v).toList() + )); + } else { + discriminatorValue = subTypeAnn.stringValue("name") + .orElse(subType.stringValue(JsonTypeName.class).orElse(subType.getSimpleName())); + } + } else { + discriminatorValue = subType.getName(); + } + + if (discriminatorValue != null) { + subTypeSchema.putProperty(discriminatorName, Schema.string().setConstValue(discriminatorValue)); + } + } + } + } + } + + private String getMinimalClassName(String parentClassPackage, String className) { + if (className.startsWith(parentClassPackage)) { + return className.substring(parentClassPackage.length()); + } + return className; + } + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/SchemaInfoAggregator.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/SchemaInfoAggregator.java new file mode 100644 index 00000000..d4a3e295 --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/SchemaInfoAggregator.java @@ -0,0 +1,42 @@ +/* + * 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.visitor.aggregator; + +import io.micronaut.inject.ast.TypedElement; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.jsonschema.visitor.JsonSchemaConfigurationVisitor.JsonSchemaContext; +import io.micronaut.jsonschema.visitor.model.Schema; + +/** + * An interface for objects responsible for aggregating JSON schema info. + * + * @since 1.0.0 + * @author Andriy Dmytruk + */ +public interface SchemaInfoAggregator { + + /** + * A method that is called for adding JSON schema info. + * + * @param element The type + * @param schema The current schema + * @param visitorContext The visitor context + * @param context The JSON schema visitor configuration + * @return The new or modified schema + */ + Schema addInfo(TypedElement element, Schema schema, VisitorContext visitorContext, JsonSchemaContext context); + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/ValidationInfoAggregator.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/ValidationInfoAggregator.java new file mode 100644 index 00000000..5c52ab54 --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/aggregator/ValidationInfoAggregator.java @@ -0,0 +1,136 @@ +/* + * 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.visitor.aggregator; + +import io.micronaut.inject.ast.ClassElement; +import io.micronaut.inject.ast.TypedElement; +import io.micronaut.inject.visitor.VisitorContext; +import io.micronaut.jsonschema.visitor.JsonSchemaConfigurationVisitor.JsonSchemaContext; +import io.micronaut.jsonschema.visitor.model.Schema; +import io.micronaut.jsonschema.visitor.model.Schema.Type; + +import java.math.BigDecimal; +import java.util.List; +import java.util.Map; + +/** + * An aggregator for adding information from the validation annotations. + */ +public class ValidationInfoAggregator implements SchemaInfoAggregator { + + private static final String JAKARTA_VALIDATION_PREFIX = "jakarta.validation.constraints."; + private static final String NULL_ANN = JAKARTA_VALIDATION_PREFIX + "Null"; + private static final String NULLABLE_ANN = "jakarta.annotation.Nullable"; + private static final String ASSERT_FALSE_ANN = JAKARTA_VALIDATION_PREFIX + "AssertFalse"; + private static final String ASSERT_TRUE_ANN = JAKARTA_VALIDATION_PREFIX + "AssertTrue"; + private static final String NOT_EMPTY_ANN = JAKARTA_VALIDATION_PREFIX + "NotEmpty"; + private static final String SIZE_ANN = JAKARTA_VALIDATION_PREFIX + "Size"; + private static final String NOT_BLANK_ANN = JAKARTA_VALIDATION_PREFIX + "NotBlank"; + private static final String NEGATIVE_ANN = JAKARTA_VALIDATION_PREFIX + "Negative"; + private static final String NEGATIVE_OR_ZERO_ANN = JAKARTA_VALIDATION_PREFIX + "NegativeOrZero"; + private static final String POSITIVE_ANN = JAKARTA_VALIDATION_PREFIX + "Positive"; + private static final String POSITIVE_OR_ZERO_ANN = JAKARTA_VALIDATION_PREFIX + "PositiveOrZero"; + private static final String MIN_ANN = JAKARTA_VALIDATION_PREFIX + "Min"; + private static final String MAX_ANN = JAKARTA_VALIDATION_PREFIX + "Max"; + private static final String DECIMAL_MIN_ANN = JAKARTA_VALIDATION_PREFIX + "DecimalMin"; + private static final String DECIMAL_MAX_ANN = JAKARTA_VALIDATION_PREFIX + "DecimalMax"; + private static final String PATTERN_ANN = JAKARTA_VALIDATION_PREFIX + "Pattern"; + private static final String EMAIL_ANN = JAKARTA_VALIDATION_PREFIX + "Email"; + private static final String LIST_SUFFIX = "$List"; + + @Override + public Schema addInfo(TypedElement element, Schema schema, VisitorContext visitorContext, JsonSchemaContext context) { + ClassElement type = element.getGenericType(); + if (element.hasAnnotation(NULL_ANN + LIST_SUFFIX)) { + schema.setType(List.of(Schema.Type.NULL)); + } else if (element.hasAnnotation(NULLABLE_ANN)) { + schema.addType(Schema.Type.NULL); + } + + if (schema.getType().contains(Type.BOOLEAN)) { + if (element.hasAnnotation(ASSERT_FALSE_ANN + LIST_SUFFIX)) { + schema.setConstValue(false); + } else if (element.hasAnnotation(ASSERT_TRUE_ANN + LIST_SUFFIX)) { + schema.setConstValue(true); + } + } + + if (type.isIterable() || type.isAssignable(Map.class)) { + if (element.hasAnnotation(NOT_EMPTY_ANN + LIST_SUFFIX)) { + schema.setMinItems(1); + } + element.getAnnotationValuesByName(SIZE_ANN).forEach(ann -> { + ann.intValue("min").ifPresent(schema::setMinItems); + ann.intValue("max").ifPresent(schema::setMaxItems); + }); + } else { + if (element.hasAnnotation(NOT_BLANK_ANN + LIST_SUFFIX) + || element.hasAnnotation(NOT_EMPTY_ANN + LIST_SUFFIX)) { + schema.setMinLength(1); + } + element.getAnnotationValuesByName(SIZE_ANN).forEach(ann -> { + ann.intValue("min").ifPresent(schema::setMinLength); + ann.intValue("max").ifPresent(schema::setMaxLength); + }); + + if (element.hasAnnotation(NEGATIVE_ANN + LIST_SUFFIX)) { + schema.setExclusiveMaximum(0); + } + if (element.hasAnnotation(NEGATIVE_OR_ZERO_ANN + LIST_SUFFIX)) { + schema.setMaximum(0); + } + if (element.hasAnnotation(POSITIVE_ANN + LIST_SUFFIX)) { + schema.setExclusiveMinimum(0); + } + if (element.hasAnnotation(POSITIVE_OR_ZERO_ANN + LIST_SUFFIX)) { + schema.setMinimum(0); + } + + element.getAnnotationValuesByName(MIN_ANN).forEach(ann -> + ann.intValue().ifPresent(v -> schema.setMinimum(BigDecimal.valueOf(v)))); + element.getAnnotationValuesByName(MAX_ANN).forEach(ann -> + ann.intValue().ifPresent(v -> schema.setMaximum(BigDecimal.valueOf(v)))); + element.getAnnotationValuesByName(DECIMAL_MIN_ANN).forEach(ann -> { + boolean exclusive = !ann.booleanValue("inclusive").orElse(true); + BigDecimal min = ann.stringValue().map(BigDecimal::new).orElse(BigDecimal.ZERO); + if (exclusive) { + schema.setExclusiveMinimum(min); + } else { + schema.setMinimum(min); + } + }); + element.getAnnotationValuesByName(DECIMAL_MAX_ANN).forEach(ann -> { + boolean exclusive = !ann.booleanValue("inclusive").orElse(true); + BigDecimal max = ann.stringValue().map(BigDecimal::new).orElse(BigDecimal.ZERO); + if (exclusive) { + schema.setExclusiveMaximum(max); + } else { + schema.setMaximum(max); + } + }); + + element.getAnnotationValuesByName(PATTERN_ANN).forEach(ann -> + ann.stringValue("regexp").ifPresent(schema::setPattern)); + if (element.hasAnnotation(EMAIL_ANN + LIST_SUFFIX)) { + schema.setFormat("idn-email"); + element.getAnnotationValuesByName(EMAIL_ANN).forEach(ann -> + ann.stringValue("regexp").ifPresent(schema::setPattern)); + } + } + return schema; + } + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/model/Schema.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/model/Schema.java new file mode 100644 index 00000000..7973669d --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/model/Schema.java @@ -0,0 +1,465 @@ +/* + * 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.visitor.model; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonValue; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * A JSON schema. + */ +public final class Schema { + + public static final String SCHEMA_DRAFT_2022_12 = "https://json-schema.org/draft/2020-12/schema"; + + public static final String THIS_SCHEMA_REF = "#"; + public static final String DEF_SCHEMA_REF_PREFIX = "#/$defs/"; + + private String $schema; + private String $id; + private String $ref; + + private String title; + private String description; + + /** + * The supported types of the schema. + */ + private List type; + private String format; + @JsonProperty("const") + private Object constValue; + @JsonProperty("enum") + private List enumValues; + private Schema items; + private Map properties; + + private Object defaultValue; + private Boolean deprecated; + private Boolean readOnly; + private Boolean writeOnly; + private List examples; + + private Object multipleOf; + private Object maximum; + private Object minimum; + private Object exclusiveMaximum; + private Object exclusiveMinimum; + + private Integer maxLength; + private Integer minLength; + private String pattern; + + private Integer maxItems; + private Integer minItems; + private Boolean uniqueItems; + private Integer maxContains; + private Integer minContains; + private List contains; + + private List required; + + private Schema additionalProperties; + + private List oneOf; + + public String getTitle() { + return title; + } + + public Schema setTitle(String title) { + this.title = title; + return this; + } + + public String getDescription() { + return description; + } + + public Schema setDescription(String description) { + this.description = description; + return this; + } + + public List getType() { + return type; + } + + public Schema setType(List type) { + this.type = type; + return this; + } + + public Schema addType(Type type) { + if (this.type == null) { + this.type = new ArrayList<>(); + } + this.type.add(type); + return this; + } + + public String getFormat() { + return format; + } + + public Schema setFormat(String format) { + this.format = format; + return this; + } + + public Object getConstValue() { + return constValue; + } + + public Schema setConstValue(Object constValue) { + this.constValue = constValue; + return this; + } + + public List getEnumValues() { + return enumValues; + } + + public Schema setEnumValues(List enumValues) { + this.enumValues = enumValues; + return this; + } + + public Schema getItems() { + return items; + } + + public Schema setItems(Schema items) { + this.items = items; + return this; + } + + public Map getProperties() { + return properties; + } + + public Schema setProperties(Map properties) { + this.properties = properties; + return this; + } + + public Schema putProperty(String name, Schema property) { + if (properties == null) { + properties = new LinkedHashMap<>(); + } + properties.put(name, property); + return this; + } + + public Object getDefaultValue() { + return defaultValue; + } + + public Schema setDefaultValue(Object defaultValue) { + this.defaultValue = defaultValue; + return this; + } + + public Boolean isDeprecated() { + return deprecated; + } + + public Schema setDeprecated(boolean deprecated) { + this.deprecated = deprecated; + return this; + } + + public Boolean isReadOnly() { + return readOnly; + } + + public Schema setReadOnly(boolean readOnly) { + this.readOnly = readOnly; + return this; + } + + public Boolean isWriteOnly() { + return writeOnly; + } + + public Schema setWriteOnly(boolean writeOnly) { + this.writeOnly = writeOnly; + return this; + } + + public List getExamples() { + return examples; + } + + public Schema setExamples(List examples) { + this.examples = examples; + return this; + } + + public Object getMultipleOf() { + return multipleOf; + } + + public Schema setMultipleOf(Object multipleOf) { + this.multipleOf = multipleOf; + return this; + } + + public Object getMaximum() { + return maximum; + } + + public Schema setMaximum(Object maximum) { + this.maximum = maximum; + return this; + } + + public Object getMinimum() { + return minimum; + } + + public Schema setMinimum(Object minimum) { + this.minimum = minimum; + return this; + } + + public Object getExclusiveMaximum() { + return exclusiveMaximum; + } + + public Schema setExclusiveMaximum(Object exclusiveMaximum) { + this.exclusiveMaximum = exclusiveMaximum; + return this; + } + + public Object getExclusiveMinimum() { + return exclusiveMinimum; + } + + public Schema setExclusiveMinimum(Object exclusiveMinimum) { + this.exclusiveMinimum = exclusiveMinimum; + return this; + } + + public Integer getMaxLength() { + return maxLength; + } + + public Schema setMaxLength(Integer maxLength) { + this.maxLength = maxLength; + return this; + } + + public Integer getMinLength() { + return minLength; + } + + public Schema setMinLength(Integer minLength) { + this.minLength = minLength; + return this; + } + + public String getPattern() { + return pattern; + } + + public Schema setPattern(String pattern) { + this.pattern = pattern; + return this; + } + + public Integer getMaxItems() { + return maxItems; + } + + public Schema setMaxItems(Integer maxItems) { + this.maxItems = maxItems; + return this; + } + + public Integer getMinItems() { + return minItems; + } + + public Schema setMinItems(Integer minItems) { + this.minItems = minItems; + return this; + } + + public Boolean isUniqueItems() { + return uniqueItems; + } + + public Schema setUniqueItems(boolean uniqueItems) { + this.uniqueItems = uniqueItems; + return this; + } + + public Integer getMaxContains() { + return maxContains; + } + + public Schema setMaxContains(Integer maxContains) { + this.maxContains = maxContains; + return this; + } + + public Integer getMinContains() { + return minContains; + } + + public Schema setMinContains(Integer minContains) { + this.minContains = minContains; + return this; + } + + public List getContains() { + return contains; + } + + public Schema setContains(List contains) { + this.contains = contains; + return this; + } + + public List getRequired() { + return required; + } + + public Schema setRequired(List required) { + this.required = required; + return this; + } + + public Schema getAdditionalProperties() { + return additionalProperties; + } + + public Schema setAdditionalProperties(Schema additionalProperties) { + this.additionalProperties = additionalProperties; + return this; + } + + public List getOneOf() { + return oneOf; + } + + public Schema setOneOf(List oneOf) { + this.oneOf = oneOf; + return this; + } + + public Schema addOneOf(Schema one) { + if (oneOf == null) { + oneOf = new ArrayList<>(); + } + oneOf.add(one); + return this; + } + + public String get$schema() { + return $schema; + } + + public Schema set$schema(String $schema) { + this.$schema = $schema; + return this; + } + + public String get$id() { + return $id; + } + + public Schema set$id(String $id) { + this.$id = $id; + return this; + } + + public String get$ref() { + return $ref; + } + + public Schema set$ref(String $ref) { + this.$ref = $ref; + return this; + } + + public static Schema string() { + return new Schema().addType(Type.STRING); + } + + public static Schema number() { + return new Schema().addType(Type.NUMBER); + } + + public static Schema integer() { + return new Schema().addType(Type.INTEGER); + } + + public static Schema object() { + return new Schema().addType(Type.OBJECT); + } + + public static Schema array() { + return new Schema().addType(Type.ARRAY); + } + + public static Schema bool() { + return new Schema().addType(Type.BOOLEAN); + } + + public static Schema reference(String id) { + return new Schema().set$ref(id); + } + + /** + * The type of schema exactly matching a primitive JSON type. + */ + public enum Type { + /** An ordered list of instances. */ + ARRAY, + /** A "true" or "false" value. */ + BOOLEAN, + /** A JSON "null" value. */ + NULL, + /** An integer. */ + INTEGER, + /** An arbitrary-precision, base-10 decimal number value. */ + NUMBER, + /** An unordered set of properties mapping a string to an instance. */ + OBJECT, + /** A string of Unicode code points. */ + STRING; + + @JsonValue + String value() { + return name().toLowerCase(Locale.ENGLISH); + } + + @JsonCreator + static Type fromString(String value) { + return valueOf(value.toUpperCase(Locale.ENGLISH)); + } + } + +} diff --git a/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/serialization/JsonSchemaMapperFactory.java b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/serialization/JsonSchemaMapperFactory.java new file mode 100644 index 00000000..b75cb6b1 --- /dev/null +++ b/json-schema-processor/src/main/java/io/micronaut/jsonschema/visitor/serialization/JsonSchemaMapperFactory.java @@ -0,0 +1,48 @@ +/* + * 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.visitor.serialization; + +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; + +/** + * A factory of mappers for swagger serialization and deserialization. + */ +public class JsonSchemaMapperFactory { + + /** + * Create a JSON object mapper. + * + * @return A JSON object mapper + */ + public static ObjectMapper createMapper() { + ObjectMapper mapper = new ObjectMapper(); + + mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); + mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false); + mapper.configure(SerializationFeature.WRITE_ENUMS_USING_TO_STRING, true); + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + mapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); + mapper.configure(SerializationFeature.WRITE_BIGDECIMAL_AS_PLAIN, true); + mapper.setSerializationInclusion(Include.NON_NULL); + mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true); + + return mapper; + } + +} diff --git a/json-schema-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor b/json-schema-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor new file mode 100644 index 00000000..6da67348 --- /dev/null +++ b/json-schema-processor/src/main/resources/META-INF/services/io.micronaut.inject.visitor.TypeElementVisitor @@ -0,0 +1,2 @@ +io.micronaut.jsonschema.visitor.JsonSchemaVisitor +io.micronaut.jsonschema.visitor.JsonSchemaConfigurationVisitor diff --git a/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/AbstractJsonSchemaSpec.groovy b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/AbstractJsonSchemaSpec.groovy new file mode 100644 index 00000000..ddb56b46 --- /dev/null +++ b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/AbstractJsonSchemaSpec.groovy @@ -0,0 +1,41 @@ +package io.micronaut.jsonschema.visitor + +import com.fasterxml.jackson.databind.ObjectMapper +import io.micronaut.annotation.processing.test.AbstractTypeElementSpec +import io.micronaut.jsonschema.visitor.model.Schema +import io.micronaut.jsonschema.visitor.serialization.JsonSchemaMapperFactory +import org.intellij.lang.annotations.Language +import org.slf4j.Logger +import org.slf4j.LoggerFactory + +abstract class AbstractJsonSchemaSpec extends AbstractTypeElementSpec { + + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractJsonSchemaSpec.class) + + protected Schema buildJsonSchema(String className, String schemaName, @Language("java") String cls, String... parameters) { + ClassLoader classLoader = buildClassLoader(className, cls.formatted(parameters)) + String json = readResource(classLoader, "META-INF/schemas/" + schemaName + ".schema.json") + LOGGER.info("Read JSON schema: ") + LOGGER.info(json) + ObjectMapper objectMapper = JsonSchemaMapperFactory.createMapper() + Schema swagger = objectMapper.readValue(json, Schema) + return swagger + } + + protected String readResource(ClassLoader classLoader, String resourcePath) { + Iterator specs = classLoader.getResources(resourcePath).asIterator() + if (!specs.hasNext()) { + throw new IllegalArgumentException("Could not find resource " + resourcePath) + } + URL spec = specs.next() + BufferedReader reader = new BufferedReader(new InputStreamReader(spec.openStream())) + StringBuilder result = new StringBuilder() + String inputLine + while ((inputLine = reader.readLine()) != null) { + result.append(inputLine).append("\n") + } + reader.close() + return result.toString() + } + +} diff --git a/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JacksonJsonSchemaVisitorSpec.groovy b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JacksonJsonSchemaVisitorSpec.groovy new file mode 100644 index 00000000..0d89cbcd --- /dev/null +++ b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JacksonJsonSchemaVisitorSpec.groovy @@ -0,0 +1,133 @@ +package io.micronaut.jsonschema.visitor + +import io.micronaut.jsonschema.visitor.model.Schema + + +class JacksonJsonSchemaVisitorSpec extends AbstractJsonSchemaSpec { + + void "schema with jackson property annotations"() { + given: + def schema = buildJsonSchema('test.Cow', 'cow', """ + package test; + + import com.fasterxml.jackson.annotation.*; + import io.micronaut.jsonschema.JsonSchema; + + @JsonSchema + public record Cow( + String name, + @JsonIgnore + int age, + @JsonProperty("weight (kg)") + double weight + ) { + } +""") + + expect: + schema.title == "Cow" + schema.properties.size() == 2 + schema.properties['name'].type == [Schema.Type.STRING] + schema.properties['age'] == null + schema.properties['weight (kg)'].type == [Schema.Type.NUMBER] + } + + void "schema with subtypes"() { + given: + def schema = buildJsonSchema('test.Reptile', 'reptile', """ + package test; + + import com.fasterxml.jackson.annotation.*; + import io.micronaut.jsonschema.JsonSchema; + + @JsonSchema + @JsonTypeInfo(%s) + @JsonSubTypes({ + @JsonSubTypes.Type(value = Salamander.class, name = "salamander"), + @JsonSubTypes.Type(value = Alligator.class, name = "alligator") + }) + public interface Reptile { + } + + record Salamander( + String name, + int age + ) implements Reptile { + } + + record Alligator( + String name, + float length + ) implements Reptile { + } +""", typeInfoParams) + + expect: + schema.title == "Reptile" + schema.properties == null + schema.oneOf.size() == 2 + + schema.oneOf[0].title == 'Salamander' + schema.oneOf[0].type == [Schema.Type.OBJECT] + schema.oneOf[0].properties[propertyName].type == [Schema.Type.STRING] + schema.oneOf[0].properties[propertyName].constValue == salamanderName + schema.oneOf[0].properties["name"].type == [Schema.Type.STRING] + schema.oneOf[0].properties["age"].type == [Schema.Type.INTEGER] + + schema.oneOf[1].title == 'Alligator' + schema.oneOf[1].type == [Schema.Type.OBJECT] + schema.oneOf[1].properties[propertyName].type == [Schema.Type.STRING] + schema.oneOf[1].properties[propertyName].constValue == alligatorName + schema.oneOf[1].properties["name"].type == [Schema.Type.STRING] + schema.oneOf[1].properties["length"].type == [Schema.Type.NUMBER] + + where: + typeInfoParams | propertyName | salamanderName | alligatorName + "use = JsonTypeInfo.Id.CLASS" | "@class" | "test.Salamander" | "test.Alligator" + "use = JsonTypeInfo.Id.MINIMAL_CLASS" | "@c" | ".Salamander" | ".Alligator" + "use = JsonTypeInfo.Id.NAME" | "@type" | "salamander" | "alligator" + "use = JsonTypeInfo.Id.NAME, property = \"name\"" | "name" | "salamander" | "alligator" + } + + void "schema with referenced subtypes"() { + given: + def schema = buildJsonSchema('test.Reptile', 'reptile', """ + package test; + + import com.fasterxml.jackson.annotation.*; + import io.micronaut.jsonschema.JsonSchema; + + @JsonSchema + @JsonTypeInfo(use = JsonTypeInfo.Id.CLASS) + @JsonSubTypes({ + @JsonSubTypes.Type(value = Salamander.class, name = "salamander"), + @JsonSubTypes.Type(value = Alligator.class, name = "alligator") + }) + public interface Reptile { + } + + @JsonSchema + record Salamander( + String name, + int age + ) implements Reptile { + } + + @JsonSchema + record Alligator( + String name, + float length + ) implements Reptile { + } +""") + + expect: + schema.title == "Reptile" + schema.properties == null + schema.oneOf.size() == 2 + + schema.oneOf[0].$ref == '/salamander' + schema.oneOf[1].$ref == '/alligator' + } + +} diff --git a/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JsonSchemaVisitorSpec.groovy b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JsonSchemaVisitorSpec.groovy new file mode 100644 index 00000000..7b3f51bf --- /dev/null +++ b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/JsonSchemaVisitorSpec.groovy @@ -0,0 +1,357 @@ +package io.micronaut.jsonschema.visitor + +import io.micronaut.jsonschema.visitor.model.Schema + + +class JsonSchemaVisitorSpec extends AbstractJsonSchemaSpec { + + void "simple record schema"() { + given: + def schema = buildJsonSchema('test.Salamander', 'salamander', """ + package test; + + import io.micronaut.jsonschema.JsonSchema; + import java.util.*; + + @JsonSchema + public record Salamander( + String name, + int age, + Color color, + List environments, + Map> complexMap + ) { + } + + enum Color { + RED, + GREEN, + BLUE + } +""") + + expect: + schema.title == "Salamander" + schema.properties['name'].type == [Schema.Type.STRING] + schema.properties['age'].type == [Schema.Type.INTEGER] + schema.properties['color'].type == [Schema.Type.STRING] + schema.properties['color'].enumValues == ["RED", "GREEN", "BLUE"] + schema.properties['environments'].type == [Schema.Type.ARRAY] + schema.properties['environments'].items.type == [Schema.Type.STRING] + schema.properties['complexMap'].type == [Schema.Type.OBJECT] + schema.properties['complexMap'].additionalProperties.type == [Schema.Type.ARRAY] + schema.properties['complexMap'].additionalProperties.items.type == [Schema.Type.STRING] + } + + void "simple record customized schema"() { + given: + def schema = buildJsonSchema('test.GreenSalamander', 'dark-green-salamander', """ + package test; + + import io.micronaut.jsonschema.JsonSchema; + import java.util.*; + + @JsonSchema( + title = "DarkGreenSalamander", + description = "A dark green salamander", + uri = "https://example.com/schemas/dark-green-salamander.schema.json" + ) + public record GreenSalamander( + ) { + } +""") + + expect: + schema.title == "DarkGreenSalamander" + schema.description == "A dark green salamander" + schema.$id == "https://example.com/schemas/dark-green-salamander.schema.json" + schema.$schema != null + } + + void "simple record with configuration schema"() { + given: + def schema = buildJsonSchema('test.Salamander', 'green-salamander', """ + package test; + + import io.micronaut.jsonschema.*; + import java.util.*; + + @JsonSchemaConfiguration( + baseUri = "https://example.com/schemas" + ) + interface AnimalJsonSchemaConfiguration { + } + + @JsonSchema(title = "GreenSalamander") + public record Salamander( + ) { + } +""") + + expect: + schema.title == "GreenSalamander" + schema.$id == "https://example.com/schemas/green-salamander" + schema.$schema != null + } + + void "simple record with configuration schema and uri"() { + given: + def schema = buildJsonSchema('test.Salamander', 'salamander/green-salamander', """ + package test; + + import io.micronaut.jsonschema.*; + import java.util.*; + + @JsonSchemaConfiguration( + baseUri = "https://example.com/schemas" + ) + interface AnimalJsonSchemaConfiguration { + } + + @JsonSchema(title = "GreenSalamander", uri = "/salamander/green-salamander") + public record Salamander( + ) { + } +""") + + expect: + schema.title == "GreenSalamander" + schema.$id == "https://example.com/schemas/salamander/green-salamander" + schema.$schema != null + } + + void "simple class schema"() { + given: + def schema = buildJsonSchema('test.Salamander', 'salamander', """ + package test; + + import io.micronaut.jsonschema.JsonSchema; + import java.util.*; + + @JsonSchema + public class Salamander { + private String name; + private int age; + private Color color; + private List environments; + private Map> complexMap; + + public Salamander(String name, int age, Color color, List environments, Map> complexMap) { + } + + public String getName() { + return name; + } + + public int getAge() { + return age; + } + + public Color getColor() { + return color; + } + + public List getEnvironments() { + return environments; + } + + public Map> getComplexMap() { + return complexMap; + } + } + + enum Color { + RED, + GREEN, + BLUE + } +""") + + expect: + schema.title == "Salamander" + schema.properties['name'].type == [Schema.Type.STRING] + schema.properties['age'].type == [Schema.Type.INTEGER] + schema.properties['color'].type == [Schema.Type.STRING] + schema.properties['color'].enumValues == ["RED", "GREEN", "BLUE"] + schema.properties['environments'].type == [Schema.Type.ARRAY] + schema.properties['environments'].items.type == [Schema.Type.STRING] + schema.properties['complexMap'].type == [Schema.Type.OBJECT] + schema.properties['complexMap'].additionalProperties.type == [Schema.Type.ARRAY] + schema.properties['complexMap'].additionalProperties.items.type == [Schema.Type.STRING] + } + + void "validation schema"() { + given: + def schema = buildJsonSchema('test.Salamander', 'salamander', """ + package test; + + import io.micronaut.jsonschema.JsonSchema; + import jakarta.annotation.Nullable; + import jakarta.validation.constraints.*; + import java.util.*; + + @JsonSchema + public record Salamander( + @NotEmpty + List colors, + @Size(min = 2, max = 10) + List<@Size(min = 3) String> environments, + @NotBlank + String skinColor, + @Size(min = 3, max = 20) + @Pattern(regexp = "[a-zA-Z \\\\-]+") + String species, + @PositiveOrZero + int age, + @Negative + int negative, + @Min(10) + @Max(100) + long integer, + @DecimalMin("10") + @DecimalMax("100.5") + double number, + @Null + String alwaysNull, + @Nullable + String nullable, + @AssertFalse + boolean alwaysFalse, + @AssertTrue + boolean alwaysTrue + ) { + } +""") + + expect: + schema.title == "Salamander" + schema.properties['colors'].minItems == 1 + schema.properties['environments'].minItems == 2 + schema.properties['environments'].maxItems == 10 + schema.properties['environments'].items.minLength == 3 + schema.properties['skinColor'].minLength == 1 + schema.properties['species'].minLength == 3 + schema.properties['species'].maxLength == 20 + schema.properties['species'].pattern == '[a-zA-Z \\-]+' + schema.properties['age'].minimum == 0 + schema.properties['negative'].exclusiveMaximum == 0 + schema.properties['integer'].minimum == 10 + schema.properties['integer'].maximum == 100 + schema.properties['number'].minimum == 10 + schema.properties['number'].maximum == 100.5 + schema.properties['alwaysNull'].type == [Schema.Type.NULL] + schema.properties['nullable'].type == [Schema.Type.STRING, Schema.Type.NULL] + schema.properties['alwaysTrue'].constValue == true + schema.properties['alwaysFalse'].constValue == false + } + + void "class schema with documentation"() { + given: + def schema = buildJsonSchema('test.Heron', 'heron', """ + package test; + + import io.micronaut.jsonschema.JsonSchema; + import java.util.*; + + /** + * A long-legged, long-necked, freshwater and coastal bird. + */ + @JsonSchema + public class Heron { + private String name; + private int age; + private Color color; + private Color beakColor; + + public Heron(String name, int age, Color color, Color beakColor) { + } + + /** + * The name. + */ + public String getName() { + return name; + } + + /** + * The age. + */ + public int getAge() { + return age; + } + + public Color getColor() { + return color; + } + + /** + * The color of the beak. + */ + public Color getBeakColor() { + return beakColor; + } + } + + /** + * The feather color. + */ + enum Color { + WHITE, + BLUE, + GREY + } +""") + + expect: + schema.title == "Heron" + schema.description == "A long-legged, long-necked, freshwater and coastal bird." + schema.properties['name'].description == "The name." + schema.properties['age'].description == "The age." + schema.properties['color'].description == "The feather color." + schema.properties['beakColor'].description == "The color of the beak." + } + + void "record schema with documentation"() { + given: + def schema = buildJsonSchema('test.Heron', 'heron', """ + package test; + + import io.micronaut.jsonschema.JsonSchema; + import java.util.*; + + /** + * A long-legged, long-necked, freshwater and coastal bird. + * + * @param name The name. + * @param age The age. + * @param beakColor The color of the beak. + */ + @JsonSchema + public record Heron ( + String name, + int age, + Color color, + Color beakColor + ) { + } + + /** + * The feather color. + */ + enum Color { + WHITE, + BLUE, + GREY + } +""") + + expect: + schema.title == "Heron" + schema.description == "A long-legged, long-necked, freshwater and coastal bird." + schema.properties['name'].description == "The name." + schema.properties['age'].description == "The age." + schema.properties['color'].description == "The feather color." + schema.properties['beakColor'].description == "The color of the beak." + } + +} diff --git a/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/ReferenceSchemaVisitorSpec.groovy b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/ReferenceSchemaVisitorSpec.groovy new file mode 100644 index 00000000..ae834733 --- /dev/null +++ b/json-schema-processor/src/test/groovy/io/micronaut/jsonschema/visitor/ReferenceSchemaVisitorSpec.groovy @@ -0,0 +1,64 @@ +package io.micronaut.jsonschema.visitor + +import io.micronaut.jsonschema.visitor.model.Schema + + +class ReferenceSchemaVisitorSpec extends AbstractJsonSchemaSpec { + + void "self-referencing schema"() { + given: + def schema = buildJsonSchema('test.Possum', 'possum', """ + package test; + + import com.fasterxml.jackson.annotation.*; + import io.micronaut.jsonschema.JsonSchema; + import java.util.List; + + @JsonSchema + public record Possum( + List children + ) { + } +""") + + expect: + schema.title == "Possum" + schema.properties.size() == 1 + schema.properties['children'].type == [Schema.Type.ARRAY] + schema.properties['children'].items.$ref == '/possum' + } + + void "schema reference"() { + given: + def schema = buildJsonSchema('test.Player', 'player', """ + package test; + + import com.fasterxml.jackson.annotation.*; + import io.micronaut.jsonschema.JsonSchema; + import java.util.List; + + @JsonSchema + public record Player( + String name, + Position pos + ) { + } + + @JsonSchema + record Position( + double x, + double y + ) { + } + + +""") + + expect: + schema.title == "Player" + schema.properties.size() == 2 + schema.properties['name'].type == [Schema.Type.STRING] + schema.properties['pos'].$ref == '/position' + } + +} diff --git a/json-schema-processor/src/test/resources/logback-test.xml b/json-schema-processor/src/test/resources/logback-test.xml new file mode 100644 index 00000000..34298613 --- /dev/null +++ b/json-schema-processor/src/test/resources/logback-test.xml @@ -0,0 +1,17 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + diff --git a/project-template-bom/build.gradle b/project-template-bom/build.gradle deleted file mode 100644 index 16287898..00000000 --- a/project-template-bom/build.gradle +++ /dev/null @@ -1,4 +0,0 @@ -plugins { - id 'io.micronaut.build.internal.project-template-base' - id "io.micronaut.build.internal.bom" -} diff --git a/project-template/build.gradle b/project-template/build.gradle deleted file mode 100644 index 457e927e..00000000 --- a/project-template/build.gradle +++ /dev/null @@ -1,3 +0,0 @@ -plugins { - id 'io.micronaut.build.internal.project-template-module' -} diff --git a/settings.gradle b/settings.gradle index 3cd86c94..75a2f006 100644 --- a/settings.gradle +++ b/settings.gradle @@ -8,14 +8,22 @@ pluginManagement { plugins { id 'io.micronaut.build.shared.settings' version '6.7.0' } +enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS' -rootProject.name = 'project-template-parent' +rootProject.name = 'json-schema-parent' -include 'project-template' -include 'project-template-bom' +include 'json-schema-bom' +include 'json-schema-annotations' +include 'json-schema-processor' +include 'test-suite' +include 'test-suite-groovy' +include 'test-suite-kotlin' enableFeaturePreview 'TYPESAFE_PROJECT_ACCESSORS' micronautBuild { + useStandardizedProjectNames = true importMicronautCatalog() + importMicronautCatalog('micronaut-validation') + importMicronautCatalog('micronaut-serde') } diff --git a/src/main/docs/guide/configuration.adoc b/src/main/docs/guide/configuration.adoc new file mode 100644 index 00000000..942c354d --- /dev/null +++ b/src/main/docs/guide/configuration.adoc @@ -0,0 +1,17 @@ +Schema generation can be configured with properties of the +link:{api}/io/micronaut/jsonschema/JsonSchema.html[JsonSchema] annotation, for example: + +snippet::io.micronaut.jsonschema.test.RWBlackbird[tags=clazz] + +<1> Configure the title and description of the generated JSON Schema. +<2> Set the relative or absolute URL. +This will affect the file name as well as the id by which this schema can be referenced. + +The generation can be configured globally by annotating a type with +link:{api}/io/micronaut/jsonschema/JsonSchema.html[JsonSchemaConfiguration]: + +snippet::io.micronaut.jsonschema.test.JsonSchemaConfig[tags=clazz] + +<1> Set the base URL for all the schemas. It will be prepended to all relative schema URLs. + Refer to the link:{api}/io/micronaut/jsonschema/JsonSchema.html[JsonSchemaConfiguration] documentation to + see all configurable parameters. diff --git a/src/main/docs/guide/introduction.adoc b/src/main/docs/guide/introduction.adoc index 30404ce4..54520d8f 100644 --- a/src/main/docs/guide/introduction.adoc +++ b/src/main/docs/guide/introduction.adoc @@ -1 +1,4 @@ -TODO \ No newline at end of file +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 allows creating schemas for beans in your applications. diff --git a/src/main/docs/guide/quickStart.adoc b/src/main/docs/guide/quickStart.adoc index 30404ce4..f3117ae0 100644 --- a/src/main/docs/guide/quickStart.adoc +++ b/src/main/docs/guide/quickStart.adoc @@ -1 +1,15 @@ -TODO \ No newline at end of file + +Annotate a bean with link:{api}/io/micronaut/jsonschema/JsonSchema.html[JsonSchema] to trigger the creation of a +schema for it during build time: + +snippet::io.micronaut.jsonschema.test.Llama[] + +<1> Add the link:{api}/io/micronaut/jsonschema/JsonSchema.html[JsonSchema] annotation. +<2> (Optional) To use Micronaut Serialization as the serialization solution for your application refer to the + link:https://micronaut-projects.github.io/micronaut-serialization/latest/guide/[Micronaut Serialization] + documentation and add `Serdeable` annotation to the bean. +<3> Add additional required annotations to your bean. See supported annotations in the following sections. +<4> The JavaDoc will be added as schema description. + +The following file will be created on the classpath: `META-INF/schemas/llama.schema.json`. +It can be used in your application and will be included in the jar file. diff --git a/src/main/docs/guide/serving.adoc b/src/main/docs/guide/serving.adoc new file mode 100644 index 00000000..0964ff79 --- /dev/null +++ b/src/main/docs/guide/serving.adoc @@ -0,0 +1,7 @@ +You can serve the JSON schemas as part of your API by defining the following controller: + +snippet::io.micronaut.jsonschema.test.SchemaController[] + +<1> The schemas will be available on the `/schemas` path, which can be customized for your specific needs. +<2> The schemas will be read from the `META-INF/schemas` classpath folder. +<3> Use a `ResourceLoader` to load the schemas optionally adding the required file extension. diff --git a/src/main/docs/guide/supported-features.adoc b/src/main/docs/guide/supported-features.adoc new file mode 100644 index 00000000..c884987b --- /dev/null +++ b/src/main/docs/guide/supported-features.adoc @@ -0,0 +1,36 @@ +Information for JSON schema is aggregated from multiple sources. + +### JavaDoc + +JavaDoc on types and properties will be added to the description properties of schemas. This includes class, property +descriptions and record parameter descriptions. + +### Validation annotations + +The following `jakarta.validation.constraints` annotations are supported: `AssertFalse`, `AssertTrue`, +`DecimalMin`, `DecimalMax`, `Email`, `Max`, `Min`, `Negative`, `NegativeOrZero`, `NotBlank`, `NotEmpty`, `NotNull`, +`Null`, `Pattern`, `Positive`, `PositiveOrZero`, `Size`. + +// The following annotation are not supported yet: `Digits`, `Future`, `FutureOrPresent`, `Past`, `PastOrPresent`. + +By default, properties are not nullable. `jakarta.annotations.Nullable` can be added to make them nullable. +Note, that validation might not correspond to actual bean values, as by default null +values are completely omitted during JSON serialization. + +NOTE: Custom validators cannot be supported, as this information is implementation-specific and not available during build time. + +### Jackson annotations + +The following `com.fasterxml.jackson.annotation` annotations are supported: +`JsonClassDescription`, `JsonIgnore`, `JsonIgnoreProperties`, `JsonInclude`, `JsonIncludeProperties`, `JsonProperty`, +`JsonPropertyDescription`, `JsonSubTypes`, `JsonTypeInfo`, `JsonTypeName`, `JsonUnwrapped`. + +NOTE: Custom serializers and deserializers cannot be supported, as this information is implementation-specific +and not available during build time. This also applies to some other features, like the `JsonFilter` annotation +which allows defining custom filters. + +// Not supported yet: JsonAlias, JsonAnyGetter, JsonAnySetter, JsonAutoDetect, JsonBackReference, JsonCreator, +// JsonEnumDefaultValue, JsonFormat, JsonGetter, JsonIdentityInfo, JsonIdentityReference, JsonIgnoreType, +// JsonKey, JsonManagedReference, JsonMerge, JsonRawValue, JsonRootName, JsonSetter, JsonTypeId, JsonValue, JsonView +// Cannot support: JacksonInject, JsonFilter, JsonPropertyOrder + diff --git a/src/main/docs/guide/toc.yml b/src/main/docs/guide/toc.yml index 55b5fcb8..3d09d279 100644 --- a/src/main/docs/guide/toc.yml +++ b/src/main/docs/guide/toc.yml @@ -3,5 +3,11 @@ introduction: releaseHistory: Release History quickStart: title: Quick Start +configuration: + title: Configuration +supported-features: + title: Supported Information Sources +serving: + title: Serving JSON Schemas repository: Repository diff --git a/test-suite-groovy/build.gradle b/test-suite-groovy/build.gradle new file mode 100644 index 00000000..7b48a45f --- /dev/null +++ b/test-suite-groovy/build.gradle @@ -0,0 +1,34 @@ +plugins { + id 'groovy' + id 'io.micronaut.build.internal.json-schema-module' +} + +dependencies { + implementation(mnValidation.validation) + implementation(projects.micronautJsonSchemaAnnotations) + implementation(mnSerde.micronaut.serde.jackson) + + implementation(projects.micronautJsonSchemaProcessor) + + testAnnotationProcessor(projects.micronautJsonSchemaProcessor) + testAnnotationProcessor(mnSerde.micronaut.serde.processor) + testAnnotationProcessor(mn.micronaut.inject.java) + testAnnotationProcessor(mn.micronaut.inject.groovy) + + api(mn.jackson.databind) + + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mn.micronaut.http.client) + testImplementation(libs.json.schema.validator) + testImplementation(mn.micronaut.inject.groovy.test) + testImplementation(mn.micronaut.inject.java.test) + testImplementation(mn.micronaut.inject) +} + +configurations.configureEach { + all*.exclude group: "ch.qos.logback" +} + +test { + maxHeapSize = "1024m" +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy new file mode 100644 index 00000000..b461ebb0 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy @@ -0,0 +1,47 @@ +package io.micronaut.jsonschema.test + +import com.networknt.schema.* +import io.micronaut.serde.ObjectMapper +import jakarta.inject.Inject +import spock.lang.Specification + +abstract class AbstractValidationSpec extends Specification { + + @Inject + ObjectMapper objectMapper + + public static final String URL_PREFIX = "https://example.com/schemas/" + public static final String CLASSPATH_PREFIX = "classpath:META-INF/schemas/" + + protected Set validateJsonWithSchema(Object value, String schemaName) { + String input = objectMapper.writeValueAsString(value) + println input + return validateJsonWithSchema(input, schemaName) + } + + protected Set validateJsonWithSchema(String input, String schemaName) { + var jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012, builder -> + // This creates a mapping from $id which starts with https://example.com/ to the retrieval URI classpath:schema/ + builder.schemaMappers(schemaMappers -> + schemaMappers.mapPrefix(URL_PREFIX, CLASSPATH_PREFIX) + .mappings(v -> v.endsWith(".schema.json") ? v : v + ".schema.json") + ) + ) + + var config = new SchemaValidatorsConfig() + // By default JSON Path is used for reporting the instance location and evaluation path + config.setPathType(PathType.JSON_POINTER) + + var schema = jsonSchemaFactory.getSchema(SchemaLocation.of(URL_PREFIX + schemaName + ".schema.json"), config) + + ExecutionContextCustomizer contextCustomizer = new ExecutionContextCustomizer() { + @Override + void customize(ExecutionContext executionContext, ValidationContext validationContext) { + // By default since Draft 2019-09 the format keyword only generates annotations and not assertions + validationContext.getConfig().setFormatAssertionsEnabled(true) + } + } + return schema.validate(input, InputFormat.JSON, contextCustomizer) + } + +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy new file mode 100644 index 00000000..e6f780c7 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy @@ -0,0 +1,14 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.jsonschema.JsonSchemaConfiguration + +/** + * A configuration. + */ +// tag::clazz[] +@JsonSchemaConfiguration( + baseUri = "https://example.com/schemas" // <1> +) +interface JsonSchemaConfig { +} +// end::clazz[] diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/Llama.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/Llama.groovy new file mode 100644 index 00000000..3c74aa25 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/Llama.groovy @@ -0,0 +1,28 @@ +package io.micronaut.jsonschema.test + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonInclude.Include +import io.micronaut.jsonschema.JsonSchema +import io.micronaut.serde.annotation.Serdeable +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.PositiveOrZero + +/** + * A llama. <4> + */ +@JsonSchema // <1> +@Serdeable // <2> +class Llama { + /** + * The name. + */ + @NotBlank // <3> + @JsonInclude(Include.NON_NULL) + String name + + /** + * The age. + */ + @PositiveOrZero // <3> + int age +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy new file mode 100644 index 00000000..63edf286 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy @@ -0,0 +1,50 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.test.extensions.spock.annotation.MicronautTest + +@MicronautTest +class ObjectsValidationSpec extends AbstractValidationSpec { + + void "valid object"() { + when: + var assertions = validateJsonWithSchema(new Llama(name: "John", age: 12), "llama") + + then: + assertions.size() == 0 + } + + void "invalid object"() { + when: + var assertions = validateJsonWithSchema(llama, "llama") + + then: + assertions.size() == 1 + assertions[0].message == message + + where: + llama | message + '{"name":"","age":12}' | "/name: must be at least 1 characters long" + '{"name":null}' | "/name: null found, [string] expected" + new Llama(name: "John", age: -12) | "/age: must have a minimum value of 0" + } + + void "valid object with changed path"() { + when: + var bird = new RWBlackbird(name: "Clara", wingSpan: 1.2) + var assertions = validateJsonWithSchema(bird, "red-winged-blackbird") + + then: + assertions.size() == 0 + } + + void "invalid object with changed path"() { + when: + var bird = '{"name":12}' + var assertions = validateJsonWithSchema(bird, "red-winged-blackbird") + + then: + assertions.size() == 1 + assertions[0].message == "/name: integer found, [string] expected" + } + +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/RWBlackbird.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/RWBlackbird.groovy new file mode 100644 index 00000000..939fcff1 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/RWBlackbird.groovy @@ -0,0 +1,27 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.jsonschema.JsonSchema +import io.micronaut.serde.annotation.Serdeable + +/** + * A red-winged blackbird. + */ +// tag::clazz[] +@JsonSchema( + title = "RedWingedBlackbird", // <1> + description = "A species of blackbird with red wings", + uri = "/red-winged-blackbird" // <2> +) +@Serdeable +class RWBlackbird { + /** + * The name. + */ + String name + + /** + * The wingspan. + */ + double wingSpan +} +// end::clazz[] diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/SchemaController.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/SchemaController.groovy new file mode 100644 index 00000000..984bbb46 --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/SchemaController.groovy @@ -0,0 +1,31 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.core.io.scan.ClassPathResourceLoader +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.server.types.files.StreamedFile + +/** + * A controller for serving schemas. + */ +@Controller("/schemas") // <1> +class SchemaController { + + public static final String SCHEMAS_PATH = "classpath:META-INF/schemas/" // <2> + + private final ClassPathResourceLoader resourceLoader + + SchemaController(ClassPathResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader + } + + @Get("/{schemaName}") + StreamedFile getSchema(String schemaName) { + if (!schemaName.endsWith(".schema.json")) { + schemaName = schemaName + ".schema.json" + } + Optional url = resourceLoader.getResource(SCHEMAS_PATH + schemaName) // <3> + return url.map(StreamedFile::new).orElse(null) + } + +} diff --git a/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy new file mode 100644 index 00000000..588dce9c --- /dev/null +++ b/test-suite-groovy/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy @@ -0,0 +1,45 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.junit.Test +import spock.lang.Specification + + +@MicronautTest +class ServingSchemasSpec extends Specification { + + @Inject + EmbeddedServer server + + @Inject + HttpClient client + + @Test + void "test get schemas"() { + when: + String result = client.toBlocking().retrieve( + HttpRequest.GET(server.getURI().resolve("/schemas/llama")).accept(MediaType.APPLICATION_JSON), + String.class + ) + + then: + result != null + result.contains("Llama") + + when: + result = client.toBlocking().retrieve( + HttpRequest.GET(server.getURI().resolve("/schemas/red-winged-blackbird")).accept(MediaType.APPLICATION_JSON), + String.class + ) + + then: + result != null + result.contains("A species of blackbird with red wings") + } + +} diff --git a/test-suite-kotlin/build.gradle b/test-suite-kotlin/build.gradle new file mode 100644 index 00000000..381c11a1 --- /dev/null +++ b/test-suite-kotlin/build.gradle @@ -0,0 +1,38 @@ +plugins { + id 'io.micronaut.build.internal.json-schema-module' + alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.kotlin.ksp) +} + +dependencies { + implementation(mnValidation.validation) + implementation(projects.micronautJsonSchemaAnnotations) + implementation(mnSerde.micronaut.serde.jackson) + + kspTest(projects.micronautJsonSchemaProcessor) + kspTest(mnSerde.micronaut.serde.processor) + kspTest(mn.micronaut.inject.kotlin) + + api(mn.jackson.databind) + + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mn.micronaut.http.client) + testImplementation(libs.json.schema.validator) + testImplementation(mn.micronaut.inject.kotlin.test) + testImplementation(mn.micronaut.inject.groovy.test) + testImplementation(mn.micronaut.inject.java.test) + testImplementation(mn.micronaut.inject) +} + +compileTestGroovy { + dependsOn tasks.getByPath('compileTestKotlin') + classpath += files(compileTestKotlin) +} + +configurations.configureEach { + all*.exclude group: "ch.qos.logback" +} + +test { + maxHeapSize = "1024m" +} diff --git a/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy new file mode 100644 index 00000000..b461ebb0 --- /dev/null +++ b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy @@ -0,0 +1,47 @@ +package io.micronaut.jsonschema.test + +import com.networknt.schema.* +import io.micronaut.serde.ObjectMapper +import jakarta.inject.Inject +import spock.lang.Specification + +abstract class AbstractValidationSpec extends Specification { + + @Inject + ObjectMapper objectMapper + + public static final String URL_PREFIX = "https://example.com/schemas/" + public static final String CLASSPATH_PREFIX = "classpath:META-INF/schemas/" + + protected Set validateJsonWithSchema(Object value, String schemaName) { + String input = objectMapper.writeValueAsString(value) + println input + return validateJsonWithSchema(input, schemaName) + } + + protected Set validateJsonWithSchema(String input, String schemaName) { + var jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012, builder -> + // This creates a mapping from $id which starts with https://example.com/ to the retrieval URI classpath:schema/ + builder.schemaMappers(schemaMappers -> + schemaMappers.mapPrefix(URL_PREFIX, CLASSPATH_PREFIX) + .mappings(v -> v.endsWith(".schema.json") ? v : v + ".schema.json") + ) + ) + + var config = new SchemaValidatorsConfig() + // By default JSON Path is used for reporting the instance location and evaluation path + config.setPathType(PathType.JSON_POINTER) + + var schema = jsonSchemaFactory.getSchema(SchemaLocation.of(URL_PREFIX + schemaName + ".schema.json"), config) + + ExecutionContextCustomizer contextCustomizer = new ExecutionContextCustomizer() { + @Override + void customize(ExecutionContext executionContext, ValidationContext validationContext) { + // By default since Draft 2019-09 the format keyword only generates annotations and not assertions + validationContext.getConfig().setFormatAssertionsEnabled(true) + } + } + return schema.validate(input, InputFormat.JSON, contextCustomizer) + } + +} diff --git a/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy new file mode 100644 index 00000000..e6f780c7 --- /dev/null +++ b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/JsonSchemaConfig.groovy @@ -0,0 +1,14 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.jsonschema.JsonSchemaConfiguration + +/** + * A configuration. + */ +// tag::clazz[] +@JsonSchemaConfiguration( + baseUri = "https://example.com/schemas" // <1> +) +interface JsonSchemaConfig { +} +// end::clazz[] diff --git a/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy new file mode 100644 index 00000000..4582526d --- /dev/null +++ b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy @@ -0,0 +1,50 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.test.extensions.spock.annotation.MicronautTest + +@MicronautTest +class ObjectsValidationSpec extends AbstractValidationSpec { + + void "valid object"() { + when: + var assertions = validateJsonWithSchema(new Llama("John", 12), "llama") + + then: + assertions.size() == 0 + } + + void "invalid object"() { + when: + var assertions = validateJsonWithSchema(llama, "llama") + + then: + assertions.size() == 1 + assertions[0].message == message + + where: + llama | message + new Llama("", 12) | "/name: must be at least 1 characters long" + '{"name":null}' | "/name: null found, [string] expected" + new Llama("John", -12) | "/age: must have a minimum value of 0" + } + + void "valid object with changed path"() { + when: + var bird = new RWBlackbird("Clara", 1.2) + var assertions = validateJsonWithSchema(bird, "red-winged-blackbird") + + then: + assertions.size() == 0 + } + + void "invalid object with changed path"() { + when: + var bird = '{"name":12}' + var assertions = validateJsonWithSchema(bird, "red-winged-blackbird") + + then: + assertions.size() == 1 + assertions[0].message == "/name: integer found, [string] expected" + } + +} diff --git a/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy new file mode 100644 index 00000000..9d04a947 --- /dev/null +++ b/test-suite-kotlin/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy @@ -0,0 +1,45 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.junit.Test +import spock.lang.Specification + + +@MicronautTest +class ServingSchemasSpec extends Specification { + + @Inject + EmbeddedServer server + + @Inject + HttpClient client + + @Test + void "test get schemas"() { + when: + String result = client.toBlocking().retrieve( + HttpRequest.GET(server.getURI().resolve("/schemas/llama")).accept(MediaType.APPLICATION_JSON), + String.class + ) + + then: + result != null + result.contains("A llama.") + + when: + result = client.toBlocking().retrieve( + HttpRequest.GET(server.getURI().resolve("/schemas/red-winged-blackbird")).accept(MediaType.APPLICATION_JSON), + String.class + ) + + then: + result != null + result.contains("A species of blackbird with red wings") + } + +} diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/Llama.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/Llama.kt new file mode 100644 index 00000000..a7023eab --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/Llama.kt @@ -0,0 +1,24 @@ +package io.micronaut.jsonschema.test + +import com.fasterxml.jackson.annotation.JsonInclude +import com.fasterxml.jackson.annotation.JsonInclude.Include +import io.micronaut.jsonschema.JsonSchema +import io.micronaut.serde.annotation.Serdeable +import jakarta.validation.constraints.NotBlank +import jakarta.validation.constraints.PositiveOrZero + +/** + * A llama. <4> + * + * @param name The name + * @param age The age + */ +@JsonSchema // <1> +@Serdeable // <2> +class Llama( + @field:JsonInclude(Include.NON_NULL) + @field:NotBlank + val name: String, + @field:PositiveOrZero + val age: Int // <3> +) diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/RWBlackbird.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/RWBlackbird.kt new file mode 100644 index 00000000..3b8967a4 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/RWBlackbird.kt @@ -0,0 +1,23 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.jsonschema.JsonSchema +import io.micronaut.serde.annotation.Serdeable + +/** + * A red-winged blackbird. + * + * @param name The name + * @param wingSpan The wing span of the bird + */ +// tag::clazz[] +@JsonSchema( + title = "RedWingedBlackbird", // <1> + description = "A species of blackbird with red wings", + uri = "/red-winged-blackbird" // <2> +) +@Serdeable +class RWBlackbird ( + val name: String, + val wingSpan: Double +) +// end::clazz[] diff --git a/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/SchemaController.kt b/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/SchemaController.kt new file mode 100644 index 00000000..c84a7199 --- /dev/null +++ b/test-suite-kotlin/src/test/kotlin/io/micronaut/jsonschema/test/SchemaController.kt @@ -0,0 +1,28 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.core.io.scan.ClassPathResourceLoader +import io.micronaut.http.annotation.Controller +import io.micronaut.http.annotation.Get +import io.micronaut.http.server.types.files.StreamedFile + +const val SCHEMAS_PATH: String = "classpath:META-INF/schemas/" // <2> + +/** + * A controller for serving schemas. + */ +@Controller("/schemas") // <1> +class SchemaController( + private var resourceLoader: ClassPathResourceLoader, +) { + + @Get("/{schemaName}") + fun getSchema (schemaName: String): StreamedFile { + var schemaFile = schemaName + if (!schemaFile.endsWith(".schema.json")) { + schemaFile += ".schema.json" + } + val url = this.resourceLoader.getResource(SCHEMAS_PATH + schemaFile) // <3> + return url.map { StreamedFile(it) }.orElse(null) + } + +} diff --git a/test-suite/build.gradle b/test-suite/build.gradle new file mode 100644 index 00000000..3c405a4b --- /dev/null +++ b/test-suite/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'io.micronaut.build.internal.json-schema-module' +} + +dependencies { + implementation(mnValidation.validation) + implementation(projects.micronautJsonSchemaAnnotations) + implementation(mnSerde.micronaut.serde.jackson) + + testAnnotationProcessor(projects.micronautJsonSchemaProcessor) + testAnnotationProcessor(mnSerde.micronaut.serde.processor) + testAnnotationProcessor(mn.micronaut.inject.java) + + api(mn.jackson.databind) + + testImplementation(mn.micronaut.http.server.netty) + testImplementation(mn.micronaut.http.client) + testImplementation(libs.json.schema.validator) + testImplementation(mn.micronaut.inject.kotlin.test) + testImplementation(mn.micronaut.inject.groovy.test) + testImplementation(mn.micronaut.inject.java.test) + testImplementation(mn.micronaut.inject) +} + +configurations.configureEach { + all*.exclude group: "ch.qos.logback" +} + +test { + maxHeapSize = "1024m" +} diff --git a/test-suite/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy b/test-suite/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy new file mode 100644 index 00000000..b461ebb0 --- /dev/null +++ b/test-suite/src/test/groovy/io/micronaut/jsonschema/test/AbstractValidationSpec.groovy @@ -0,0 +1,47 @@ +package io.micronaut.jsonschema.test + +import com.networknt.schema.* +import io.micronaut.serde.ObjectMapper +import jakarta.inject.Inject +import spock.lang.Specification + +abstract class AbstractValidationSpec extends Specification { + + @Inject + ObjectMapper objectMapper + + public static final String URL_PREFIX = "https://example.com/schemas/" + public static final String CLASSPATH_PREFIX = "classpath:META-INF/schemas/" + + protected Set validateJsonWithSchema(Object value, String schemaName) { + String input = objectMapper.writeValueAsString(value) + println input + return validateJsonWithSchema(input, schemaName) + } + + protected Set validateJsonWithSchema(String input, String schemaName) { + var jsonSchemaFactory = JsonSchemaFactory.getInstance(SpecVersion.VersionFlag.V202012, builder -> + // This creates a mapping from $id which starts with https://example.com/ to the retrieval URI classpath:schema/ + builder.schemaMappers(schemaMappers -> + schemaMappers.mapPrefix(URL_PREFIX, CLASSPATH_PREFIX) + .mappings(v -> v.endsWith(".schema.json") ? v : v + ".schema.json") + ) + ) + + var config = new SchemaValidatorsConfig() + // By default JSON Path is used for reporting the instance location and evaluation path + config.setPathType(PathType.JSON_POINTER) + + var schema = jsonSchemaFactory.getSchema(SchemaLocation.of(URL_PREFIX + schemaName + ".schema.json"), config) + + ExecutionContextCustomizer contextCustomizer = new ExecutionContextCustomizer() { + @Override + void customize(ExecutionContext executionContext, ValidationContext validationContext) { + // By default since Draft 2019-09 the format keyword only generates annotations and not assertions + validationContext.getConfig().setFormatAssertionsEnabled(true) + } + } + return schema.validate(input, InputFormat.JSON, contextCustomizer) + } + +} diff --git a/test-suite/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy b/test-suite/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy new file mode 100644 index 00000000..05413f4f --- /dev/null +++ b/test-suite/src/test/groovy/io/micronaut/jsonschema/test/ObjectsValidationSpec.groovy @@ -0,0 +1,149 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.test.extensions.spock.annotation.MicronautTest + +@MicronautTest +class ObjectsValidationSpec extends AbstractValidationSpec { + + void "valid record"() { + when: + var assertions = validateJsonWithSchema(new Llama("John", 12), "llama") + + then: + assertions.size() == 0 + } + + void "invalid record"() { + when: + var assertions = validateJsonWithSchema(llama, "llama") + + then: + assertions.size() == 1 + assertions[0].message == message + + where: + llama | message + new Llama("", 12) | "/name: must be at least 1 characters long" + '{"name":null}' | "/name: null found, [string] expected" + new Llama("John", -12) | "/age: must have a minimum value of 0" + } + + void "valid object"() { + when: + var assertions = validateJsonWithSchema( + new Salamander().setColors(["green", "red"]) + .setEnvironments(["pond", "river"]) + .setSkinColor("green") + .setSpecies("Pond Salamander") + .setAge(1) + .setNegative(-12) + .setInteger(15) + .setNumber(20.25), + "salamander" + ) + + then: + assertions.size() == 0 + } + + void "invalid object"() { + when: + var assertions = validateJsonWithSchema(salamander, "salamander") + + then: + assertions.size() == 1 + assertions[0].message == message + + where: + salamander | message + new Salamander().setColors([]) | "/colors: must have at least 1 items but found 0" + new Salamander().setEnvironments(["pond"]) | "/environments: must have at least 2 items but found 1" + new Salamander().setSkinColor("") | "/skinColor: must be at least 1 characters long" + new Salamander().setSpecies("a-very-long-species-name") | "/species: must be at most 20 characters long" + new Salamander().setSpecies("invalidChar\$") | "/species: does not match the regex pattern ^[a-zA-Z \\-]+\$" + new Salamander().setAge(-12) | "/age: must have a minimum value of 0" + new Salamander().setNegative(12) | "/negative: must have an exclusive maximum value of 0" + new Salamander().setInteger(1) | "/integer: must have a minimum value of 10" + new Salamander().setInteger(120) | "/integer: must have a maximum value of 100" + new Salamander().setNumber(10) | "/number: must have an exclusive minimum value of 10" + new Salamander().setNumber(100.6) | "/number: must have a maximum value of 100.5" + } + + void "valid object with inheritance"() { + when: + var assertions = validateJsonWithSchema(bird, "bird") + + then: + assertions.size() == 0 + + where: + bird | _ + new Bird.Ostrich("Bob", 10.5) | _ + new Bird.Eagle("Blob", 31.2) | _ + } + + void "invalid object with inheritance"() { + when: + var assertions = validateJsonWithSchema(bird, "bird") + + then: + assertions.size() == 3 + assertions[0].message == ": must be valid to one and only one schema, but 0 are valid" + assertions[1].message == message1 + assertions[2].message == message2 + + + where: + bird | message1 | message2 + '{"@type":"unknown-bird"}' | "/@type: must be the constant value 'ostrich-bird'" | "/@type: must be the constant value 'eagle-bird'" + new Bird.Ostrich("Glob", -12) | "/runSpeed: must have an exclusive minimum value of 0" | "/@type: must be the constant value 'eagle-bird'" + new Bird.Eagle("Blob", 0.5) | "/@type: must be the constant value 'ostrich-bird'" | "/flySpeed: must have a minimum value of 1" + } + + void "valid object with reference"() { + when: + var possum = new Possum("Bob", [ + new Possum("Alice", [], new Possum.Environment("field")) + ], new Possum.Environment("marshland")) + var assertions = validateJsonWithSchema(possum, "possum") + + then: + assertions.size() == 0 + } + + void "invalid object with references"() { + when: + var assertions = validateJsonWithSchema(possum, "possum") + + then: + assertions.size() == 1 + assertions[0].message == message + + where: + possum | message + new Possum("", [], new Possum.Environment("forest")) | "/name: must be at least 1 characters long" + new Possum("Bob", [], new Possum.Environment("f")) | "/environment/name: must be at least 2 characters long" + new Possum("Bob", [new Possum("", null, null)], null) | "/children/0/name: must be at least 1 characters long" + new Possum("Bob", [new Possum("Alice", null, new Possum.Environment("f"))], null) | "/children/0/environment/name: must be at least 2 characters long" + } + + void "valid object with changed path"() { + when: + var bird = new RWBlackbird("Clara", 1.2) + var assertions = validateJsonWithSchema(bird, "red-winged-blackbird") + + then: + assertions.size() == 0 + } + + void "invalid object with changed path"() { + when: + var bird = '{"name":12}' + var assertions = validateJsonWithSchema(bird, "red-winged-blackbird") + + then: + assertions.size() == 1 + assertions[0].message == "/name: integer found, [string] expected" + } + +} diff --git a/test-suite/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy b/test-suite/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy new file mode 100644 index 00000000..9d04a947 --- /dev/null +++ b/test-suite/src/test/groovy/io/micronaut/jsonschema/test/ServingSchemasSpec.groovy @@ -0,0 +1,45 @@ +package io.micronaut.jsonschema.test + +import io.micronaut.http.HttpRequest +import io.micronaut.http.MediaType +import io.micronaut.http.client.HttpClient +import io.micronaut.runtime.server.EmbeddedServer +import io.micronaut.test.extensions.spock.annotation.MicronautTest +import jakarta.inject.Inject +import org.junit.Test +import spock.lang.Specification + + +@MicronautTest +class ServingSchemasSpec extends Specification { + + @Inject + EmbeddedServer server + + @Inject + HttpClient client + + @Test + void "test get schemas"() { + when: + String result = client.toBlocking().retrieve( + HttpRequest.GET(server.getURI().resolve("/schemas/llama")).accept(MediaType.APPLICATION_JSON), + String.class + ) + + then: + result != null + result.contains("A llama.") + + when: + result = client.toBlocking().retrieve( + HttpRequest.GET(server.getURI().resolve("/schemas/red-winged-blackbird")).accept(MediaType.APPLICATION_JSON), + String.class + ) + + then: + result != null + result.contains("A species of blackbird with red wings") + } + +} diff --git a/test-suite/src/test/java/io/micronaut/jsonschema/test/Bird.java b/test-suite/src/test/java/io/micronaut/jsonschema/test/Bird.java new file mode 100644 index 00000000..8d6db8f4 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/jsonschema/test/Bird.java @@ -0,0 +1,55 @@ +package io.micronaut.jsonschema.test; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonSubTypes.Type; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonTypeName; +import io.micronaut.jsonschema.JsonSchema; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Positive; + +/** + * A bird. + */ +@JsonTypeInfo(use = Id.NAME) +@JsonSubTypes({ + @Type(value = Bird.Ostrich.class, name = "ostrich-bird"), + @Type(value = Bird.Eagle.class) +}) +@Serdeable +@JsonSchema +public interface Bird { + + /** + * An ostrich. + * + * @param name The name + * @param runSpeed The run speed + */ + @Serdeable + record Ostrich( + String name, + @Positive + float runSpeed + ) implements Bird { + } + + /** + * The eagle. + * + * @param name The name + * @param flySpeed The fly speed + */ + @JsonTypeName("eagle-bird") + record Eagle( + String name, + @Min(1) + float flySpeed + ) implements Bird { + } + +} + + diff --git a/test-suite/src/test/java/io/micronaut/jsonschema/test/JsonSchemaConfig.java b/test-suite/src/test/java/io/micronaut/jsonschema/test/JsonSchemaConfig.java new file mode 100644 index 00000000..f710e9f2 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/jsonschema/test/JsonSchemaConfig.java @@ -0,0 +1,14 @@ +package io.micronaut.jsonschema.test; + +import io.micronaut.jsonschema.JsonSchemaConfiguration; + +/** + * A configuration. + */ +// tag::clazz[] +@JsonSchemaConfiguration( + baseUri = "https://example.com/schemas" // <1> +) +public interface JsonSchemaConfig { +} +// end::clazz[] diff --git a/test-suite/src/test/java/io/micronaut/jsonschema/test/Llama.java b/test-suite/src/test/java/io/micronaut/jsonschema/test/Llama.java new file mode 100644 index 00000000..976f7cb2 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/jsonschema/test/Llama.java @@ -0,0 +1,25 @@ +package io.micronaut.jsonschema.test; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.micronaut.jsonschema.JsonSchema; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.PositiveOrZero; + +/** + * A llama. <4> + * + * @param name The name + * @param age The age + */ +@JsonSchema // <1> +@Serdeable // <2> +public record Llama( + @NotBlank // <3> + @JsonInclude(Include.NON_NULL) + String name, + @PositiveOrZero // <3> + int age +) { +} diff --git a/test-suite/src/test/java/io/micronaut/jsonschema/test/Possum.java b/test-suite/src/test/java/io/micronaut/jsonschema/test/Possum.java new file mode 100644 index 00000000..57857bf0 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/jsonschema/test/Possum.java @@ -0,0 +1,41 @@ +package io.micronaut.jsonschema.test; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.micronaut.jsonschema.JsonSchema; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * A possum. + * + * @param name The name + * @param children The children + * @param environment The environment + */ +@JsonSchema +@Serdeable +public record Possum( + @NotBlank + @JsonInclude(Include.NON_NULL) + String name, + List children, + Environment environment +) { + + /** + * The environment. + * + * @param name The name + */ + @JsonSchema + @Serdeable + public record Environment( + @Size(min = 2) String name + ) { + } + +} diff --git a/test-suite/src/test/java/io/micronaut/jsonschema/test/RWBlackbird.java b/test-suite/src/test/java/io/micronaut/jsonschema/test/RWBlackbird.java new file mode 100644 index 00000000..00d7a627 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/jsonschema/test/RWBlackbird.java @@ -0,0 +1,24 @@ +package io.micronaut.jsonschema.test; + +import io.micronaut.jsonschema.JsonSchema; +import io.micronaut.serde.annotation.Serdeable; + +/** + * A red-winged blackbird. + * + * @param name The name + * @param wingSpan The wing span of the bird + */ +// tag::clazz[] +@JsonSchema( + title = "RedWingedBlackbird", // <1> + description = "A species of blackbird with red wings", + uri = "/red-winged-blackbird" // <2> +) +@Serdeable +public record RWBlackbird( + String name, + double wingSpan +) { +} +// end::clazz[] diff --git a/test-suite/src/test/java/io/micronaut/jsonschema/test/Salamander.java b/test-suite/src/test/java/io/micronaut/jsonschema/test/Salamander.java new file mode 100644 index 00000000..933c8c3a --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/jsonschema/test/Salamander.java @@ -0,0 +1,126 @@ +package io.micronaut.jsonschema.test; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +import io.micronaut.jsonschema.JsonSchema; +import io.micronaut.serde.annotation.Serdeable; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.Negative; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; + +import java.util.List; + +/** + * A salamander. + */ +@JsonSchema +@Serdeable +public final class Salamander { + @NotEmpty + @JsonInclude(Include.NON_NULL) + List colors; + + @Size(min = 2, max = 10) + List<@Size(min = 3) String> environments; + + @NotBlank + @JsonInclude(Include.NON_NULL) + String skinColor; + + @Size(min = 3, max = 20) + @Pattern(regexp = "^[a-zA-Z \\-]+$") + String species; + + @PositiveOrZero + Integer age; + + @Negative + Integer negative; + + @Min(10) + @Max(100) + Long integer; + + @DecimalMin(value = "10", inclusive = false) + @DecimalMax("100.5") + Double number; + + public List getColors() { + return colors; + } + + public Salamander setColors(List colors) { + this.colors = colors; + return this; + } + + public List getEnvironments() { + return environments; + } + + public Salamander setEnvironments(List environments) { + this.environments = environments; + return this; + } + + public String getSkinColor() { + return skinColor; + } + + public Salamander setSkinColor(String skinColor) { + this.skinColor = skinColor; + return this; + } + + public String getSpecies() { + return species; + } + + public Salamander setSpecies(String species) { + this.species = species; + return this; + } + + public Integer getAge() { + return age; + } + + public Salamander setAge(Integer age) { + this.age = age; + return this; + } + + public Integer getNegative() { + return negative; + } + + public Salamander setNegative(Integer negative) { + this.negative = negative; + return this; + } + + public Long getInteger() { + return integer; + } + + public Salamander setInteger(Long integer) { + this.integer = integer; + return this; + } + + public Double getNumber() { + return number; + } + + public Salamander setNumber(Double number) { + this.number = number; + return this; + } +} diff --git a/test-suite/src/test/java/io/micronaut/jsonschema/test/SchemaController.java b/test-suite/src/test/java/io/micronaut/jsonschema/test/SchemaController.java new file mode 100644 index 00000000..b8d86372 --- /dev/null +++ b/test-suite/src/test/java/io/micronaut/jsonschema/test/SchemaController.java @@ -0,0 +1,34 @@ +package io.micronaut.jsonschema.test; + +import io.micronaut.core.io.scan.ClassPathResourceLoader; +import io.micronaut.http.annotation.Controller; +import io.micronaut.http.annotation.Get; +import io.micronaut.http.server.types.files.StreamedFile; + +import java.net.URL; +import java.util.Optional; + +/** + * A controller for serving schemas. + */ +@Controller("/schemas") // <1> +public class SchemaController { + + public static final String SCHEMAS_PATH = "classpath:META-INF/schemas/"; // <2> + + private final ClassPathResourceLoader resourceLoader; + + public SchemaController(ClassPathResourceLoader resourceLoader) { + this.resourceLoader = resourceLoader; + } + + @Get("/{schemaName}") + StreamedFile getSchema(String schemaName) { + if (!schemaName.endsWith(".schema.json")) { + schemaName = schemaName + ".schema.json"; + } + Optional url = resourceLoader.getResource(SCHEMAS_PATH + schemaName); // <3> + return url.map(StreamedFile::new).orElse(null); + } + +}