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

Can maven plugin generate schemas which externally reference each other? #246

Closed
antiBaconMachine opened this issue Apr 12, 2022 · 4 comments
Assignees
Labels
question Further information is requested

Comments

@antiBaconMachine
Copy link

Hi, thanks for the great work on this project.

I struggled to come up with a good title for this question but let me expand by borrowing the answer from #167 which starts with this setup:

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.CustomDefinition;
import com.github.victools.jsonschema.generator.OptionPreset;
import com.github.victools.jsonschema.generator.SchemaGenerator;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfig;
import com.github.victools.jsonschema.generator.SchemaGeneratorConfigBuilder;
import com.github.victools.jsonschema.generator.SchemaKeyword;
import com.github.victools.jsonschema.generator.SchemaVersion;

public class Example {

    public static void main(String[] args) throws JsonProcessingException {
        SchemaGeneratorConfigBuilder configBuilder = new SchemaGeneratorConfigBuilder(SchemaVersion.DRAFT_2019_09, OptionPreset.PLAIN_JSON);
        configBuilder.forTypesInGeneral()
                .withCustomDefinitionProvider((javaType, context) -> {
                    if (javaType.getErasedType() == Person.class
                            || javaType.getErasedType().getTypeName().startsWith("java")) {
                        // selected types should be treated normally
                        return null;
                    }
                    // define your custom reference value
                    String refValue = ':' + javaType.getErasedType().getSimpleName();
                    ObjectNode customNode = context.getGeneratorConfig().createObjectNode()
                            .put(context.getKeyword(SchemaKeyword.TAG_REF), refValue);
                    return new CustomDefinition(customNode,
                            CustomDefinition.DefinitionType.INLINE,
                            CustomDefinition.AttributeInclusion.NO);
                });
        SchemaGeneratorConfig config = configBuilder.build();
        SchemaGenerator generator = new SchemaGenerator(config);
        JsonNode jsonSchema = generator.generateSchema(Person.class);
        System.out.println(jsonSchema.toPrettyString());
    }

    static class Person {
        String name;
        Address address;
    }

    static class Address {
        String other;
    }
}

This works specifically for the Person class, but what if we also wanted to generate the schema for Address? Currently that would result in a schema which is just a $ref pointing to itself.

Now say I have a whole package of schemas, what I'm trying to do is use the Maven plugin to generate all the schemas for the package, using external $refs to each other as appropriate. So e.g. if we had

static class Person {
      String name;
      Address address;
  }

static class Building {
     Person owner;
     Address address;
}

static class Address {
    String other;
}

Then I would want to see three schemas along these lines:

{
  "$id": "http://foo.bar/Person",
  "type": "object",
  "properties": {
     "name": {"type": "string"},
     "address": {"$ref": "http://foo.bar/Person"}
  }
}

{
  "$id": "http://foo.bar/Building",
  "type": "object",
  "properties": {
     "owner": {"$ref": "http://foo.bar/Person"},
     "address": {"$ref": "http://foo.bar/Person"}
  }
}


{
  "$id": "http://foo.bar/Address",
  "type": "object",
  "properties": {...}
}

At first glance I though this would be easy using a Module with an IdResolver and a CustomDefintiionProvider but I quickly ran into trouble:

public class MySchemaModule implements Module {

    public static final String ID_PREFIX = "http://foo.bar/";

    // I don't love using a regex for this, could be a job for reflection instead 
    public static final Pattern externalRefPattern = Pattern.compile("^(bar\\.foo\\.)(.*)$");

    @Override
    public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
      // for any type in my chosen package, construct a custom id in FQ uri format
      builder.forTypesInGeneral().withIdResolver(scope -> {
            var matcher = externalRefPattern.matcher(scope.getType().getErasedType().getCanonicalName());
            if (matcher.matches()) {
                return makeId(matcher.group(2));
            }
            return null;
        }).withCustomDefinitionProvider((javaType, context) -> {
            if (!javaType.getErasedType().getTypeName().startsWith("bar.foo")) {
                // selected types should be treated normally
                return null;
            }
            // define your custom reference value
            String refValue = makeId(javaType.getErasedType().getCanonicalName());
            ObjectNode customNode = context.getGeneratorConfig().createObjectNode()
                    .put(context.getKeyword(SchemaKeyword.TAG_REF), refValue);
            return new CustomDefinition(customNode,
                    CustomDefinition.DefinitionType.INLINE,
                    CustomDefinition.AttributeInclusion.NO);
        });


    }
    
    public String makeId(String entity) {
        return ID_PREFIX + entity;
    }
}

