joelittlejohn / jsonschema2pojo

Generate Java types from JSON or JSON Schema and annotate those types for data-binding with Jackson, Gson, etc
http://www.jsonschema2pojo.org
Apache License 2.0
6.22k stars 1.66k forks source link

Generator generated Empty Java class from perfectly valid Jackson Schema #1387

Open oliTheTM opened 2 years ago

oliTheTM commented 2 years ago

Here is the schema:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://domain/schema/property.schema.json",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "address",
    "propertyValuation",
    "propertyType",
    "bedrooms",
    "keyWorkerScheme",
    "buyToLet",
    "tenure",
    "floors",
    "country",
    "assetCharges"
  ],
  "properties": {
    "address": {
      "$ref": "https://domain/schema/address.schema.json"
    },
    "propertyValuation": {
      "type": "number"
    },
    "propertyType": {
      "type": "string",
      "enum": [
        "House",
        "Terraced Property",
        "Semi-Detached Property",
        "Detached Property",
        "Flat/Maisonette",
        "End Terrace House",
        "Mid Terrace House",
        "Semi-Detached House",
        "Detached House",
        "Semi-Detached Bungalow",
        "Detached Bungalow",
        "Converted Flat",
        "Purpose Built Flat",
        "Retirement Flat (Not Used)",
        "Bungalow Property",
        "Terraced Bungalow",
        "Town House (Not Used)",
        "End Terrace Bungalow",
        "Mid Terrace Bungalow",
        "End Terrace Property",
        "Mid Terrace Property",
        "Terraced House"
      ]
    },
    "bedrooms": {
      "type": "number"
    },
    "keyWorkerScheme": {
      "type": "boolean"
    },
    "buyToLet": {
      "type": "boolean"
    },
    "tenure": {
      "type": "string",
      "enum": ["Leasehold", "Freehold"]
    },
    "floors": {
      "type": "number"
    },
    "country": {
      "type": "string",
      "enum": ["England", "Scotland", "Wales", "Northern Ireland"]
    },
    "assetCharges": {
      "type": "array",
      "items": {
        "$ref": "https://domain/schema/property-asset-charge.json"
      }
    }
  },
  "if": {
    "properties": { "buyToLet": { "const": true } }
  },
  "then": {
    "properties": {
      "buyToLetType": {
        "type": "string",
        "enum": ["Commercial", "Consumer"]
      }
    },
    "required": ["buyToLetType"],
    "if": {
      "properties": { "buyToLetType" : { "const": "Commercial" } }
    },
    "then": {
      "properties": {
        "selfFunding": {
          "type": "boolean"
        }
      },
      "required": ["selfFunding"]
    }
  }
}

Here's the Java file image:

package domain.model;

import com.fasterxml.jackson.annotation.JsonInclude;

@JsonInclude(JsonInclude.Include.NON_NULL)
public class Property {

}

Here's my config:

package domain.util;

import java.io.File;
import java.io.IOException;
import java.util.*;
import java.util.function.Predicate;
import java.util.regex.Pattern;
import com.sun.codemodel.*;
import org.jsonschema2pojo.*;
import org.jsonschema2pojo.rules.RuleFactory;
import javax.validation.constraints.NotNull;

public class GenerateClassFromSchema
{
  private static final String BASE_PATH = "src";
  private static final SchemaMapper schemaMapper;
  private static final JCodeModel schemaTransformer;

  static {
    GenerationConfig config = new DefaultGenerationConfig() {
      @Override
      public boolean isIncludeGeneratedAnnotation() {
        return false;
      }
    };
    schemaMapper = new SchemaMapper(new RuleFactory(
            config, (new Jackson2Annotator(config)),
            (new SchemaStore())
    ), (new SchemaGenerator()));
    schemaTransformer = new JCodeModel();
  }

