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

Adding a new property to ConfigBuilder #319

Closed
theonepichael opened this issue Mar 6, 2023 · 11 comments
Closed

Adding a new property to ConfigBuilder #319

theonepichael opened this issue Mar 6, 2023 · 11 comments
Assignees
Labels
question Further information is requested

Comments

@theonepichael
Copy link

theonepichael commented Mar 6, 2023

I would like to add a "$schema" field to the "properties" object on the schema returned by the schema generator. I want to avoid adding this field to the model object classes themselves.

I want this:

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "uuid" : {
      "type" : "string",
      "description" : "Universally unique identifier",
      "format" : "uuid"
    }
  },
  "required" : [ "uuid" ],
  "additionalProperties" : false
}

To be this:

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "uuid" : {
      "type" : "string",
      "description" : "Universally unique identifier",
      "format" : "uuid"
    },
    "$schema": {
         type": "string"
     }
  },
  "required" : [ "uuid" ],
  "additionalProperties" : false
}
@CarstenWickner
Copy link
Member

Hi @theonepichael,

The easiest way to do this would probably be to just manipulate the schema for each type (or just the ones you care about) via a "Type Attribute Override", e.g. like the one below:

config.forTypesInGeneral().withTypeAttributeOverride((schema, scope, context) -> schema
        .with(context.getKeyword(SchemaKeyword.TAG_PROPERTIES))
        .set(context.getKeyword(SchemaKeyword.TAG_SCHEMA),
                context.createStandardDefinitionReference(scope.getContext().resolve(String.class), null)));

@theonepichael
Copy link
Author

When calling .with(context.getKeyword(SchemaKeyword.TAG_PROPERTIES)), I get the following error:

Invalid input: JSON Pointer expression must start with '/': "properties"

I appreciate the response.

@CarstenWickner
Copy link
Member

Hi @theonepichael,

Which Jackson library version are you using?

  • The context.getKeyword(SchemaKeyword.TAG_PROPERTIES) is just a way to avoid hard-coding "properties" in your code, which appears to be working correctly.
  • The ObjectNode.with("properties") call should be returning a fresh jackson ObjectNode.
    I don't see an obvious error.

Please provide a full stack trace, in order to get a better feeling where that error is being raised.

@theonepichael
Copy link
Author

theonepichael commented Mar 7, 2023

I'm using version 2.14.2
Below is the stack trace:

Exception in thread "main" java.lang.IllegalArgumentException: Invalid input: JSON Pointer expression must start with '/': "properties"
        at com.fasterxml.jackson.core.JsonPointer.compile(JsonPointer.java:162)
        at com.fasterxml.jackson.databind.JsonNode.withObject(JsonNode.java:1135)
        at com.example.jsonschemagenerator.JsonschemaGeneratorApplication.lambda$4(JsonschemaGeneratorApplication.java:82)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.lambda$traverseGenericType$1(SchemaGenerationContextImpl.java:352)
        at java.base/java.util.ArrayList.forEach(ArrayList.java:1511)
        at java.base/java.util.Collections$UnmodifiableCollection.forEach(Collections.java:1092)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.traverseGenericType(SchemaGenerationContextImpl.java:352)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.populateMemberSchema(SchemaGenerationContextImpl.java:774)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.createFieldSchema(SchemaGenerationContextImpl.java:633)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.populateFieldSchema(SchemaGenerationContextImpl.java:608)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.generateObjectDefinition(SchemaGenerationContextImpl.java:459)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.addSubtypeReferencesInDefinition(SchemaGenerationContextImpl.java:366)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.traverseGenericType(SchemaGenerationContextImpl.java:339)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.traverseGenericType(SchemaGenerationContextImpl.java:276)
        at com.github.victools.jsonschema.generator.impl.SchemaGenerationContextImpl.parseType(SchemaGenerationContextImpl.java:97)
        at com.github.victools.jsonschema.generator.SchemaBuilder.createSchemaForSingleType(SchemaBuilder.java:111)
        at com.github.victools.jsonschema.generator.SchemaBuilder.createSingleTypeSchema(SchemaBuilder.java:56)
        at com.github.victools.jsonschema.generator.SchemaGenerator.generateSchema(SchemaGenerator.java:60)
        at com.example.jsonschemagenerator.JsonschemaGeneratorApplication.toJsonSchema(JsonschemaGeneratorApplication.java:90)
        at com.example.jsonschemagenerator.JsonschemaGeneratorApplication.loadClass(JsonschemaGeneratorApplication.java:56)
        at com.example.jsonschemagenerator.JsonschemaGeneratorApplication.main(JsonschemaGeneratorApplication.java:43)