And in the POM:

...
<plugin>
  <groupId>com.github.victools</groupId>
  <artifactId>jsonschema-maven-plugin</artifactId>
  <executions>
    <execution>
      <goals>
        <goal>generate</goal>
      </goals>
    </execution>
  </executions>
  <configuration>
    <packageNames>bar.foo</packageNames>
    <modules>
      <module>
        <className>spam.eggs.MySchemaModule</className>
      </module>
    </modules>
  </configuration>
</plugin>
...

This almost works except I still haven't solved the original replacement issue so any instance of the class witll be replaced with it's $ref including the root schema for the type.

So possibly this question boils down to

How can I disambiguate the root schema defintion for a class from any subsequent references to the class?

If I could do that then I could conditonally return null from the . Given jsonSchema is stateless and fractal I kind of suspect that is not going to be possible so maybe I'm just going the wrong way altogether in which case the question is a more general

Can can I achieve generation of multiple schemas which reference each other?

Thanks for taking the time to read this somewhat long question, any advice appreciated.

@CarstenWickner
Copy link
Member

HI @antiBaconMachine,

Thanks for raising this.

I'll have to ponder it a bit. The Maven plugin is somewhat limited in scope, as it's not my main focus to be honest.
Your use-case makes sense to me though and I believe you're right in the assumption that it's currently not straightforward to achieve, even regardless of the Maven plugin.

Someone else already found a way to identify whether or not a given type is the main type or not by populating a separate variable inside a TypeAttributeOverride or CustomDefinitionProvider: #59 (comment)
That could be used as inspiration here.

I'm unsure when I'd get to try this out myself, unfortunately. Too much to do right now. 😅

@CarstenWickner
Copy link
Member

CarstenWickner commented Apr 14, 2022

HI @antiBaconMachine,

As mentioned before, if it is acceptable that the target package is defined in the module code, you could use something like the following:

public class ExternalRefPluginModule implements Module {

    private static final String PACKAGE_FOR_EXTERNAL_REFS = "com.github.victools.example";
    private static final String ID_PREFIX = "http://foo.bar/";
    private static final String ID_SUFFIX = ".json";

    private Class<?> mainType;

    @Override
    public void applyToConfigBuilder(SchemaGeneratorConfigBuilder builder) {
        builder.forTypesInGeneral()
                .withCustomDefinitionProvider(this::provideCustomSchemaDefinition)
                .withIdResolver(this::resolveMainTypeId)
                .withTypeAttributeOverride(this::overrideTypeAttributes);
    }

    private CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, SchemaGenerationContext context) {
        Class<?> erasedType = javaType.getErasedType();
        if (this.mainType == null) {
            this.mainType = erasedType;
        } else if (this.mainType != erasedType
                && erasedType.getPackage() != null
                && erasedType.getPackage().getName().startsWith(PACKAGE_FOR_EXTERNAL_REFS)) {
            ObjectNode externalRef = context.getGeneratorConfig().createObjectNode()
                    .put(context.getKeyword(SchemaKeyword.TAG_REF), ID_PREFIX + erasedType.getName()+ ID_SUFFIX);
            return new CustomDefinition(externalRef, CustomDefinition.DefinitionType.INLINE, CustomDefinition.AttributeInclusion.YES);
        }
        return null;
    }

    private String resolveMainTypeId(TypeScope scope) {
        if (this.mainType == scope.getType().getErasedType()) {
            return ID_PREFIX + this.mainType.getName()+ ID_SUFFIX;
        }
        return null;
    }

    private void overrideTypeAttributes(ObjectNode collectedTypeAttributes, TypeScope scope, SchemaGenerationContext context) {
        if (this.mainType == scope.getType().getErasedType()) {
            this.mainType = null;
        }
    }
}

@CarstenWickner CarstenWickner self-assigned this Apr 15, 2022
@CarstenWickner CarstenWickner added the question Further information is requested label Apr 15, 2022
@antiBaconMachine
Copy link
Author

@CarstenWickner apologies for slow response I've been out this week. This approach does seem to do the job, neat trick to cache the base class like that.

Thanks so much for taking the time to respond, please let me know if there is anything I can do to help the project in return.

@CarstenWickner
Copy link
Member

That's very kind of you to offer.
I've personally no particular features planned right now, but I'm up for suggestions.

There are occasional feature request and questions coming up as Issues here. Feel free to watch the repository and provide answers or a PR where you feel comfortable.

Either way, happy to help – no strings attached. 😉

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