  private static void addJSONAnnotations(JDefinedClass serializableClass) {
    serializableClass.annotate(com.fasterxml.jackson.annotation.JsonIgnoreProperties.class).
      param("ignoreUnknown",true);
  }
  private static void removeAllSetters(JDefinedClass serializableClass) {
    serializableClass.methods().removeIf((m) ->
      (m.type().name().equals("void") && (!m.params().isEmpty()))
    );
  }
  private static void addJSONCreatorConstructor(JDefinedClass serializableClass) {
    JMethod schemaClassConstructor = serializableClass.constructor(JMod.PUBLIC);
    schemaClassConstructor.annotate(com.fasterxml.jackson.annotation.JsonCreator.class);
    Predicate<String> isRequired = Pattern.compile("required", Pattern.CASE_INSENSITIVE).asPredicate();
    serializableClass.fields().forEach((k, v) -> {
      //@TODO: Assumption that the generator will comment all required fields with "..(Required).."
      String paramName = ('_'+k);
      if (isRequired.test(v.javadoc().toString())) {
        schemaClassConstructor.param(v.type(), paramName).
          annotate(com.fasterxml.jackson.annotation.JsonProperty.class).
          param("value", k).
          param("required", true);
      } else
        schemaClassConstructor.param(v.type(), paramName).
          annotate(com.fasterxml.jackson.annotation.JsonProperty.class).
          param("value", k);
      schemaClassConstructor.body().assign(v, schemaClassConstructor.params().get(schemaClassConstructor.params().size() - 1));
    });
  }

  public static List<String> classFromSchema(@NotNull() String namespace, @NotNull() String className, @NotNull() String schema) {
    JType thePackage = null;
    try {
      thePackage = schemaMapper.generate(schemaTransformer, className, namespace, schema);
    } catch (IOException e) {
      System.err.println(e.getMessage());
    }
    ArrayList<String> classLocations = new ArrayList<String>();
    // 0. Get generated class to modify:
    JClass boxedPackage = thePackage.boxify();
    if (!Objects.isNull(boxedPackage))
      boxedPackage._package().classes().forEachRemaining((c) -> {
        if (c.name().equals(className)) {
          addJSONAnnotations(c);
          addJSONCreatorConstructor(c);
          removeAllSetters(c);
          //Write the class to file using schemaTransformer:
          File schemaFile = new File(BASE_PATH);
          System.out.println("*Registering model*: " + namespace + '.' + className);
          try {
            schemaTransformer.build(schemaFile);
          } catch (IOException e) {
            System.err.println(e.getMessage());
          }
          classLocations.add(
            schemaFile.getAbsolutePath()+'\\'+namespace.replaceAll("\\.", "\\\\")+
            className+".java"
          );
        }
      });
    else
      System.out.println("Could not register model: "+namespace+'.'+className);
    return classLocations;
  }
}
oliTheTM commented 2 years ago

False-Negative?

joelittlejohn commented 2 years ago

This schema seems to work fine for me when I try it at jsonschema2pojo.org, which suggests that you have a problem in your own code.

If you remove all the additional code you have added to modify the classes, and just build the result of schemaMapper.generate, does it work correctly?

oliTheTM commented 2 years ago

I'll try that now, thanks.

oliTheTM commented 2 years ago

Right, so what exactly do you get as an output when you test this in your env? Also, what version of the library are you using?

If I run it just by itself without config I don't even produce a file anymore.. :(

unkish commented 2 years ago

@oliTheTM URI's like "https://domain/schema/address.schema.json" are not resolvable and hence were probably omitted by joelittlejohn when attempting to validate the claim.

If those ref's are omitted (there's no possibility to determine their content) jsonschema2pojo ver. 1.1.1 generated output that starts has following content (providing only head and tail of body):

import java.util.HashMap;
import java.util.Map;
import javax.annotation.processing.Generated;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import com.fasterxml.jackson.annotation.JsonValue;

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
    "propertyValuation",
    "propertyType",
    "bedrooms",
    "keyWorkerScheme",
    "buyToLet",
    "tenure",
    "floors",
    "country"
})
@Generated("jsonschema2pojo")
public class Property {