@theonepichael
Copy link
Author

theonepichael commented Mar 7, 2023

A follow up:
I want to generate a schema that looks like the following:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Example",
  "anyOf": [
    {
      "required": [
        "foo"
      ]
    },
    {
      "required": [
        "bar"
      ]
    },
    {
      "required": [
        "baz"
      ]
    },
    {
      "required": [
        "fizz"
      ]
    },
    {
      "required": [
        "buzz"
      ]
    }
  ],
  "properties": {
    "$schema": {
      "type": "string"
    },
    "foo": {
      "description": "foo",
      "type": "number",
      "minimum": 0.0,
      "maximum": 360.0
    },
    "bar": {
      "description": "bar",
      "type": "number",
      "minimum": 0.0
    },
    "baz": {
      "description": "baz",
      "type": "number"
    },
    "fizz": {
      "description": "fizz",
      "type": "number",
      "minimum": 0.0
    },
    "buzz": {
      "description": "buzz",
      "type": "number",
      "minimum": 0.0
    }
  },
  "additionalProperties": false
}

One line explanation of the schema:
"The json can have any of the required properties, but if any of them are included then they must conform to
their corresponding entry in 'properties'"

@CarstenWickner
Copy link
Member

CarstenWickner commented Mar 8, 2023

Hi @theonepichael,

I can't reproduce your exact error, but to make it work I had to add an extra filter in the TypeAttributeOverride configuration to avoid an endless loop.
My minimal working example is this:

public class TypeAttributeOverride319 {

    public static void main(String[] args) {
        SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2020_12, OptionPreset.PLAIN_JSON);
        configBuilder.forTypesInGeneral().withTypeAttributeOverride((schema, scope, context) -> {
            if (scope.getType().getErasedType() == TestType.class) {
                ObjectNode propertiesNode = (ObjectNode) schema
                        .putIfAbsent(context.getKeyword(SchemaKeyword.TAG_PROPERTIES), schema.objectNode());
                propertiesNode.set(context.getKeyword(SchemaKeyword.TAG_SCHEMA),
                        context.createStandardDefinition(context.getTypeContext().resolve(String.class), null));
            }
        });
        SchemaGeneratorConfig config = configBuilder.build();
        SchemaGenerator generator = new SchemaGenerator(config);
        JsonNode jsonSchema = generator.generateSchema(TestType.class);

        System.out.println(jsonSchema.toPrettyString());
    }

    public static class TestType {
        public String otherProperty;
    }
}

This produces:

{
  "$schema" : "https://json-schema.org/draft/2020-12/schema",
  "type" : "object",
  "properties" : {
    "otherProperty" : {
      "type" : "string"
    },
    "$schema" : {
      "type" : "string"
    }
  }
}

Regarding your follow-up question:

  • If the $schema property was always required, you could just set the minProperties to 2 in order to ensure that at least one of the "normal" properties is present.
  • Alternatively, you can do something as in the following example:
public class AnyOfRequired319 {

    public static void main(String[] args) {
        SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_7, OptionPreset.PLAIN_JSON);
        configBuilder.forTypesInGeneral()
                // use CustomDefinitionProvider to add anyOf that marks each individual property as required
                .withCustomDefinitionProvider(new AnyPropertyRequiredCustomDefinitionProvider(TestType.class))
                // preserve declaration order of properties (instead of default alphabetic sorting)
                .withPropertySorter((a, b) -> 0);
        configBuilder
                // include JakartaValidationModule in order to pick up minimum/maximum constraints
                .with(new JakartaValidationModule())
                // include JacksonModule in order to pick up annotated descriptions
                .with(new JacksonModule());
        SchemaGeneratorConfig config = configBuilder.build();
        SchemaGenerator generator = new SchemaGenerator(config);
        JsonNode jsonSchema = generator.generateSchema(TestType.class);

