Commit f901a236 authored by Morten Kjetland's avatar Morten Kjetland
Browse files

Fixed #2 - Use oneOf for Option/Optional when generating JsonSchema used for HTML5 GUI Generating

parent afb31b05
Loading
Loading
Loading
Loading
+53 −14
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@ package com.kjetland.jackson.jsonSchema
import java.lang.reflect.{Field, Method, ParameterizedType}
import java.time.{LocalDate, LocalDateTime, LocalTime, OffsetDateTime}
import java.util
import java.util.Optional
import javax.validation.constraints.NotNull

import com.fasterxml.jackson.annotation.{JsonSubTypes, JsonTypeInfo}
@@ -25,7 +26,9 @@ object JsonSchemaConfig {
  val vanillaJsonSchemaDraft4 = JsonSchemaConfig(
    useHTML5DateTimeLocal = false,
    autoGenerateTitleForProperties = false,
    defaultArrayFormat = None)
    defaultArrayFormat = None,
    useOneOfForOption = false
  )

  /**
    * Use this configuration if using the JsonSchema to generate HTML5 GUI, eg. by using https://github.com/jdorn/json-editor
@@ -38,14 +41,17 @@ object JsonSchemaConfig {
  val html5EnabledSchema = JsonSchemaConfig(
    useHTML5DateTimeLocal = true,
    autoGenerateTitleForProperties = true,
    defaultArrayFormat = Some("table"))
    defaultArrayFormat = Some("table"),
    useOneOfForOption = true
  )
}

case class JsonSchemaConfig
(
  useHTML5DateTimeLocal:Boolean = false,
  autoGenerateTitleForProperties:Boolean = true,
  defaultArrayFormat:Option[String] = Some("table")
  useHTML5DateTimeLocal:Boolean,
  autoGenerateTitleForProperties:Boolean,
  defaultArrayFormat:Option[String],
  useOneOfForOption:Boolean
)


@@ -440,13 +446,46 @@ class JsonSchemaGenerator
                val propertyType = prop.getType
                l(s"JsonObjectFormatVisitor - ${propertyName}: ${propertyType}")

                // Need to check for Option/Optional-special-case before we know what node to use here.

                case class PropertyNode(main:ObjectNode, meta:ObjectNode)

                val thisPropertyNode:PropertyNode = {
                  val thisPropertyNode = JsonNodeFactory.instance.objectNode()
                  propertiesNode.set(propertyName, thisPropertyNode)

                val childNode = JsonNodeFactory.instance.objectNode()
                  // Check for Option/Optional-special-case
                  if ( config.useOneOfForOption &&
                       (    classOf[Option[_]].isAssignableFrom(propertyType.getRawClass)
                         || classOf[Optional[_]].isAssignableFrom(propertyType.getRawClass)) ) {
                    // Need to special-case for property using Option/Optional
                    // Should insert oneOf between 'real one' and 'null'
                    val oneOfArray = JsonNodeFactory.instance.arrayNode()
                    thisPropertyNode.set("oneOf", oneOfArray)


                    // Create the one used when Option is empty
                    val oneOfNull = JsonNodeFactory.instance.objectNode()
                    oneOfNull.put("type", "null")
                    oneOfNull.put("title", "Not included")
                    oneOfArray.add(oneOfNull)

                    // Create the one used when Option is defined with the real value
                    val oneOfReal = JsonNodeFactory.instance.objectNode()
                    oneOfArray.add(oneOfReal)

                    // Return oneOfReal which, from now on, will be used as the node representing this property
                    PropertyNode(oneOfReal, thisPropertyNode)

                  } else {
                    // Not special-casing - using thisPropertyNode as is
                    PropertyNode(thisPropertyNode, thisPropertyNode)
                  }
                }

                // Continue processing this property

                val childVisitor = createChild(thisPropertyNode)
                val childVisitor = createChild(thisPropertyNode.main)

                // Workaround for scala lists and so on
                if ( (propertyType.isArrayType || propertyType.isCollectionLikeType) && !classOf[Option[_]].isAssignableFrom(propertyType.getRawClass) && propertyType.containedTypeCount() >= 1) {
@@ -458,9 +497,9 @@ class JsonSchemaGenerator
                  val itemType:JavaType = resolveType(prop, objectMapper)

                  childVisitor.expectArrayFormat(itemType).itemsFormat(null, itemType)
                } else if(classOf[Option[_]].isAssignableFrom(propertyType.getRawClass) && propertyType.containedTypeCount() >= 1) {
                } else if( (classOf[Option[_]].isAssignableFrom(propertyType.getRawClass) || classOf[Optional[_]].isAssignableFrom(propertyType.getRawClass) ) && propertyType.containedTypeCount() >= 1) {

                  // Property is scala Option.
                  // Property is scala Option or Java Optional.
                  //
                  // Due to Java's Type Erasure, the type behind Option is lost.
                  // To workaround this, we use the same workaround as jackson-scala-module described here:
@@ -491,13 +530,13 @@ class JsonSchemaGenerator

                resolvePropertyFormat(prop).foreach {
                  format =>
                    setFormat(thisPropertyNode, format)
                    setFormat(thisPropertyNode.main, format)
                }

                // Optionally add description
                Option(prop.getAnnotation(classOf[JsonSchemaDescription])).map {
                  jsonSchemaDescription =>
                    thisPropertyNode.put("description", jsonSchemaDescription.value())
                    thisPropertyNode.meta.put("description", jsonSchemaDescription.value())
                }

                // Optionally add title
@@ -510,7 +549,7 @@ class JsonSchemaGenerator
                  }
                  .map {
                  title =>
                    thisPropertyNode.put("title", title)
                    thisPropertyNode.meta.put("title", title)
                }

              }
+105 −3
Original line number Diff line number Diff line
package com.kjetland.jackson.jsonSchema

import java.time.OffsetDateTime
import java.util.TimeZone
import java.util
import java.util.{Optional, TimeZone}

import com.fasterxml.jackson.annotation.{JsonSubTypes, JsonTypeInfo, JsonValue}
import com.fasterxml.jackson.databind.annotation.JsonDeserialize
@@ -40,8 +41,9 @@ class JsonSchemaGeneratorTest extends FunSuite with Matchers {


  val jsonSchemaGenerator = new JsonSchemaGenerator(_objectMapper, debug = true)
  val jsonSchemaGeneratorHTML5Date = new JsonSchemaGenerator(_objectMapper, debug = true, config = JsonSchemaConfig.html5EnabledSchema)
  val jsonSchemaGeneratorHTML5 = new JsonSchemaGenerator(_objectMapper, debug = true, config = JsonSchemaConfig.html5EnabledSchema)
  val jsonSchemaGeneratorScala = new JsonSchemaGenerator(_objectMapperScala, debug = true)
  val jsonSchemaGeneratorScalaHTML5 = new JsonSchemaGenerator(_objectMapperScala, debug = true, config = JsonSchemaConfig.html5EnabledSchema)

  val testData = new TestData{}

@@ -334,6 +336,27 @@ class JsonSchemaGeneratorTest extends FunSuite with Matchers {

  }

  test("java using option") {
    val jsonNode = assertToFromJson(jsonSchemaGenerator, testData.pojoUsingOptionalJava)
    val schema = generateAndValidateSchema(jsonSchemaGenerator, testData.pojoUsingOptionalJava.getClass, Some(jsonNode))

    assert(schema.at("/properties/_string/type").asText() == "string")
    assert(!getRequiredList(schema).contains("_string")) // Should allow null by default

    assert(schema.at("/properties/_integer/type").asText() == "integer")
    assert(!getRequiredList(schema).contains("_integer")) // Should allow null by default

    val child1 = getNodeViaRefs(schema, schema.at("/properties/child1"), "Child1")

    assertJsonSubTypesInfo(child1, "type", "child1")
    assert( child1.at("/properties/parentString/type").asText() == "string" )
    assert( child1.at("/properties/child1String/type").asText() == "string" )

    assert(schema.at("/properties/optionalList/type").asText() == "array")
    assert(schema.at("/properties/optionalList/items/$ref").asText() == "#/definitions/ClassNotExtendingAnything")

  }

  test("custom serializer not overriding JsonSerializer.acceptJsonFormatVisitor") {

    val jsonNode = assertToFromJson(jsonSchemaGenerator, testData.pojoWithCustomSerializer)
@@ -414,7 +437,7 @@ class JsonSchemaGeneratorTest extends FunSuite with Matchers {
  test("pojo Using Custom Annotations") {
    val jsonNode = assertToFromJson(jsonSchemaGenerator, testData.pojoUsingFormat)
    val schema = generateAndValidateSchema(jsonSchemaGenerator, testData.pojoUsingFormat.getClass, Some(jsonNode))
    val schemaHTML5Date = generateAndValidateSchema(jsonSchemaGeneratorHTML5Date, testData.pojoUsingFormat.getClass, Some(jsonNode))
    val schemaHTML5Date = generateAndValidateSchema(jsonSchemaGeneratorHTML5, testData.pojoUsingFormat.getClass, Some(jsonNode))

    assert( schema.at("/format").asText() == "grid")
    assert( schema.at("/description").asText() == "This is our pojo")
@@ -443,6 +466,83 @@ class JsonSchemaGeneratorTest extends FunSuite with Matchers {

  }

  test("scala using option with HTML5") {
    val jsonNode = assertToFromJson(jsonSchemaGeneratorScalaHTML5, testData.pojoUsingOptionScala)
    val schema = generateAndValidateSchema(jsonSchemaGeneratorScalaHTML5, testData.pojoUsingOptionScala.getClass, Some(jsonNode))

    assert(schema.at("/properties/_string/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/_string/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/_string/oneOf/1/type").asText() == "string")
    assert(!getRequiredList(schema).contains("_string")) // Should allow null by default
    assert(schema.at("/properties/_string/title").asText() == "_string")

    assert(schema.at("/properties/_integer/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/_integer/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/_integer/oneOf/1/type").asText() == "integer")
    assert(!getRequiredList(schema).contains("_integer")) // Should allow null by default
    assert(schema.at("/properties/_integer/title").asText() == "_integer")

    assert(schema.at("/properties/_boolean/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/_boolean/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/_boolean/oneOf/1/type").asText() == "boolean")
    assert(!getRequiredList(schema).contains("_boolean")) // Should allow null by default
    assert(schema.at("/properties/_boolean/title").asText() == "_boolean")

    assert(schema.at("/properties/_double/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/_double/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/_double/oneOf/1/type").asText() == "number")
    assert(!getRequiredList(schema).contains("_double")) // Should allow null by default
    assert(schema.at("/properties/_double/title").asText() == "_double")

    assert(schema.at("/properties/child1/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/child1/oneOf/0/title").asText() == "Not included")
    val child1 = getNodeViaRefs(schema, schema.at("/properties/child1/oneOf/1"), "Child1Scala")
    assert(schema.at("/properties/child1/title").asText() == "Child 1")

    assertJsonSubTypesInfo(child1, "type", "child1")
    assert( child1.at("/properties/parentString/type").asText() == "string" )
    assert( child1.at("/properties/child1String/type").asText() == "string" )

    assert(schema.at("/properties/optionalList/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/optionalList/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/optionalList/oneOf/1/type").asText() == "array")
    assert(schema.at("/properties/optionalList/oneOf/1/items/$ref").asText() == "#/definitions/ClassNotExtendingAnythingScala")
    assert(schema.at("/properties/optionalList/title").asText() == "Optional List")
  }

  test("java using optional with HTML5") {
    val jsonNode = assertToFromJson(jsonSchemaGeneratorHTML5, testData.pojoUsingOptionalJava)
    val schema = generateAndValidateSchema(jsonSchemaGeneratorHTML5, testData.pojoUsingOptionalJava.getClass, Some(jsonNode))

    assert(schema.at("/properties/_string/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/_string/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/_string/oneOf/1/type").asText() == "string")
    assert(!getRequiredList(schema).contains("_string")) // Should allow null by default
    assert(schema.at("/properties/_string/title").asText() == "_string")

    assert(schema.at("/properties/_integer/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/_integer/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/_integer/oneOf/1/type").asText() == "integer")
    assert(!getRequiredList(schema).contains("_integer")) // Should allow null by default
    assert(schema.at("/properties/_integer/title").asText() == "_integer")


    assert(schema.at("/properties/child1/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/child1/oneOf/0/title").asText() == "Not included")
    val child1 = getNodeViaRefs(schema, schema.at("/properties/child1/oneOf/1"), "Child1")
    assert(schema.at("/properties/child1/title").asText() == "Child 1")

    assertJsonSubTypesInfo(child1, "type", "child1")
    assert( child1.at("/properties/parentString/type").asText() == "string" )
    assert( child1.at("/properties/child1String/type").asText() == "string" )

    assert(schema.at("/properties/optionalList/oneOf/0/type").asText() == "null")
    assert(schema.at("/properties/optionalList/oneOf/0/title").asText() == "Not included")
    assert(schema.at("/properties/optionalList/oneOf/1/type").asText() == "array")
    assert(schema.at("/properties/optionalList/oneOf/1/items/$ref").asText() == "#/definitions/ClassNotExtendingAnything")
    assert(schema.at("/properties/optionalList/title").asText() == "Optional List")
  }

}

trait TestData {
@@ -489,6 +589,8 @@ trait TestData {

  val pojoUsingOptionScala = PojoUsingOptionScala(Some("s1"), Some(1), Some(true), Some(0.1), Some(child1Scala), Some(List(classNotExtendingAnythingScala)))

  val pojoUsingOptionalJava = new PojoUsingOptionalJava(Optional.of("s"), Optional.of(1), Optional.of(child1), Optional.of(util.Arrays.asList(classNotExtendingAnything)))

  val pojoWithCustomSerializer = {
    val p = new PojoWithCustomSerializer
    p.myString = "xxx"
+55 −0
Original line number Diff line number Diff line
package com.kjetland.jackson.jsonSchema.testData;

import java.util.List;
import java.util.Optional;

public class PojoUsingOptionalJava {

    public Optional<String> _string;
    public Optional<Integer> _integer;
    public Optional<Child1> child1;
    public Optional<List<ClassNotExtendingAnything>> optionalList;

    public PojoUsingOptionalJava() {
    }

    public PojoUsingOptionalJava(Optional<String> _string, Optional<Integer> _integer, Optional<Child1> child1, Optional<List<ClassNotExtendingAnything>> optionalList) {
        this._string = _string;
        this._integer = _integer;
        this.child1 = child1;
        this.optionalList = optionalList;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        PojoUsingOptionalJava that = (PojoUsingOptionalJava) o;

        if (_string != null ? !_string.equals(that._string) : that._string != null) return false;
        if (_integer != null ? !_integer.equals(that._integer) : that._integer != null) return false;
        if (child1 != null ? !child1.equals(that.child1) : that.child1 != null) return false;
        return optionalList != null ? optionalList.equals(that.optionalList) : that.optionalList == null;

    }

    @Override
    public int hashCode() {
        int result = _string != null ? _string.hashCode() : 0;
        result = 31 * result + (_integer != null ? _integer.hashCode() : 0);
        result = 31 * result + (child1 != null ? child1.hashCode() : 0);
        result = 31 * result + (optionalList != null ? optionalList.hashCode() : 0);
        return result;
    }

    @Override
    public String toString() {
        return "PojoUsingOptionalJava{" +
                "_string=" + _string +
                ", _integer=" + _integer +
                ", child1=" + child1 +
                ", optionalList=" + optionalList +
                '}';
    }
}