blob: e8d9c751ea710f922dec0fc0a4fc271f65e02286 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one or more
* contributor license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright ownership.
* The ASF licenses this file to You under the Apache License, Version 2.0
* (the "License"); you may not use this file except in compliance with
* the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.geronimo.microprofile.openapi.impl.processor;
import org.apache.geronimo.microprofile.openapi.impl.model.DiscriminatorImpl;
import org.apache.geronimo.microprofile.openapi.impl.model.SchemaImpl;
import org.eclipse.microprofile.openapi.annotations.enums.SchemaType;
import org.eclipse.microprofile.openapi.annotations.media.DiscriminatorMapping;
import org.eclipse.microprofile.openapi.annotations.media.Schema;
import org.eclipse.microprofile.openapi.models.Components;
import javax.json.Json;
import javax.json.JsonArray;
import javax.json.JsonNumber;
import javax.json.JsonObject;
import javax.json.JsonReader;
import javax.json.JsonReaderFactory;
import javax.json.JsonString;
import javax.json.JsonValue;
import javax.json.bind.annotation.JsonbProperty;
import java.io.StringReader;
import java.lang.reflect.AnnotatedElement;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.lang.reflect.WildcardType;
import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.function.Predicate;
import java.util.function.Supplier;
import java.util.stream.Stream;
import static java.beans.Introspector.decapitalize;
import static java.util.Arrays.asList;
import static java.util.Collections.emptyMap;
import static java.util.Optional.of;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.toList;
import static java.util.stream.Collectors.toMap;
public class SchemaProcessor {
private final Map<Type, org.eclipse.microprofile.openapi.models.media.Schema> cache = new HashMap<>();
private final Map<Class<?>, String> providedRefs = new HashMap<>();
private final Class<?> persistenceCapable;
private final Class<?> responseType;
private final JsonReaderFactory jsonReaderFactory;
public SchemaProcessor() {
final ClassLoader loader = Thread.currentThread().getContextClassLoader();
Class<?> pc = null;
try {
pc = loader.loadClass("org.apache.openjpa.enhance.PersistenceCapable");
} catch (final NoClassDefFoundError | ClassNotFoundException e) {
// no-op
}
Class<?> responseType = null;
try {
responseType = loader.loadClass("javax.ws.rs.core.Response");
} catch (final NoClassDefFoundError | ClassNotFoundException e) {
// no-op
}
jsonReaderFactory = Json.createReaderFactory(emptyMap());
persistenceCapable = pc;
this.responseType = responseType;
}
public org.eclipse.microprofile.openapi.models.media.Schema mapSchemaFromClass(
final Supplier<Components> components, final Type model) {
final org.eclipse.microprofile.openapi.models.media.Schema cached = cache.get(model);
if (cached != null) {
return new SchemaImpl().type(cached.getType()).ref(toRef(Class.class.cast(model), null));
}
final SchemaImpl schema = new SchemaImpl();
fillSchema(components, model, schema, null);
return schema;
}
private String toRef(final Class<?> model, final String providedRef) {
return "#/components/schemas/" + toRefName(model, providedRef);
}
// todo: introduce naming strategy? simplename can conflict so this is safer but ugly
private String toRefName(final Class<?> model, final String providedRef) {
return providedRefs.computeIfAbsent(
model, k -> ofNullable(providedRef)
.orElseGet(() -> k.getName().replace('.', '_').replace('$', '_')));
}
public void fillSchema(
final Supplier<org.eclipse.microprofile.openapi.models.Components>components,
final Type rawModel,
final org.eclipse.microprofile.openapi.models.media.Schema schema,
final String providedRef) {
final Type model = unwrapType(rawModel);
if (Class.class.isInstance(model)) {
if (boolean.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.BOOLEAN);
} else if (Boolean.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.BOOLEAN).nullable(true);
} else if (String.class == model || JsonString.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.STRING);
} else if (double.class == model || float.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.NUMBER);
} else if (Double.class == model || Float.class == model || JsonNumber.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.NUMBER).nullable(true);
} else if (int.class == model || short.class == model || byte.class == model || long.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.INTEGER);
} else if (Integer.class == model || Short.class == model || Byte.class == model || Long.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.INTEGER).nullable(true);
} else if (Object.class == model || responseType == model || JsonObject.class == model || JsonValue.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.OBJECT)
.nullable(true)
.properties(new HashMap<>());
} else if (BigDecimal.class == model || BigInteger.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.STRING).nullable(true);
} else if (JsonArray.class == model) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.ARRAY)
.nullable(true)
.items(new SchemaImpl()
.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.OBJECT)
.properties(new HashMap<>()));
} else if (isStringable(model)) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.STRING).nullable(true);
} else {
final Class<?> from = Class.class.cast(model);
if (from.isEnum()) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.STRING)
.enumeration(asList(from.getEnumConstants()))
.nullable(true);
} else if (from.isArray()) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.ARRAY);
final SchemaImpl items = new SchemaImpl();
fillSchema(components, from.getComponentType(), items, null);
schema.items(items);
} else if (Collection.class.isAssignableFrom(from)) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.ARRAY);
final SchemaImpl items = new SchemaImpl();
fillSchema(components, Object.class, items, null);
schema.items(items);
} else {
//if providedRef is null and the Schema name is not Empty, we set it as providedRef
final String ref = ofNullable(from.getAnnotation(Schema.class))
.filter(a -> !a.name().isEmpty())
.map(s -> {
final String sRef = s.name();
sets(components, s, schema, sRef);
return sRef;
})
.orElse(providedRef);
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.OBJECT);
final org.eclipse.microprofile.openapi.models.media.Schema objectSchema = getOrCreateReusableObjectComponent(components, from, ref);
if (schema != objectSchema) {
schema.ref(toRef(from, ref));
}
}
}
} else {
schema.items(new SchemaImpl());
if (ParameterizedType.class.isInstance(model)) {
final ParameterizedType pt = ParameterizedType.class.cast(model);
if (Class.class.isInstance(pt.getRawType()) && Map.class.isAssignableFrom(Class.class.cast(pt.getRawType()))) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.OBJECT);
} else if (pt.getActualTypeArguments().length == 1 && Class.class.isInstance(pt.getActualTypeArguments()[0])) {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.ARRAY);
final SchemaImpl items = new SchemaImpl();
fillSchema(components, Class.class.cast(pt.getActualTypeArguments()[0]), items, null);
schema.items(items);
} else {
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.ARRAY);
}
} else { // todo?
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.ARRAY);
}
}
}
/**
* Creates or get from the cache the reusable object component
*
* @param components components, where the schema will be added, if new created
* @param from the class for which we want create the schema
* @param providedRef providedRef
* @return the schema (from the cache or new created) for the given class
*/
private org.eclipse.microprofile.openapi.models.media.Schema getOrCreateReusableObjectComponent(
final Supplier<org.eclipse.microprofile.openapi.models.Components> components,
final Class from,
final String providedRef) {
final org.eclipse.microprofile.openapi.models.media.Schema existingSchema = cache.get(from);
if (existingSchema != null) {
return existingSchema;
}
final SchemaImpl schema = new SchemaImpl();
ofNullable(from.getAnnotation(Schema.class)).ifPresent(
s -> sets(components, Schema.class.cast(s), schema, null));
schema.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.OBJECT);
if (cache.putIfAbsent(from, schema) == null) {
components.get().addSchema(toRefName(from, providedRef), schema);
}
final Predicate<String> ignored = createIgnorePredicate(from);
schema.properties(new HashMap<>());
Class<?> current = from;
while (current != null && current != Object.class) {
Stream.of(current.getDeclaredFields())
.filter(it -> isVisible(it, it.getModifiers()))
.peek(f -> handleRequired(schema, f, () -> findFieldName(f)))
.forEach(f -> {
final String fieldName = findFieldName(f);
if (!ignored.test(fieldName)) {
schema.getProperties().put(fieldName, mapField(components, f, f.getGenericType()));
}
});
Stream.of(current.getDeclaredMethods())
.filter(it -> isVisible(it, it.getModifiers()))
.filter(it -> (it.getName().startsWith("get") || it.getName().startsWith("is")) && it.getName().length() > 2)
.peek(m -> handleRequired(schema, m, () -> findMethodName(m)))
.forEach(m -> {
final String key = findMethodName(m);
if (!ignored.test(key) && !schema.getProperties().containsKey(key)) {
schema.getProperties().put(key, mapField(components, m, m.getGenericReturnType()));
}
});
current = current.getSuperclass();
}
return schema;
}
private Predicate<String> createIgnorePredicate(final Class from) {
return persistenceCapable != null && persistenceCapable.isAssignableFrom(from) ?
v -> v.startsWith("pc") : v -> false;
}
private boolean isVisible(final AnnotatedElement elt, final int modifiers) {
final boolean explicit = elt.isAnnotationPresent(Schema.class);
if (!explicit || !elt.getAnnotation(Schema.class).hidden()) {
return (!Modifier.isPrivate(modifiers) || elt.isAnnotationPresent(Schema.class)) && !Modifier.isStatic(modifiers);
}
return false;
}
private Type unwrapType(final Type rawModel) {
if (ParameterizedType.class.isInstance(rawModel) &&
Stream.of(ParameterizedType.class.cast(rawModel).getActualTypeArguments()).allMatch(WildcardType.class::isInstance)) {
return ParameterizedType.class.cast(rawModel).getRawType();
}
return rawModel;
}
private boolean isStringable(final Type model) {
return Date.class == model || model.getTypeName().startsWith("java.time.") || Class.class == model || Type.class == model;
}
private void handleRequired(final org.eclipse.microprofile.openapi.models.media.Schema schema,
final AnnotatedElement element, final Supplier<String> nameSupplier) {
if (!element.isAnnotationPresent(Schema.class) || !element.getAnnotation(Schema.class).required()) {
return;
}
if (schema.getRequired() == null) {
schema.required(new ArrayList<>());
}
final String name = nameSupplier.get();
if (!schema.getRequired().contains(name)) {
schema.getRequired().add(name);
}
}
private org.eclipse.microprofile.openapi.models.media.Schema mapField(final Supplier<Components> components,
final AnnotatedElement element,
final Type type) {
final Schema annotation = element.getAnnotation(Schema.class);
return ofNullable(annotation).map(s -> mapSchema(components, s, null)).orElseGet(() -> {
final org.eclipse.microprofile.openapi.models.media.Schema schemaFromClass = mapSchemaFromClass(
components, type);
if (annotation != null) {
mergeSchema(components, schemaFromClass, annotation, type);
}
return schemaFromClass;
});
}
private String findFieldName(final Field f) {
return findSchemaName(f)
.orElseGet(() -> {
if (f.isAnnotationPresent(JsonbProperty.class)) {
return f.getAnnotation(JsonbProperty.class).value();
}
// getter
final String fName = f.getName();
final String subName = Character.toUpperCase(fName.charAt(0))
+ (fName.length() > 1 ? fName.substring(1) : "");
try {
final Method getter = f.getDeclaringClass().getMethod("get" + subName);
if (getter.isAnnotationPresent(JsonbProperty.class)) {
return getter.getAnnotation(JsonbProperty.class).value();
}
} catch (final NoSuchMethodException e) {
if (boolean.class == f.getType()) {
try {
final Method isser = f.getDeclaringClass().getMethod("is" + subName);
if (isser.isAnnotationPresent(JsonbProperty.class)) {
return isser.getAnnotation(JsonbProperty.class).value();
}
} catch (final NoSuchMethodException e2) {
// no-op
}
}
}
return fName;
});
}
private String findMethodName(final Method m) {
return findSchemaName(m)
.orElseGet(() -> {
if (m.isAnnotationPresent(JsonbProperty.class)) {
return m.getAnnotation(JsonbProperty.class).value();
}
final String name = m.getName();
if (name.startsWith("get")) {
return decapitalize(name.substring("get".length()));
}
if (name.startsWith("is")) {
return decapitalize(name.substring("is".length()));
}
return decapitalize(name);
});
}
private Optional<String> findSchemaName(final AnnotatedElement m) {
return ofNullable(m.getAnnotation(Schema.class))
.map(Schema::name)
.filter(it -> !it.isEmpty());
}
private void mergeSchema(final Supplier<Components> components,
final org.eclipse.microprofile.openapi.models.media.Schema impl,
final Schema schema, final Type type) {
if (schema.deprecated()) {
impl.deprecated(schema.deprecated());
}
if (schema.type() != SchemaType.DEFAULT) {
impl.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.valueOf(schema.type().name()));
}
if (!schema.title().isEmpty()) {
impl.title(schema.title());
}
if (!schema.description().isEmpty()) {
impl.description(schema.description());
}
if (!schema.format().isEmpty()) {
impl.format(schema.format());
}
if (!schema.ref().isEmpty()) {
impl.ref(schema.ref());
}
final String example = schema.example();
if (!example.isEmpty()) {
if (type != null) {
if (type == double.class || type == Double.class ||
type == float.class || type == Float.class) {
impl.example(Double.parseDouble(example));
} else if (type == long.class || type == Long.class ||
type == int.class || type == Integer.class ||
type == short.class || type == Short.class ||
type == byte.class || type == Byte.class) {
impl.example(Integer.parseInt(example));
} else if (type == boolean.class || type == Boolean.class) {
impl.example(Boolean.parseBoolean(example));
} else if (type == String.class) {
impl.example(example);
} else if ((example.startsWith("{") && example.endsWith("}")) ||
(example.startsWith("[") && example.endsWith("}"))) {
try (final JsonReader reader = jsonReaderFactory.createReader(new StringReader(example))) {
impl.example(reader.readValue());
} catch (final Exception e) {
impl.example(example);
}
} else {
impl.example(example);
}
} else {
impl.example(example);
}
}
of(schema.not()).filter(it -> it != Void.class).ifPresent(t -> impl.not(mapSchemaFromClass(components, t)));
final List<org.eclipse.microprofile.openapi.models.media.Schema> oneOf = Stream.of(schema.oneOf())
.map(it -> mapSchemaFromClass(components, it)).collect(toList());
if (!oneOf.isEmpty()) {
impl.oneOf(oneOf);
}
final List<org.eclipse.microprofile.openapi.models.media.Schema> anyOf = Stream.of(schema.anyOf())
.map(it -> mapSchemaFromClass(components, it)).collect(toList());
if (!anyOf.isEmpty()) {
impl.anyOf(anyOf);
}
final List<org.eclipse.microprofile.openapi.models.media.Schema> allOf = Stream.of(schema.allOf())
.map(it -> mapSchemaFromClass(components, it)).collect(toList());
if (!allOf.isEmpty()) {
impl.allOf(allOf);
}
if (schema.multipleOf() != 0) {
impl.multipleOf(BigDecimal.valueOf(schema.multipleOf()));
}
impl.minimum(toBigDecimal(schema.minimum()));
impl.maximum(toBigDecimal(schema.maximum()));
if (schema.exclusiveMinimum()) {
impl.exclusiveMinimum(schema.exclusiveMinimum());
}
if (schema.exclusiveMaximum()) {
impl.exclusiveMaximum(schema.exclusiveMaximum());
}
if (schema.minLength() >= 0 && schema.minLength() != 0) {
impl.minLength(schema.minLength());
}
if (schema.maxLength() >= 0 && schema.maxLength() != Integer.MAX_VALUE) {
impl.maxLength(schema.maxLength());
}
if (!schema.pattern().isEmpty()) {
impl.pattern(schema.pattern());
}
if (schema.nullable()) {
impl.nullable(schema.nullable());
}
if (schema.minProperties() > 0) {
impl.minProperties(schema.minProperties());
}
if (schema.minProperties() > 0) {
impl.maxProperties(schema.maxProperties());
}
if (schema.minItems() < Integer.MAX_VALUE) {
impl.minItems(schema.minItems());
}
if (schema.maxItems() > Integer.MIN_VALUE) {
impl.maxItems(schema.maxItems());
}
if (schema.uniqueItems()) {
impl.uniqueItems(schema.uniqueItems());
}
if (schema.readOnly()) {
impl.readOnly(schema.readOnly());
}
if (schema.writeOnly()) {
impl.writeOnly(schema.writeOnly());
}
impl.defaultValue(toType(schema.defaultValue(), impl.getType()));
final List<String> required = Stream.of(schema.requiredProperties()).collect(toList());
if (!required.isEmpty()) {
impl.required(required);
}
if (schema.discriminatorMapping().length > 0) {
impl.discriminator(new DiscriminatorImpl().mapping(Stream.of(schema.discriminatorMapping())
.collect(toMap(DiscriminatorMapping::value, it -> it.schema().getName()))));
}
final List<Object> enums = Stream.of(schema.enumeration()).collect(toList());
if (!enums.isEmpty()) {
impl.setEnumeration(enums);
}
}
public org.eclipse.microprofile.openapi.models.media.Schema mapSchema(
final Supplier<org.eclipse.microprofile.openapi.models.Components>components, final Schema schema,
final String providedRefMapping) {
if (schema.hidden() || (schema.implementation() == Void.class && schema.name().isEmpty() && schema.ref().isEmpty())) {
if (schema.type() != SchemaType.DEFAULT) {
return new SchemaImpl()
.type(org.eclipse.microprofile.openapi.models.media.Schema.SchemaType.valueOf(schema.type().name()));
}
return null;
}
final SchemaImpl impl = new SchemaImpl();
sets(components, schema, impl, providedRefMapping);
return impl;
}
private void sets(final Supplier<Components> components, final Schema schema,
final org.eclipse.microprofile.openapi.models.media.Schema impl,
final String providedRef) {
if (!schema.ref().isEmpty()) {
impl.ref(resolveSchemaRef(schema.ref()));
} else {
if (schema.implementation() != Void.class) {
final boolean array = schema.type() == SchemaType.ARRAY;
if (array) {
final SchemaImpl itemSchema = new SchemaImpl();
fillSchema(components, schema.implementation(), itemSchema, providedRef);
impl.items(itemSchema);
} else {
fillSchema(components, schema.implementation(), impl, providedRef);
}
}
mergeSchema(components, impl, schema, null);
}
}
private String resolveSchemaRef(final String ref) {
if (ref.startsWith("#")) {
return ref;
}
return "#/components/schemas/" + ref;
}
private BigDecimal toBigDecimal(final String minimum) {
return minimum.isEmpty() ? null : new BigDecimal(minimum);
}
private Object toType(final String s, final org.eclipse.microprofile.openapi.models.media.Schema.SchemaType type) {
if (type == null || s.isEmpty()) {
return null;
}
switch (type) {
case STRING:
return s;
case INTEGER:
return Integer.valueOf(s);
case NUMBER:
return Double.valueOf(s);
case BOOLEAN:
return Boolean.parseBoolean(s);
case OBJECT:
case ARRAY:
// todo using jsonb
default:
return null;
}
}
}