    /**
     * 
     * (Required)
     * 
     */
    @JsonProperty("propertyValuation")
    private Double propertyValuation;
    /**
     * 
     * (Required)
     * 
     */
    @JsonProperty("propertyType")
    private Property.PropertyType propertyType;

... CODE DELIBERATELY OMITTED TO REDUCE SIZE ...

    @Generated("jsonschema2pojo")
    public enum Tenure {

        LEASEHOLD("Leasehold"),
        FREEHOLD("Freehold");
        private final String value;
        private final static Map<String, Property.Tenure> CONSTANTS = new HashMap<String, Property.Tenure>();

        static {
            for (Property.Tenure c: values()) {
                CONSTANTS.put(c.value, c);
            }
        }

        Tenure(String value) {
            this.value = value;
        }

        @Override
        public String toString() {
            return this.value;
        }

        @JsonValue
        public String value() {
            return this.value;
        }

        @JsonCreator
        public static Property.Tenure fromValue(String value) {
            Property.Tenure constant = CONSTANTS.get(value);
            if (constant == null) {
                throw new IllegalArgumentException(value);
            } else {
                return constant;
            }
        }

    }

}

So if the claim holds that nothing is generated without providing custom configuration - perhaps the issue is with referenced schemas

oliTheTM commented 2 years ago

I have ordered my schemas starting with the Independant ones and then followed by 1st degree dependent ones then 2nd, etc.. I have observed that this works for other entities of my model. Could it be the "if" expression that's part of this schema that's the problem??

I mean, surely it reads the URL as an ID and just pattern-matches with the IDs of pre-processed schemas; no??

unkish commented 2 years ago

It didn't turn out to be a problem for me, here's what steps have been taken:

  1. json schema copied from https://github.com/joelittlejohn/jsonschema2pojo/issues/1387#issue-1186444703
  2. following parts removed from json schema file
    "address": {
      "$ref": "https://domain/schema/address.schema.json"
    },
    "assetCharges": {
      "type": "array",
      "items": {
        "$ref": "https://domain/schema/property-asset-charge.json"
      }
    }
  3. following command executed: jsonschema2pojo -s Property.json -t .
  4. copied json schema (with ref's removed from it) to https://www.jsonschema2pojo.org/ 4.a. Selected Source type to be JSON Schema 4.b. Clicked Preview button
oliTheTM commented 2 years ago

But isn't it true that the $ref could be either an ID lookup or, if there were a schema-register, an HTTP|GET. Then, in my case it would be a lookup because the $id matches an entity that it parsed before? Right?

What I'm trying to say is, does it not remember what it processed before???

oliTheTM commented 2 years ago

It didn't turn out to be a problem for me, here's what steps have been taken:

  1. json schema copied from Generator generated Empty Java class from perfectly valid Jackson Schema #1387 (comment)
  2. following parts removed from json schema file
    "address": {
      "$ref": "https://domain/schema/address.schema.json"
    },
    "assetCharges": {
      "type": "array",
      "items": {
        "$ref": "https://domain/schema/property-asset-charge.json"
      }
    }
  1. following command executed: jsonschema2pojo -s Property.json -t .
  2. copied json schema (with ref's removed from it) to https://www.jsonschema2pojo.org/ 4.a. Selected Source type to be JSON Schema 4.b. Clicked Preview button

I really can't omit those dependencies, sorry. There needs to be another way. Besides, it does actually seem to work in most cases.

oliTheTM commented 2 years ago

I assume the answer to my question is Yes. If that is so, what is causing this particular schema to return empty?

You still haven't shown me what is produced on your end?

unkish commented 2 years ago

You still haven't shown me what is produced on your end?

Partial output was provided here Also all the steps were provided - and it should be possible to reproduce output.

Without content of $ref's issue wasn't reproduced

oliTheTM commented 2 years ago

There is a conditional part to the schema (the "if" components). I would like to see how that got interpreted.

Unfortunately this is omitted from the partial output.

Or if you like you can just tell me how "if" components are generally handled?

oliTheTM commented 2 years ago

You still haven't shown me what is produced on your end?

Partial output was provided here Also all the steps were provided - and it should be possible to reproduce output.

Without content of $ref's issue wasn't reproduced

I cannot ignore the $ref's

unkish commented 2 years ago

I cannot ignore the $ref's

It's understood as should be understood that without these $ref's being valid/reachable/having meaningful content it's impossible to replicate your issue.

Or if you like you can just tell me how "if" components are generally handled?

It's quite simple - conditional subschemas are not supported they are ignored, to assert that claim we could take an overly simplified version of schema at hand:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "$id": "https://domain/schema/property.schema.json",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "buyToLet"
  ],
  "properties": {
    "buyToLet": {
      "type": "boolean"
    }
  },
  "if": {
    "properties": { "buyToLet": { "const": true } }
  },
  "then": {
    "properties": {
      "buyToLetType": {
        "type": "string",
        "enum": ["Commercial", "Consumer"]
      }
    },
    "required": ["buyToLetType"],
    "if": {
      "properties": { "buyToLetType" : { "const": "Commercial" } }
    },
    "then": {
      "properties": {
        "selfFunding": {
          "type": "boolean"
        }
      },
      "required": ["selfFunding"]
    }
  }
}

