jsonschema2pojo icon indicating copy to clipboard operation
jsonschema2pojo copied to clipboard

Implementation of oneOf support.

Open piotrtomiak opened this issue 4 years ago • 7 comments

I've tried to avoid oneOf for a long time due to issues with Pojo generation, but with my recent large and complex JSON schema I just couldn't do it. So I have implemented support for oneOf, which would map to POJOs where possible.

I would love to contribute that support to the project, but I want to know whether the idea sounds good for you and the contribution would be not be declined immediately.

There are 5 main scenarios I have handled. Except for scenario 1, properties on the schema with oneOf are ignored.

  1. There is single branch in oneOf. In this case the branch is treated as extends.

  2. All of branches are objects and there is a common constant string field present. E.g.:

"framework-context": {
  "type": "object",
  "oneOf": [ {
      "properties": {
        "kind": { "type": "string","const": "node-package"},
        (...)
      }
    }, {
      "properties": {
        "kind": {"type": "string","const": "script-urls"},
        (...)
      }
  }]
}

It results in generation of a super class with enum "kind" property and following annotations:

@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind", visible = true)
@JsonSubTypes({
    @JsonSubTypes.Type(name = "node-package", value = FrameworkContextNodePackage.class),
    @JsonSubTypes.Type(name = "script-urls", value = FrameworkContextScriptUrls.class)
})
  1. All of the branches are objects and objects are distinguishable based on the required fields.
"source": {
  "oneOf": [
    {
      "type": "object",
      "properties": { "file": {...}, "offset": {...} },
      "required": [ "file", "offset" ]
    },
    {
      "type": "object",
      "properties": { "module": {...}, "symbol": {...} },
      "required": [ "symbol" ]
    }
  ]
}

It results in generation of an empty super class with following annotations (deduction is available since Jackson 2.12.0):

@JsonTypeInfo(use = JsonTypeInfo.Id.DEDUCTION)
@JsonSubTypes({
    @JsonSubTypes.Type(SourceFileOffset.class),
    @JsonSubTypes.Type(SourceSymbol.class)
})
  1. There are two branches, of which one is array with items of the same type as the other branch. This is an optional convenience feature.
"generic-contributions": {
  "oneOf": [
    {
      "type": "array",
      "items": {"$ref": "#/definitions/generic-contribution"}
    },
    {"$ref": "#/definitions/generic-contribution"}
  ]
}

This results in a collection class extending ArrayList or HashSet with custom deserializer:

@JsonDeserialize(using = GenericContributions.MyDeserializer.class)
public class GenericContributions extends ArrayList<GenericContribution> {
  
  public static class MyDeserializer extends JsonDeserializer<GenericContributions> {
    
    @Override
    public GenericContributions deserialize(JsonParser parser, DeserializationContext deserializationContext)
      throws IOException {
      GenericContributions result = new GenericContributions();
      JsonToken token = parser.currentToken();
      if (token == JsonToken.START_ARRAY) {
        while (parser.nextToken() != JsonToken.END_ARRAY) {
          result.add(parser.readValueAs(GenericContribution.class));
        }
      }
      else {
        result.add(parser.readValueAs(GenericContribution.class));
      }
      return result;
    }
  }
}
  1. In any other case, when each branch is of different type, or is object and object branches are distinguishable - all object branches are processed as per pt. 1 or pt. 2 and an umbrella class with single field value and custom deserializer is created. E.g:
"type": {
  "oneOf": [
    { "type": "string" },
    { "$ref": "#/definitions/complex-type" },
    { "$ref": "#/definitions/type-reference" }
  ]
}

Results in (getter/setter removed from the example):

@JsonDeserialize(using = Type.MyDeserializer.class)
public class Type {

  /**
   * Type: {@code String | TypeBase}
   */
  public Object value;

  public static class MyDeserializer  extends JsonDeserializer<Type> {


    @Override
    public Type deserialize(JsonParser parser, DeserializationContext deserializationContext) throws IOException {
      Type result = new Type();
      JsonToken token = parser.currentToken();
      if (token == JsonToken.VALUE_STRING) {
        result.value = parser.readValueAs(String.class);
      }
      else if (token == JsonToken.START_OBJECT) {
        result.value = parser.readValueAs(TypeBase.class);
      }
      else {
        deserializationContext.handleUnexpectedToken(Object.class, parser);
      }
      return result;
    }
  }
}

