Skip to content

Commit

Permalink
Generalize strategy to define Fields from a set of Types
Browse files Browse the repository at this point in the history
  • Loading branch information
ivy-rew committed Oct 14, 2024
1 parent c4efcdb commit dc77370
Show file tree
Hide file tree
Showing 7 changed files with 347 additions and 6 deletions.
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,44 @@ Will generate a schema as follows:
}
```

### @TypesAsFields

Adds implementations of a generic type to explicit field references.
This is perfect if you maintain a Map with well known keys in your Java objects.

```java
public CheckTypes checks;

@TypesAsFields(Checks)
public interface CheckTypes implements Map<String, MyChecker>


public static class Checks implements FieldRegistry {
@Override
public Set<Class<?>> types() {
return Set.of(Specific.class, Another.class);
}
}
```

Will generate a schema as follows:

```json
"CheckTypes" : {
"type" : "object",
"additionalProperties" : {
"$ref" : "#/$defs/MyChecker"
},
"properties" : {
"Another" : {
"$ref" : "#/$defs/Another"
},
"Specific" : {
"$ref" : "#/$defs/Specific"
}
}
}
```


### @AdditionalProperties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
import io.github.axonivy.json.schema.impl.ExamplesProvider;
import io.github.axonivy.json.schema.impl.ImplementationTypesProvider;
import io.github.axonivy.json.schema.impl.RemoteRefProvider;
import io.github.axonivy.json.schema.impl.TypesAsFieldsProvider;


public class ExpressiveSchemaModule implements Module {
Expand Down Expand Up @@ -67,7 +68,8 @@ public void applyToConfigBuilder(SchemaGeneratorConfigBuilder configBuilder) {
private void applyImplementations(SchemaGeneratorConfigBuilder configBuilder) {
boolean conditionals = options.contains(ExpressiveSchemaOption.PREFER_CONDITIONAL_SUBTYPES);
configBuilder.forTypesInGeneral()
.withCustomDefinitionProvider(new ImplementationTypesProvider(conditionals));
.withCustomDefinitionProvider(new ImplementationTypesProvider(conditionals))
.withCustomDefinitionProvider(new TypesAsFieldsProvider());
}

private static Object jacksonDefaultVal(FieldScope field) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.Set;


/**
Expand All @@ -26,9 +25,7 @@
/** the provider of at least all valid-subtypes */
public Class<? extends TypeReqistry> value();

public static interface TypeReqistry {

Set<Class<?>> types();
public static interface TypeReqistry extends MultiTypes {

default Class<?> base() {
return null; // no common properties on impls
Expand All @@ -44,7 +41,6 @@ default String typeName(Class<?> type) {
default String typeDesc(@SuppressWarnings("unused") Class<?> type) {
return null;
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package io.github.axonivy.json.schema.annotations;

import java.util.Set;

public interface MultiTypes {

Set<Class<?>> types();

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package io.github.axonivy.json.schema.annotations;

import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.ElementType.TYPE;

import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;


@Target({FIELD, METHOD, TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface TypesAsFields {

/** the provider of at least all valid-subtypes */
public Class<? extends FieldRegistry> value();

public static interface FieldRegistry extends MultiTypes {

default String fieldName(Class<?> type) {
return type.getSimpleName();
}

default String fieldDescription(@SuppressWarnings("unused") Class<?> type) {
return null;
}

default Class<?> valueType(Class<?> type) {
return type;
}

}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package io.github.axonivy.json.schema.impl;

import java.lang.reflect.Constructor;
import java.util.Optional;

import com.fasterxml.classmate.ResolvedType;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.github.victools.jsonschema.generator.CustomDefinition;
import com.github.victools.jsonschema.generator.CustomDefinitionProviderV2;
import com.github.victools.jsonschema.generator.SchemaGenerationContext;
import com.github.victools.jsonschema.generator.SchemaKeyword;

import io.github.axonivy.json.schema.annotations.TypesAsFields;
import io.github.axonivy.json.schema.annotations.TypesAsFields.FieldRegistry;


public class TypesAsFieldsProvider implements CustomDefinitionProviderV2 {

@Override
public CustomDefinition provideCustomSchemaDefinition(ResolvedType javaType, SchemaGenerationContext context) {
TypesAsFields typesAsFields = javaType.getErasedType().getAnnotation(TypesAsFields.class);
if (typesAsFields == null) {
return null;
}
ObjectNode std = context.createStandardDefinition(javaType, this);
String properties = context.getKeyword(SchemaKeyword.TAG_PROPERTIES);
var props = Optional.ofNullable((ObjectNode)std.get(properties))
.orElseGet(() -> std.putObject(properties));
FieldRegistry registry = create(typesAsFields.value());
new FieldCreator(registry, context).typesAsFields(props);
return new CustomDefinition(std);
}

private static class FieldCreator {

private final FieldRegistry registry;
private final SchemaGenerationContext context;

private FieldCreator(FieldRegistry registry, SchemaGenerationContext context) {
this.registry = registry;
this.context = context;
}

private void typesAsFields(ObjectNode props) {
registry.types().stream()
.sorted((c1,c2) -> String.CASE_INSENSITIVE_ORDER.compare(c1.getSimpleName(), c2.getSimpleName()))
.forEachOrdered(type -> {
toProperty(props, type);
});
}

private void toProperty(ObjectNode props, Class<?> type) {
String name = registry.fieldName(type);
var refType = registry.valueType(type);
var inner = context.getTypeContext().resolve(refType);
var def = context.createDefinitionReference(inner);
Optional.ofNullable(registry.fieldDescription(type)).ifPresent(desc -> {
def.put(context.getKeyword(SchemaKeyword.TAG_DESCRIPTION), desc);
});
props.set(name, def);
}

}

private static FieldRegistry create(Class<? extends FieldRegistry> registryType) {
try {
Constructor<?> constructor = registryType.getConstructors()[0];
return (FieldRegistry) constructor.newInstance();
} catch (Exception ex) {
throw new RuntimeException("Failed to create type registry: "+registryType+". Does it have a public zero-arg constructor?", ex);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
package io.github.axonivy.json.schema.tests;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.junit.jupiter.api.Test;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

import io.github.axonivy.json.schema.annotations.TypesAsFields;
import io.github.axonivy.json.schema.annotations.TypesAsFields.FieldRegistry;


class TestTypesAsFields {

@Test
void fields() {
ObjectNode schema = new ExpressiveSchemaGenerator().generateSchema(MyRootType.class);
JsonNode defs = schema.get("$defs");

JsonNode props = defs.get(Collection.class.getSimpleName()).get("properties");
assertThat(namesOf(props))
.containsExactly("Another", "Container", "Specific");

assertThat(props.get("Another").get("$ref").asText())
.isEqualTo("#/$defs/Another");

JsonNode another = defs.get("Another").get("properties");
assertThat(namesOf(another))
.contains("common", "version");
}

static class MyRootType {
public Collection provider;
}

@TypesAsFields(LocalFactory.class)
public static interface Collection extends Map<String, Generic> { }

public static class LocalFactory implements FieldRegistry {
@Override
public Set<Class<?>> types() {
return Set.of(Specific.class, Another.class, Container.class);
}
}

@Test
void customFieldName() {
ObjectNode schema = new ExpressiveSchemaGenerator().generateSchema(MyRootType2.class);
JsonNode defs = schema.get("$defs");

JsonNode props = defs.get(CollectionCustomFieldNames.class.getSimpleName()).get("properties");
assertThat(namesOf(props))
.containsExactly("Another", "Container", "specialName");
}

static class MyRootType2 {
public CollectionCustomFieldNames provider;
}

@TypesAsFields(LocalFactory2.class)
public static interface CollectionCustomFieldNames extends Map<String, Generic> { }

public static class LocalFactory2 implements FieldRegistry {
@Override
public Set<Class<?>> types() {
return Set.of(Specific.class, Another.class, Container.class);
}

@Override
public String fieldName(Class<?> type) {
if (Specific.class == type) {
return "specialName";
}
return type.getSimpleName();
}
}

@Test
void customBaseType() {
ObjectNode schema = new ExpressiveSchemaGenerator().generateSchema(MyRootType3.class);
JsonNode defs = schema.get("$defs");

JsonNode props = defs.get(CollectionOnBase.class.getSimpleName()).get("properties");
assertThat(namesOf(props))
.containsExactly("Another", "Container", "Specific");

assertThat(props.get("Another").get("$ref").asText())
.as("ref to common base")
.isEqualTo("#/$defs/Base");
}

static class MyRootType3 {
public CollectionOnBase provider;
}

@TypesAsFields(LocalFactory3.class)
public static interface CollectionOnBase extends Map<String, Generic> { }

public static class LocalFactory3 implements FieldRegistry {
@Override
public Set<Class<?>> types() {
return Set.of(Specific.class, Another.class, Container.class);
}

@Override
public Class<?> valueType(Class<?> type) {
return Base.class; // resolve all fields to the same type
}
}

@Test
void customDescription() {
ObjectNode schema = new ExpressiveSchemaGenerator().generateSchema(MyRootType4.class);
JsonNode defs = schema.get("$defs");

JsonNode props = defs.get(DescribedCollection.class.getSimpleName()).get("properties");
assertThat(namesOf(props))
.containsExactly("Another", "Container", "Specific");

JsonNode another = props.get("Another");
assertThat(namesOf(another))
.containsOnly("$ref", "description");
assertThat(another.get("description").asText())
.isEqualTo("Lorem ipsum");
}

static class MyRootType4 {
public DescribedCollection provider;
}

@TypesAsFields(LocalFactory4.class)
public static interface DescribedCollection extends Map<String, Generic> { }

public static class LocalFactory4 implements FieldRegistry {
@Override
public Set<Class<?>> types() {
return Set.of(Specific.class, Another.class, Container.class);
}

@Override
public String fieldDescription(Class<?> type) {
if (type == Another.class) {
return "Lorem ipsum";
}
return null;
}
}


static List<String> namesOf(JsonNode defs) {
var names = new ArrayList<String>();
defs.fieldNames().forEachRemaining(names::add);
return names;
}

public static interface Generic {
String id();
}

public static class Base implements Generic {
@Override
public String id() {
return null;
}

public String common;
}

public static class Specific extends Base implements Generic {
public String customName;
}

public static class Another extends Base implements Generic {
public int version;
}

public static class Container extends Base implements Generic {
public Generic child;
}


}

0 comments on commit dc77370

Please sign in to comment.