Which (jsonschema2pojo.bat -s Property.json -t .) produces following result:

import javax.annotation.processing.Generated;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonPropertyOrder({
    "buyToLet"
})
@Generated("jsonschema2pojo")
public class Property {

    /**
     *
     * (Required)
     *
     */
    @JsonProperty("buyToLet")
    private Boolean buyToLet;

    /**
     *
     * (Required)
     *
     */
    @JsonProperty("buyToLet")
    public Boolean getBuyToLet() {
        return buyToLet;
    }

    /**
     *
     * (Required)
     *
     */
    @JsonProperty("buyToLet")
    public void setBuyToLet(Boolean buyToLet) {
        this.buyToLet = buyToLet;
    }

    @Override
    public String toString() {
        StringBuilder sb = new StringBuilder();
        sb.append(Property.class.getName()).append('@').append(Integer.toHexString(System.identityHashCode(this))).append('[');
        sb.append("buyToLet");
        sb.append('=');
        sb.append(((this.buyToLet == null)?"<null>":this.buyToLet));
        sb.append(',');
        if (sb.charAt((sb.length()- 1)) == ',') {
            sb.setCharAt((sb.length()- 1), ']');
        } else {
            sb.append(']');
        }
        return sb.toString();
    }

    @Override
    public int hashCode() {
        int result = 1;
        result = ((result* 31)+((this.buyToLet == null)? 0 :this.buyToLet.hashCode()));
        return result;
    }

    @Override
    public boolean equals(Object other) {
        if (other == this) {
            return true;
        }
        if ((other instanceof Property) == false) {
            return false;
        }
        Property rhs = ((Property) other);
        return ((this.buyToLet == rhs.buyToLet)||((this.buyToLet!= null)&&this.buyToLet.equals(rhs.buyToLet)));
    }

}
oliTheTM commented 2 years ago

Is there any configuration I can use (an overload e.g.) that allows me to manage "if" elements?

unkish commented 2 years ago

Is there any configuration I can use (an overload e.g.) that allows me to manage "if" elements?

Perhaps overriding RuleFactory::getObjectRule to return extended/customized ObjectRule

oliTheTM commented 2 years ago

Is there any configuration I can use (an overload e.g.) that allows me to manage "if" elements?

Perhaps overriding RuleFactory::getObjectRule to return extended/customized ObjectRule

Er... how exactly do you make a custom ObjectRule?

Also, ObjectRule has protected instantiation.

Thanks.

unkish commented 2 years ago

There are several strategies, for example:

oliTheTM commented 2 years ago

I can't do the 1st 2 because, as I said, ObjectRule has protected instantiation. A practical example would be nice. Thanks.