Where TypeBase is created as per pt. 2.

piotrtomiak avatar Feb 05 '21 13:02 piotrtomiak

any news on this? I ended up splitting my json schema into multiple files so that all pojos are generated, though in a sort of detached state, and then combined those pojos in the generic map obtained from the main schema, but it is not very nice.

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "type": "object",
  "oneOf": [
    {
      "$ref": "definitions.json#/definitions/def1"
    },
    {
      "$ref": "definitions.json#/definitions/def2"
    }
  ]
}

having this schema generates a java class like

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({

})
@Generated("jsonschema2pojo")
public class AssigneeEvent {

    @JsonIgnore
    private Map<String, Object> additionalProperties = new HashMap<String, Object>();

    @JsonAnyGetter
    public Map<String, Object> getAdditionalProperties() {
        return this.additionalProperties;
    }

    @JsonAnySetter
    public void setAdditionalProperty(String name, Object value) {
        this.additionalProperties.put(name, value);
    }
}

and having individual pojos generated from definitions.json I only need to use strings for the top level fields and set the entire pojo as additional property

var def1 // pojo from definitions.json#/definitions/def1
var event = new AssigneeEvent()
event.setAdditionalProperty("someProp", def1.getSomeProp())
event.setAdditionalProperty("someObj", def1.getSomeObj())

so it is not too bad, but obviously event.setSomeProp(...) would be better and more type-safe

ThanksForAllTheFish avatar Mar 18 '21 16:03 ThanksForAllTheFish

@ThanksForAllTheFish I am able to work on the contribution, but I need some info from the maintainer (cc @joelittlejohn), before I dedicate time.

piotrtomiak avatar Mar 18 '21 19:03 piotrtomiak

This idea sounds crazy good and this is exactly the thing we're missing badly. Please let me know if you need some help with it.

Our use-case is to use following stuff as input

{
  "$id": "https://example.com/some.schema.json",
  "title": "some",
  "type": "object",
  "properties": {
    "id": {
      "type": "string"
    },
    "someEnum": {
      "type": "object",
      "oneOf": [
        {
          "properties": {
            "businessId": {
              "const": "001"
            },
            "name": {
              "const": "first"
            }
          },
          "required": ["businessId", "name"]
        },
        {
          "properties": {
            "businessId": {
              "const": "002"
            },
            "name": {
              "const": "second"
            }
          },
          "required": ["fachId", "name"]
        }
      ]
    }
  },

  "required": ["id", "someEnum"]
}

And generate enums or value objects from it. At the moment generator just generates empty file for someEnum

tillias avatar Mar 30 '21 07:03 tillias

any news?

Murik avatar Jul 19 '21 11:07 Murik

any news?

pantinor avatar Mar 08 '22 15:03 pantinor

@piotrtomiak @joelittlejohn Is there any way to do the oneOf pojo generation with a workaround in the json, without factoring out the oneOf array from separate common properties of the objects?

pantinor avatar Mar 08 '22 15:03 pantinor

Hi @piotrtomiak

What do you think about the following idea of implementation of the oneOf support?

Make a little bit extended POJO with setters that, in case of oneOf, not only set the value of the appropriate field but also remove (set null) values of all other non-common fields of all other branches. This approach doesn't need any special serializer/deserializer and/or class inheritance. For the regular serializers/deserializers like ObjectMapper in the Jackson project such extended POJO should look exactly like regular POJO.

For example, for this part of schema:

"identification": {
  "type": "object",
  "oneOf": [ {
      "properties": {
        "idNumber": { "type": "string" }
      }
    }, {
      "properties": {
        "person": { "$ref": "person.json" },
      }
  }]
}

The generated Identification POJO will look like this one:

public class Identification {

    private String idNumber;
    private Person person;

    public String getIdNumber() {
        return idNumber;
    }

    public void setIdNumber(String idNumber) {
        this.idNumber = idNumber;
        this.person = null;
    }

    public Person getPerson() {
        return person;
    }

    public void setPerson(Person person) {
        this.person = person;
        this.idNumber = null;
    }
}

rostidev avatar Mar 14 '23 11:03 rostidev