From 226ebab2033ecac10f6aa3b6862e6c25fb7d1ff7 Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Fri, 12 Sep 2025 15:26:58 +0900 Subject: [PATCH 01/11] feat(docservice): Support Jackson polymorphism annotations (#6370) 0929-2 --- .../annotation/AnnotatedDocServicePlugin.java | 151 +---- .../DefaultDescriptiveTypeInfoProvider.java | 2 +- ...ReflectiveDescriptiveTypeInfoProvider.java | 2 +- .../server/docs/DocServiceTypeUtil.java | 195 +++++++ .../JacksonPolymorphismTypeInfoProvider.java | 123 ++++ .../server/docs/DiscriminatorInfo.java | 98 ++++ .../armeria/server/docs/DocService.java | 6 +- .../server/docs/JsonSchemaGenerator.java | 544 ++++++++---------- .../armeria/server/docs/StructInfo.java | 74 ++- ...ia.server.docs.DescriptiveTypeInfoProvider | 1 + .../AnnotatedDocServicePluginTest.java | 8 +- .../annotation/AnnotatedDocServiceTest.java | 8 +- ...efaultDescriptiveTypeInfoProviderTest.java | 4 +- .../server/annotation/DocServiceTestUtil.java | 33 ++ .../PolymorphismDocServiceTest.java | 417 ++++++++++++++ .../server/docs/JsonSchemaGeneratorTest.java | 316 ++++++---- .../grpc/GrpcDocServiceJsonSchemaTest.java | 170 +++--- ...ataClassDefaultNameTypeInfoProviderTest.kt | 2 +- ...ClassDefaultNameTypeInfoProviderTest.scala | 2 +- 19 files changed, 1486 insertions(+), 670 deletions(-) create mode 100644 core/src/main/java/com/linecorp/armeria/internal/server/docs/DocServiceTypeUtil.java create mode 100644 core/src/main/java/com/linecorp/armeria/internal/server/docs/JacksonPolymorphismTypeInfoProvider.java create mode 100644 core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java create mode 100644 core/src/main/resources/META-INF/services/com.linecorp.armeria.server.docs.DescriptiveTypeInfoProvider create mode 100644 core/src/test/java/com/linecorp/armeria/internal/server/annotation/DocServiceTestUtil.java create mode 100644 core/src/test/java/com/linecorp/armeria/internal/server/annotation/PolymorphismDocServiceTest.java 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/DocService.java b/core/src/main/java/com/linecorp/armeria/server/docs/DocService.java index 0e30c1e0f00..4d6b0c05b5b 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/DocService.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/DocService.java @@ -42,7 +42,7 @@ import com.fasterxml.jackson.annotation.JsonInclude.Include; import com.fasterxml.jackson.core.JsonProcessingException; 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.ImmutableListMultimap; import com.google.common.collect.ImmutableMap; @@ -345,14 +345,14 @@ private CompletableFuture loadSchemas( CompletableFuture specificationFuture) { return files.computeIfAbsent(SCHEMAS_PATH, key -> specificationFuture.thenApply(spec -> { try { - final ArrayNode jsonSpec = JsonSchemaGenerator.generate(spec); + final ObjectNode jsonSpec = JsonSchemaGenerator.generate(spec); final byte[] content = jsonMapper.writerWithDefaultPrettyPrinter() .writeValueAsBytes(jsonSpec); return toFile(content, MediaType.JSON_UTF_8); } catch (JsonProcessingException e) { logger.warn("Failed to generate JSON schemas:", e); - return toFile("[]".getBytes(), MediaType.JSON_UTF_8); + return toFile("{}".getBytes(), MediaType.JSON_UTF_8); } })); } 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..70b7b8d889f 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,13 +16,13 @@ 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 java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -30,11 +30,8 @@ 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; /** @@ -45,353 +42,298 @@ final class JsonSchemaGenerator { private static final Logger logger = LoggerFactory.getLogger(JsonSchemaGenerator.class); - 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 final Map polymorphismToBase; 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())) { - // TypeSignature.signature() could be StructInfo.alias() if the type is a protobuf Message. - typeSignatureToStructMappingBuilder.put(struct.alias(), struct); + for (final StructInfo structInfo : serviceSpecification.structs()) { + structsBuilder.put(structInfo.name(), structInfo); + if (structInfo.alias() != null) { + structsBuilder.put(structInfo.alias(), structInfo); + } + } + structs = structsBuilder.build(); + + enums = serviceSpecification.enums().stream() + .collect(toImmutableMap(EnumInfo::name, Function.identity())); + + // Pre-compute mappings from subtype to its base type's DiscriminatorInfo + polymorphismToBase = new HashMap<>(); + for (final StructInfo structInfo : serviceSpecification.structs()) { + if (structInfo.discriminator() != null && !structInfo.oneOf().isEmpty()) { + for (TypeSignature subType : structInfo.oneOf()) { + polymorphismToBase.put(subType.name(), structInfo.discriminator()); + } } } - typeSignatureToStructMapping = typeSignatureToStructMappingBuilder.build(); - typeNameToEnumMapping = serviceSpecification.enums().stream().collect( - toImmutableMap(EnumInfo::name, Function.identity())); } - private ArrayNode generate() { - final ArrayNode definitions = mapper.createArrayNode(); + // Public static entry point + static ObjectNode generate(ServiceSpecification serviceSpecification) { + return new JsonSchemaGenerator(serviceSpecification).doGenerate(); + } - final Set methodDefinitions = - serviceInfos.stream() - .flatMap(serviceInfo -> serviceInfo.methods().stream().map(this::generate)) - .collect(toImmutableSet()); + 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: { + final TypeSignature inner = + ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + return getSchemaType(inner); + } + default: + break; + } - return definitions.addAll(methodDefinitions); + 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"; + } } - /** - * 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) { + private ObjectNode doGenerate() { final ObjectNode root = mapper.createObjectNode(); + final ServiceInfo representativeService = serviceSpecification.services().iterator().next(); + // Use a representative service name for the title and ID for now. + final String serviceName = representativeService.name(); - 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"); - - 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(); - } - visited.put(signature, currentPath); - } else { - methodFields = methodInfo.parameters(); - } + root.put("$schema", "https://json-schema.org/draft/2020-12/schema"); + root.put("$id", serviceName); + root.put("title", serviceName); + + final ObjectNode defs = root.putObject("$defs"); + defs.set("models", generateModels()); + defs.set("methods", generateMethods()); - generateProperties(methodFields, visited, currentPath, root); return root; } - /** - * 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(); - - fieldNode.put("description", field.descriptionInfo().docString()); - - // Fill required fields for the current object. - if (required != null && field.requirement() == FieldRequirement.REQUIRED) { - required.add(field.name()); + private ObjectNode generateModels() { + final ObjectNode modelsNode = mapper.createObjectNode(); + for (final StructInfo structInfo : serviceSpecification.structs()) { + modelsNode.set(structInfo.name(), generateStructDefinition(structInfo)); } + for (final EnumInfo enumInfo : serviceSpecification.enums()) { + modelsNode.set(enumInfo.name(), generateEnumDefinition(enumInfo)); + } + return modelsNode; + } - 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()); + private ObjectNode generateMethods() { + final ObjectNode methodsNode = mapper.createObjectNode(); + for (final ServiceInfo svc : serviceSpecification.services()) { + for (final MethodInfo m : svc.methods()) { + // To avoid potential name collision, we can use a more unique key like method id. + // For now, using method name as requested. + methodsNode.set(m.name(), generateMethodSchema(m)); + } + } + return methodsNode; + } - // Field is not visited, create a new type definition for it. - fieldNode.put("type", schemaType); + 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); + } - if (field.typeSignature().type() == TypeSignatureType.ENUM) { - fieldNode.set("enum", getEnumType(field.typeSignature())); + 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", "#/$defs/models/" + 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"); + // Update mapping paths + discriminator.mapping().forEach((key, value) -> { + final String newPath = value.replace("#/definitions/", "#/$defs/models/"); + mapping.put(key, newPath); + }); + } } + return schemaNode; + } - final String currentPath; - if (field.name().isEmpty()) { - currentPath = path; - } else { - currentPath = path + '/' + field.name(); - } + final ObjectNode props = mapper.createObjectNode(); + final ArrayNode required = mapper.createArrayNode(); - // 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); - } + // Check if this struct is a subtype and add the discriminator property + final DiscriminatorInfo discriminatorInfo = polymorphismToBase.get(structInfo.name()); + if (discriminatorInfo != null) { + final ObjectNode propertySchema = props.putObject(discriminatorInfo.propertyName()); + propertySchema.put("type", "string"); + } - // 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); + for (final FieldInfo field : structInfo.fields()) { + props.set(field.name(), generateFieldSchema(field)); + if (field.requirement() == FieldRequirement.REQUIRED) { + required.add(field.name()); } } - - // 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); - } else { - parent.set(field.name(), fieldNode); + if (!props.isEmpty()) { + schemaNode.set("properties", props); } - } - - /** - * 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(); - - for (FieldInfo field : fields) { - if (VALID_FIELD_LOCATIONS.contains(field.location())) { - generateField(field, visited, path + "/properties", objectNode, required); + if (!required.isEmpty()) { + // Filter out discriminator property from required list as it's often not in the constructor + final List requiredFields = structInfo.fields().stream() + .filter(f -> f.requirement() == + FieldRequirement.REQUIRED) + .map(FieldInfo::name) + .collect(Collectors.toList()); + + if (discriminatorInfo != null) { + requiredFields.add(discriminatorInfo.propertyName()); + } + if (!requiredFields.isEmpty()) { + final ArrayNode requiredNode = mapper.createArrayNode(); + requiredFields.forEach(requiredNode::add); + schemaNode.set("required", requiredNode); } } - - parent.set("properties", objectNode); - parent.set("required", required); - } - - /** - * 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); + return schemaNode; } - /** - * 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); + 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; } - /** - * 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 ObjectNode generateMethodSchema(MethodInfo methodInfo) { + 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); } - // Iterate over each child field, generate their definitions. - if (fieldStructInfo != null && !fieldStructInfo.fields().isEmpty()) { - generateProperties(fieldStructInfo.fields(), visited, path, fieldNode); - } - } + root.put("additionalProperties", false); + root.put("type", "object"); + + final ObjectNode propertiesNode = mapper.createObjectNode(); + final ArrayNode requiredNode = mapper.createArrayNode(); - /** - * 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()); + 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()); + } + } + } - if (enumInfo != null) { - enumInfo.values().forEach(x -> enumArray.add(x.name())); + if (!propertiesNode.isEmpty()) { + root.set("properties", propertiesNode); + } + if (!requiredNode.isEmpty()) { + root.set("required", requiredNode); } - return enumArray; + return root; } - /** - * 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"; + 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); } - if (typeSignature.type() == TypeSignatureType.ITERABLE) { - switch (typeSignature.name().toLowerCase()) { - case "repeated": - case "list": - case "array": - case "set": - return "array"; - default: - return "object"; - } + if (typeSignature.type() == TypeSignatureType.STRUCT || + typeSignature.type() == TypeSignatureType.ENUM) { + fieldNode.put("$ref", "#/$defs/models/" + typeSignature.name()); + return fieldNode; } - if (typeSignature.type() == TypeSignatureType.MAP) { - return "object"; + if (typeSignature.type() == TypeSignatureType.OPTIONAL || + typeSignature.type() == TypeSignatureType.CONTAINER) { + final TypeSignature inner = + ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + return generateFieldSchema(FieldInfo.of("", inner)); } - 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"; + final String schemaType = getSchemaType(typeSignature); + fieldNode.put("type", schemaType); + + 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 "object"; + return 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..ecb97b967fd 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,239 @@ 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"); + private enum Color { + RED, GREEN, BLUE + } + + // ---- Test helpers ------------------------------------------------------- - // 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 static String modelNodePath(String modelName) { + return "$defs.models." + modelName.replace(".", "\\."); } - private static FieldInfo newFieldInfo() { - return FieldInfo.of("request", TypeSignature.ofStruct(methodName, new Object())); + private static String methodNodePath(String methodName) { + return "$defs.methods." + methodName; } - private static MethodInfo newMethodInfo(FieldInfo... parameters) { - return new MethodInfo( - "test-service", - methodName, + private static String modelRefValue(String modelName) { + return "#/$defs/models/" + modelName; + } + + private static ServiceSpecification specWithSingleRestMethod(List params, + List structs, + List enums) { + final MethodInfo m = new MethodInfo( + "test-service", "test-method", 0, TypeSignature.ofBase("void"), - Arrays.asList(parameters), - true, - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - ImmutableList.of(), - HttpMethod.POST, - methodDescription - ); + ImmutableList.copyOf(params), + ImmutableList.of(), // exampleHeaders + ImmutableList.of(), // endpoints + HttpMethod.POST, DescriptionInfo.empty()); + + return new ServiceSpecification( + ImmutableList.of(new ServiceInfo("test-service", ImmutableList.of(m))), + ImmutableList.copyOf(enums), + ImmutableList.copyOf(structs), + ImmutableList.of()); } - private static ServiceSpecification generateServiceSpecification(StructInfo... structInfos) { + private static ServiceSpecification specWithSingleGrpcMethod( + String requestStructName, + ImmutableList requestStructFields) { + + final FieldInfo requestParam = FieldInfo.builder("request", + TypeSignature.ofStruct( + requestStructName, new Object())) + .requirement(FieldRequirement.REQUIRED) + .build(); + final MethodInfo grpcMethod = new MethodInfo( + "svc.grpc", "test-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<>(); + allStructs.add(new StructInfo(requestStructName, requestStructFields)); + return new ServiceSpecification( - ImmutableList.of( - new ServiceInfo( - "test-service", - ImmutableList.of(newMethodInfo(newFieldInfo())), - DescriptionInfo.empty() - ) - ), - ImmutableList.of(), - Arrays.stream(structInfos).collect(Collectors.toList()), - ImmutableList.of() - ); + ImmutableList.of(new ServiceInfo("svc.grpc", 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 FieldInfo request = FieldInfo.of("request", TypeSignature.ofStruct("S", new Object())); + + final ServiceSpecification spec = specWithSingleRestMethod( + ImmutableList.of(request), ImmutableList.of(s), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec); + final JsonNode sModel = schema.path("$defs").path("models").path("S"); + + assertThatJson(schema).node(methodNodePath("test-method") + ".properties.request.$ref") + .isEqualTo(modelRefValue("S")); + assertThatJson(sModel).node("properties.maybe.type").isEqualTo("integer"); + assertThat(sModel.get("required")).isNull(); + } + + @Test + 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( + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("Holder", new Object()))), + ImmutableList.of(holder, foo), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec); + final JsonNode holderModel = schema.path("$defs").path("models").path("Holder"); + + assertThatJson(holderModel).node("properties.list.items.$ref").isEqualTo(modelRefValue("Foo")); + assertThatJson(holderModel).node("properties.map.additionalProperties.$ref").isEqualTo( + modelRefValue("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( + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("Dto", new Object()))), + ImmutableList.of(dto), + ImmutableList.of(colorInfo)); + + final JsonNode schema = JsonSchemaGenerator.generate(spec); + assertThatJson(schema).node(modelNodePath("Dto") + ".properties.color.$ref") + .isEqualTo(modelRefValue(enumName)); + + final JsonNode colorModel = schema.path("$defs").path("models").path(enumName); + assertThat(colorModel.get("type").asText()).isEqualTo("string"); + assertThat(colorModel.get("enum")).isNotNull(); + assertThat(colorModel.get("enum").size()).isEqualTo(3); + } + + @Test + void grpc_methodSchemaIsRef_andModelContainsFields() { + final ImmutableList reqFields = ImmutableList.of( + FieldInfo.of("a", TypeSignature.ofBase("int")), + FieldInfo.of("b", TypeSignature.ofBase("string"))); + final ServiceSpecification spec = specWithSingleGrpcMethod("AddRequest", reqFields); + + final JsonNode schema = JsonSchemaGenerator.generate(spec); + + // Verify that the method schema now contains a $ref to the model + assertThatJson(schema).node(methodNodePath("test-method") + ".properties.request.$ref") + .isEqualTo(modelRefValue("AddRequest")); + + // Verify that the model itself is defined in models and contains the fields + final JsonNode addRequestModel = schema.path("$defs").path("models").path("AddRequest"); + assertThatJson(addRequestModel).node("properties.a.type").isEqualTo("integer"); + assertThatJson(addRequestModel).node("properties.b.type").isEqualTo("string"); } @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 rest_filtersOutPathQueryHeader_keepsOnlyBodyAndUnspecified() { + final FieldInfo path = FieldInfo.builder("id", TypeSignature.ofBase("int")) + .location(FieldLocation.PATH).build(); + final FieldInfo query = FieldInfo.builder("q", TypeSignature.ofBase("string")) + .location(FieldLocation.QUERY).build(); + final FieldInfo header = FieldInfo.builder("h", TypeSignature.ofBase("string")) + .location(FieldLocation.HEADER).build(); + final FieldInfo body = FieldInfo.builder("payload", + TypeSignature.ofStruct("Payload", new Object())) + .location(FieldLocation.BODY).build(); + + final StructInfo payload = new StructInfo("Payload", + ImmutableList.of(FieldInfo.of("x", + TypeSignature.ofBase("int")))); + + final ServiceSpecification spec = specWithSingleRestMethod( + ImmutableList.of(path, query, header, body), + ImmutableList.of(payload), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec); + final JsonNode methodSchema = schema.path("$defs").path("methods").path("test-method"); + + assertThat(methodSchema.get("properties").size()).isEqualTo(1); + assertThat(methodSchema.get("properties").has("payload")).isTrue(); + assertThatJson(methodSchema).node("properties.payload.$ref").isEqualTo(modelRefValue("Payload")); } @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 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( + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("S", new Object()))), + ImmutableList.of(s), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec); + final JsonNode sModel = schema.path("$defs").path("models").path("S"); + + assertThat(sModel.get("required")).isNotNull(); + assertThatJson(sModel).node("required").isArray().ofLength(1); + assertThatJson(sModel).node("required[0]").isEqualTo("r"); } @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 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( + ImmutableList.of(FieldInfo.of("request", TypeSignature.ofStruct("HasBox", new Object()))), + ImmutableList.of(s), ImmutableList.of()); + + final JsonNode schema = JsonSchemaGenerator.generate(spec); + assertThatJson(schema).node(modelNodePath("HasBox") + ".properties.box.type").isEqualTo("integer"); } } diff --git a/grpc/src/test/java/com/linecorp/armeria/internal/server/grpc/GrpcDocServiceJsonSchemaTest.java b/grpc/src/test/java/com/linecorp/armeria/internal/server/grpc/GrpcDocServiceJsonSchemaTest.java index 54aaed3b113..1c96e1e95a6 100644 --- a/grpc/src/test/java/com/linecorp/armeria/internal/server/grpc/GrpcDocServiceJsonSchemaTest.java +++ b/grpc/src/test/java/com/linecorp/armeria/internal/server/grpc/GrpcDocServiceJsonSchemaTest.java @@ -19,8 +19,6 @@ import static net.javacrumbs.jsonunit.fluent.JsonFluentAssert.assertThatJson; import static org.assertj.core.api.Assertions.assertThat; -import java.util.List; - import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.RegisterExtension; @@ -47,13 +45,19 @@ class GrpcDocServiceJsonSchemaTest { + // Define model names as constants for readability and to prevent typos. + private static final String EXT_MESSAGE_NAME = "armeria.grpc.testing.ExtendedTestMessage"; + private static final String NESTED_MESSAGE_NAME = EXT_MESSAGE_NAME + ".Nested"; + private static final String NESTED_SELF_MESSAGE_NAME = EXT_MESSAGE_NAME + ".NestedSelf"; + private static final String NESTED_NESTED_SELF_MESSAGE_NAME = EXT_MESSAGE_NAME + ".NestedNestedSelf"; + private static final String TEST_MESSAGE_NAME = "armeria.grpc.testing.TestMessage"; + private static final String TEST_ENUM_NAME = "armeria.grpc.testing.TestEnum"; + private static class TestService extends TestServiceImplBase { @Override public void unaryCallWithAllDifferentParameterTypes( ExtendedTestMessage request, - StreamObserver responseObserver - ) { - // Just return the requested object. + StreamObserver responseObserver) { responseObserver.onNext(request); responseObserver.onCompleted(); } @@ -80,17 +84,25 @@ protected void configure(ServerBuilder sb) throws Exception { } }; - private static List getJsonSchemas() throws JsonProcessingException { + private static JsonNode getJsonSchema() throws JsonProcessingException { final WebClient client = WebClient.of(server.httpUri()); final AggregatedHttpResponse res = client.get("/docs/schemas.json").aggregate().join(); assertThat(res.status()).isSameAs(HttpStatus.OK); final ObjectMapper mapper = new ObjectMapper(); + return mapper.readTree(res.contentUtf8()); + } - final JsonNode schemaJson = mapper.readTree(res.contentUtf8()); + // Helper method to create a path for JsonUnit assertions. + // Dots in model names must be escaped for JsonUnit. + private static String modelNodePath(String modelName) { + return "$defs.models." + modelName.replace(".", "\\."); + } - return ImmutableList.copyOf(schemaJson::elements); + // Helper method to create the expected string value for a $ref. + private static String modelRefValue(String modelName) { + return "#/$defs/models/" + modelName; } @Test @@ -98,97 +110,107 @@ void testOk() throws Exception { if (TestUtil.isDocServiceDemoMode()) { Thread.sleep(Long.MAX_VALUE); } - - final List jsonSchemas = getJsonSchemas(); - - assertThat(jsonSchemas).hasSize(1); + final JsonNode jsonSchema = getJsonSchema(); + assertThat(jsonSchema).isNotNull(); + assertThat(jsonSchema.path("$defs").path("methods") + .has("UnaryCallWithAllDifferentParameterTypes")).isTrue(); } @Test void testBaseTypes() throws Exception { - final JsonNode jsonSchema = getJsonSchemas().get(0); - - assertThatJson(jsonSchema).node("properties.bool.type").isEqualTo("boolean"); - assertThatJson(jsonSchema).node("properties.int32.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.int64.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.uint32.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.uint64.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.sint32.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.sint64.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.fixed32.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.fixed64.type").isEqualTo("integer"); - assertThatJson(jsonSchema).node("properties.float.type").isEqualTo("number"); - assertThatJson(jsonSchema).node("properties.double.type").isEqualTo("number"); - assertThatJson(jsonSchema).node("properties.string.type").isEqualTo("string"); - assertThatJson(jsonSchema).node("properties.bytes.type").isEqualTo("string"); + final JsonNode jsonSchema = getJsonSchema(); + final String propertiesPath = modelNodePath(EXT_MESSAGE_NAME) + ".properties"; + + assertThatJson(jsonSchema).node(propertiesPath + ".bool.type").isEqualTo("boolean"); + assertThatJson(jsonSchema).node(propertiesPath + ".int32.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".int64.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".uint32.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".uint64.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".sint32.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".sint64.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".fixed32.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".fixed64.type").isEqualTo("integer"); + assertThatJson(jsonSchema).node(propertiesPath + ".float.type").isEqualTo("number"); + assertThatJson(jsonSchema).node(propertiesPath + ".double.type").isEqualTo("number"); + assertThatJson(jsonSchema).node(propertiesPath + ".string.type").isEqualTo("string"); + assertThatJson(jsonSchema).node(propertiesPath + ".bytes.type").isEqualTo("string"); } @Test void testEnum() throws Exception { - final JsonNode jsonSchema = getJsonSchemas().get(0); + final JsonNode jsonSchema = getJsonSchema(); - assertThatJson(jsonSchema).node("properties.test_enum.type").isEqualTo("string"); - assertThatJson(jsonSchema).node("properties.test_enum.enum").isEqualTo( - ImmutableList.of("ZERO", "ONE", "TWO")); + assertThatJson(jsonSchema).node(modelNodePath(EXT_MESSAGE_NAME) + ".properties.test_enum.$ref") + .isEqualTo(modelRefValue(TEST_ENUM_NAME)); + assertThatJson(jsonSchema).node(modelNodePath(TEST_ENUM_NAME) + ".type").isEqualTo("string"); + assertThatJson(jsonSchema).node(modelNodePath(TEST_ENUM_NAME) + ".enum") + .isEqualTo(ImmutableList.of("ZERO", "ONE", "TWO")); } @Test void testRepeated() throws Exception { - final JsonNode jsonSchema = getJsonSchemas().get(0); - - final JsonNode stringsField = jsonSchema.get("properties").get("complex_other_message").get( - "properties").get("strings"); - assertThatJson(stringsField).node("type").isEqualTo("array"); - assertThatJson(stringsField).node("items.type").isEqualTo("string"); - - final JsonNode nestedsField = jsonSchema.get("properties").get("nesteds"); - assertThatJson(nestedsField).node("type").isEqualTo("array"); - assertThatJson(nestedsField).node("items.$ref").isEqualTo("#/properties/nested"); - - final JsonNode selvesField = jsonSchema.get("properties").get("selves"); - assertThatJson(selvesField).node("type").isEqualTo("array"); - assertThatJson(selvesField).node("items.$ref").isEqualTo("#"); + final JsonNode jsonSchema = getJsonSchema(); + + assertThatJson(jsonSchema).node(modelNodePath(TEST_MESSAGE_NAME) + ".properties.strings.type") + .isEqualTo("array"); + assertThatJson(jsonSchema).node(modelNodePath(TEST_MESSAGE_NAME) + ".properties.strings.items.type") + .isEqualTo("string"); + + assertThatJson(jsonSchema).node(modelNodePath(EXT_MESSAGE_NAME) + ".properties.nesteds.type") + .isEqualTo("array"); + assertThatJson(jsonSchema).node(modelNodePath(EXT_MESSAGE_NAME) + ".properties.nesteds.items.$ref") + .isEqualTo(modelRefValue(NESTED_MESSAGE_NAME)); + + assertThatJson(jsonSchema).node(modelNodePath(EXT_MESSAGE_NAME) + ".properties.selves.type") + .isEqualTo("array"); + assertThatJson(jsonSchema).node(modelNodePath(EXT_MESSAGE_NAME) + ".properties.selves.items.$ref") + .isEqualTo(modelRefValue(EXT_MESSAGE_NAME)); } @Test void testMap() throws Exception { - final JsonNode jsonSchema = getJsonSchemas().get(0); - final JsonNode properties = jsonSchema.get("properties"); - - assertThatJson(properties).node("int_to_string_map.type").isEqualTo("object"); - assertThatJson(properties).node("int_to_string_map.additionalProperties.type").isEqualTo("string"); - - // "string_to_int_map" references to "#/properties/complex_other_message/properties/map" - assertThatJson(properties).node("string_to_int_map.$ref").isEqualTo( - "#/properties/complex_other_message/properties/map"); - final JsonNode stringToIntMap = properties.get("complex_other_message").get("properties") - .get("map"); - assertThatJson(stringToIntMap).node("type").isEqualTo("object"); - assertThatJson(stringToIntMap).node("additionalProperties.type").isEqualTo("integer"); - - assertThatJson(properties).node("message_map.type").isEqualTo("object"); - assertThatJson(properties).node("message_map.additionalProperties.$ref").isEqualTo( - "#/properties/nested"); - - assertThatJson(properties).node("self_map.additionalProperties.$ref").isEqualTo("#"); - assertThatJson(properties).node("self_map.type").isEqualTo("object"); + final JsonNode jsonSchema = getJsonSchema(); + final String propertiesPath = modelNodePath(EXT_MESSAGE_NAME) + ".properties"; + + assertThatJson(jsonSchema).node(propertiesPath + ".int_to_string_map.type").isEqualTo("object"); + assertThatJson(jsonSchema).node(propertiesPath + ".int_to_string_map.additionalProperties.type") + .isEqualTo("string"); + + assertThatJson(jsonSchema).node(propertiesPath + ".string_to_int_map.type").isEqualTo("object"); + assertThatJson(jsonSchema).node(propertiesPath + ".string_to_int_map.additionalProperties.type") + .isEqualTo("integer"); + + assertThatJson(jsonSchema).node(propertiesPath + ".message_map.type").isEqualTo("object"); + assertThatJson(jsonSchema).node(propertiesPath + ".message_map.additionalProperties.$ref") + .isEqualTo(modelRefValue(NESTED_MESSAGE_NAME)); + + assertThatJson(jsonSchema).node(propertiesPath + ".self_map.type").isEqualTo("object"); + assertThatJson(jsonSchema).node(propertiesPath + ".self_map.additionalProperties.$ref") + .isEqualTo(modelRefValue(EXT_MESSAGE_NAME)); } @Test void testMessage() throws Exception { - final JsonNode jsonSchema = getJsonSchemas().get(0); + final JsonNode jsonSchema = getJsonSchema(); - assertThatJson(jsonSchema).node("properties.nested.type").isEqualTo("object"); - assertThatJson(jsonSchema).node("properties.nested.properties.string.type").isEqualTo("string"); + assertThatJson(jsonSchema).node(modelNodePath(EXT_MESSAGE_NAME) + ".properties.nested.$ref") + .isEqualTo(modelRefValue(NESTED_MESSAGE_NAME)); + assertThatJson(jsonSchema).node(modelNodePath(NESTED_MESSAGE_NAME) + ".type").isEqualTo("object"); + assertThatJson(jsonSchema).node(modelNodePath(NESTED_MESSAGE_NAME) + ".properties.string.type") + .isEqualTo("string"); } @Test void testRecursiveMessage() throws Exception { - final JsonNode jsonSchema = getJsonSchemas().get(0); - - assertThatJson(jsonSchema).node("properties.self.$ref").isEqualTo("#"); - assertThatJson(jsonSchema).node("properties.nested_self.properties.self.$ref").isEqualTo("#"); - assertThatJson(jsonSchema).node("properties.nested_nested_self.properties.nested_self.$ref").isEqualTo( - "#/properties/nested_self"); + final JsonNode jsonSchema = getJsonSchema(); + + assertThatJson(jsonSchema).node(modelNodePath(EXT_MESSAGE_NAME) + ".properties.self.$ref") + .isEqualTo(modelRefValue(EXT_MESSAGE_NAME)); + assertThatJson(jsonSchema).node(modelNodePath(NESTED_SELF_MESSAGE_NAME) + ".properties.self.$ref") + .isEqualTo(modelRefValue(EXT_MESSAGE_NAME)); + assertThatJson(jsonSchema).node( + modelNodePath(NESTED_NESTED_SELF_MESSAGE_NAME) + + ".properties.nested_self.$ref") + .isEqualTo(modelRefValue(NESTED_SELF_MESSAGE_NAME)); } } 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 From 0a0e5e2b39db96be9625fa6d08a083013050659e Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Sat, 3 Jan 2026 00:12:11 +0900 Subject: [PATCH 02/11] refactor: Address CodeRabbit feedback --- .../server/docs/DiscriminatorInfo.java | 24 +- .../server/docs/JsonSchemaGenerator.java | 58 ++- licenses/web-licenses.txt | 386 ------------------ 3 files changed, 40 insertions(+), 428 deletions(-) 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 index eeb581256a7..b0efbe88724 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java @@ -29,8 +29,12 @@ /** * 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 + * This corresponds to the {@code discriminator} object in the OpenAPI + * Specification. + * + * @see Inheritance + * and Polymorphism */ @UnstableApi public final class DiscriminatorInfo { @@ -39,8 +43,9 @@ public final class DiscriminatorInfo { 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. + * Creates a new {@link DiscriminatorInfo} with {@code propertyName}, the name + * of the property + * in 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) { @@ -56,7 +61,8 @@ public static DiscriminatorInfo of(String propertyName, Map mapp } /** - * Returns the name of the property that is used to differentiate between schemas. + * Returns the name of the property that is used to differentiate between + * schemas. */ @JsonProperty public String propertyName() { @@ -65,8 +71,10 @@ public String 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"}). + * 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() { @@ -93,6 +101,6 @@ public int hashCode() { @Override public String toString() { return MoreObjects.toStringHelper(this).add("propertyName", propertyName).add("mapping", mapping) - .toString(); + .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 70b7b8d889f..b1d88731760 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 @@ -18,11 +18,11 @@ import static com.google.common.collect.ImmutableMap.toImmutableMap; import static java.util.Objects.requireNonNull; +import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.function.Function; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -52,8 +52,8 @@ final class JsonSchemaGenerator { private JsonSchemaGenerator(ServiceSpecification serviceSpecification) { this.serviceSpecification = requireNonNull(serviceSpecification, "serviceSpecification"); - final ImmutableMap.Builder structsBuilder = - ImmutableMap.builderWithExpectedSize(serviceSpecification.structs().size()); + final ImmutableMap.Builder structsBuilder = ImmutableMap + .builderWithExpectedSize(serviceSpecification.structs().size()); for (final StructInfo structInfo : serviceSpecification.structs()) { structsBuilder.put(structInfo.name(), structInfo); if (structInfo.alias() != null) { @@ -63,7 +63,7 @@ private JsonSchemaGenerator(ServiceSpecification serviceSpecification) { structs = structsBuilder.build(); enums = serviceSpecification.enums().stream() - .collect(toImmutableMap(EnumInfo::name, Function.identity())); + .collect(toImmutableMap(EnumInfo::name, Function.identity())); // Pre-compute mappings from subtype to its base type's DiscriminatorInfo polymorphismToBase = new HashMap<>(); @@ -92,8 +92,7 @@ private static String getSchemaType(TypeSignature typeSignature) { return "object"; case OPTIONAL: case CONTAINER: { - final TypeSignature inner = - ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + final TypeSignature inner = ((ContainerTypeSignature) typeSignature).typeParameters().get(0); return getSchemaType(inner); } default: @@ -168,7 +167,8 @@ private ObjectNode generateMethods() { final ObjectNode methodsNode = mapper.createObjectNode(); for (final ServiceInfo svc : serviceSpecification.services()) { for (final MethodInfo m : svc.methods()) { - // To avoid potential name collision, we can use a more unique key like method id. + // To avoid potential name collision, we can use a more unique key like method + // id. // For now, using method name as requested. methodsNode.set(m.name(), generateMethodSchema(m)); } @@ -211,7 +211,6 @@ private ObjectNode generateStructDefinition(StructInfo structInfo) { } final ObjectNode props = mapper.createObjectNode(); - final ArrayNode required = mapper.createArrayNode(); // Check if this struct is a subtype and add the discriminator property final DiscriminatorInfo discriminatorInfo = polymorphismToBase.get(structInfo.name()); @@ -220,31 +219,25 @@ private ObjectNode generateStructDefinition(StructInfo structInfo) { propertySchema.put("type", "string"); } + final List requiredFields = new ArrayList<>(); for (final FieldInfo field : structInfo.fields()) { props.set(field.name(), generateFieldSchema(field)); if (field.requirement() == FieldRequirement.REQUIRED) { - required.add(field.name()); + requiredFields.add(field.name()); } } if (!props.isEmpty()) { schemaNode.set("properties", props); } - if (!required.isEmpty()) { - // Filter out discriminator property from required list as it's often not in the constructor - final List requiredFields = structInfo.fields().stream() - .filter(f -> f.requirement() == - FieldRequirement.REQUIRED) - .map(FieldInfo::name) - .collect(Collectors.toList()); - - if (discriminatorInfo != null) { - requiredFields.add(discriminatorInfo.propertyName()); - } - if (!requiredFields.isEmpty()) { - final ArrayNode requiredNode = mapper.createArrayNode(); - requiredFields.forEach(requiredNode::add); - schemaNode.set("required", requiredNode); - } + + if (discriminatorInfo != null) { + requiredFields.add(discriminatorInfo.propertyName()); + } + + if (!requiredFields.isEmpty()) { + final ArrayNode requiredNode = mapper.createArrayNode(); + requiredFields.forEach(requiredNode::add); + schemaNode.set("required", requiredNode); } return schemaNode; } @@ -302,15 +295,14 @@ private ObjectNode generateFieldSchema(FieldInfo field) { } if (typeSignature.type() == TypeSignatureType.STRUCT || - typeSignature.type() == TypeSignatureType.ENUM) { + typeSignature.type() == TypeSignatureType.ENUM) { fieldNode.put("$ref", "#/$defs/models/" + typeSignature.name()); return fieldNode; } if (typeSignature.type() == TypeSignatureType.OPTIONAL || - typeSignature.type() == TypeSignatureType.CONTAINER) { - final TypeSignature inner = - ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + typeSignature.type() == TypeSignatureType.CONTAINER) { + final TypeSignature inner = ((ContainerTypeSignature) typeSignature).typeParameters().get(0); return generateFieldSchema(FieldInfo.of("", inner)); } @@ -319,16 +311,14 @@ private ObjectNode generateFieldSchema(FieldInfo field) { switch (typeSignature.type()) { case ITERABLE: { - final TypeSignature itemType = - ((ContainerTypeSignature) typeSignature).typeParameters().get(0); + final TypeSignature itemType = ((ContainerTypeSignature) typeSignature).typeParameters().get(0); fieldNode.set("items", generateFieldSchema(FieldInfo.of("", itemType))); break; } case MAP: { - final TypeSignature valueType = - ((MapTypeSignature) typeSignature).valueTypeSignature(); + final TypeSignature valueType = ((MapTypeSignature) typeSignature).valueTypeSignature(); fieldNode.set("additionalProperties", - generateFieldSchema(FieldInfo.of("", valueType))); + generateFieldSchema(FieldInfo.of("", valueType))); break; } default: diff --git a/licenses/web-licenses.txt b/licenses/web-licenses.txt index 7b2ac30c380..a6e69ba71ac 100644 --- a/licenses/web-licenses.txt +++ b/licenses/web-licenses.txt @@ -485,58 +485,6 @@ OTHER DEALINGS IN THE SOFTWARE. -character-entities-legacy -MIT -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -character-reference-invalid -MIT -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - chevrotain Apache-2.0 @@ -756,32 +704,6 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -comma-separated-tokens -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - core-js MIT Copyright (c) 2014-2025 Denis Pushkarev @@ -1819,58 +1741,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -hast-util-parse-selector -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -hastscript -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - highlight.js BSD-3-Clause BSD 3-Clause License @@ -1997,58 +1867,6 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. inline-style-parser MIT -is-alphabetical -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -is-alphanumerical -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - is-buffer MIT The MIT License (MIT) @@ -2074,58 +1892,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -is-decimal -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -is-hexadecimal -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - is-in-browser MIT @@ -2587,32 +2353,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -parse-entities -MIT -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - path-to-regexp MIT The MIT License (MIT) @@ -8640,31 +8380,6 @@ By: Ika > SOFTWARE. -prismjs -MIT -MIT LICENSE - -Copyright (c) 2012 Lea Verou - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - prop-types MIT MIT License @@ -8690,32 +8405,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -property-information -MIT -(The MIT License) - -Copyright (c) 2015 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - react MIT MIT License @@ -9002,32 +8691,6 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -refractor -MIT -(The MIT License) - -Copyright (c) 2017 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - resolve-pathname MIT MIT License @@ -9103,32 +8766,6 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -space-separated-tokens -MIT -(The MIT License) - -Copyright (c) 2016 Titus Wormer - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - state-local MIT MIT License @@ -9294,26 +8931,3 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -xtend -MIT -The MIT License (MIT) -Copyright (c) 2012-2014 Raynos. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. From 77915f1f185a724b5a120339812f02b219ea9c49 Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Sat, 3 Jan 2026 01:09:06 +0900 Subject: [PATCH 03/11] Address CodeRabbit feedback 2 --- .../JacksonPolymorphismTypeInfoProvider.java | 38 ++++++++++--------- .../server/docs/DiscriminatorInfo.java | 2 +- .../server/docs/JsonSchemaGenerator.java | 9 +---- ...ia.server.docs.DescriptiveTypeInfoProvider | 1 + 4 files changed, 23 insertions(+), 27 deletions(-) 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 index 6d107f6d347..681473c72fc 100644 --- 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 @@ -44,22 +44,26 @@ 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}. + * 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 + * 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. + * @return a new {@link StructInfo} with polymorphism metadata, or {@code null} + * if the + * {@code typeDescriptor} is not a supported polymorphic type. */ @Override @Nullable @@ -74,7 +78,6 @@ public DescriptiveTypeInfo newDescriptiveTypeInfo(Object typeDescriptor) { final JsonSubTypes jsonSubTypes = clazz.getAnnotation(JsonSubTypes.class); if (jsonTypeInfo == null || jsonSubTypes == null) { - return null; } @@ -92,32 +95,31 @@ public DescriptiveTypeInfo newDescriptiveTypeInfo(Object typeDescriptor) { 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); + mapping.put(key, "#/$defs/models/" + schemaName); }); final DiscriminatorInfo discriminator = DiscriminatorInfo.of(propertyName, mapping); - final List oneOf = - Arrays.stream(jsonSubTypes.value()) - .map(subType -> TypeSignature.ofStruct(subType.value())) - .collect(toImmutableList()); + 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()); + .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); + final DescriptionInfo descriptionInfo = classDescription == null ? DescriptionInfo.empty() + : DescriptionInfo.from(classDescription); return new StructInfo(clazz.getName(), null, fields, - descriptionInfo, oneOf, discriminator); + 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 index b0efbe88724..d5dce322a1c 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java @@ -74,7 +74,7 @@ public String propertyName() { * 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"}). + * {@code "#/$defs/models/Cat"}). */ @JsonProperty public Map mapping() { 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 b1d88731760..4f3f5c5d4e7 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 @@ -24,9 +24,6 @@ import java.util.Map; import java.util.function.Function; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; @@ -41,7 +38,6 @@ */ final class JsonSchemaGenerator { - private static final Logger logger = LoggerFactory.getLogger(JsonSchemaGenerator.class); private static final ObjectMapper mapper = JacksonUtil.newDefaultObjectMapper(); private final ServiceSpecification serviceSpecification; @@ -201,10 +197,7 @@ private ObjectNode generateStructDefinition(StructInfo structInfo) { if (!discriminator.mapping().isEmpty()) { final ObjectNode mapping = disc.putObject("mapping"); // Update mapping paths - discriminator.mapping().forEach((key, value) -> { - final String newPath = value.replace("#/definitions/", "#/$defs/models/"); - mapping.put(key, newPath); - }); + discriminator.mapping().forEach(mapping::put); } } return schemaNode; 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 index 65189f0d421..5dffaceb84d 100644 --- 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 @@ -1 +1,2 @@ com.linecorp.armeria.internal.server.docs.JacksonPolymorphismTypeInfoProvider + From 4f07df3a21dad1e00b524c5da15fc6d5ca2229a8 Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Sat, 3 Jan 2026 01:38:11 +0900 Subject: [PATCH 04/11] Address CodeRabbit feedback 3 --- .../linecorp/armeria/server/docs/JsonSchemaGenerator.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) 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 4f3f5c5d4e7..fe6233cdb08 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 @@ -133,6 +133,9 @@ private static String getSchemaType(TypeSignature typeSignature) { private ObjectNode doGenerate() { final ObjectNode root = mapper.createObjectNode(); + if (serviceSpecification.services().isEmpty()) { + throw new IllegalArgumentException("serviceSpecification must contain at least one service."); + } final ServiceInfo representativeService = serviceSpecification.services().iterator().next(); // Use a representative service name for the title and ID for now. final String serviceName = representativeService.name(); @@ -163,9 +166,8 @@ private ObjectNode generateMethods() { final ObjectNode methodsNode = mapper.createObjectNode(); for (final ServiceInfo svc : serviceSpecification.services()) { for (final MethodInfo m : svc.methods()) { - // To avoid potential name collision, we can use a more unique key like method - // id. - // For now, using method name as requested. + // To avoid potential name collision, we can use a more unique key like + // method id. methodsNode.set(m.name(), generateMethodSchema(m)); } } From 7d0b91b79342bdb4e91703ff613e7a8ef9bbca89 Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Sat, 3 Jan 2026 02:08:45 +0900 Subject: [PATCH 05/11] Address CodeRabbit feedback 4 - Fix container description loss and safely handle aliases --- .../server/docs/JsonSchemaGenerator.java | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) 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 fe6233cdb08..d30bc22bd64 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 @@ -62,12 +62,24 @@ private JsonSchemaGenerator(ServiceSpecification serviceSpecification) { .collect(toImmutableMap(EnumInfo::name, Function.identity())); // Pre-compute mappings from subtype to its base type's DiscriminatorInfo + final Map nameToAlias = new HashMap<>(); + for (final StructInfo struct : serviceSpecification.structs()) { + if (struct.alias() != null) { + nameToAlias.put(struct.name(), struct.alias()); + } + } + polymorphismToBase = new HashMap<>(); - for (final StructInfo structInfo : serviceSpecification.structs()) { - if (structInfo.discriminator() != null && !structInfo.oneOf().isEmpty()) { - for (TypeSignature subType : structInfo.oneOf()) { - polymorphismToBase.put(subType.name(), structInfo.discriminator()); - } + for (final StructInfo struct : serviceSpecification.structs()) { + final DiscriminatorInfo discriminator = struct.discriminator(); + if (discriminator != null) { + struct.oneOf().forEach(sub -> { + polymorphismToBase.putIfAbsent(sub.name(), discriminator); + final String alias = nameToAlias.get(sub.name()); + if (alias != null) { + polymorphismToBase.putIfAbsent(alias, discriminator); + } + }); } } } @@ -298,7 +310,11 @@ private ObjectNode generateFieldSchema(FieldInfo field) { if (typeSignature.type() == TypeSignatureType.OPTIONAL || typeSignature.type() == TypeSignatureType.CONTAINER) { final TypeSignature inner = ((ContainerTypeSignature) typeSignature).typeParameters().get(0); - return generateFieldSchema(FieldInfo.of("", inner)); + final ObjectNode innerNode = generateFieldSchema(FieldInfo.of("", inner)); + if (!docString.isEmpty()) { + innerNode.put("description", docString); + } + return innerNode; } final String schemaType = getSchemaType(typeSignature); From f3984786850ec3a5bb84c15fcac53b9db434be14 Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Sat, 3 Jan 2026 02:26:46 +0900 Subject: [PATCH 06/11] Address CodeRabbit feedback 5 - Use locale - independent lowercase for schema type mapping --- .../com/linecorp/armeria/server/docs/JsonSchemaGenerator.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 d30bc22bd64..0a930319ab0 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 @@ -21,6 +21,7 @@ import java.util.ArrayList; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.Function; @@ -107,7 +108,7 @@ private static String getSchemaType(TypeSignature typeSignature) { break; } - switch (typeSignature.name().toLowerCase()) { + switch (typeSignature.name().toLowerCase(Locale.ROOT)) { case "boolean": case "bool": return "boolean"; From 8da30c92307ba8ddcf36347609ea9f7232b244b8 Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Sat, 3 Jan 2026 02:33:43 +0900 Subject: [PATCH 07/11] Address CodeRabbit feedback 6 - add Enum description --- .../com/linecorp/armeria/server/docs/JsonSchemaGenerator.java | 4 ++++ 1 file changed, 4 insertions(+) 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 0a930319ab0..ccbae0772ef 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 @@ -253,6 +253,10 @@ private ObjectNode generateStructDefinition(StructInfo structInfo) { private static ObjectNode generateEnumDefinition(EnumInfo enumInfo) { final ObjectNode schemaNode = mapper.createObjectNode(); schemaNode.put("type", "string"); + final String docString = enumInfo.descriptionInfo().docString(); + if (!docString.isEmpty()) { + schemaNode.put("description", docString); + } final ArrayNode enumValues = mapper.createArrayNode(); enumInfo.values().forEach(value -> enumValues.add(value.name())); schemaNode.set("enum", enumValues); From 9923514c0f5d641d88d6bba53e4844413935aefe Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Sat, 3 Jan 2026 03:00:25 +0900 Subject: [PATCH 08/11] fix: Restore web-licenses.txt to unintended change --- licenses/web-licenses.txt | 386 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 386 insertions(+) diff --git a/licenses/web-licenses.txt b/licenses/web-licenses.txt index a6e69ba71ac..7b2ac30c380 100644 --- a/licenses/web-licenses.txt +++ b/licenses/web-licenses.txt @@ -485,6 +485,58 @@ OTHER DEALINGS IN THE SOFTWARE. +character-entities-legacy +MIT +(The MIT License) + +Copyright (c) 2015 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +character-reference-invalid +MIT +(The MIT License) + +Copyright (c) 2015 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + chevrotain Apache-2.0 @@ -704,6 +756,32 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +comma-separated-tokens +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + core-js MIT Copyright (c) 2014-2025 Denis Pushkarev @@ -1741,6 +1819,58 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +hast-util-parse-selector +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +hastscript +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + highlight.js BSD-3-Clause BSD 3-Clause License @@ -1867,6 +1997,58 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. inline-style-parser MIT +is-alphabetical +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +is-alphanumerical +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + is-buffer MIT The MIT License (MIT) @@ -1892,6 +2074,58 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +is-decimal +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +is-hexadecimal +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + is-in-browser MIT @@ -2353,6 +2587,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +parse-entities +MIT +(The MIT License) + +Copyright (c) 2015 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + path-to-regexp MIT The MIT License (MIT) @@ -8380,6 +8640,31 @@ By: Ika > SOFTWARE. +prismjs +MIT +MIT LICENSE + +Copyright (c) 2012 Lea Verou + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + prop-types MIT MIT License @@ -8405,6 +8690,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +property-information +MIT +(The MIT License) + +Copyright (c) 2015 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + react MIT MIT License @@ -8691,6 +9002,32 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +refractor +MIT +(The MIT License) + +Copyright (c) 2017 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + resolve-pathname MIT MIT License @@ -8766,6 +9103,32 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +space-separated-tokens +MIT +(The MIT License) + +Copyright (c) 2016 Titus Wormer + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + state-local MIT MIT License @@ -8931,3 +9294,26 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +xtend +MIT +The MIT License (MIT) +Copyright (c) 2012-2014 Raynos. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. From c0de833b0da67d2031d2806bf47b54f9b6455665 Mon Sep 17 00:00:00 2001 From: minwoox Date: Tue, 10 Feb 2026 10:25:18 +0900 Subject: [PATCH 09/11] Update Ui to use new JSON schema --- .../server/docs/ServiceSpecification.java | 14 +++++++ .../server/docs/JsonSchemaGeneratorTest.java | 2 - docs-client/src/containers/App/index.tsx | 8 ++-- .../src/containers/MethodPage/DebugInputs.tsx | 2 +- .../src/containers/MethodPage/DebugPage.tsx | 2 +- .../src/containers/MethodPage/RequestBody.tsx | 37 +++++++++++++++++-- .../src/containers/MethodPage/index.tsx | 2 +- 7 files changed, 55 insertions(+), 12 deletions(-) diff --git a/core/src/main/java/com/linecorp/armeria/server/docs/ServiceSpecification.java b/core/src/main/java/com/linecorp/armeria/server/docs/ServiceSpecification.java index b1dbc618402..9cda529650f 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/ServiceSpecification.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/ServiceSpecification.java @@ -29,6 +29,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.MoreObjects; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableMap; import com.google.common.collect.Iterables; @@ -302,4 +303,17 @@ public Map docServiceExtraInfo() { public void setDocServiceExtraInfo(Map docServiceExtraInfo) { this.docServiceExtraInfo = requireNonNull(docServiceExtraInfo,"docServiceExtraInfo"); } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("services", services) + .add("enums", enums) + .add("structs", structs) + .add("exceptions", exceptions) + .add("exampleHeaders", exampleHeaders) + .add("docStrings", docStrings.keySet()) + .add("docServiceRoute", docServiceRoute) + .toString(); + } } 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 ac32c1c3077..7f3738993c9 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 @@ -365,8 +365,6 @@ void testDocStringsForParametersWhenNotUsingParameterAsRoot() { assertThatJson(generated).node("$defs.methods." + methodName + ".properties.queryParam").isAbsent(); assertThatJson(generated).node("$defs.methods." + methodName + ".properties.headerParam").isAbsent(); - System.err.println(generated.toPrettyString()); - // Body parameter should have its description from docStrings assertThatJson(generated).node("$defs.methods." + methodName + ".properties.bodyParam.type") .isEqualTo("boolean"); diff --git a/docs-client/src/containers/App/index.tsx b/docs-client/src/containers/App/index.tsx index b59ecdb9e7f..4fa3e52a438 100644 --- a/docs-client/src/containers/App/index.tsx +++ b/docs-client/src/containers/App/index.tsx @@ -408,7 +408,7 @@ const AppDrawer: React.FunctionComponent = ({ interface RouterServicesProps { versions: Versions | undefined; specification: Specification; - jsonSchemas: any[]; + jsonSchemas: any; } const RouterServices: React.FunctionComponent = ({ @@ -467,7 +467,7 @@ const dummySpecification = new Specification({ const App: React.FunctionComponent = (props) => { const [mobileDrawerOpen, setMobileDrawerOpen] = useState(false); - const [jsonSchemas, setJsonSchemas] = useState([]); + const [jsonSchemas, setJsonSchemas] = useState({}); const [specification, setSpecification] = useState(dummySpecification); const [specLoadingStatus, setSpecLoadingStatus] = useState( @@ -512,13 +512,13 @@ const App: React.FunctionComponent = (props) => { } try { - const schemaData: any[] = await fetch(`schemas.json`).then((r) => + const schemaData: any = await fetch(`schemas.json`).then((r) => r.json(), ); setJsonSchemas(schemaData); } catch (e) { // Ignore the error and continue - setJsonSchemas([]); + setJsonSchemas({}); } setSpecLoadingStatus(SpecLoadingStatus.SUCCESS); diff --git a/docs-client/src/containers/MethodPage/DebugInputs.tsx b/docs-client/src/containers/MethodPage/DebugInputs.tsx index 6b08c5fe9a5..965c06a3488 100644 --- a/docs-client/src/containers/MethodPage/DebugInputs.tsx +++ b/docs-client/src/containers/MethodPage/DebugInputs.tsx @@ -32,7 +32,7 @@ interface OwnProps { method: Method; useRequestBody: boolean; requestBody: string; - jsonSchemas: any[]; + jsonSchemas: any; setRequestBody: Dispatch>; additionalPath: string; setAdditionalPath: Dispatch>; diff --git a/docs-client/src/containers/MethodPage/DebugPage.tsx b/docs-client/src/containers/MethodPage/DebugPage.tsx index cd6442672b3..22998a602d3 100644 --- a/docs-client/src/containers/MethodPage/DebugPage.tsx +++ b/docs-client/src/containers/MethodPage/DebugPage.tsx @@ -83,7 +83,7 @@ interface OwnProps { useRequestBody: boolean; debugFormIsOpen: boolean; setDebugFormIsOpen: Dispatch>; - jsonSchemas: any[]; + jsonSchemas: any; docServiceRoute?: Route; } diff --git a/docs-client/src/containers/MethodPage/RequestBody.tsx b/docs-client/src/containers/MethodPage/RequestBody.tsx index 9d52194f7e5..4b357347073 100644 --- a/docs-client/src/containers/MethodPage/RequestBody.tsx +++ b/docs-client/src/containers/MethodPage/RequestBody.tsx @@ -39,7 +39,7 @@ interface Props { onDebugFormChange: (value: string) => void; method: Method; serviceType: ServiceType; - jsonSchemas: any[]; + jsonSchemas: any; } const RequestBody: React.FunctionComponent = ({ @@ -59,13 +59,44 @@ const RequestBody: React.FunctionComponent = ({ serviceType === ServiceType.GRPC || serviceType === ServiceType.THRIFT; useMemo(() => { if (supportsJsonSchema) { - const schema = jsonSchemas.find((s: any) => s.$id === method.id) || {}; + // Find the method schema from the JSON Schema structure. + // Structure: { $defs: { methods: { "method-name": { $id: "service/method/HTTP", ... } } } } + let methodSchema: any = {}; + const methods = jsonSchemas?.$defs?.methods; + const models = jsonSchemas?.$defs?.models; + if (methods && models) { + const found: any = Object.values(methods).find( + (m: any) => m.$id === method.id, + ); + if (found) { + // Extract the request type schema directly for autocomplete + // The method schema has { properties: { request: { $ref: "..." } } } + // We want to use the referenced model as the root schema + const requestProp = found.properties?.request; + if (requestProp?.$ref) { + // Extract model name from $ref (e.g., "#/$defs/models/X" -> "X") + const modelName = requestProp.$ref.replace('#/$defs/models/', ''); + const requestModel = models[modelName]; + if (requestModel) { + methodSchema = { + ...requestModel, + $defs: { models }, + }; + } + } else { + methodSchema = { + ...found, + $defs: { models }, + }; + } + } + } monacoEditor?.languages.json.jsonDefaults.setDiagnosticsOptions({ validate: true, schemas: [ { - schema, + schema: methodSchema, fileMatch: ['*'], uri: '*', }, diff --git a/docs-client/src/containers/MethodPage/index.tsx b/docs-client/src/containers/MethodPage/index.tsx index 466e41b9245..bc8f3cb1c91 100644 --- a/docs-client/src/containers/MethodPage/index.tsx +++ b/docs-client/src/containers/MethodPage/index.tsx @@ -45,7 +45,7 @@ import ReturnType from './ReturnType'; interface OwnProps { specification: Specification; - jsonSchemas: any[]; + jsonSchemas: any; } function getExampleHeaders( From c2d7d805ce3810a61a8bb899366a9caf32a1d158 Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Thu, 12 Feb 2026 16:24:39 +0900 Subject: [PATCH 10/11] refactor: address review comments for JsonSchema generation - Apply LY OSS formatting and improve readability for JacksonPolymorphismTypeInfoProvider. - Update DiscriminatorInfo Javadoc to use FQCN for clarity. - Remove unused fields and imports in JsonSchemaGenerator. - Fix Checkstyle errors regarding operator wrapping in JsonSchemaGenerator. --- .../JacksonPolymorphismTypeInfoProvider.java | 18 +++---- .../server/docs/DiscriminatorInfo.java | 10 ++-- .../server/docs/JsonSchemaGenerator.java | 50 ++++++------------- 3 files changed, 30 insertions(+), 48 deletions(-) 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 index 681473c72fc..43de266b8b2 100644 --- 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 @@ -101,25 +101,25 @@ public DescriptiveTypeInfo newDescriptiveTypeInfo(Object typeDescriptor) { final DiscriminatorInfo discriminator = DiscriminatorInfo.of(propertyName, mapping); final List oneOf = Arrays.stream(jsonSubTypes.value()) - .map(subType -> TypeSignature.ofStruct(subType.value())) - .collect(toImmutableList()); + .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()); + .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); + : DescriptionInfo.from( + classDescription); - return new StructInfo(clazz.getName(), null, fields, - descriptionInfo, oneOf, discriminator); + 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 index d5dce322a1c..47d6b2e6e32 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/DiscriminatorInfo.java @@ -39,9 +39,6 @@ @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 @@ -52,6 +49,9 @@ public static DiscriminatorInfo of(String propertyName, Map mapp return new DiscriminatorInfo(propertyName, mapping); } + private final String propertyName; + private final Map mapping; + /** * Creates a new instance. */ @@ -74,7 +74,7 @@ public String propertyName() { * 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 "#/$defs/models/Cat"}). + * {@code "#/$defs/models/com.linecorp.armeria.Cat"}). */ @JsonProperty public Map mapping() { @@ -101,6 +101,6 @@ public int hashCode() { @Override public String toString() { return MoreObjects.toStringHelper(this).add("propertyName", propertyName).add("mapping", mapping) - .toString(); + .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 0db7467f0c8..9bc730975f8 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 @@ -15,7 +15,6 @@ */ package com.linecorp.armeria.server.docs; -import static com.google.common.collect.ImmutableMap.toImmutableMap; import static java.util.Objects.requireNonNull; import java.util.ArrayList; @@ -23,12 +22,10 @@ import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.function.Function; 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.ImmutableMap; import com.linecorp.armeria.internal.common.JacksonUtil; @@ -42,8 +39,6 @@ final class JsonSchemaGenerator { private static final ObjectMapper mapper = JacksonUtil.newDefaultObjectMapper(); private final ServiceSpecification serviceSpecification; - private final Map structs; - private final Map enums; private final Map polymorphismToBase; private final Map docStrings; @@ -51,19 +46,6 @@ private JsonSchemaGenerator(ServiceSpecification serviceSpecification) { this.serviceSpecification = requireNonNull(serviceSpecification, "serviceSpecification"); docStrings = serviceSpecification.docStrings(); - final ImmutableMap.Builder structsBuilder = ImmutableMap - .builderWithExpectedSize(serviceSpecification.structs().size()); - for (final StructInfo structInfo : serviceSpecification.structs()) { - structsBuilder.put(structInfo.name(), structInfo); - if (structInfo.alias() != null) { - structsBuilder.put(structInfo.alias(), structInfo); - } - } - structs = structsBuilder.build(); - - enums = serviceSpecification.enums().stream() - .collect(toImmutableMap(EnumInfo::name, Function.identity())); - // Pre-compute mappings from subtype to its base type's DiscriminatorInfo final Map nameToAlias = new HashMap<>(); for (final StructInfo struct : serviceSpecification.structs()) { @@ -146,6 +128,19 @@ private static String getSchemaType(TypeSignature typeSignature) { } } + private static ObjectNode generateEnumDefinition(EnumInfo enumInfo) { + final ObjectNode schemaNode = mapper.createObjectNode(); + schemaNode.put("type", "string"); + final String docString = enumInfo.descriptionInfo().docString(); + if (!docString.isEmpty()) { + schemaNode.put("description", docString); + } + final ArrayNode enumValues = mapper.createArrayNode(); + enumInfo.values().forEach(value -> enumValues.add(value.name())); + schemaNode.set("enum", enumValues); + return schemaNode; + } + private ObjectNode doGenerate() { final ObjectNode root = mapper.createObjectNode(); if (serviceSpecification.services().isEmpty()) { @@ -252,19 +247,6 @@ private ObjectNode generateStructDefinition(StructInfo structInfo) { return schemaNode; } - private static ObjectNode generateEnumDefinition(EnumInfo enumInfo) { - final ObjectNode schemaNode = mapper.createObjectNode(); - schemaNode.put("type", "string"); - final String docString = enumInfo.descriptionInfo().docString(); - if (!docString.isEmpty()) { - schemaNode.put("description", docString); - } - final ArrayNode enumValues = mapper.createArrayNode(); - enumInfo.values().forEach(value -> enumValues.add(value.name())); - schemaNode.set("enum", enumValues); - return schemaNode; - } - private ObjectNode generateMethodSchema(String serviceName, MethodInfo methodInfo) { final ObjectNode root = mapper.createObjectNode(); root.put("$id", methodInfo.id()); @@ -322,13 +304,13 @@ private ObjectNode generateFieldSchema(FieldInfo field) { } if (typeSignature.type() == TypeSignatureType.STRUCT || - typeSignature.type() == TypeSignatureType.ENUM) { + typeSignature.type() == TypeSignatureType.ENUM) { fieldNode.put("$ref", "#/$defs/models/" + typeSignature.name()); return fieldNode; } if (typeSignature.type() == TypeSignatureType.OPTIONAL || - typeSignature.type() == TypeSignatureType.CONTAINER) { + typeSignature.type() == TypeSignatureType.CONTAINER) { final TypeSignature inner = ((ContainerTypeSignature) typeSignature).typeParameters().get(0); final ObjectNode innerNode = generateFieldSchema(FieldInfo.of("", inner)); if (!docString.isEmpty()) { @@ -349,7 +331,7 @@ private ObjectNode generateFieldSchema(FieldInfo field) { case MAP: { final TypeSignature valueType = ((MapTypeSignature) typeSignature).valueTypeSignature(); fieldNode.set("additionalProperties", - generateFieldSchema(FieldInfo.of("", valueType))); + generateFieldSchema(FieldInfo.of("", valueType))); break; } default: From 0b5e0875c21614d26458e48c74b148fcc0713bce Mon Sep 17 00:00:00 2001 From: YoungHoney Date: Mon, 23 Feb 2026 22:44:59 +0900 Subject: [PATCH 11/11] docs: add Jackson polymorphism limitation note to DescriptiveTypeInfoProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Introduce a “Limitations” section in the class‑level Javadoc that documents the only supported JsonTypeInfo.As inclusion modes (PROPERTY and EXISTING_PROPERTY) when used with JacksonPolymorphismTypeInfoProvider. - Refactor surrounding Javadoc wording for clearer, more consistent documentation. --- .../JacksonPolymorphismTypeInfoProvider.java | 18 ++++++++++++++++-- .../docs/DescriptiveTypeInfoProvider.java | 13 +++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) 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 index 43de266b8b2..4817d82a148 100644 --- 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 @@ -48,6 +48,11 @@ * {@link DescriptiveTypeInfo} for a polymorphic * type by inspecting Jackson annotations such as {@link JsonTypeInfo} and * {@link JsonSubTypes}. + * + *

Note that this provider currently only supports + * {@link JsonTypeInfo.As#PROPERTY} and + * {@link JsonTypeInfo.As#EXISTING_PROPERTY}. Other inclusion types are not + * supported. */ public final class JacksonPolymorphismTypeInfoProvider implements DescriptiveTypeInfoProvider { @@ -81,9 +86,18 @@ public DescriptiveTypeInfo newDescriptiveTypeInfo(Object typeDescriptor) { return null; } - final String propertyName = jsonTypeInfo.property(); + String propertyName = jsonTypeInfo.property(); if (propertyName.isEmpty()) { - return null; + final JsonTypeInfo.Id use = jsonTypeInfo.use(); + if (use == JsonTypeInfo.Id.CLASS) { + propertyName = "@class"; + } else if (use == JsonTypeInfo.Id.MINIMAL_CLASS) { + propertyName = "@c"; + } else if (use == JsonTypeInfo.Id.NAME || use == JsonTypeInfo.Id.SIMPLE_NAME) { + propertyName = "@type"; + } else { + return null; + } } if (jsonSubTypes.value().length == 0) { diff --git a/core/src/main/java/com/linecorp/armeria/server/docs/DescriptiveTypeInfoProvider.java b/core/src/main/java/com/linecorp/armeria/server/docs/DescriptiveTypeInfoProvider.java index 9997e9d55b4..8e1390999ac 100644 --- a/core/src/main/java/com/linecorp/armeria/server/docs/DescriptiveTypeInfoProvider.java +++ b/core/src/main/java/com/linecorp/armeria/server/docs/DescriptiveTypeInfoProvider.java @@ -26,6 +26,19 @@ * Creates a new {@link DescriptiveTypeInfo} loaded dynamically via Java SPI (Service Provider Interface). * The loaded {@link DescriptiveTypeInfoProvider}s are used in the {@link DocServicePlugin}s to extract * a {@link DescriptiveTypeInfo} from the given {@code typeDescriptor}. + * + *

Limitations

+ * + *

When used with {@code JacksonPolymorphismTypeInfoProvider}, only the + * following + * {@link com.fasterxml.jackson.annotation.JsonTypeInfo.As} inclusion modes are + * supported: + * {@link com.fasterxml.jackson.annotation.JsonTypeInfo.As#PROPERTY} and + * {@link com.fasterxml.jackson.annotation.JsonTypeInfo.As#EXISTING_PROPERTY}. + * Other modes such as {@code WRAPPER_OBJECT} or {@code WRAPPER_ARRAY} are not + * currently + * supported and may result in incomplete documentation. + *

*/ @UnstableApi @FunctionalInterface