diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePlugin.java b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePlugin.java index 7468e258c1d..ae8985185af 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePlugin.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePlugin.java @@ -16,12 +16,12 @@ package com.linecorp.armeria.internal.server.annotation; -import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableSet.toImmutableSet; import static com.linecorp.armeria.internal.server.annotation.KotlinUtil.isKFunction; import static com.linecorp.armeria.internal.server.annotation.KotlinUtil.isReturnTypeNothing; import static com.linecorp.armeria.internal.server.annotation.KotlinUtil.kFunctionGenericReturnType; import static com.linecorp.armeria.internal.server.annotation.KotlinUtil.kFunctionReturnType; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.toTypeSignature; import static com.linecorp.armeria.server.docs.FieldLocation.HEADER; import static com.linecorp.armeria.server.docs.FieldLocation.PATH; import static com.linecorp.armeria.server.docs.FieldLocation.QUERY; @@ -29,24 +29,15 @@ import static java.util.Objects.requireNonNull; import java.lang.annotation.Annotation; -import java.lang.reflect.GenericArrayType; import java.lang.reflect.Method; -import java.lang.reflect.ParameterizedType; -import java.lang.reflect.Type; -import java.lang.reflect.TypeVariable; -import java.lang.reflect.WildcardType; -import java.nio.ByteBuffer; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Set; -import java.util.stream.Stream; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.TreeNode; -import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.ObjectWriter; import com.google.common.annotations.VisibleForTesting; import com.google.common.collect.ImmutableList; @@ -84,36 +75,11 @@ import com.linecorp.armeria.server.docs.TypeSignature; import com.linecorp.armeria.server.docs.TypeSignatureType; -import io.netty.buffer.ByteBuf; - /** * A {@link DocServicePlugin} implementation that supports the {@link AnnotatedService}. */ public final class AnnotatedDocServicePlugin implements DocServicePlugin { - @VisibleForTesting - static final TypeSignature VOID = TypeSignature.ofBase("void"); - @VisibleForTesting - static final TypeSignature BOOLEAN = TypeSignature.ofBase("boolean"); - @VisibleForTesting - static final TypeSignature BYTE = TypeSignature.ofBase("byte"); - @VisibleForTesting - static final TypeSignature SHORT = TypeSignature.ofBase("short"); - @VisibleForTesting - static final TypeSignature INT = TypeSignature.ofBase("int"); - @VisibleForTesting - static final TypeSignature LONG = TypeSignature.ofBase("long"); - @VisibleForTesting - static final TypeSignature FLOAT = TypeSignature.ofBase("float"); - @VisibleForTesting - static final TypeSignature DOUBLE = TypeSignature.ofBase("double"); - @VisibleForTesting - static final TypeSignature CHAR = TypeSignature.ofBase("char"); - @VisibleForTesting - static final TypeSignature STRING = TypeSignature.ofBase("string"); - @VisibleForTesting - static final TypeSignature BINARY = TypeSignature.ofBase("binary"); - private static final ObjectWriter objectWriter = JacksonUtil.newDefaultObjectMapper() .writerWithDefaultPrettyPrinter(); @@ -311,121 +277,6 @@ private static FieldInfo fieldInfo(AnnotatedValueResolver resolver) { .build(); } - static TypeSignature toTypeSignature(Type type) { - requireNonNull(type, "type"); - - if (type instanceof JavaType) { - return toTypeSignature((JavaType) type); - } - - // The data types defined by the OpenAPI Specification: - - if (type == Void.class || type == void.class) { - return VOID; - } - if (type == Boolean.class || type == boolean.class) { - return BOOLEAN; - } - if (type == Byte.class || type == byte.class) { - return BYTE; - } - if (type == Short.class || type == short.class) { - return SHORT; - } - if (type == Integer.class || type == int.class) { - return INT; - } - if (type == Long.class || type == long.class) { - return LONG; - } - if (type == Float.class || type == float.class) { - return FLOAT; - } - if (type == Double.class || type == double.class) { - return DOUBLE; - } - if (type == Character.class || type == char.class) { - return CHAR; - } - if (type == String.class) { - return STRING; - } - if (type == byte[].class || type == Byte[].class || - type == ByteBuffer.class || type == ByteBuf.class) { - return BINARY; - } - // End of data types defined by the OpenAPI Specification. - - if (type instanceof ParameterizedType) { - final ParameterizedType parameterizedType = (ParameterizedType) type; - final Class rawType = (Class) parameterizedType.getRawType(); - if (List.class.isAssignableFrom(rawType)) { - return TypeSignature.ofList(toTypeSignature(parameterizedType.getActualTypeArguments()[0])); - } - if (Set.class.isAssignableFrom(rawType)) { - return TypeSignature.ofSet(toTypeSignature(parameterizedType.getActualTypeArguments()[0])); - } - - if (Map.class.isAssignableFrom(rawType)) { - final TypeSignature key = toTypeSignature(parameterizedType.getActualTypeArguments()[0]); - final TypeSignature value = toTypeSignature(parameterizedType.getActualTypeArguments()[1]); - return TypeSignature.ofMap(key, value); - } - - if (Optional.class.isAssignableFrom(rawType) || "scala.Option".equals(rawType.getName())) { - return TypeSignature.ofOptional(toTypeSignature(parameterizedType.getActualTypeArguments()[0])); - } - - final List actualTypes = Stream.of(parameterizedType.getActualTypeArguments()) - .map(AnnotatedDocServicePlugin::toTypeSignature) - .collect(toImmutableList()); - return TypeSignature.ofContainer(rawType.getSimpleName(), actualTypes); - } - - if (type instanceof WildcardType) { - // Create an unresolved type with an empty string so that the type name will be '?'. - return TypeSignature.ofUnresolved(""); - } - if (type instanceof TypeVariable) { - return TypeSignature.ofBase(type.getTypeName()); - } - if (type instanceof GenericArrayType) { - return TypeSignature.ofList(toTypeSignature(((GenericArrayType) type).getGenericComponentType())); - } - - if (!(type instanceof Class)) { - return TypeSignature.ofBase(type.getTypeName()); - } - - final Class clazz = (Class) type; - if (clazz.isArray()) { - // If it's an array, return it as a list. - return TypeSignature.ofList(toTypeSignature(clazz.getComponentType())); - } - - return TypeSignature.ofStruct(clazz); - } - - static TypeSignature toTypeSignature(JavaType type) { - if (type.isArrayType() || type.isCollectionLikeType()) { - return TypeSignature.ofList(toTypeSignature(type.getContentType())); - } - - if (type.isMapLikeType()) { - final TypeSignature key = toTypeSignature(type.getKeyType()); - final TypeSignature value = toTypeSignature(type.getContentType()); - return TypeSignature.ofMap(key, value); - } - - if (Optional.class.isAssignableFrom(type.getRawClass()) || - "scala.Option".equals(type.getRawClass().getName())) { - return TypeSignature.ofOptional( - toTypeSignature(type.getBindings().getBoundType(0))); - } - - return toTypeSignature(type.getRawClass()); - } - private static FieldLocation location(AnnotatedValueResolver resolver) { if (resolver.isPathVariable()) { return PATH; diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProvider.java b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProvider.java index 4cc23db0b51..e3dbb93a310 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProvider.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProvider.java @@ -18,8 +18,8 @@ import static com.google.common.base.MoreObjects.firstNonNull; import static com.google.common.collect.ImmutableList.toImmutableList; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.toTypeSignature; import static com.linecorp.armeria.internal.server.annotation.AnnotatedValueResolver.isAnnotatedNullable; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.toTypeSignature; import static java.util.Objects.requireNonNull; import java.lang.reflect.AnnotatedElement; diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/ReflectiveDescriptiveTypeInfoProvider.java b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/ReflectiveDescriptiveTypeInfoProvider.java index bc902c5403b..f33aaa7334e 100644 --- a/core/src/main/java/com/linecorp/armeria/internal/server/annotation/ReflectiveDescriptiveTypeInfoProvider.java +++ b/core/src/main/java/com/linecorp/armeria/internal/server/annotation/ReflectiveDescriptiveTypeInfoProvider.java @@ -17,8 +17,8 @@ package com.linecorp.armeria.internal.server.annotation; import static com.google.common.collect.ImmutableList.toImmutableList; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.toTypeSignature; import static com.linecorp.armeria.internal.server.annotation.DefaultDescriptiveTypeInfoProvider.isNullable; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.toTypeSignature; import java.lang.reflect.AnnotatedElement; import java.lang.reflect.Field; diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/docs/DocServiceTypeUtil.java b/core/src/main/java/com/linecorp/armeria/internal/server/docs/DocServiceTypeUtil.java new file mode 100644 index 00000000000..9a9aa659664 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/server/docs/DocServiceTypeUtil.java @@ -0,0 +1,195 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.server.docs; + +import static com.google.common.collect.ImmutableList.toImmutableList; +import static java.util.Objects.requireNonNull; + +import java.lang.reflect.GenericArrayType; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; +import java.nio.ByteBuffer; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Stream; + +import com.fasterxml.jackson.databind.JavaType; +import com.google.common.annotations.VisibleForTesting; + +import com.linecorp.armeria.server.docs.DocService; +import com.linecorp.armeria.server.docs.TypeSignature; + +import io.netty.buffer.ByteBuf; + +/** + * A utility class that provides methods for converting type representations into + * {@link TypeSignature} for {@link DocService}. + * This class centralizes the logic for interpreting various Java and Jackson types + * and mapping them to the standardized documentation model. + */ +public final class DocServiceTypeUtil { + + @VisibleForTesting + public static final TypeSignature VOID = TypeSignature.ofBase("void"); + @VisibleForTesting + public static final TypeSignature BOOLEAN = TypeSignature.ofBase("boolean"); + @VisibleForTesting + public static final TypeSignature BYTE = TypeSignature.ofBase("byte"); + @VisibleForTesting + public static final TypeSignature SHORT = TypeSignature.ofBase("short"); + @VisibleForTesting + public static final TypeSignature INT = TypeSignature.ofBase("int"); + @VisibleForTesting + public static final TypeSignature LONG = TypeSignature.ofBase("long"); + @VisibleForTesting + public static final TypeSignature FLOAT = TypeSignature.ofBase("float"); + @VisibleForTesting + public static final TypeSignature DOUBLE = TypeSignature.ofBase("double"); + @VisibleForTesting + public static final TypeSignature CHAR = TypeSignature.ofBase("char"); + @VisibleForTesting + public static final TypeSignature STRING = TypeSignature.ofBase("string"); + @VisibleForTesting + public static final TypeSignature BINARY = TypeSignature.ofBase("binary"); + + /** + * Creates a {@link TypeSignature} from the specified {@link JavaType}. + * This method acts as a bridge between Jackson's type representation and Armeria's documentation model. + */ + public static TypeSignature toTypeSignature(JavaType type) { + if (type.isArrayType() || type.isCollectionLikeType()) { + return TypeSignature.ofList(toTypeSignature(type.getContentType())); + } + + if (type.isMapLikeType()) { + final TypeSignature key = toTypeSignature(type.getKeyType()); + final TypeSignature value = toTypeSignature(type.getContentType()); + return TypeSignature.ofMap(key, value); + } + + if (Optional.class.isAssignableFrom(type.getRawClass()) || + "scala.Option".equals(type.getRawClass().getName())) { + return TypeSignature.ofOptional( + toTypeSignature(type.getBindings().getBoundType(0))); + } + + return toTypeSignature(type.getRawClass()); + } + + /** + * Creates a {@link TypeSignature} from the specified {@link Type}. + */ + public static TypeSignature toTypeSignature(Type type) { + requireNonNull(type, "type"); + + if (type instanceof JavaType) { + return toTypeSignature((JavaType) type); + } + + // The data types defined by the OpenAPI Specification: + + if (type == Void.class || type == void.class) { + return VOID; + } + if (type == Boolean.class || type == boolean.class) { + return BOOLEAN; + } + if (type == Byte.class || type == byte.class) { + return BYTE; + } + if (type == Short.class || type == short.class) { + return SHORT; + } + if (type == Integer.class || type == int.class) { + return INT; + } + if (type == Long.class || type == long.class) { + return LONG; + } + if (type == Float.class || type == float.class) { + return FLOAT; + } + if (type == Double.class || type == double.class) { + return DOUBLE; + } + if (type == Character.class || type == char.class) { + return CHAR; + } + if (type == String.class) { + return STRING; + } + if (type == byte[].class || type == Byte[].class || + type == ByteBuffer.class || type == ByteBuf.class) { + return BINARY; + } + // End of data types defined by the OpenAPI Specification. + + if (type instanceof ParameterizedType) { + final ParameterizedType parameterizedType = (ParameterizedType) type; + final Class rawType = (Class) parameterizedType.getRawType(); + if (List.class.isAssignableFrom(rawType)) { + return TypeSignature.ofList(toTypeSignature(parameterizedType.getActualTypeArguments()[0])); + } + if (Set.class.isAssignableFrom(rawType)) { + return TypeSignature.ofSet(toTypeSignature(parameterizedType.getActualTypeArguments()[0])); + } + + if (Map.class.isAssignableFrom(rawType)) { + final TypeSignature key = toTypeSignature(parameterizedType.getActualTypeArguments()[0]); + final TypeSignature value = toTypeSignature(parameterizedType.getActualTypeArguments()[1]); + return TypeSignature.ofMap(key, value); + } + + if (Optional.class.isAssignableFrom(rawType) || "scala.Option".equals(rawType.getName())) { + return TypeSignature.ofOptional(toTypeSignature(parameterizedType.getActualTypeArguments()[0])); + } + + final List actualTypes = Stream.of(parameterizedType.getActualTypeArguments()) + .map(DocServiceTypeUtil::toTypeSignature) + .collect(toImmutableList()); + return TypeSignature.ofContainer(rawType.getSimpleName(), actualTypes); + } + + if (type instanceof WildcardType) { + // Create an unresolved type with an empty string so that the type name will be '?'. + return TypeSignature.ofUnresolved(""); + } + if (type instanceof TypeVariable) { + return TypeSignature.ofBase(type.getTypeName()); + } + if (type instanceof GenericArrayType) { + return TypeSignature.ofList(toTypeSignature(((GenericArrayType) type).getGenericComponentType())); + } + + if (!(type instanceof Class)) { + return TypeSignature.ofBase(type.getTypeName()); + } + + final Class clazz = (Class) type; + if (clazz.isArray()) { + // If it's an array, return it as a list. + return TypeSignature.ofList(toTypeSignature(clazz.getComponentType())); + } + + return TypeSignature.ofStruct(clazz); + } + + private DocServiceTypeUtil() {} +} diff --git a/core/src/main/java/com/linecorp/armeria/internal/server/docs/JacksonPolymorphismTypeInfoProvider.java b/core/src/main/java/com/linecorp/armeria/internal/server/docs/JacksonPolymorphismTypeInfoProvider.java new file mode 100644 index 00000000000..6d107f6d347 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/internal/server/docs/JacksonPolymorphismTypeInfoProvider.java @@ -0,0 +1,123 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.server.docs; + +import static com.google.common.base.Strings.isNullOrEmpty; +import static com.google.common.collect.ImmutableList.toImmutableList; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.toTypeSignature; +import static java.util.Objects.requireNonNull; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.BeanDescription; +import com.fasterxml.jackson.databind.JavaType; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.introspect.BeanPropertyDefinition; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.internal.common.JacksonUtil; +import com.linecorp.armeria.server.annotation.Description; +import com.linecorp.armeria.server.docs.DescriptionInfo; +import com.linecorp.armeria.server.docs.DescriptiveTypeInfo; +import com.linecorp.armeria.server.docs.DescriptiveTypeInfoProvider; +import com.linecorp.armeria.server.docs.DiscriminatorInfo; +import com.linecorp.armeria.server.docs.FieldInfo; +import com.linecorp.armeria.server.docs.StructInfo; +import com.linecorp.armeria.server.docs.TypeSignature; + +/** + * A {@link DescriptiveTypeInfoProvider} that provides {@link DescriptiveTypeInfo} for a polymorphic + * type by inspecting Jackson annotations such as {@link JsonTypeInfo} and {@link JsonSubTypes}. + */ +public final class JacksonPolymorphismTypeInfoProvider implements DescriptiveTypeInfoProvider { + + private static final ObjectMapper mapper = JacksonUtil.newDefaultObjectMapper(); + + /** + * Creates a new {@link StructInfo} for the specified {@code typeDescriptor} if it is a polymorphic + * base type annotated with {@link JsonTypeInfo} and {@link JsonSubTypes}. + * The generated {@link StructInfo} will contain {@link StructInfo#oneOf()} and + * {@link StructInfo#discriminator()} metadata. + * + * @param typeDescriptor the {@link Class} to be inspected. + * @return a new {@link StructInfo} with polymorphism metadata, or {@code null} if the + * {@code typeDescriptor} is not a supported polymorphic type. + */ + @Override + @Nullable + public DescriptiveTypeInfo newDescriptiveTypeInfo(Object typeDescriptor) { + requireNonNull(typeDescriptor, "typeDescriptor"); + if (!(typeDescriptor instanceof Class)) { + return null; + } + + final Class clazz = (Class) typeDescriptor; + final JsonTypeInfo jsonTypeInfo = clazz.getAnnotation(JsonTypeInfo.class); + final JsonSubTypes jsonSubTypes = clazz.getAnnotation(JsonSubTypes.class); + + if (jsonTypeInfo == null || jsonSubTypes == null) { + + return null; + } + + final String propertyName = jsonTypeInfo.property(); + if (propertyName.isEmpty()) { + return null; + } + + if (jsonSubTypes.value().length == 0) { + return null; + } + + final Map mapping = new LinkedHashMap<>(); + Arrays.stream(jsonSubTypes.value()).forEach(subType -> { + final Class subClass = subType.value(); + final String key = isNullOrEmpty(subType.name()) ? subClass.getSimpleName() : subType.name(); + final String schemaName = TypeSignature.ofStruct(subClass).name(); + mapping.put(key, "#/definitions/" + schemaName); + }); + + final DiscriminatorInfo discriminator = DiscriminatorInfo.of(propertyName, mapping); + + final List oneOf = + Arrays.stream(jsonSubTypes.value()) + .map(subType -> TypeSignature.ofStruct(subType.value())) + .collect(toImmutableList()); + + final JavaType javaType = mapper.constructType(clazz); + final BeanDescription description = mapper.getSerializationConfig().introspect(javaType); + final List properties = description.findProperties(); + + final List fields = properties.stream() + .map(prop -> FieldInfo.of(prop.getName(), + toTypeSignature( + prop.getPrimaryType()))) + .collect(toImmutableList()); + + final Description classDescription = clazz.getAnnotation(Description.class); + + final DescriptionInfo descriptionInfo = + classDescription == null ? DescriptionInfo.empty() : DescriptionInfo.from(classDescription); + + return new StructInfo(clazz.getName(), null, fields, + descriptionInfo, oneOf, discriminator); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java b/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java new file mode 100644 index 00000000000..eeb581256a7 --- /dev/null +++ b/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java @@ -0,0 +1,98 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.server.docs; + +import static java.util.Objects.requireNonNull; + +import java.util.Map; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.MoreObjects; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.common.annotation.Nullable; +import com.linecorp.armeria.common.annotation.UnstableApi; + +/** + * Metadata about a discriminator object, which is used for polymorphism. + * This corresponds to the {@code discriminator} object in the OpenAPI Specification. + * @see Inheritance and Polymorphism + */ +@UnstableApi +public final class DiscriminatorInfo { + + private final String propertyName; + private final Map mapping; + + /** + * Creates a new {@link DiscriminatorInfo} with {@code propertyName}, the name of the property + * int the payload that will be used to differentiate between schemas. + * and {@code mapping} a map of payload values to schema names or references. + */ + public static DiscriminatorInfo of(String propertyName, Map mapping) { + return new DiscriminatorInfo(propertyName, mapping); + } + + /** + * Creates a new instance. + */ + DiscriminatorInfo(String propertyName, Map mapping) { + this.propertyName = requireNonNull(propertyName, "propertyName"); + this.mapping = ImmutableMap.copyOf(requireNonNull(mapping, "mapping")); + } + + /** + * Returns the name of the property that is used to differentiate between schemas. + */ + @JsonProperty + public String propertyName() { + return propertyName; + } + + /** + * Returns the map of payload values to schema names. + * The keys are the values that appear in the {@link #propertyName()} field, and the values are + * the schema definitions to use for that value (e.g., {@code "#/definitions/Cat"}). + */ + @JsonProperty + public Map mapping() { + return mapping; + } + + @Override + public boolean equals(@Nullable Object o) { + if (this == o) { + return true; + } + if (!(o instanceof DiscriminatorInfo)) { + return false; + } + final DiscriminatorInfo that = (DiscriminatorInfo) o; + return propertyName.equals(that.propertyName) && mapping.equals(that.mapping); + } + + @Override + public int hashCode() { + return Objects.hash(propertyName, mapping); + } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this).add("propertyName", propertyName).add("mapping", mapping) + .toString(); + } +} diff --git a/core/src/main/java/com/linecorp/armeria/server/docs/JsonSchemaGenerator.java b/core/src/main/java/com/linecorp/armeria/server/docs/JsonSchemaGenerator.java index 9796d3d15b2..c3a2f4755aa 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/JsonSchemaGenerator.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/JsonSchemaGenerator.java @@ -16,12 +16,11 @@ package com.linecorp.armeria.server.docs; import static com.google.common.collect.ImmutableMap.toImmutableMap; -import static com.google.common.collect.ImmutableSet.toImmutableSet; +import static java.util.Objects.requireNonNull; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Set; import java.util.function.Function; import org.slf4j.Logger; @@ -30,9 +29,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; -import com.google.common.collect.ImmutableMap.Builder; import com.linecorp.armeria.common.annotation.Nullable; import com.linecorp.armeria.internal.common.JacksonUtil; @@ -48,350 +45,366 @@ final class JsonSchemaGenerator { private static final ObjectMapper mapper = JacksonUtil.newDefaultObjectMapper(); - private static final List VALID_FIELD_LOCATIONS = ImmutableList.of( - FieldLocation.BODY, - FieldLocation.UNSPECIFIED); - - private static final List MEMORIZED_JSON_TYPES = ImmutableList.of("array", "object"); - - /** - * Generate an array of json schema specifications for each method inside the service. - * - * @param serviceSpecification the service specification to generate the json schema from. - * - * @return ArrayNode that contains service specifications - */ - static ArrayNode generate(ServiceSpecification serviceSpecification) { - // TODO: Test for Thrift and annotated services - final JsonSchemaGenerator generator = new JsonSchemaGenerator(serviceSpecification); - return generator.generate(); - } - - private final Set serviceInfos; - private final Map typeSignatureToStructMapping; - private final Map typeNameToEnumMapping; + private final ServiceSpecification serviceSpecification; + private final Map structs; + private final Map enums; private JsonSchemaGenerator(ServiceSpecification serviceSpecification) { - serviceInfos = serviceSpecification.services(); - final ImmutableMap.Builder typeSignatureToStructMappingBuilder = + this.serviceSpecification = requireNonNull(serviceSpecification, "serviceSpecification"); + + final ImmutableMap.Builder structsBuilder = ImmutableMap.builderWithExpectedSize(serviceSpecification.structs().size()); - for (StructInfo struct : serviceSpecification.structs()) { - typeSignatureToStructMappingBuilder.put(struct.name(), struct); - if (struct.alias() != null && !struct.alias().equals(struct.name())) { + for (final StructInfo structInfo : serviceSpecification.structs()) { + structsBuilder.put(structInfo.name(), structInfo); + if (structInfo.alias() != null) { // TypeSignature.signature() could be StructInfo.alias() if the type is a protobuf Message. - typeSignatureToStructMappingBuilder.put(struct.alias(), struct); + structsBuilder.put(structInfo.alias(), structInfo); } } - typeSignatureToStructMapping = typeSignatureToStructMappingBuilder.build(); - typeNameToEnumMapping = serviceSpecification.enums().stream().collect( - toImmutableMap(EnumInfo::name, Function.identity())); - } + structs = structsBuilder.build(); - private ArrayNode generate() { - final ArrayNode definitions = mapper.createArrayNode(); - - final Set methodDefinitions = - serviceInfos.stream() - .flatMap(serviceInfo -> serviceInfo.methods().stream().map(this::generate)) - .collect(toImmutableSet()); + enums = serviceSpecification.enums().stream() + .collect(toImmutableMap(EnumInfo::name, Function.identity())); + } - return definitions.addAll(methodDefinitions); + // Public static entry point + static ArrayNode generate(ServiceSpecification serviceSpecification) { + return new JsonSchemaGenerator(serviceSpecification).doGenerate(); } - /** - * Generate the JSON Schema for the given {@link MethodInfo}. - * - * @param methodInfo the method to generate the JSON Schema for. - * - * @return ObjectNode containing the JSON schema for the parameter type. - */ - private ObjectNode generate(MethodInfo methodInfo) { - final ObjectNode root = mapper.createObjectNode(); + // Map an Armeria TypeSignature to JSON Schema "type" + private static String getSchemaType(TypeSignature typeSignature) { + switch (typeSignature.type()) { + case ENUM: + return "string"; + case ITERABLE: + return "array"; + case MAP: + case STRUCT: + return "object"; + case OPTIONAL: + case CONTAINER: { + // Unwrap and return the inner type's schema type + final TypeSignature inner = + ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + return getSchemaType(inner); + } + default: + break; + } - root.put("$id", methodInfo.id()) - .put("title", methodInfo.name()) - .put("description", methodInfo.descriptionInfo().docString()) - .put("additionalProperties", false) - // TODO: Assumes every method takes an object, which is only valid for RPC based services - // and most of the REST services. - .put("type", "object"); + // Base types + switch (typeSignature.name().toLowerCase()) { + case "boolean": + case "bool": + return "boolean"; + case "short": + case "float": + case "double": + return "number"; + case "i8": + case "i16": + case "i32": + case "i64": + case "integer": + case "int": + case "long": + case "int32": + case "int64": + case "uint32": + case "uint64": + case "sint32": + case "sint64": + case "fixed32": + case "fixed64": + case "sfixed32": + case "sfixed64": + return "integer"; + case "binary": + case "byte": + case "bytes": + case "string": + return "string"; + default: + return "object"; + } + } - final List methodFields; - final Map visited = new HashMap<>(); - final String currentPath = "#"; - - if (methodInfo.useParameterAsRoot()) { - final TypeSignature signature = methodInfo.parameters().get(0) - .typeSignature(); - final StructInfo structInfo = typeSignatureToStructMapping.get(signature.signature()); - if (structInfo == null) { - logger.debug("Could not find root parameter with signature: {}", signature); - root.put("additionalProperties", true); - methodFields = ImmutableList.of(); - } else { - methodFields = structInfo.fields(); + private ArrayNode doGenerate() { + final ObjectNode definitions = generateDefinitions(); + final ArrayNode methodSchemas = mapper.createArrayNode(); + for (final ServiceInfo svc : serviceSpecification.services()) { + for (final MethodInfo m : svc.methods()) { + final ObjectNode schema = generateMethodSchema(m, definitions); + methodSchemas.add(schema); } - visited.put(signature, currentPath); - } else { - methodFields = methodInfo.parameters(); } - - generateProperties(methodFields, visited, currentPath, root); - return root; + return methodSchemas; } - /** - * Generate the JSON Schema for the given {@link FieldInfo} and add it to the given {@link ObjectNode} - * and add required fields to the {@link ArrayNode}. - * - * @param field field to generate schema for - * @param visited map of visited types and their paths - * @param path current path in tree traversal of fields - * @param parent the parent to add schema properties - * @param required the array node to add required field names, if parent doesn't support, it is null. - */ - private void generateField(FieldInfo field, Map visited, String path, - ObjectNode parent, - @Nullable ArrayNode required) { - final ObjectNode fieldNode = mapper.createObjectNode(); - final TypeSignature fieldTypeSignature = field.typeSignature(); + private ObjectNode generateDefinitions() { + final ObjectNode definitionsNode = mapper.createObjectNode(); + for (final StructInfo structInfo : serviceSpecification.structs()) { + definitionsNode.set(structInfo.name(), generateStructDefinition(structInfo)); + } + for (final EnumInfo enumInfo : serviceSpecification.enums()) { + definitionsNode.set(enumInfo.name(), generateEnumDefinition(enumInfo)); + } + return definitionsNode; + } - fieldNode.put("description", field.descriptionInfo().docString()); + private ObjectNode generateStructDefinition(StructInfo structInfo) { + final ObjectNode schemaNode = mapper.createObjectNode(); + schemaNode.put("type", "object"); + schemaNode.put("title", structInfo.name()); + final String docString = structInfo.descriptionInfo().docString(); + if (!docString.isEmpty()) { + schemaNode.put("description", docString); + } - // Fill required fields for the current object. - if (required != null && field.requirement() == FieldRequirement.REQUIRED) { - required.add(field.name()); + final List oneOf = structInfo.oneOf(); + if (!oneOf.isEmpty()) { + final ArrayNode oneOfNode = schemaNode.putArray("oneOf"); + oneOf.forEach(sub -> { + final ObjectNode ref = mapper.createObjectNode(); + ref.put("$ref", "#/definitions/" + sub.name()); + oneOfNode.add(ref); + }); + + final DiscriminatorInfo discriminator = structInfo.discriminator(); + if (discriminator != null) { + final ObjectNode disc = schemaNode.putObject("discriminator"); + disc.put("propertyName", discriminator.propertyName()); + if (!discriminator.mapping().isEmpty()) { + final ObjectNode mapping = disc.putObject("mapping"); + discriminator.mapping().forEach(mapping::put); + } + } + return schemaNode; } - if (visited.containsKey(fieldTypeSignature)) { - // If field is already visited, add a reference to the field instead of iterating its children. - final String pathName = visited.get(fieldTypeSignature); - fieldNode.put("$ref", pathName); - } else { - final String schemaType = getSchemaType(field.typeSignature()); + final ObjectNode props = mapper.createObjectNode(); + final ArrayNode required = mapper.createArrayNode(); + for (final FieldInfo field : structInfo.fields()) { + props.set(field.name(), generateFieldSchema(field)); + if (field.requirement() == FieldRequirement.REQUIRED) { + required.add(field.name()); + } + } + if (!props.isEmpty()) { + schemaNode.set("properties", props); + } + if (!required.isEmpty()) { + schemaNode.set("required", required); + } + return schemaNode; + } - // Field is not visited, create a new type definition for it. - fieldNode.put("type", schemaType); + private static ObjectNode generateEnumDefinition(EnumInfo enumInfo) { + final ObjectNode schemaNode = mapper.createObjectNode(); + schemaNode.put("type", "string"); + final ArrayNode enumValues = mapper.createArrayNode(); + enumInfo.values().forEach(value -> enumValues.add(value.name())); + schemaNode.set("enum", enumValues); + return schemaNode; + } - if (field.typeSignature().type() == TypeSignatureType.ENUM) { - fieldNode.set("enum", getEnumType(field.typeSignature())); + private ObjectNode generateMethodSchema(MethodInfo methodInfo, ObjectNode definitions) { + final ObjectNode root = mapper.createObjectNode(); + root.put("$id", methodInfo.id()); + root.put("title", methodInfo.name()); + final String docString = methodInfo.descriptionInfo().docString(); + if (!docString.isEmpty()) { + root.put("description", docString); + } + root.put("additionalProperties", false); + root.put("type", "object"); + if (!methodInfo.useParameterAsRoot()) { + final ObjectNode propertiesNode = mapper.createObjectNode(); + final ArrayNode requiredNode = mapper.createArrayNode(); + + for (final FieldInfo field : methodInfo.parameters()) { + final FieldLocation loc = field.location(); + if (loc == FieldLocation.BODY || loc == FieldLocation.UNSPECIFIED) { + propertiesNode.set(field.name(), generateFieldSchema(field)); + if (field.requirement() == FieldRequirement.REQUIRED) { + requiredNode.add(field.name()); + } + } } - final String currentPath; - if (field.name().isEmpty()) { - currentPath = path; - } else { - currentPath = path + '/' + field.name(); + if (!propertiesNode.isEmpty()) { + root.set("properties", propertiesNode); } - - // Only Struct types map to custom objects to we need reference to those structs. - // Having references to primitives do not make sense. - if (MEMORIZED_JSON_TYPES.contains(schemaType)) { - visited.put(fieldTypeSignature, currentPath); + if (!requiredNode.isEmpty()) { + root.set("required", requiredNode); } - // Based on field type, we need to call the appropriate method to generate the schema. - // For example maps have `additionalProperties` field, arrays have `items` field and structs - // have `properties` field. - if (field.typeSignature().type() == TypeSignatureType.MAP) { - generateMapFields(fieldNode, field, visited, currentPath); - } else if (field.typeSignature().type() == TypeSignatureType.ITERABLE) { - generateArrayFields(fieldNode, field, visited, currentPath); - } else if ("object".equals(schemaType)) { - generateStructFields(fieldNode, field, visited, currentPath); - } + root.set("definitions", definitions); + return root; } - // Set current field inside the returned object. - // If field is nameless, unpack it. - // Example: - // For `list x` we should have `{"x": {"items": {"type": "integer"}}}` - // Not `{"x": {"items": {"": {"type": "integer"}}}}` - if (field.name().isEmpty()) { - parent.setAll(fieldNode); + //gRPC + final Map visited = new HashMap<>(); + final FieldInfo firstParam = methodInfo.parameters().get(0); + final StructInfo structInfo = structs.get(firstParam.typeSignature().signature()); + + if (structInfo != null) { + visited.put(firstParam.typeSignature(), "#"); + generateProperties(structInfo.fields(), visited, "#", root); } else { - parent.set(field.name(), fieldNode); + logger.warn("Could not find root struct for signature: {}", + firstParam.typeSignature().signature()); + root.put("additionalProperties", true); } + return root; } - /** - * Generate properties for the given fields and writes to the object node. - * - * @param fields list of fields that the child has. - * @param visited a map of visited fields, required for cycle detection. - * @param path current path as defined in JSON Schema spec, required for cyclic references. - * @param parent object node that the results will be written to. - */ - private void generateProperties(List fields, Map visited, String path, - ObjectNode parent) { - final ObjectNode objectNode = mapper.createObjectNode(); - final ArrayNode required = mapper.createArrayNode(); + private ObjectNode generateFieldSchema(FieldInfo field) { + final ObjectNode fieldNode = mapper.createObjectNode(); + final TypeSignature typeSignature = field.typeSignature(); + final String docString = field.descriptionInfo().docString(); + if (!docString.isEmpty()) { + fieldNode.put("description", docString); + } - for (FieldInfo field : fields) { - if (VALID_FIELD_LOCATIONS.contains(field.location())) { - generateField(field, visited, path + "/properties", objectNode, required); - } + if (typeSignature.type() == TypeSignatureType.STRUCT || + typeSignature.type() == TypeSignatureType.ENUM) { + fieldNode.put("$ref", "#/definitions/" + typeSignature.name()); + return fieldNode; } - parent.set("properties", objectNode); - parent.set("required", required); - } + if (typeSignature.type() == TypeSignatureType.OPTIONAL || + typeSignature.type() == TypeSignatureType.CONTAINER) { + final TypeSignature inner = + ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + final ObjectNode innerNode = generateFieldSchema(FieldInfo.of("", inner)); + fieldNode.setAll(innerNode); + return fieldNode; + } - /** - * Create the JSON node for a map field. - * Example for `map(string, int)`: {"type": "object", "additionalProperties": {"type": "integer"}} - * - * @see JSON Schema - */ - private void generateMapFields(ObjectNode fieldNode, FieldInfo field, Map visited, - String path) { - final ObjectNode additionalProperties = mapper.createObjectNode(); - - // Keys are always converted to strings. - final TypeSignature valueType = ((MapTypeSignature) field.typeSignature()).valueTypeSignature(); - // Create a field info with no name. Field infos with no name are considered to be unpacked. - final FieldInfo valueFieldInfo = FieldInfo.builder("", valueType) - .location(FieldLocation.BODY) - .requirement(FieldRequirement.OPTIONAL) - .build(); - - // Recursively generate the field. - generateField(valueFieldInfo, visited, path + "/additionalProperties", additionalProperties, null); - - fieldNode.set("additionalProperties", additionalProperties); - } + final String schemaType = getSchemaType(typeSignature); + fieldNode.put("type", schemaType); - /** - * Create the JSON node for an array field. - * Example for `list(int)`: {"type": "array", "items": {"type": "integer"}} - * - * @see JSON Schema - */ - private void generateArrayFields(ObjectNode fieldNode, FieldInfo field, Map visited, - String path) { - final ObjectNode items = mapper.createObjectNode(); - - final TypeSignature itemsType = - ((ContainerTypeSignature) field.typeSignature()).typeParameters().get(0); - // Create a field info with no name. Field infos with no name are considered to be unpacked. - final FieldInfo itemFieldInfo = FieldInfo.builder("", itemsType) - .location(FieldLocation.BODY) - .requirement(FieldRequirement.OPTIONAL) - .build(); - - generateField(itemFieldInfo, visited, path + "/items", items, null); - - fieldNode.set("items", items); + switch (typeSignature.type()) { + case ITERABLE: { + final TypeSignature itemType = + ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + fieldNode.set("items", generateFieldSchema(FieldInfo.of("", itemType))); + break; + } + case MAP: { + final TypeSignature valueType = + ((MapTypeSignature) typeSignature).valueTypeSignature(); + fieldNode.set("additionalProperties", + generateFieldSchema(FieldInfo.of("", valueType))); + break; + } + default: + break; + } + return fieldNode; } - /** - * Create the JSON node for a struct (object) field. Most custom classes are serialized as structs. - * Example for `Foo(Integer x)`: {"type": "object", "properties": {"x": {"type": "integer"}}} - * - * @see JSON Schema - */ - private void generateStructFields(ObjectNode fieldNode, FieldInfo field, Map visited, - String path) { - - final StructInfo fieldStructInfo = typeSignatureToStructMapping.get(field.typeSignature().signature()); - fieldNode.put("additionalProperties", fieldStructInfo == null); - - if (fieldStructInfo == null) { - logger.debug("Could not find struct with signature: {}", - field.typeSignature().signature()); + private void generateProperties(List fields, Map visited, + String path, ObjectNode parent) { + final ObjectNode propertiesNode = mapper.createObjectNode(); + final ArrayNode requiredNode = mapper.createArrayNode(); + + for (final FieldInfo field : fields) { + generateFieldSchemaInline(field, visited, path + "/properties", propertiesNode, requiredNode); } - // Iterate over each child field, generate their definitions. - if (fieldStructInfo != null && !fieldStructInfo.fields().isEmpty()) { - generateProperties(fieldStructInfo.fields(), visited, path, fieldNode); + if (!propertiesNode.isEmpty()) { + parent.set("properties", propertiesNode); + } + if (!requiredNode.isEmpty()) { + parent.set("required", requiredNode); } } - /** - * Get the JSON type for the given enum type. - * Example: `enum Foo { BAR, BAZ }`: {"type": "string", "enum": ["BAR", "BAZ"]} - */ - private ArrayNode getEnumType(TypeSignature type) { - final ArrayNode enumArray = mapper.createArrayNode(); - final EnumInfo enumInfo = typeNameToEnumMapping.get(type.signature()); - - if (enumInfo != null) { - enumInfo.values().forEach(x -> enumArray.add(x.name())); + private void generateFieldSchemaInline(FieldInfo field, Map visited, + String path, ObjectNode parent, @Nullable ArrayNode required) { + final ObjectNode fieldNode = mapper.createObjectNode(); + final TypeSignature typeSignature = field.typeSignature(); + final String docString = field.descriptionInfo().docString(); + if (!docString.isEmpty()) { + fieldNode.put("description", docString); } - return enumArray; - } - - /** - * Get the JSON type for the given type. Unknown types are returned as `object`. - * This list can be extended to support more types. - * - * @see JSON Schema - */ - private static String getSchemaType(TypeSignature typeSignature) { - if (typeSignature.type() == TypeSignatureType.ENUM) { - return "string"; + if (required != null && field.requirement() == FieldRequirement.REQUIRED) { + required.add(field.name()); } - if (typeSignature.type() == TypeSignatureType.ITERABLE) { - switch (typeSignature.name().toLowerCase()) { - case "repeated": - case "list": - case "array": - case "set": - return "array"; - default: - return "object"; - } + if (visited.containsKey(typeSignature)) { + fieldNode.put("$ref", visited.get(typeSignature)); + parent.set(field.name(), fieldNode); + return; } + if (typeSignature.type() == TypeSignatureType.OPTIONAL || + typeSignature.type() == TypeSignatureType.CONTAINER) { + final TypeSignature inner = + ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + generateFieldSchemaInline(FieldInfo.of(field.name(), inner), visited, path, parent, required); + return; + } + + final String currentPath = path + '/' + field.name(); + final String schemaType = getSchemaType(typeSignature); + fieldNode.put("type", schemaType); - if (typeSignature.type() == TypeSignatureType.MAP) { - return "object"; + if ("object".equals(schemaType) || "array".equals(schemaType)) { + visited.put(typeSignature, currentPath); } - if (typeSignature.type() == TypeSignatureType.BASE) { - switch (typeSignature.name().toLowerCase()) { - case "boolean": - case "bool": - return "boolean"; - case "short": - case "number": - case "float": - case "double": - return "number"; - case "i": - case "i8": - case "i16": - case "i32": - case "i64": - case "integer": - case "int": - case "l32": - case "l64": - case "long": - case "long32": - case "long64": - case "int32": - case "int64": - case "uint32": - case "uint64": - case "sint32": - case "sint64": - case "fixed32": - case "fixed64": - case "sfixed32": - case "sfixed64": - return "integer"; - case "binary": - case "byte": - case "bytes": - case "string": - return "string"; - default: - return "object"; + switch (typeSignature.type()) { + case ENUM: { + final EnumInfo enumInfo = enums.get(typeSignature.name()); + if (enumInfo != null) { + final ArrayNode enumValues = mapper.createArrayNode(); + enumInfo.values().forEach(value -> enumValues.add(value.name())); + fieldNode.set("enum", enumValues); + } + break; } + case STRUCT: { + final StructInfo structInfo = structs.get(typeSignature.signature()); + if (structInfo == null) { + logger.warn("Unknown struct signature: {}", typeSignature.signature()); + fieldNode.put("additionalProperties", true); + } else { + fieldNode.put("additionalProperties", false); + if (!structInfo.fields().isEmpty()) { + generateProperties(structInfo.fields(), visited, currentPath, fieldNode); + } + } + break; + } + case ITERABLE: { + final TypeSignature itemType = + ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + final ObjectNode itemsNode = mapper.createObjectNode(); + generateFieldSchemaInline(FieldInfo.of("", itemType), visited, path + "/items", itemsNode, + null); + if (itemsNode.has("")) { + fieldNode.set("items", itemsNode.get("")); + } + break; + } + case MAP: { + final TypeSignature valueType = + ((MapTypeSignature) typeSignature).valueTypeSignature(); + final ObjectNode additionalPropertiesNode = mapper.createObjectNode(); + generateFieldSchemaInline(FieldInfo.of("", valueType), visited, + path + "/additionalProperties", additionalPropertiesNode, null); + if (additionalPropertiesNode.has("")) { + fieldNode.set("additionalProperties", additionalPropertiesNode.get("")); + } + break; + } + default: + break; } - - return "object"; + parent.set(field.name(), fieldNode); } } diff --git a/core/src/main/java/com/linecorp/armeria/server/docs/StructInfo.java b/core/src/main/java/com/linecorp/armeria/server/docs/StructInfo.java index dd9890ae15f..ff3e25b3e4c 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/StructInfo.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/StructInfo.java @@ -46,29 +46,36 @@ public final class StructInfo implements DescriptiveTypeInfo { private final List fields; private final DescriptionInfo descriptionInfo; + private final List oneOf; + @Nullable + private final DiscriminatorInfo discriminator; + /** * Creates a new instance. */ public StructInfo(String name, Iterable fields) { - this(name, null, fields, DescriptionInfo.empty()); + this(name, null, fields, DescriptionInfo.empty(), ImmutableList.of(), null); } /** * Creates a new instance. */ public StructInfo(String name, Iterable fields, DescriptionInfo descriptionInfo) { - this(name, null, fields, descriptionInfo); + this(name, null, fields, descriptionInfo, ImmutableList.of(), null); } /** * Creates a new instance. */ public StructInfo(String name, @Nullable String alias, Iterable fields, - DescriptionInfo descriptionInfo) { + DescriptionInfo descriptionInfo, Iterable oneOf, + @Nullable DiscriminatorInfo discriminator) { this.name = requireNonNull(name, "name"); this.alias = alias; this.fields = ImmutableList.copyOf(requireNonNull(fields, "fields")); this.descriptionInfo = requireNonNull(descriptionInfo, "descriptionInfo"); + this.oneOf = ImmutableList.copyOf(requireNonNull(oneOf, "oneOf")); + this.discriminator = discriminator; } @Override @@ -102,7 +109,7 @@ public StructInfo withAlias(String alias) { return this; } - return new StructInfo(name, alias, fields, descriptionInfo); + return new StructInfo(name, alias, fields, descriptionInfo, oneOf, discriminator); } /** @@ -123,7 +130,7 @@ public StructInfo withFields(Iterable fields) { return this; } - return new StructInfo(name, alias, fields, descriptionInfo); + return new StructInfo(name, alias, fields, descriptionInfo, oneOf, discriminator); } /** @@ -145,13 +152,39 @@ public StructInfo withDescriptionInfo(DescriptionInfo descriptionInfo) { return this; } - return new StructInfo(name, alias, fields, descriptionInfo); + return new StructInfo(name, alias, fields, descriptionInfo, oneOf, discriminator); + } + + /** + * Returns the list of subtypes for polymorphism. This corresponds to the {@code oneOf} keyword + * in the OpenAPI Specification. + * + * @return a list of {@link TypeSignature}s for the possible subtypes. + */ + @JsonProperty + @JsonInclude(Include.NON_EMPTY) + public List oneOf() { + return oneOf; + } + + /** + * Returns the discriminator information for polymorphism. This corresponds to the {@code discriminator} + * object in the OpenAPI Specification. + * + * @return the {@link DiscriminatorInfo} object, or {@code null} if not defined. + */ + @JsonProperty + @JsonInclude(Include.NON_NULL) + @Nullable + public DiscriminatorInfo discriminator() { + return discriminator; } @Override public Set findDescriptiveTypes() { final Set collectedDescriptiveTypes = new HashSet<>(); fields().forEach(f -> ServiceInfo.findDescriptiveTypes(collectedDescriptiveTypes, f.typeSignature())); + oneOf().forEach(t -> ServiceInfo.findDescriptiveTypes(collectedDescriptiveTypes, t)); return ImmutableSortedSet.copyOf(comparing(TypeSignature::name), collectedDescriptiveTypes); } @@ -169,22 +202,33 @@ public boolean equals(@Nullable Object o) { return name.equals(that.name) && Objects.equals(alias, that.alias) && fields.equals(that.fields) && - descriptionInfo.equals(that.descriptionInfo); + descriptionInfo.equals(that.descriptionInfo) && + oneOf.equals(that.oneOf) && + Objects.equals(discriminator, that.discriminator); } @Override public int hashCode() { - return Objects.hash(name, alias, fields, descriptionInfo); + return Objects.hash(name, alias, fields, descriptionInfo, oneOf, discriminator); } @Override public String toString() { - return MoreObjects.toStringHelper(this) - .omitNullValues() - .add("name", name) - .add("alias", alias) - .add("fields", fields) - .add("descriptionInfo", descriptionInfo) - .toString(); + final MoreObjects.ToStringHelper stringHelper = + MoreObjects.toStringHelper(this) + .add("name", name) + .add("alias", alias) + .add("fields", fields) + .add("descriptionInfo", descriptionInfo); + + if (!oneOf.isEmpty()) { + stringHelper.add("oneOf", oneOf); + } + + if (discriminator != null) { + stringHelper.add("discriminator", discriminator); + } + + return stringHelper.toString(); } } diff --git a/core/src/main/resources/META-INF/services/com.linecorp.armeria.server.docs.DescriptiveTypeInfoProvider b/core/src/main/resources/META-INF/services/com.linecorp.armeria.server.docs.DescriptiveTypeInfoProvider new file mode 100644 index 00000000000..65189f0d421 --- /dev/null +++ b/core/src/main/resources/META-INF/services/com.linecorp.armeria.server.docs.DescriptiveTypeInfoProvider @@ -0,0 +1 @@ +com.linecorp.armeria.internal.server.docs.JacksonPolymorphismTypeInfoProvider diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePluginTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePluginTest.java index 835e4a0cd51..5980568050d 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePluginTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServicePluginTest.java @@ -19,13 +19,13 @@ import static com.google.common.collect.ImmutableList.toImmutableList; import static com.google.common.collect.ImmutableMap.toImmutableMap; import static com.google.common.collect.ImmutableSet.toImmutableSet; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.LONG; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.STRING; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.VOID; import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.endpointInfo; import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.newDescriptiveTypeInfo; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.toTypeSignature; import static com.linecorp.armeria.internal.server.annotation.DefaultDescriptiveTypeInfoProviderTest.REQUEST_STRUCT_INFO_PROVIDER; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.LONG; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.STRING; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.VOID; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.toTypeSignature; import static com.linecorp.armeria.internal.server.docs.DocServiceUtil.unifyFilter; import static com.linecorp.armeria.server.docs.FieldLocation.HEADER; import static com.linecorp.armeria.server.docs.FieldLocation.QUERY; diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServiceTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServiceTest.java index cc1d64d0797..52884259daa 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServiceTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/AnnotatedDocServiceTest.java @@ -16,11 +16,11 @@ package com.linecorp.armeria.internal.server.annotation; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.INT; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.LONG; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.STRING; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.toTypeSignature; import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePluginTest.compositeBean; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.INT; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.LONG; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.STRING; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.toTypeSignature; import static com.linecorp.armeria.server.docs.FieldLocation.PATH; import static com.linecorp.armeria.server.docs.FieldLocation.QUERY; import static com.linecorp.armeria.server.docs.FieldRequirement.REQUIRED; diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProviderTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProviderTest.java index 2799650b723..815fc583f20 100644 --- a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProviderTest.java +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/DefaultDescriptiveTypeInfoProviderTest.java @@ -16,8 +16,8 @@ package com.linecorp.armeria.internal.server.annotation; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.INT; -import static com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.STRING; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.INT; +import static com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.STRING; import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/DocServiceTestUtil.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/DocServiceTestUtil.java new file mode 100644 index 00000000000..7a46f351d25 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/DocServiceTestUtil.java @@ -0,0 +1,33 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation 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: + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law of 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 com.linecorp.armeria.internal.server.annotation; + +import com.linecorp.armeria.server.docs.DescriptiveTypeInfoProvider; + +/** + * A test utility class for DocService related tests. + * This class resides in the same package as internal classes to provide access for testing purposes. + */ +public final class DocServiceTestUtil { + + /** + * Creates a new instance of the package-private {@link DefaultDescriptiveTypeInfoProvider}. + */ + public static DescriptiveTypeInfoProvider newDefaultDescriptiveTypeInfoProvider(boolean request) { + return new DefaultDescriptiveTypeInfoProvider(request); + } + + private DocServiceTestUtil() {} +} diff --git a/core/src/test/java/com/linecorp/armeria/internal/server/annotation/PolymorphismDocServiceTest.java b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/PolymorphismDocServiceTest.java new file mode 100644 index 00000000000..8d294a19fa2 --- /dev/null +++ b/core/src/test/java/com/linecorp/armeria/internal/server/annotation/PolymorphismDocServiceTest.java @@ -0,0 +1,417 @@ +/* + * Copyright 2025 LY Corporation + * + * LY Corporation 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: + * + * https://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 com.linecorp.armeria.internal.server.annotation; + +import static java.util.Objects.requireNonNull; +import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; + +import com.linecorp.armeria.client.WebClient; +import com.linecorp.armeria.common.AggregatedHttpResponse; +import com.linecorp.armeria.common.HttpStatus; +import com.linecorp.armeria.common.MediaType; +import com.linecorp.armeria.internal.testing.TestUtil; +import com.linecorp.armeria.server.ServerBuilder; +import com.linecorp.armeria.server.annotation.Post; +import com.linecorp.armeria.server.docs.DocService; +import com.linecorp.armeria.server.docs.TypeSignature; +import com.linecorp.armeria.testing.junit5.server.ServerExtension; + +class PolymorphismDocServiceTest { + + private static final Logger logger = LoggerFactory.getLogger(PolymorphismDocServiceTest.class); + private static final ObjectMapper mapper = new ObjectMapper(); + + private static final String vetRecordJson = + "{\"vaccinationHistory\":{\"Rabies\":\"2025-01-01\", \"FeLV\":\"2025-02-01\"}}"; + + private static final String dogExampleRequest = + "{\"species\":\"dog\", \"name\":\"Buddy\", \"age\":5, \"favoriteFoods\":[\"beef\"]," + + "\"favoriteToy\":{\"toyName\":\"ball\", \"color\":\"red\"}," + + "\"vetRecord\":" + vetRecordJson + '}'; + + private static final String catExampleRequest = + "{\"species\":\"cat\", \"name\":\"Lucy\", \"likesTuna\":true," + + "\"scratchPost\":{\"toyName\":\"tower\", \"color\":\"beige\"}," + + "\"vetRecord\":" + vetRecordJson + '}'; + + @RegisterExtension + static final ServerExtension server = new ServerExtension() { + @Override + protected void configure(ServerBuilder sb) throws Exception { + if (TestUtil.isDocServiceDemoMode()) { + sb.http(8081); + } + sb.annotatedService("/api", new AnimalService()); + sb.serviceUnder("/docs", + DocService.builder() + .exampleRequests(AnimalService.class, "processAnimal", + dogExampleRequest, catExampleRequest) + .build()); + } + }; + + @Test + void specificationShouldBeGeneratedCorrectly() throws Exception { + + if (TestUtil.isDocServiceDemoMode()) { + Thread.sleep(Long.MAX_VALUE); + } + final WebClient client = WebClient.of(server.httpUri()); + final AggregatedHttpResponse res = client.get("/docs/specification.json").aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + + final String specificationJson = res.contentUtf8(); + final JsonNode specNode = mapper.readTree(specificationJson); + + final JsonNode structsNode = specNode.path("structs"); + final String animalClassName = TypeSignature.ofStruct(Animal.class).name(); + final String vetRecordClassName = TypeSignature.ofStruct(VetRecord.class).name(); + final String dogClassName = TypeSignature.ofStruct(Dog.class).name(); + final String catClassName = TypeSignature.ofStruct(Cat.class).name(); + + boolean animalStructFound = false; + boolean vetRecordStructFound = false; + + for (final JsonNode struct : structsNode) { + final String currentName = struct.path("name").asText(); + if (animalClassName.equals(currentName)) { + animalStructFound = true; + final JsonNode oneOfNode = struct.path("oneOf"); + assertThat(oneOfNode.isArray()).isTrue(); + final List oneOfList = new ArrayList<>(); + oneOfNode.forEach(node -> oneOfList.add(node.asText())); + assertThat(oneOfList).containsExactlyInAnyOrder(dogClassName, catClassName); + assertThatJson(struct).node("discriminator.propertyName").isStringEqualTo("species"); + } + if (vetRecordClassName.equals(currentName)) { + vetRecordStructFound = true; + } + } + + assertThat(animalStructFound).as("Animal struct with polymorphism info not found").isTrue(); + assertThat(vetRecordStructFound).as("VetRecord struct (for MAP test) not found").isTrue(); + + final JsonNode methodsNode = specNode.path("services").get(0).path("methods"); + boolean apiResponseMethodFound = false; + for (final JsonNode method : methodsNode) { + if ("getExampleResponse".equals(method.path("name").asText())) { + apiResponseMethodFound = true; + final String expectedReturnType = TypeSignature.ofContainer( + ApiResponse.class.getSimpleName(), + ImmutableList.of(TypeSignature.ofStruct(Toy.class)) + ).signature(); + + assertThatJson(method).node("returnTypeSignature").isStringEqualTo(expectedReturnType); + break; + } + } + assertThat(apiResponseMethodFound).as("getExampleResponse method for ApiResponse test not found") + .isTrue(); + } + + @Test + void shouldDeserializePolymorphicObject() { + final WebClient client = WebClient.builder(server.httpUri()) + .addHeader("Content-Type", MediaType.JSON_UTF_8.toString()) + .build(); + + final AggregatedHttpResponse responseForDog = client.post("/api/animal", dogExampleRequest).aggregate() + .join(); + assertThat(responseForDog.status()).isEqualTo(HttpStatus.OK); + assertThat(responseForDog.contentUtf8()).contains("woof"); + + final AggregatedHttpResponse responseForCat = client.post("/api/animal", catExampleRequest).aggregate() + .join(); + assertThat(responseForCat.status()).isEqualTo(HttpStatus.OK); + assertThat(responseForCat.contentUtf8()).contains("meow"); + } + + @Test + void shouldDeserializeNestedPolymorphicList() { + final WebClient client = WebClient.builder(server.httpUri()) + .addHeader("Content-Type", MediaType.JSON_UTF_8.toString()) + .build(); + final String zooRequest = "{\"animals\": [" + dogExampleRequest + ',' + catExampleRequest + "]}"; + final AggregatedHttpResponse response = client.post("/api/zoo", zooRequest).aggregate().join(); + assertThat(response.status()).isEqualTo(HttpStatus.OK); + assertThat(response.contentUtf8()).isEqualTo("Received 2 animals"); + } + + @Test + void shouldDeserializeMapAndContainerTypes() { + final WebClient client = WebClient.builder(server.httpUri()) + .addHeader("Content-Type", MediaType.JSON_UTF_8.toString()) + .build(); + + final AggregatedHttpResponse responseForRecord = client.post("/api/animal/record", dogExampleRequest) + .aggregate().join(); + assertThat(responseForRecord.status()).isEqualTo(HttpStatus.OK); + assertThat(responseForRecord.contentUtf8()).contains("Rabies", "FeLV"); + + final AggregatedHttpResponse responseForOptional = client.post("/api/animal/optional", + dogExampleRequest).aggregate().join(); + assertThat(responseForOptional.status()).isEqualTo(HttpStatus.OK); + assertThat(responseForOptional.contentUtf8()).isEqualTo("Received optional animal: Buddy"); + } + + @Test + void specificationForEmptySubTypes() throws Exception { + final WebClient client = WebClient.of(server.httpUri()); + final AggregatedHttpResponse res = client.get("/docs/specification.json").aggregate().join(); + assertThat(res.status()).isEqualTo(HttpStatus.OK); + + final String specificationJson = res.contentUtf8(); + final JsonNode specNode = mapper.readTree(specificationJson); + + final String misconfiguredClassName = TypeSignature.ofStruct(MisconfiguredAnimal.class).name(); + boolean structFound = false; + for (final JsonNode struct : specNode.path("structs")) { + if (misconfiguredClassName.equals(struct.path("name").asText())) { + structFound = true; + assertThatJson(struct).node("oneOf").isAbsent(); + assertThatJson(struct).node("discriminator").isAbsent(); + assertThatJson(struct).node("fields").isArray().isEmpty(); + break; + } + } + assertThat(structFound).as("MisconfiguredAnimal struct should exist and be simple").isTrue(); + } + + // --- DTOs and Service for the test --- + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "species") + @JsonSubTypes({ + @JsonSubTypes.Type(value = Dog.class, name = "dog"), + @JsonSubTypes.Type(value = Cat.class, name = "cat") + }) + interface Animal { + String name(); + } + + abstract static class Mammal implements Animal { + @JsonProperty + private final String name; + + protected Mammal(String name) { + this.name = requireNonNull(name, "name"); + } + + @Override + public String name() { + return name; + } + + public abstract String sound(); + } + + static final class Toy { + @JsonProperty + private final String toyName; + @JsonProperty + private final String color; + + @JsonCreator + Toy(@JsonProperty("toyName") String toyName, @JsonProperty("color") String color) { + this.toyName = requireNonNull(toyName, "toyName"); + this.color = requireNonNull(color, "color"); + } + } + + static final class VetRecord { + @JsonProperty + private final Map vaccinationHistory; + + @JsonCreator + VetRecord(@JsonProperty("vaccinationHistory") Map history) { + this.vaccinationHistory = ImmutableMap.copyOf(requireNonNull(history, "history")); + } + + public Map vaccinationHistory() { + return vaccinationHistory; + } + } + + static final class Dog extends Mammal { + @JsonProperty + private final int age; + @JsonProperty + private final String[] favoriteFoods; + @JsonProperty + private final Toy favoriteToy; + @JsonProperty + private final VetRecord vetRecord; + + @JsonCreator + Dog(@JsonProperty("name") String name, @JsonProperty("age") int age, + @JsonProperty("favoriteFoods") String[] favoriteFoods, @JsonProperty("favoriteToy") Toy toy, + @JsonProperty("vetRecord") VetRecord vetRecord) { + super(name); + this.age = age; + this.favoriteFoods = requireNonNull(favoriteFoods, "favoriteFoods"); + this.favoriteToy = requireNonNull(toy, "favoriteToy"); + this.vetRecord = requireNonNull(vetRecord, "vetRecord"); + } + + @Override + public String sound() { + return "woof"; + } + + public VetRecord vetRecord() { + return vetRecord; + } + } + + static final class Cat extends Mammal { + @JsonProperty + private final boolean likesTuna; + @JsonProperty + private final Toy scratchPost; + @JsonProperty + private final VetRecord vetRecord; + + @JsonCreator + Cat(@JsonProperty("name") String name, @JsonProperty("likesTuna") boolean likesTuna, + @JsonProperty("scratchPost") Toy scratchPost, @JsonProperty("vetRecord") VetRecord vetRecord) { + super(name); + this.likesTuna = likesTuna; + this.scratchPost = requireNonNull(scratchPost, "scratchPost"); + this.vetRecord = requireNonNull(vetRecord, "vetRecord"); + } + + @Override + public String sound() { + return "meow"; + } + + public VetRecord vetRecord() { + return vetRecord; + } + } + + static class Zoo { + @JsonProperty + private final List animals; + + @JsonCreator + Zoo(@JsonProperty("animals") List animals) { + this.animals = ImmutableList.copyOf(requireNonNull(animals, "animals")); + } + } + + static final class ApiResponse { + @JsonProperty + private final int status; + @JsonProperty + private final T data; + + ApiResponse(int status, T data) { + this.status = status; + this.data = data; + } + } + + @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") + @JsonSubTypes({}) + interface MisconfiguredAnimal { + String name(); + } + + static final class Inventory { + @JsonProperty + private final Map consumableCounts; + @JsonProperty + private final Map equipment; + + @JsonCreator + Inventory(@JsonProperty("consumableCounts") Map consumableCounts, + @JsonProperty("equipment") Map equipment) { + this.consumableCounts = requireNonNull(consumableCounts, "consumableCounts"); + this.equipment = requireNonNull(equipment, "equipment"); + } + } + + public static class AnimalService { + + @Post("/animal") + public String processAnimal(Animal animal) { + String response = "Received animal named: " + animal.name() + "."; + if (animal instanceof Mammal) { + response += " It says: " + ((Mammal) animal).sound(); + } + return response; + } + + @Post("/zoo") + public String processZoo(Zoo zoo) { + return String.format("Received %d animals", zoo.animals.size()); + } + + @Post("/animal/record") + public String processAnimalRecord(Animal animal) { + if (animal instanceof Dog) { + return "Dog's vaccinations: " + ((Dog) animal).vetRecord().vaccinationHistory().keySet(); + } + if (animal instanceof Cat) { + return "Cat's vaccinations: " + ((Cat) animal).vetRecord().vaccinationHistory().keySet(); + } + return "Unknown animal record."; + } + + @Post("/animal/optional") + public String processOptionalAnimal(Optional animal) { + return "Received optional animal: " + animal.map(Animal::name).orElse("empty"); + } + // This method's purpose is to make DocService discover the ApiResponse type. + + @Post("/dummy/api_response") + public ApiResponse getExampleResponse() { + return new ApiResponse<>(200, null); + } + + @Post("/misconfigured") + public String processMisconfigured(MisconfiguredAnimal misconfigured) { + return "Received: " + misconfigured.name(); + } + + @Post("/dummy/inventory") + public Inventory getInventory() { + return null; + } + } +} + diff --git a/core/src/test/java/com/linecorp/armeria/server/docs/JsonSchemaGeneratorTest.java b/core/src/test/java/com/linecorp/armeria/server/docs/JsonSchemaGeneratorTest.java index 29e4a6ea150..34c2972390f 100644 --- a/core/src/test/java/com/linecorp/armeria/server/docs/JsonSchemaGeneratorTest.java +++ b/core/src/test/java/com/linecorp/armeria/server/docs/JsonSchemaGeneratorTest.java @@ -17,149 +17,268 @@ package com.linecorp.armeria.server.docs; import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; +import static org.assertj.core.api.Assertions.assertThat; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.stream.Collectors; -import org.hamcrest.CustomTypeSafeMatcher; import org.junit.jupiter.api.Test; -import net.javacrumbs.jsonunit.core.internal.Node.JsonMap; - import com.fasterxml.jackson.databind.JsonNode; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.linecorp.armeria.common.HttpMethod; class JsonSchemaGeneratorTest { - // Common Fixtures private static final String methodName = "test-method"; - private static final DescriptionInfo methodDescription = DescriptionInfo.of("test method"); - // Generate a fake ServiceSpecification that only contains the happy path to parameters - private static StructInfo newStructInfo(String name, List parameters) { - return new StructInfo(name, parameters); + private enum Color { + RED, GREEN, BLUE } - private static FieldInfo newFieldInfo() { - return FieldInfo.of("request", TypeSignature.ofStruct(methodName, new Object())); - } + // ---- Test helpers ------------------------------------------------------- - private static MethodInfo newMethodInfo(FieldInfo... parameters) { - return new MethodInfo( - "test-service", - methodName, + private static ServiceSpecification newServiceSpecificationWithRequestStruct(StructInfo... structInfos) { + final MethodInfo methodInfo = new MethodInfo( + "test-service", methodName, 0, TypeSignature.ofBase("void"), - Arrays.asList(parameters), - true, - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - HttpMethod.POST, - methodDescription - ); - } + ImmutableList.of(FieldInfo.of("request", + TypeSignature.ofStruct(methodName, new Object()))), + ImmutableList.of(), // exampleHeaders + ImmutableList.of(), // endpoints + HttpMethod.POST, DescriptionInfo.empty()); - private static ServiceSpecification generateServiceSpecification(StructInfo... structInfos) { return new ServiceSpecification( - ImmutableList.of( - new ServiceInfo( - "test-service", - ImmutableList.of(newMethodInfo(newFieldInfo())), - DescriptionInfo.empty() - ) - ), + ImmutableList.of(new ServiceInfo("test-service", ImmutableList.of(methodInfo))), ImmutableList.of(), Arrays.stream(structInfos).collect(Collectors.toList()), - ImmutableList.of() - ); + ImmutableList.of()); + } + + private static ServiceSpecification specWithSingleRestMethod(String svc, String method, + List params, + List structs, + List enums) { + final MethodInfo m = new MethodInfo( + svc, method, 0, + TypeSignature.ofBase("void"), + ImmutableList.copyOf(params), + ImmutableList.of(), // exampleHeaders + ImmutableList.of(), // endpoints + HttpMethod.POST, DescriptionInfo.empty()); + + return new ServiceSpecification( + ImmutableList.of(new ServiceInfo(svc, ImmutableList.of(m))), + ImmutableList.copyOf(enums), + ImmutableList.copyOf(structs), + ImmutableList.of()); + } + + private static ServiceSpecification specWithSingleGrpcMethod( + String svc, String method, String requestStructName, + ImmutableList requestStructFields, + boolean includeRequestStruct, + ImmutableList extraStructs) { + + final FieldInfo requestParam = FieldInfo.builder("request", + TypeSignature.ofStruct( + requestStructName, new Object())) + .requirement(FieldRequirement.REQUIRED) + .build(); + final MethodInfo grpcMethod = new MethodInfo( + svc, method, TypeSignature.ofBase("void"), + ImmutableList.of(requestParam), + /* useParameterAsRoot */ true, + ImmutableList.of(), ImmutableSet.of(), + ImmutableList.of(), ImmutableList.of(), + ImmutableList.of(), ImmutableList.of(), + HttpMethod.POST, DescriptionInfo.empty()); + + final List allStructs = new ArrayList<>(); + if (includeRequestStruct) { + allStructs.add(new StructInfo(requestStructName, requestStructFields)); + } + allStructs.addAll(extraStructs); + + return new ServiceSpecification( + ImmutableList.of(new ServiceInfo(svc, ImmutableList.of(grpcMethod))), + ImmutableList.of(), allStructs, ImmutableList.of()); + } + + // ---- Tests -------------------------------------------------------------- + + @Test + void optionalIsUnwrapped_andNotRequired() { + final FieldInfo opt = FieldInfo.builder("maybe", + TypeSignature.ofOptional(TypeSignature.ofBase("int"))) + .requirement(FieldRequirement.OPTIONAL) + .build(); + + final StructInfo s = new StructInfo("S", ImmutableList.of(opt)); + + final ServiceSpecification spec = specWithSingleRestMethod( + "svc", "m", + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("S", new Object()))), + ImmutableList.of(s), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + final JsonNode def = schema.get("definitions").get("S"); + + assertThatJson(schema).node("properties.request.$ref").isEqualTo("#/definitions/S"); + assertThatJson(def).node("properties.maybe.type").isEqualTo("integer"); + assertThat(def.get("required")).isNull(); } @Test - void testGenerateSimpleMethodWithoutParameters() { - final List parameters = ImmutableList.of(); - final StructInfo structInfo = newStructInfo(methodName, parameters); - - final ServiceSpecification serviceSpecification = generateServiceSpecification(structInfo); - final JsonNode jsonSchema = JsonSchemaGenerator.generate(serviceSpecification).get(0); - - // Base properties - assertThatJson(jsonSchema).node("title").isEqualTo(methodName); - assertThatJson(jsonSchema).node("description").isEqualTo(methodDescription.docString()); - assertThatJson(jsonSchema).node("type").isEqualTo("object"); - - // Method specific properties - assertThatJson(jsonSchema).node("properties").matches( - new CustomTypeSafeMatcher("has no key") { - @Override - protected boolean matchesSafely(JsonMap item) { - return item.keySet().size() == 0; - } - }); - assertThatJson(jsonSchema).node("additionalProperties").isEqualTo(false); + void arrayAndMapOfStructs_areUnpackedCorrectly() { + final StructInfo foo = new StructInfo("Foo", ImmutableList.of( + FieldInfo.of("x", TypeSignature.ofBase("int")))); + final FieldInfo listFoo = FieldInfo.of("list", + TypeSignature.ofList( + TypeSignature.ofStruct("Foo", new Object()))); + final FieldInfo mapFoo = FieldInfo.of("map", + TypeSignature.ofMap(TypeSignature.ofBase("string"), + TypeSignature.ofStruct("Foo", new Object()))); + final StructInfo holder = new StructInfo("Holder", ImmutableList.of(listFoo, mapFoo)); + + final ServiceSpecification spec = specWithSingleRestMethod( + "svc", "m", + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("Holder", new Object()))), + ImmutableList.of(holder, foo), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + final JsonNode def = schema.get("definitions").get("Holder"); + + assertThatJson(def).node("properties.list.items.$ref").isEqualTo("#/definitions/Foo"); + assertThatJson(def).node("properties.map.additionalProperties.$ref").isEqualTo("#/definitions/Foo"); + } + + @Test + void enumField_isRefAndDefinitionsContainEnumArray() { + final String enumName = TypeSignature.ofEnum(Color.class).name(); + final EnumInfo colorInfo = new EnumInfo( + enumName, + Arrays.asList(new EnumValueInfo("RED", null), + new EnumValueInfo("GREEN", null), + new EnumValueInfo("BLUE", null)), + DescriptionInfo.empty()); + + final FieldInfo enumField = FieldInfo.of("color", TypeSignature.ofEnum(Color.class)); + final StructInfo dto = new StructInfo("Dto", ImmutableList.of(enumField)); + + final ServiceSpecification spec = specWithSingleRestMethod( + "svc", "m", + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("Dto", new Object()))), + ImmutableList.of(dto), + ImmutableList.of(colorInfo)); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + assertThatJson(schema).node("properties.request.$ref").isEqualTo("#/definitions/Dto"); + assertThatJson(schema).node("definitions.Dto.properties.color.$ref") + .isEqualTo("#/definitions/" + enumName); + final JsonNode defs = schema.get("definitions"); + assertThat(defs).isNotNull(); + assertThat(defs.has(enumName)).isTrue(); + + final JsonNode colorDef = defs.get(enumName); + assertThat(colorDef.get("type").asText()).isEqualTo("string"); + assertThat(colorDef.get("enum")).isNotNull(); + assertThat(colorDef.get("enum").size()).isEqualTo(3); + } + + @Test + void grpc_useParameterAsRoot_expandsRootStructFields() { + final ImmutableList reqFields = ImmutableList.of( + FieldInfo.of("a", TypeSignature.ofBase("int")), + FieldInfo.of("b", TypeSignature.ofBase("string"))); + final ServiceSpecification spec = specWithSingleGrpcMethod( + "svc.grpc", "Add", "AddRequest", reqFields, true, ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + assertThatJson(schema).node("properties.a.type").isEqualTo("integer"); + assertThatJson(schema).node("properties.b.type").isEqualTo("string"); } @Test - void testGenerateSimpleMethodWithPrimitiveParameters() { - final List parameters = ImmutableList.of( - FieldInfo.of("param1", TypeSignature.ofBase("int"), DescriptionInfo.of("param1 description")), - FieldInfo.of("param2", TypeSignature.ofBase("double"), - DescriptionInfo.of("param2 description")), - FieldInfo.of("param3", TypeSignature.ofBase("string"), - DescriptionInfo.of("param3 description")), - FieldInfo.of("param4", TypeSignature.ofBase("boolean"), - DescriptionInfo.of("param4 description"))); - final StructInfo structInfo = newStructInfo(methodName, parameters); - - final ServiceSpecification serviceSpecification = generateServiceSpecification(structInfo); - final JsonNode jsonSchema = JsonSchemaGenerator.generate(serviceSpecification).get(0); - - // Base properties - assertThatJson(jsonSchema).node("title").isEqualTo(methodName); - assertThatJson(jsonSchema).node("description").isEqualTo(methodDescription.docString()); - assertThatJson(jsonSchema).node("type").isEqualTo("object"); - - // Method specific properties - assertThatJson(jsonSchema).node("properties").matches( - new CustomTypeSafeMatcher("has 4 keys") { - @Override - protected boolean matchesSafely(JsonMap item) { - return item.keySet().size() == 4; - } - }); - assertThatJson(jsonSchema).node("properties.param1.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.param2.type").isEqualTo("number"); - assertThatJson(jsonSchema).node("properties.param3.type").isEqualTo("string"); - assertThatJson(jsonSchema).node("properties.param4.type").isEqualTo("boolean"); + void grpc_unknownStruct_fallsBackToAdditionalPropertiesTrue() { + final ServiceSpecification spec = specWithSingleGrpcMethod( + "svc.grpc", "Unknown", "UnknownReq", + ImmutableList.of(FieldInfo.of("x", TypeSignature.ofBase("int"))), + /* includeRequestStruct */ false, + /* extraStructs */ ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + assertThatJson(schema).node("additionalProperties").isEqualTo(true); + } + + @Test + void rest_filtersOutPathQueryHeader_keepsOnlyBodyAndUnspecified() { + final FieldInfo path = FieldInfo.builder("id", TypeSignature.ofBase("int")) + .location(FieldLocation.PATH) + .requirement(FieldRequirement.REQUIRED).build(); + final FieldInfo query = FieldInfo.builder("q", TypeSignature.ofBase("string")) + .location(FieldLocation.QUERY) + .requirement(FieldRequirement.OPTIONAL).build(); + final FieldInfo header = FieldInfo.builder("h", TypeSignature.ofBase("string")) + .location(FieldLocation.HEADER) + .requirement(FieldRequirement.OPTIONAL).build(); + final FieldInfo body = FieldInfo.builder("payload", + TypeSignature.ofStruct("Payload", new Object())) + .location(FieldLocation.BODY) + .requirement(FieldRequirement.REQUIRED).build(); + + final StructInfo payload = new StructInfo("Payload", + ImmutableList.of(FieldInfo.of("x", + TypeSignature.ofBase("int")))); + + final ServiceSpecification spec = specWithSingleRestMethod( + "svc", "m", + ImmutableList.of(path, query, header, body), + ImmutableList.of(payload), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + assertThat(schema.get("properties").size()).isEqualTo(1); + assertThat(schema.get("properties").has("payload")).isTrue(); + assertThatJson(schema).node("properties.payload.$ref").isEqualTo("#/definitions/Payload"); } @Test - void testMethodWithRecursivePath() { - final Object commonTypeObjectForRecursion = new Object(); - final List parameters = ImmutableList.of( - FieldInfo.of("param1", TypeSignature.ofBase("int"), DescriptionInfo.of("param1 description")), - FieldInfo.builder("paramRecursive", TypeSignature.ofStruct("rec", commonTypeObjectForRecursion)) - .build() - ); - - final StructInfo structInfo = newStructInfo(methodName, parameters); - - final List parametersOfRec = ImmutableList.of( - FieldInfo.of("inner-param1", TypeSignature.ofBase("int32")), - FieldInfo.of("inner-recurse", TypeSignature.ofStruct("rec", commonTypeObjectForRecursion)) - ); - final StructInfo rec = newStructInfo("rec", parametersOfRec); - - final ServiceSpecification serviceSpecification = generateServiceSpecification(structInfo, rec); - final JsonNode jsonSchema = JsonSchemaGenerator.generate(serviceSpecification).get(0); - - assertThatJson(jsonSchema).node("properties.paramRecursive.properties.inner-param1").isPresent(); - assertThatJson(jsonSchema).node("properties.paramRecursive.properties.inner-recurse.$ref").isEqualTo( - "#/properties/paramRecursive"); + void requiredArray_createdOnlyForRequiredFields() { + final FieldInfo r = FieldInfo.builder("r", TypeSignature.ofBase("int")) + .requirement(FieldRequirement.REQUIRED).build(); + final FieldInfo o = FieldInfo.builder("o", TypeSignature.ofBase("string")) + .requirement(FieldRequirement.OPTIONAL).build(); + + final StructInfo s = new StructInfo("S", ImmutableList.of(r, o)); + final ServiceSpecification spec = specWithSingleRestMethod( + "svc", "m", + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("S", new Object()))), + ImmutableList.of(s), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + final JsonNode def = schema.get("definitions").get("S"); + assertThat(def.get("required")).isNotNull(); + assertThatJson(def).node("required").isArray().ofLength(1); + assertThatJson(def).node("required[0]").isEqualTo("r"); + } + + @Test + void containerType_isUnwrappedToInnerType() { + final FieldInfo box = FieldInfo.of("box", + TypeSignature.ofContainer("Box", ImmutableList.of( + TypeSignature.ofBase("int")))); + final StructInfo s = new StructInfo("HasBox", ImmutableList.of(box)); + + final ServiceSpecification spec = specWithSingleRestMethod( + "svc", "m", + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("HasBox", new Object()))), + ImmutableList.of(s), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec).get(0); + assertThatJson(schema).node("definitions.HasBox.properties.box.type").isEqualTo("integer"); } } diff --git a/kotlin/src/test/kotlin/com/linecorp/armeria/internal/server/annotation/DataClassDefaultNameTypeInfoProviderTest.kt b/kotlin/src/test/kotlin/com/linecorp/armeria/internal/server/annotation/DataClassDefaultNameTypeInfoProviderTest.kt index 5064c7d6afc..a3a0c813dcf 100644 --- a/kotlin/src/test/kotlin/com/linecorp/armeria/internal/server/annotation/DataClassDefaultNameTypeInfoProviderTest.kt +++ b/kotlin/src/test/kotlin/com/linecorp/armeria/internal/server/annotation/DataClassDefaultNameTypeInfoProviderTest.kt @@ -17,7 +17,7 @@ package com.linecorp.armeria.internal.server.annotation import com.fasterxml.jackson.annotation.JsonProperty -import com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.STRING +import com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.STRING import com.linecorp.armeria.server.annotation.Description import com.linecorp.armeria.server.docs.DescriptionInfo import com.linecorp.armeria.server.docs.EnumInfo diff --git a/scala/scala_2.13/src/test/scala/com/linecorp/armeria/internal/server/annotation/CaseClassDefaultNameTypeInfoProviderTest.scala b/scala/scala_2.13/src/test/scala/com/linecorp/armeria/internal/server/annotation/CaseClassDefaultNameTypeInfoProviderTest.scala index e38c8f31eeb..22bbf3f65af 100644 --- a/scala/scala_2.13/src/test/scala/com/linecorp/armeria/internal/server/annotation/CaseClassDefaultNameTypeInfoProviderTest.scala +++ b/scala/scala_2.13/src/test/scala/com/linecorp/armeria/internal/server/annotation/CaseClassDefaultNameTypeInfoProviderTest.scala @@ -16,7 +16,7 @@ package com.linecorp.armeria.internal.server.annotation -import com.linecorp.armeria.internal.server.annotation.AnnotatedDocServicePlugin.STRING +import com.linecorp.armeria.internal.server.docs.DocServiceTypeUtil.STRING import com.linecorp.armeria.internal.testing.GenerateNativeImageTrace import com.linecorp.armeria.scala.implicits._ import com.linecorp.armeria.server.annotation.Description