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

New strategy to define Fields from FieldRegistry; describing Map<String, Extension> values #104

Merged
merged 1 commit into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}


}
Loading