        System.out.println(jsonSchema.toPrettyString());
    }

    private static class AnyPropertyRequiredCustomDefinitionProvider implements CustomDefinitionProviderV2 {

        private final Class<?> erasedTargetType;

        public AnyPropertyRequiredCustomDefinitionProvider(Class<?> erasedTargetType) {
            this.erasedTargetType = erasedTargetType;
        }

        @Override
        public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, SchemaGenerationContext context) {
            if (javaType.getErasedType() != this.erasedTargetType) {
                return null;
            }
            ObjectNode schema = context.createStandardDefinition(javaType, this);
            ObjectNode propertiesNode = (ObjectNode) schema.putIfAbsent(context.getKeyword(SchemaKeyword.TAG_PROPERTIES), schema.objectNode());
            if (!propertiesNode.isEmpty()) {
                // wrap additional anyOf into allOf in order to avoid conflicting with potentially existing anyOf
                // if the allOf is unnecessary, it will be automatically removed in the end
                ArrayNode anyOfNode = schema.withArray(context.getKeyword(SchemaKeyword.TAG_ALLOF))
                            .addObject().withArray(context.getKeyword(SchemaKeyword.TAG_ANYOF));
                propertiesNode.fieldNames()
                        .forEachRemaining(fieldName -> anyOfNode.addObject()
                                .withArray(context.getKeyword(SchemaKeyword.TAG_REQUIRED))
                                .add(fieldName));
            }
            // also add the additional "$schema" property while we're at it
            propertiesNode.set(context.getKeyword(SchemaKeyword.TAG_SCHEMA),
                        context.createStandardDefinition(context.getTypeContext().resolve(String.class), null));
            return new CustomDefinition(schema, CustomDefinition.DefinitionType.STANDARD, CustomDefinition.AttributeInclusion.YES);
        }
    }

    public static class TestType {
        @DecimalMin("0.0")
        @DecimalMax("360.0")
        @JsonPropertyDescription("foo")
        public double foo;
        @DecimalMin("0.0")
        @JsonPropertyDescription("bar")
        public double bar;
        @JsonPropertyDescription("baz")
        public double baz;
        @DecimalMin("0.0")
        @JsonPropertyDescription("fizz")
        public double fizz;
        @DecimalMin("0.0")
        @JsonPropertyDescription("buzz")
        public double buzz;
    }
}

This produces:

{
  "$schema" : "http://json-schema.org/draft-07/schema#",
  "type" : "object",
  "properties" : {
    "foo" : {
      "type" : "number",
      "description" : "foo",
      "minimum" : 0.0,
      "maximum" : 360.0
    },
    "bar" : {
      "type" : "number",
      "description" : "bar",
      "minimum" : 0.0
    },
    "baz" : {
      "type" : "number",
      "description" : "baz"
    },
    "fizz" : {
      "type" : "number",
      "description" : "fizz",
      "minimum" : 0.0
    },
    "buzz" : {
      "type" : "number",
      "description" : "buzz",
      "minimum" : 0.0
    },
    "$schema" : {
      "type" : "string"
    }
  },
  "anyOf" : [ {
    "required" : [ "foo" ]
  }, {
    "required" : [ "bar" ]
  }, {
    "required" : [ "baz" ]
  }, {
    "required" : [ "fizz" ]
  }, {
    "required" : [ "buzz" ]
  } ]
}

I hope that helps. 😃

@theonepichael
Copy link
Author

Thanks for the response. Looks like the .with method has been deprecated:

schema.with(context.getKeyword(SchemaKeyword.TAG_PROPERTIES))

The suggested alternative of the .withObject does not work either and I get a stack trace that is extremely similar to the one from my earlier post. Your advise is much appreciated.

Including pom.xml:

	<parent>
		<groupId>org.springframework.boot</groupId>
		<artifactId>spring-boot-starter-parent</artifactId>
		<version>3.0.4</version>
		<relativePath /> <!-- lookup parent from repository -->
	</parent>
	<properties>
		<java.version>17</java.version>
	</properties>
	<dependencies>
		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter</artifactId>
		</dependency>

		<dependency>
			<groupId>org.springframework.boot</groupId>
			<artifactId>spring-boot-starter-test</artifactId>
			<scope>test</scope>
		</dependency>

		<dependency>
			<groupId>org.projectlombok</groupId>
			<artifactId>lombok</artifactId>
			<version>1.18.24</version>
		</dependency>

		<dependency>
			<groupId>com.github.victools</groupId>
			<artifactId>jsonschema-generator</artifactId>
			<version>4.28.0</version>
		</dependency>

		<dependency>
			<groupId>com.github.victools</groupId>
			<artifactId>jsonschema-module-jackson</artifactId>
			<version>4.28.0</version>
		</dependency>

		<dependency>
			<groupId>com.github.victools</groupId>
			<artifactId>jsonschema-module-swagger-2</artifactId>
			<version>4.21.0</version>
		</dependency>

		<dependency>
			<groupId>org.springdoc</groupId>
			<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
			<version>2.0.2</version>
		</dependency>

	</dependencies>

