mbknor / mbknor-jackson-jsonSchema

Generate JSON Schema with Polymorphism using Jackson annotations
MIT License
232 stars 75 forks source link

Schema generation for JsonSchemaExamples on double valued field throws an exception #184

Open tayloj opened 5 months ago

tayloj commented 5 months ago

Description

Generating schemas for classes that have double-valued fields annotated with @JsonSchemaExamples annotation causes an exception to be thrown. For instance, schema generation for this class with an int-valued field works (note that the example value doesn't even have to adhere to the syntax of the field type):

  public static class IntHolder {
    @JsonSchemaExamples("2.3")
    public int someInt;
  }

But schema generation for this field fails:

  public static class DoubleHolder {
    @JsonSchemaExamples("2.3")
    public double someDouble;
  }

It fails regardless of the specific example (I've tried with, for instance, "2", "2.3", and "foobar", and even an empty array of examples, i.e., @JsonSchemaExamples({})). Here's a test case that reproduces the problem.

import java.io.IOException;
import org.junit.jupiter.api.Test;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.kjetland.jackson.jsonSchema.JsonSchemaGenerator;
import com.kjetland.jackson.jsonSchema.annotations.JsonSchemaExamples;

public class TestDoubleExamples {

  public static class DoubleHolder {
    @JsonSchemaExamples("2.3")
    public double someDouble;
  }

  public static class IntHolder {
    @JsonSchemaExamples("2")
    public int someInt;
  }

  /**
   * Generate a schema and write it to standard output.
   *
   * @param clazz the class to generate a scheam for
   * @throws IOException if an I/O error occurs
   */
  void generateSchema(final Class<?> clazz) throws IOException {
    final ObjectMapper om = new ObjectMapper().enable(SerializationFeature.INDENT_OUTPUT);
    final JsonSchemaGenerator gen = new JsonSchemaGenerator(om);
    final JsonNode schema = gen.generateJsonSchema(clazz);
    final String txt = om.writeValueAsString(schema);
    System.out.println(txt);
  }

  /**
   * Prints
   *
   * <pre>
  {
    "$schema" : "http://json-schema.org/draft-04/schema#",
    "title" : "Int Holder",
    "type" : "object",
    "additionalProperties" : false,
    "properties" : {
      "someInt" : {
        "type" : "integer",
        "examples" : [ "2" ]
      }
    },
    "required" : [ "someInt" ]
  }
   * </pre>
   *
   * @throws IOException
   */
  @Test
  public void getWithInt() throws IOException {
    generateSchema(IntHolder.class);
  }

  /**
   * Fails with trace:
   *
   * <pre>
java.lang.ClassCastException: class com.fasterxml.jackson.databind.node.ObjectNode cannot be cast to class scala.runtime.Nothing$ (com.fasterxml.jackson.databind.node.ObjectNode and scala.runtime.Nothing$ are in unnamed module of loader 'app')
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator$MyJsonFormatVisitorWrapper.$anonfun$expectNumberFormat$8(JsonSchemaGenerator.scala:710)
    at scala.Option.map(Option.scala:230)
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator$MyJsonFormatVisitorWrapper.$anonfun$expectNumberFormat$2(JsonSchemaGenerator.scala:705)
    at scala.Option.map(Option.scala:230)
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator$MyJsonFormatVisitorWrapper.expectNumberFormat(JsonSchemaGenerator.scala:678)
    at com.fasterxml.jackson.databind.ser.std.StdSerializer.visitFloatFormat(StdSerializer.java:250)
    at com.fasterxml.jackson.databind.ser.std.NumberSerializers$Base.acceptJsonFormatVisitor(NumberSerializers.java:94)
    at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.acceptJsonFormatVisitor(DefaultSerializerProvider.java:588)
    at com.fasterxml.jackson.databind.ObjectMapper.acceptJsonFormatVisitor(ObjectMapper.java:4744)
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator$MyJsonFormatVisitorWrapper$$anon$9.myPropertyHandler(JsonSchemaGenerator.scala:1191)
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator$MyJsonFormatVisitorWrapper$$anon$9.optionalProperty(JsonSchemaGenerator.scala:1265)
    at com.fasterxml.jackson.databind.ser.BeanPropertyWriter.depositSchemaProperty(BeanPropertyWriter.java:843)
    at com.fasterxml.jackson.databind.ser.std.BeanSerializerBase.acceptJsonFormatVisitor(BeanSerializerBase.java:912)
    at com.fasterxml.jackson.databind.ser.DefaultSerializerProvider.acceptJsonFormatVisitor(DefaultSerializerProvider.java:588)
    at com.fasterxml.jackson.databind.ObjectMapper.acceptJsonFormatVisitor(ObjectMapper.java:4744)
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator.generateJsonSchema(JsonSchemaGenerator.scala:1477)
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator.generateJsonSchema(JsonSchemaGenerator.scala:1440)
    at com.kjetland.jackson.jsonSchema.JsonSchemaGenerator.generateJsonSchema(JsonSchemaGenerator.scala:1415)
    at TestDoubleExamples.generateSchema(TestDoubleExamples.java:32)
    at TestDoubleExamples.getWithDouble(TestDoubleExamples.java:97)
    at java.base/java.lang.reflect.Method.invoke(Method.java:568)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
    at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
   * </pre>
   *
   * @throws IOException
   */
  @Test
  public void getWithDouble() throws IOException {
    generateSchema(DoubleHolder.class);
  }
}

If I remove the annotation from the DoubleHolder class, then a schema is generated correctly:

{
  "$schema" : "http://json-schema.org/draft-04/schema#",
  "title" : "Double Holder",
  "type" : "object",
  "additionalProperties" : false,
  "properties" : {
    "someDouble" : {
      "type" : "number"
    }
  },
  "required" : [ "someDouble" ]
}

Platform

Java

% java -version
java version "17.0.6" 2023-01-17 LTS
Java(TM) SE Runtime Environment (build 17.0.6+9-LTS-190)
Java HotSpot(TM) 64-Bit Server VM (build 17.0.6+9-LTS-190, mixed mode, sharing)

Platform

% uname -a
Darwin SOMEHOSTNAME 22.6.0 Darwin Kernel Version 22.6.0: Tue Nov  7 21:42:24 PST 2023; root:xnu-8796.141.3.702.9~2/RELEASE_ARM64_T6020 arm64

Maven dependencies

[INFO] +- com.fasterxml.jackson.core:jackson-databind:jar:2.16.1:compile
[INFO] |  +- com.fasterxml.jackson.core:jackson-annotations:jar:2.16.1:compile
[INFO] |  \- com.fasterxml.jackson.core:jackson-core:jar:2.16.1:compile
[INFO] +- com.kjetland:mbknor-jackson-jsonschema_2.12:jar:1.0.39:compile
[INFO] |  +- org.scala-lang:scala-library:jar:2.12.10:compile
[INFO] |  +- org.jetbrains.kotlin:kotlin-scripting-compiler-embeddable:jar:1.3.50:compile
[INFO] |  |  +- org.jetbrains.kotlin:kotlin-scripting-compiler-impl-embeddable:jar:1.3.50:runtime
[INFO] |  |  |  +- org.jetbrains.kotlin:kotlin-scripting-common:jar:1.3.50:runtime
[INFO] |  |  |  |  \- org.jetbrains.kotlin:kotlin-reflect:jar:1.3.50:runtime
[INFO] |  |  |  +- org.jetbrains.kotlin:kotlin-scripting-jvm:jar:1.3.50:runtime
[INFO] |  |  |  |  \- org.jetbrains.kotlin:kotlin-script-runtime:jar:1.3.50:runtime
[INFO] |  |  |  \- org.jetbrains.kotlinx:kotlinx-coroutines-core:jar:1.1.1:runtime
[INFO] |  |  \- org.jetbrains.kotlin:kotlin-stdlib:jar:1.3.50:runtime
[INFO] |  |     +- org.jetbrains.kotlin:kotlin-stdlib-common:jar:1.3.50:runtime
[INFO] |  |     \- org.jetbrains:annotations:jar:13.0:runtime
[INFO] |  +- javax.validation:validation-api:jar:2.0.1.Final:compile
[INFO] |  +- org.slf4j:slf4j-api:jar:1.7.26:compile
[INFO] |  \- io.github.classgraph:classgraph:jar:4.8.21:compile
[INFO] +- com.fasterxml.jackson.datatype:jackson-datatype-jdk8:jar:2.16.1:compile
[INFO] +- com.fasterxml.jackson.datatype:jackson-datatype-jsr310:jar:2.16.1:compile
[INFO] \- org.junit.jupiter:junit-jupiter:jar:5.10.2:test
[INFO]    +- org.junit.jupiter:junit-jupiter-api:jar:5.10.2:test
[INFO]    |  +- org.opentest4j:opentest4j:jar:1.3.0:test
[INFO]    |  +- org.junit.platform:junit-platform-commons:jar:1.10.2:test
[INFO]    |  \- org.apiguardian:apiguardian-api:jar:1.1.2:test
[INFO]    +- org.junit.jupiter:junit-jupiter-params:jar:5.10.2:test
[INFO]    \- org.junit.jupiter:junit-jupiter-engine:jar:5.10.2:test
[INFO]       \- org.junit.platform:junit-platform-engine:jar:1.10.2:test
tayloj commented 5 months ago

Just a note, in case anyone runs into the same problem, in light of the documentation about examples (emphasis added):

The examples keyword is a place to provide an array of examples that validate against the schema. This isn't used for validation, but may help with explaining the effect and purpose of the schema to a reader. Each entry should validate against the schema in which it resides, but that isn't strictly required. There is no need to duplicate the default value in the examples array, since default will be treated as another example.

The default value and examples, then, really ought be of the right type, and thus a string-valued example for a double-valued field isn't ideal (though it's allowed, since it isn't "strictly" required). Using the @JsonSchemaInject annotation, I'm able to get a working examples field, and with a correctly typed value, at the cost of some beauty in the source code:

    @JsonSchemaInject(json = "{\"examples\":[2.3]}")
    public double someDouble;

yields

  "properties" : {
    "someDouble" : {
      "type" : "number",
      "examples" : [ 2.3 ]
    }
  },

This may be a useful workaround, especially since the typing requirements in Java's annotations would require either multiple fields in the @JsonSchemaExamples annotation, e.g., to be able to write @JsonSchemaExamples(doubles = { 2.3 }, strings = {"some string"}) and so on. Alternatively, different variants could be used, I suppose, e.g., @JsonSchemaExamplesDoubles(2.3) and @JsonSchemaExamplesStrings("a string"), but that seems worse.