unkish commented 2 years ago
    static class CustomObjectRule extends ObjectRule {

        public CustomObjectRule(
                RuleFactory ruleFactory,
                ParcelableHelper parcelableHelper,
                ReflectionHelper reflectionHelper) {
            super(ruleFactory, parcelableHelper, reflectionHelper);
        }

        @Override
        public JType apply(String nodeName, JsonNode node, JsonNode parent, JPackage _package, Schema schema) {
            final JType result = super.apply(nodeName, node, parent, _package, schema);
            // custom logic goes here
            return result;
        }
    }

    static {
        GenerationConfig config = new DefaultGenerationConfig() {
            @Override
            public boolean isIncludeGeneratedAnnotation() {
                return false;
            }
        };
        schemaMapper = new SchemaMapper(
                new RuleFactory(
                        config,
                        new Jackson2Annotator(config),
                        new SchemaStore()) {

                    @Override
                    public Rule<JPackage, JType> getObjectRule() {
                        return new CustomObjectRule(this, new ParcelableHelper(), getReflectionHelper());
                    }
                },
                new SchemaGenerator());
        schemaTransformer = new JCodeModel();
    }
oliTheTM commented 2 years ago

One more question sorry.

Is this method apply called multiple times during 1 class generation or is it only called once? I assume the former given it has the parameter nodeName.

unkish commented 2 years ago

ObjectRule::apply should be called once per object definition.

oliTheTM commented 2 years ago

Ah.. so then how would I traverse the Node tree in order to find an "if" ?

unkish commented 2 years ago

Ah.. so then how would I traverse the Node tree in order to find an "if" ?

In provided example above you'd be doing it here // custom logic goes here

\ P.S. Please note that:

oliTheTM commented 2 years ago

Let me do some things and get back to you as to whether/not there really is an Issue; sorry.

unkish commented 2 years ago

No problem.

oliTheTM commented 2 years ago

@joelittlejohn @unkish @HanSolo @davidpadbury What do you think of this?\/ It's only possible if I can mute & clone schemas & their components.

[*Premise: jsonSchema2POJO is called on the original schema and the following hook is triggered*]

a. PARSE the Original schema in order to compute (propertyDefinitions : Map<String, (Property : <name,JSONtype,required?,predicate:Function<?, bool>,generator:Function<Random,?>>)>)

b. ENUMERATE the conditional-part of the Original-Schema in order to compute (polymorphisms : List<Polymorphism>)

c. GIVEN propertyDefinitions & polymorphisms..

d. DELETE the "if","then","else" on the original schema

e. COMPLETE the generation of the new class corresponding to the original-schema without ifThenElse

f. ASSIGN the superType property to all elements in polymorphisms as this newly generated type

g. COPY the Original Schema as a Polymorphism schema |polymorphisms| times

h. ForAll schema copies & corresponding polymorphisms (<Si, Pi>):

    h1. Use Pi in order to MUTE Si such that:

        h1i. Iff required then ensures existence in "required" JsonNode

        h1ii. The type is polymorphic to JSONtype

    h2. CALL jsonSchema2POJO on Si

    h3. GIVEN another hook in jsonSchema2POJO; it's called only now, then it does the following:

        h3i. Mute the generated-class so that it inherits from Si.superClass (see [f])

        h3ii. Mute the generated-class constructor so that it initially calls super()

        h3iii. Mute said constructor again so that all predicates of Si are asserted else EXCEPTION

    h4. COMPLETE the generation of this sub-class       

    h5. ASSIGN Si.subType as this new generated class

            [*EVERY OBJECT HERE/\ INTERNAL TO GeneratedClassFactory*]

i. GIVEN all polymorphism Java classes are now generated (and all inherit the super) and are determined by their associated Predicate & Generator..

j. STORE all of this information in GeneratedClassFactory; in order for the following to be applied:

    j1. GeneratedClassFactory::newSample : <Class<T>, Integer>  -->  List<T>

    j2. GeneratedClassFactory::deserialize : <String, Class<T>>  -->  ~T

            [*Since any Polymorphism corresponds to a Test-Case, it follows that newSample knows, ahead of time, which Polymorphism to use*]