@CarstenWickner
Copy link
Member

Hi @theonepichael,

I've edited my previous answer to replace the usage of the deprecated line with this.

ObjectNode propertiesNode = (ObjectNode) schema.putIfAbsent(context.getKeyword(SchemaKeyword.TAG_PROPERTIES), schema.objectNode());

If one assumes, that a "properties" element is always present, one could also just get() it:

(ObjectNode) schema.get(context.getKeyword(SchemaKeyword.TAG_PROPERTIES))

@CarstenWickner CarstenWickner added the question Further information is requested label Mar 12, 2023
@CarstenWickner CarstenWickner self-assigned this Mar 12, 2023
@theonepichael
Copy link
Author

Thanks for clearing that up. I have an additional follow up.

I want to produce a schema that looks like the following.
(WHERE I WANT TO GET TO):

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Example",
  "required": [
    "alpha",
    "beta",
    "sigma"
  ],
  "properties": {
    "$schema": {
      "type": "string"
    },
    "alpha": {
      "description": "alpha",
      "type": "string"
    },
    "beta": {
      "$ref": "./BetaSchema.json"
    },
    "sigma": {
      "description": "sigma",
      "type": "number"
    },
    "omega": {
      "type": "array",
      "items": {
        "oneOf": [
          {
            "$ref": "./ThetaSchema.json"
          },
          {
            "$ref": "./TauSchema.json"
          }
        ]
      }
    }
  },
  "additionalProperties": false
}

One line explanation of the schema:
"The json must have all of the required properties, and if 'omega' is included, the array can only contain one of the $ref objects"

