Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create changes to fix issues #24, #25, #26 #27

Merged
merged 5 commits into from
Apr 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@
import java.io.IOException;
import java.io.Writer;
import java.net.URI;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalAmount;
import java.util.*;

import static io.micronaut.jsonschema.visitor.context.JsonSchemaContext.JSON_SCHEMA_CONTEXT_PROPERTY;
Expand Down Expand Up @@ -192,25 +194,41 @@ private static void setSchemaType(TypedElement element, VisitorContext visitorCo
} 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));
if (type.isAssignable(Set.class)) {
schema.setUniqueItems(true);
}
} 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());
context.currentOriginatingElements().add(enumElement);
} else if (type.isAssignable(Number.class)) {
switch (type.getName()) {
case "java.lang.Integer", "java.lang.Long", "java.lang.Short",
"java.lang.Byte", "java.math.BigInteger" -> schema.addType(Type.INTEGER);
default -> schema.addType(Type.NUMBER);
}
} else if (type.isAssignable(CharSequence.class)) {
schema.addType(Type.STRING);
} else if (type.isAssignable(Temporal.class)) {
schema.addType(Type.STRING);
switch (type.getName()) {
// Time and date are new in draft 7
case "java.time.OffsetTime", "java.time.LocalTime" -> schema.setFormat("time");
case "java.time.LocalDate", "java.time.ChronoLocalDate" -> schema.setFormat("date");
default -> schema.setFormat("date-time");
}
} else if (type.isAssignable(TemporalAmount.class)) {
schema.addType(Type.STRING).setFormat("duration"); // New in draft 2019-09
} else {
switch (type.getName()) {
case "boolean", "java.lang.Boolean" -> schema.addType(Type.BOOLEAN);
case "int", "java.lang.Integer", "long", "java.lang.Long",
"short", "java.lang.Short", "byte", "java.lang.Byte" -> 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 "int", "long", "short", "byte", "java.lang.Byte" -> schema.addType(Type.INTEGER);
case "float", "double" -> schema.addType(Type.NUMBER);
case "java.util.UUID" -> schema.addType(Type.STRING).setFormat("uuid");
case "java.util.Date", "java.sql.Date" -> schema.addType(Type.STRING).setFormat("date-time");
default -> setBeanSchemaProperties(type, visitorContext, context, schema);
}
}
Expand All @@ -227,7 +245,9 @@ public static void setBeanSchemaProperties(ClassElement element, VisitorContext
}
context.createdSchemasByType().put(element.getGenericType().getName(), schema);
for (PropertyElement property: element.getBeanProperties()) {
schema.putProperty(property.getName(), createSchema(property, visitorContext, context));
Schema propertySchema = createSchema(property, visitorContext, context);
propertySchema.setSourceElement(property);
schema.putProperty(property.getName(), propertySchema);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,8 @@ private void addSubtypeInfo(ClassElement element, Schema schema, VisitorContext

if (discriminatorValue != null) {
if (as == As.PROPERTY || as == As.EXISTING_PROPERTY) {
subTypeSchema.putProperty(discriminatorName, Schema.string().setConstValue(discriminatorValue));
subTypeSchema.putProperty(discriminatorName, Schema.string().setConstValue(discriminatorValue))
.addRequired(discriminatorName);
} else if (as == As.WRAPPER_OBJECT) {
subTypeSchema = Schema.object().putProperty(discriminatorValue, subTypeSchema);
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,19 +26,23 @@
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;

/**
* An aggregator for adding information from the validation annotations.
*/
@Internal
public class ValidationInfoAggregator implements SchemaInfoAggregator {

private static final String JAKARTA_ANNOTATION_PREFIX = "jakarta.annotation.";
private static final String JAKARTA_VALIDATION_PREFIX = "jakarta.validation.constraints.";
private static final String NULLABLE_ANN = JAKARTA_ANNOTATION_PREFIX + "Nullable";
private static final String NON_NULL_ANN = JAKARTA_ANNOTATION_PREFIX + "Nonnull";
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 NOT_NULL_ANN = JAKARTA_VALIDATION_PREFIX + "NotNull";
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";
Expand Down Expand Up @@ -69,11 +73,11 @@ public Schema addInfo(TypedElement element, Schema schema, VisitorContext visito
visitorContext.warn("Could not add annotation " + ann + " to schema as it is not supported by the JacksonInfoAggregator", element)
);

addRequiredPropertiesInfo(element.getGenericType(), schema, 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)) {
Expand Down Expand Up @@ -163,4 +167,20 @@ public Schema addInfo(TypedElement element, Schema schema, VisitorContext visito
return schema;
}

private void addRequiredPropertiesInfo(ClassElement element, Schema schema, JsonSchemaContext context) {
if (schema.getProperties() != null) {
for (Entry<String, Schema> property: schema.getProperties().entrySet()) {
TypedElement sourceElement = property.getValue().getSourceElement();
if (context.strictMode() && !sourceElement.hasAnnotation(NON_NULL_ANN)) {
schema.addRequired(property.getKey());
} else if (sourceElement.isPrimitive()
|| sourceElement.hasAnnotation(NOT_NULL_ANN + LIST_SUFFIX)
|| sourceElement.hasAnnotation(NON_NULL_ANN)
) {
schema.addRequired(property.getKey());
}
}
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@
* Currently only 2020-12 draft is supported.
* @param strictMode Whether to generate schemas in strict mode.
* In strict mode unresolved properties in JSON will cause an error.
* All the properties that are not annotated as nullable must be non-null.
* @param createdSchemasByType A cache of crated schemas
* @param currentOriginatingElements The originating elements for the current schema
*/
public record JsonSchemaContext(
String outputLocation,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,11 @@
package io.micronaut.jsonschema.visitor.model;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonValue;
import io.micronaut.core.annotation.Internal;
import io.micronaut.inject.ast.TypedElement;

import java.util.ArrayList;
import java.util.LinkedHashMap;
Expand Down Expand Up @@ -97,6 +99,9 @@ public final class Schema {

private Schema not;

@JsonIgnore
private TypedElement sourceElement;

public String getTitle() {
return title;
}
Expand Down Expand Up @@ -365,6 +370,14 @@ public Schema setRequired(List<String> required) {
return this;
}

public Schema addRequired(String requiredProperty) {
if (required == null) {
required = new ArrayList<>();
}
this.required.add(requiredProperty);
return this;
}

public Schema getAdditionalProperties() {
return additionalProperties;
}
Expand Down Expand Up @@ -455,6 +468,15 @@ public Schema setNot(Schema not) {
return this;
}

public TypedElement getSourceElement() {
return sourceElement;
}

public Schema setSourceElement(TypedElement sourceElement) {
this.sourceElement = sourceElement;
return this;
}

/**
* The type of schema exactly matching a primitive JSON type.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package io.micronaut.jsonschema.visitor

import io.micronaut.jsonschema.visitor.model.Schema


class JsonSchemaVisitorSpec extends AbstractJsonSchemaSpec {

void "simple record schema"() {
Expand Down Expand Up @@ -43,6 +42,42 @@ class JsonSchemaVisitorSpec extends AbstractJsonSchemaSpec {
schema.properties['complexMap'].additionalProperties.items.type == [Schema.Type.STRING]
}

void "types schema test"() {
given:
def schema = buildJsonSchema('test.Single', 'single', """
package test;

import io.micronaut.jsonschema.JsonSchema;
import java.util.*;

@JsonSchema
public record Single(
%s value
) {
}
""", type)

expect:
schema.title == "Single"
schema.properties['value'] != null
check(schema.properties['value'])

where:
type | check
"java.lang.Number" | (p) -> p.type == [Schema.Type.NUMBER]
"java.math.BigInteger" | (p) -> p.type == [Schema.Type.INTEGER]
"java.math.BigDecimal" | (p) -> p.type == [Schema.Type.NUMBER]
"java.time.ZonedDateTime" | (p) -> p.type == [Schema.Type.STRING] && p.format == "date-time"
"java.time.Instant" | (p) -> p.type == [Schema.Type.STRING] && p.format == "date-time"
"java.time.LocalDate" | (p) -> p.type == [Schema.Type.STRING] && p.format == "date"
"java.time.OffsetTime" | (p) -> p.type == [Schema.Type.STRING] && p.format == "time"
"java.time.Duration" | (p) -> p.type == [Schema.Type.STRING] && p.format == "duration"
"java.util.Date" | (p) -> p.type == [Schema.Type.STRING] && p.format == "date-time"
"List<String>" | (p) -> p.type == [Schema.Type.ARRAY] && !p.uniqueItems
"Set<String>" | (p) -> p.type == [Schema.Type.ARRAY] && p.uniqueItems
"Map<String, String>" | (p) -> p.type == [Schema.Type.OBJECT] && p.additionalProperties.type == [Schema.Type.STRING]
}

void "simple record customized schema"() {
given:
def schema = buildJsonSchema('test.GreenSalamander', 'dark-green-salamander', """
Expand Down Expand Up @@ -192,6 +227,7 @@ class JsonSchemaVisitorSpec extends AbstractJsonSchemaSpec {
schema.title == "Salamander"
schema.properties.size() == 3
schema.additionalProperties == Schema.FALSE
schema.required == ["name", "poisonous", "age"]
}

void "validation schema"() {
Expand Down Expand Up @@ -257,7 +293,7 @@ class JsonSchemaVisitorSpec extends AbstractJsonSchemaSpec {
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['nullable'].type == [Schema.Type.STRING]
schema.properties['alwaysTrue'].constValue == true
schema.properties['alwaysFalse'].constValue == false
schema.properties['digits'].type == [Schema.Type.NUMBER]
Expand All @@ -266,6 +302,34 @@ class JsonSchemaVisitorSpec extends AbstractJsonSchemaSpec {
schema.properties['digits'].multipleOf == 0.0001
}

void "required properties schema"() {
given:
def schema = buildJsonSchema('test.ClownFish', 'clown-fish', """
package test;

import io.micronaut.core.annotation.NonNull;
import io.micronaut.jsonschema.JsonSchema;
import jakarta.annotation.Nonnull;
import jakarta.validation.constraints.*;

@JsonSchema
public record ClownFish(
@Nonnull
String name,
@NotNull
String color,
@NonNull
Double weight,
Integer age
) {
}
""")

expect:
schema.title == "ClownFish"
schema.required == ['name', 'color', 'weight']
}

void "class schema with documentation"() {
given:
def schema = buildJsonSchema('test.Heron', 'heron', """
Expand Down
2 changes: 1 addition & 1 deletion json-schema-validation/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@ dependencies {
testImplementation(mnTest.micronaut.test.junit5)
testRuntimeOnly(libs.junit.jupiter.engine)
testImplementation(libs.junit.jupiter.params)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ public record JsonSchemaValidatorConfiguration(
String classpathFolder
) {

/**
* The JSON schema validation configuration prefix.
*/
public static final String PREFIX = "micronaut.jsonschema.validation";

}
Original file line number Diff line number Diff line change
Expand Up @@ -46,4 +46,9 @@ public String getMessage() {
public com.networknt.schema.ValidationMessage getValidationMessage() {
return validationMessage;
}

@Override
public String toString() {
return "ValidationMessageAdapter{message=" + getMessage() + "}";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ private static Stream<Arguments> provideValid() {

private static Stream<Arguments> provideInvalid() {
return Stream.of(
Arguments.of(new Ostrich("Glob", -12), "/runSpeed: must have an exclusive minimum value of 0", "/@type: must be the constant value 'eagle-bird'"),
Arguments.of(new Ostrich("Glob", -12f), "/runSpeed: must have an exclusive minimum value of 0", "/@type: must be the constant value 'eagle-bird'"),
Arguments.of(new Eagle("Blob", 0.5f), "/@type: must be the constant value 'ostrich-bird'", "/flySpeed: must have a minimum value of 1")
);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,6 @@
record Eagle(
String name,
@Min(1)
float flySpeed
Float flySpeed
) implements Bird {
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@
record Ostrich(
String name,
@Positive
float runSpeed
Float runSpeed
) implements Bird {
}
Original file line number Diff line number Diff line change
Expand Up @@ -45,4 +45,4 @@ private static Stream<Arguments> provideInvalid() {
Arguments.of(new Possum("Bob", List.of(new Possum("", null, null)), null) , "/children/0/name: must be at least 1 characters long")
);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
@Introspected
public record RWBlackbird(
String name,
double wingSpan
Double wingSpan
) {
}
// end::clazz[]
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class RWBlackbirdTest {

@Test
void validObjectWithChangedPath() throws IOException {
var bird = new RWBlackbird("Clara", 1.2f);
var bird = new RWBlackbird("Clara", 1.2d);
var assertions = validator.validate(bird, RWBlackbird.class);
assertEquals(0, assertions.size());
}
Expand All @@ -35,4 +35,4 @@ void llamaSchema(ResourceLoader resourceLoader, JsonMapper jsonMapper) throws IO
String result = new String(resultOptional.get().readAllBytes(), StandardCharsets.UTF_8);
assertEquals(jsonMapper.readValue(expected, Map.class), jsonMapper.readValue(result, Map.class));
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@
"type":["string"],
"minLength":1
}
}
}
},
"required":["age"]
}
Loading
Loading