(WHERE I'M CURRENTLY AT) The current output is:

{
  "$schema": "http://json-schema.org/draft-07/schema#",
  "title": "Example",
  "required": [
    "alpha",
    "beta",
    "sigma"
  ],
  "properties": {
    "$schema": {
      "type": "string"
    },
    "alpha": {
      "description": "alpha",
      "type": "string"
    },
    "beta": {
      "$ref": "./BetaSchema.json"
    },
    "sigma": {
      "description": "sigma",
      "type": "number"
    },
    "omega": {
      "type": "array",
      "items": {
        "anyOf": [ // should be "oneOf"
          {
            //theta schema is inlined -> should be "$ref" : "..."
          },
          {
            //tau schema is inlined -> should be "$ref" : "..."
          }
        ]
      }
    }
  },
  "additionalProperties": false
}

Note: The "Omega" class is the supertype of Theta and Tau. So only one of those subclass types is allowed in the array

class Example {
    /// required properties omitted

  @Schema(name = "omega", type = "array", oneOf = {Theta.class, Tau.class})
  private List<Omega> omega;
} 

@Schema(
    title = "Theta",
    description = "Theta",
    additionalProperties = Schema.AdditionalPropertiesValue.FALSE,
    ref = "./ThetaSchema.json")
class Theta extends Omega {}

// Implementation omitted for brevity
@Schema(
    title = "Tau",
    description = "Tau",
    additionalProperties = Schema.AdditionalPropertiesValue.FALSE,
    ref = "./TauSchema.json")
class Tau extends Omega {}

What changes do I need to make to get to my desired output?

@CarstenWickner
Copy link
Member

CarstenWickner commented Mar 13, 2023

Hi @theonepichael,

It's okay to create a new question btw., no need to keep adding more things to the same issue. 😜

That being said, your annotation of the List<Omega> seems incorrect for what you want to achieve.
It should be this instead:

    @ArraySchema(schema = @Schema(oneOf = {Theta.class, Tau.class}))
    private List<Omega> omega;

However, I can confirm that the @Schema(ref = "...") attribute is not being considered, when it is on the type.

I've added a corresponding CustomDefinitionProvider to the Swagger2Module now (in #326), which you appear to be using. It's similar to what I did in another question related to replacing schemas with external references:
#246 (comment)

Until that is released, you can configure such a CustomDefinitionProvider yourself.

A complete example would be:

public class ExternalRef319 {

    public static void main(String[] args) {
        SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_7, OptionPreset.PLAIN_JSON);
        configBuilder.with(new Swagger2Module());
        configBuilder.forTypesInGeneral().withTypeAttributeOverride((schema, scope, context) -> {
            if (scope.getType().getErasedType() == Example.class) {
                ((ObjectNode) schema.putIfAbsent(context.getKeyword(SchemaKeyword.TAG_PROPERTIES), schema.objectNode()))
                    .set(context.getKeyword(SchemaKeyword.TAG_SCHEMA),
                            context.createStandardDefinition(context.getTypeContext().resolve(String.class), null));
            }
        });
        configBuilder.forTypesInGeneral().withCustomDefinitionProvider(new SchemaRefDefinitionProvider());
        SchemaGeneratorConfig config = configBuilder.build();
        SchemaGenerator generator = new SchemaGenerator(config);
        JsonNode jsonSchema = generator.generateSchema(Example.class);

        System.out.println(jsonSchema.toPrettyString());
    }

    private static class SchemaRefDefinitionProvider implements CustomDefinitionProviderV2 {

        private Class<?> mainType;

        @Override
        public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, SchemaGenerationContext context) {
            Class<?> erasedType = javaType.getErasedType();
            if (this.mainType == null) {
                this.mainType = erasedType;
            }
            if (this.mainType == erasedType) {
                return null;
            }
            return Optional.ofNullable(erasedType.getAnnotation(Schema.class))
                    .map(Schema::ref)
                    .filter(ref -> !ref.isEmpty())
                    .map(ref -> context.getGeneratorConfig().createObjectNode()
                            .put(context.getKeyword(SchemaKeyword.TAG_REF), ref))
                    .map(schema -> new CustomDefinition(schema,
                            CustomDefinition.INLINE_DEFINITION,
                            CustomDefinition.INCLUDING_ATTRIBUTES))
                    .orElse(null);
        }
    }

    class Example {
        @Schema(description = "alpha")
        private String alpha;
        @Schema(ref = "./BetaSchema.json")
        private Object beta;
        @Schema(description = "sigma")
        private Double sigma;
        @ArraySchema(schema = @Schema(oneOf = {Theta.class, Tau.class}))
        private List<Omega> omega;
    }

    class Omega {
    }

    @Schema(
            title = "Theta",
            description = "Theta",
            additionalProperties = Schema.AdditionalPropertiesValue.FALSE,
            ref = "./ThetaSchema.json")
    class Theta extends Omega {
    }

    // Implementation omitted for brevity
    @Schema(
            title = "Tau",
            description = "Tau",
            additionalProperties = Schema.AdditionalPropertiesValue.FALSE,
            ref = "./TauSchema.json")
    class Tau extends Omega {
    }
}

The output of the above is:

{
  "$schema" : "http://json-schema.org/draft-07/schema#",
  "type" : "object",
  "properties" : {
    "alpha" : {
      "type" : "string",
      "description" : "alpha"
    },
    "beta" : {
      "$ref" : "./BetaSchema.json"
    },
    "omega" : {
      "type" : "array",
      "items" : {
        "type" : "object",
        "oneOf" : [ {
          "$ref" : "./ThetaSchema.json",
          "title" : "Theta",
          "description" : "Theta"
        }, {
          "$ref" : "./TauSchema.json",
          "title" : "Tau",
          "description" : "Tau"
        } ]
      }
    },
    "sigma" : {
      "type" : "number",
      "description" : "sigma"
    },
    "$schema" : {
      "type" : "string"
    }
  }
}

@CarstenWickner
Copy link
Member

HI @theonepichael,

I've just released v4.29.0, i.e., this is now part of the default Swagger2Module behavior.

Now, you just need to change the aforementioned annotation to the following (and update your jsonschema-generator dependency versions):

    @ArraySchema(schema = @Schema(oneOf = {Theta.class, Tau.class}))
    private List<Omega> omega;

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Development

No branches or pull requests

2 participants