diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfiguration.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfiguration.java index 031e7560..cc662be5 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfiguration.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfiguration.java @@ -2,11 +2,11 @@ import io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties.OpenApiGenericsProperties; import io.github.blueprintplatform.openapi.generics.server.core.introspection.*; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.DefaultSupportedContainerTypesResolver; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerTypesResolver; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver.ConfiguredContainerTypesResolver; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver.DefaultSupportedContainerTypesResolver; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver.SupportedContainerTypesResolver; import io.github.blueprintplatform.openapi.generics.server.core.pipeline.OpenApiPipelineOrchestrator; import io.github.blueprintplatform.openapi.generics.server.core.schema.ContractSchemaExclusionApplier; -import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaEnricher; import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaProcessor; import io.github.blueprintplatform.openapi.generics.server.core.validation.OpenApiContractGuard; import io.github.blueprintplatform.openapi.generics.server.mvc.MvcResponseTypeDiscoveryStrategy; @@ -20,7 +20,13 @@ import org.springframework.context.annotation.Bean; import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping; -@AutoConfiguration +/** + * Main auto-configuration for OpenAPI Generics server support. + * + *

Registers response type discovery, generic response introspection, contract guarding, and the + * OpenAPI customization pipeline. + */ +@AutoConfiguration(after = OpenApiGenericsSchemaAutoConfiguration.class) @ConditionalOnClass(OpenApiCustomizer.class) @ConditionalOnWebApplication @EnableConfigurationProperties(OpenApiGenericsProperties.class) @@ -40,11 +46,19 @@ public SupportedContainerTypesResolver supportedContainerTypesResolver() { return new DefaultSupportedContainerTypesResolver(); } + @Bean + @ConditionalOnMissingBean + public ConfiguredContainerTypesResolver configuredContainerTypesResolver() { + return new ConfiguredContainerTypesResolver(); + } + @Bean @ConditionalOnMissingBean public ResponseIntrospectionPolicyResolver responseIntrospectionPolicyResolver( - SupportedContainerTypesResolver supportedContainerTypesResolver) { - return new ResponseIntrospectionPolicyResolver(supportedContainerTypesResolver); + SupportedContainerTypesResolver supportedContainerTypesResolver, + ConfiguredContainerTypesResolver configuredContainerTypesResolver) { + return new ResponseIntrospectionPolicyResolver( + supportedContainerTypesResolver, configuredContainerTypesResolver); } @Bean @@ -66,12 +80,6 @@ public ContractSchemaExclusionApplier schemaGenerationControlMarker() { return new ContractSchemaExclusionApplier(); } - @Bean - @ConditionalOnMissingBean - public WrapperSchemaProcessor wrapperSchemaProcessor(WrapperSchemaEnricher enricher) { - return new WrapperSchemaProcessor(enricher); - } - @Bean @ConditionalOnMissingBean public OpenApiContractGuard openApiContractGuard() { diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsMissingDependencyAutoConfiguration.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsMissingDependencyAutoConfiguration.java index a424cff4..36a5b290 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsMissingDependencyAutoConfiguration.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsMissingDependencyAutoConfiguration.java @@ -48,20 +48,16 @@ public class OpenApiGenericsMissingDependencyAutoConfiguration { This starter activates only when Springdoc is present. - To enable OpenAPI customization, add one of the following: - - - org.springdoc:springdoc-openapi-starter-webmvc-ui - - org.springdoc:springdoc-openapi-starter-webflux-ui + To enable OpenAPI customization, add dependency on Springdoc OpenAPI in your build configuration: + - For Maven: + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ------------------------------------------------------------------------- """; - /** - * Logs a warning message indicating that Springdoc is missing. - * - *

This method is invoked after bean initialization and provides a clear diagnostic message to - * the user. - */ @PostConstruct public void logWarning() { log.warn(MESSAGE); diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsSchemaAutoConfiguration.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsSchemaAutoConfiguration.java index d09f2f38..3142aef2 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsSchemaAutoConfiguration.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsSchemaAutoConfiguration.java @@ -1,12 +1,11 @@ package io.github.blueprintplatform.openapi.generics.server.autoconfigure; -import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaEnricher; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.ContentArrayItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.DirectArrayItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.ComponentContainerSchemaResolver; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.WrapperPayloadArraySchemaResolver; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.*; -import java.util.List; +import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaProcessor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.ContainerSchemaMetadataResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.WrapperSchemaEnricher; +import io.github.blueprintplatform.openapi.generics.server.core.schema.extraction.ArrayItemReferenceExtractor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.ComponentContainerSchemaResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.WrapperPayloadArraySchemaResolver; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; @@ -14,21 +13,21 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.context.annotation.Bean; +/** + * Schema auto-configuration for OpenAPI Generics server support. + * + *

Registers schema resolution and enrichment components used to process projected generic + * wrapper schemas. + */ @AutoConfiguration(before = OpenApiGenericsAutoConfiguration.class) @ConditionalOnClass(OpenApiCustomizer.class) @ConditionalOnWebApplication public class OpenApiGenericsSchemaAutoConfiguration { @Bean - @ConditionalOnMissingBean(ContentArrayItemExtractor.class) - public ContentArrayItemExtractor contentArrayItemExtractor() { - return new ContentArrayItemExtractor(); - } - - @Bean - @ConditionalOnMissingBean(DirectArrayItemExtractor.class) - public DirectArrayItemExtractor directArrayItemExtractor() { - return new DirectArrayItemExtractor(); + @ConditionalOnMissingBean(ArrayItemReferenceExtractor.class) + public ArrayItemReferenceExtractor arrayItemReferenceExtractor() { + return new ArrayItemReferenceExtractor(); } @Bean @@ -44,41 +43,27 @@ public WrapperPayloadArraySchemaResolver wrapperPayloadArraySchemaResolver() { } @Bean - @ConditionalOnMissingBean(PageContainerSchemaStrategy.class) - public PageContainerSchemaStrategy pageContainerSchemaStrategy( - ComponentContainerSchemaResolver componentContainerSchemaResolver, - ContentArrayItemExtractor contentArrayItemExtractor) { - return new PageContainerSchemaStrategy( - componentContainerSchemaResolver, contentArrayItemExtractor); - } - - @Bean - @ConditionalOnMissingBean(ListContainerSchemaStrategy.class) - public ListContainerSchemaStrategy listContainerSchemaStrategy( - WrapperPayloadArraySchemaResolver wrapperPayloadArraySchemaResolver, - DirectArrayItemExtractor directArrayItemExtractor) { - return new ListContainerSchemaStrategy( - wrapperPayloadArraySchemaResolver, directArrayItemExtractor); - } - - @Bean - @ConditionalOnMissingBean(SetContainerSchemaStrategy.class) - public SetContainerSchemaStrategy setContainerSchemaStrategy( + @ConditionalOnMissingBean + public ContainerSchemaMetadataResolver containerSchemaMetadataResolver( WrapperPayloadArraySchemaResolver wrapperPayloadArraySchemaResolver, - DirectArrayItemExtractor directArrayItemExtractor) { - return new SetContainerSchemaStrategy( - wrapperPayloadArraySchemaResolver, directArrayItemExtractor); + ComponentContainerSchemaResolver componentContainerSchemaResolver, + ArrayItemReferenceExtractor arrayItemReferenceExtractor) { + return new ContainerSchemaMetadataResolver( + wrapperPayloadArraySchemaResolver, + componentContainerSchemaResolver, + arrayItemReferenceExtractor); } @Bean @ConditionalOnMissingBean - public ContainerSchemaRegistry containerSchemaRegistry(List strategies) { - return new ContainerSchemaRegistry(strategies); + public WrapperSchemaEnricher wrapperSchemaEnricher( + ContainerSchemaMetadataResolver containerSchemaMetadataResolver) { + return new WrapperSchemaEnricher(containerSchemaMetadataResolver); } @Bean @ConditionalOnMissingBean - public WrapperSchemaEnricher wrapperSchemaEnricher(ContainerSchemaRegistry registry) { - return new WrapperSchemaEnricher(registry); + public WrapperSchemaProcessor wrapperSchemaProcessor(WrapperSchemaEnricher enricher) { + return new WrapperSchemaProcessor(enricher); } } diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/properties/ContainerProperties.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/properties/ContainerProperties.java new file mode 100644 index 00000000..11c381cd --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/properties/ContainerProperties.java @@ -0,0 +1,12 @@ +package io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties; + +/** + * Configuration describing a custom generic container contract. + * + *

Each configured container becomes eligible for deterministic generic reconstruction during + * OpenAPI projection. + * + * @param type fully-qualified generic container class name + * @param itemProperty JSON property containing the generic item collection + */ +public record ContainerProperties(String type, String itemProperty) {} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/properties/OpenApiGenericsProperties.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/properties/OpenApiGenericsProperties.java index 557d036c..8b9cf340 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/properties/OpenApiGenericsProperties.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/properties/OpenApiGenericsProperties.java @@ -1,6 +1,7 @@ package io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties; import jakarta.validation.Valid; +import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.validation.annotation.Validated; @@ -8,7 +9,9 @@ * Configuration properties for the OpenAPI Generics server starter. * * @param envelope envelope-related configuration + * @param containers custom generic container contract configuration */ @Validated @ConfigurationProperties(prefix = "openapi-generics") -public record OpenApiGenericsProperties(@Valid EnvelopeProperties envelope) {} +public record OpenApiGenericsProperties( + @Valid EnvelopeProperties envelope, @Valid List containers) {} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicy.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicy.java index 7480a42e..f1fd5cf8 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicy.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicy.java @@ -1,36 +1,16 @@ package io.github.blueprintplatform.openapi.generics.server.core.introspection; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import java.util.Set; /** - * Immutable configuration describing how response types should be interpreted by the introspection - * pipeline. + * Defines the active response introspection policy. * - *

This policy defines: - * - *

- * - *

For the default platform configuration this typically corresponds to: - * - *

{@code
- * ServiceResponse
- * ServiceResponse>
- * ServiceResponse>
- * }
- * - *

Additional containers may be contributed through the {@link SupportedContainerType} model - * without changing the core introspection algorithm. - * - * @param envelopeType envelope type to detect (for example {@code ServiceResponse}) - * @param payloadPropertyName property representing the payload within the envelope - * @param supportedContainers supported generic container definitions + * @param envelopeType active response envelope type + * @param payloadPropertyName JSON property carrying the envelope payload + * @param supportedContainers supported generic container contracts */ public record ResponseIntrospectionPolicy( Class envelopeType, String payloadPropertyName, - Set supportedContainers) {} + Set supportedContainers) {} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolver.java index f2c3909e..f4963380 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolver.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolver.java @@ -3,7 +3,9 @@ import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties.EnvelopeProperties; import io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties.OpenApiGenericsProperties; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerTypesResolver; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver.ConfiguredContainerTypesResolver; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver.SupportedContainerTypesResolver; import io.github.blueprintplatform.openapi.generics.server.core.schema.constant.PropertyNames; import java.lang.reflect.Field; import java.lang.reflect.GenericArrayType; @@ -11,56 +13,61 @@ import java.lang.reflect.ParameterizedType; import java.lang.reflect.Type; import java.lang.reflect.TypeVariable; +import java.util.LinkedHashSet; +import java.util.Set; /** - * Resolves and validates the response envelope type used for contract introspection. + * Resolves the response introspection policy from built-in defaults and application configuration. * - *

Supports both the default {@code ServiceResponse} and custom envelopes (BYOE), ensuring - * they comply with strict structural constraints: - * - *

- * - *

Produces a {@link ResponseIntrospectionPolicy} used by the introspection pipeline. + *

Determines the response envelope contract, payload property, and supported generic container + * types used during response type introspection. */ public class ResponseIntrospectionPolicyResolver { private final SupportedContainerTypesResolver supportedContainerTypesResolver; + private final ConfiguredContainerTypesResolver configuredContainerTypesResolver; public ResponseIntrospectionPolicyResolver( - SupportedContainerTypesResolver supportedContainerTypesResolver) { + SupportedContainerTypesResolver supportedContainerTypesResolver, + ConfiguredContainerTypesResolver configuredContainerTypesResolver) { this.supportedContainerTypesResolver = supportedContainerTypesResolver; + this.configuredContainerTypesResolver = configuredContainerTypesResolver; } public ResponseIntrospectionPolicy resolve(OpenApiGenericsProperties properties) { String configuredType = extractConfiguredEnvelopeType(properties); + Set supportedContainers = resolveSupportedContainers(properties); if (configuredType == null) { return new ResponseIntrospectionPolicy( - ServiceResponse.class, PropertyNames.DATA, supportedContainerTypesResolver.resolve()); + ServiceResponse.class, PropertyNames.DATA, supportedContainers); } Class envelopeType = resolveExternalEnvelopeType(configuredType); String payloadPropertyName = validateExternalEnvelopeType(envelopeType); - return new ResponseIntrospectionPolicy( - envelopeType, payloadPropertyName, supportedContainerTypesResolver.resolve()); + return new ResponseIntrospectionPolicy(envelopeType, payloadPropertyName, supportedContainers); } - private String extractConfiguredEnvelopeType(OpenApiGenericsProperties properties) { - if (properties == null) { - return null; + private Set resolveSupportedContainers( + OpenApiGenericsProperties properties) { + + Set containers = + new LinkedHashSet<>(supportedContainerTypesResolver.resolve()); + + if (properties != null) { + containers.addAll(configuredContainerTypesResolver.resolve(properties.containers())); } - EnvelopeProperties envelope = properties.envelope(); - if (envelope == null) { + return Set.copyOf(containers); + } + + private String extractConfiguredEnvelopeType(OpenApiGenericsProperties properties) { + if (properties == null || properties.envelope() == null) { return null; } + EnvelopeProperties envelope = properties.envelope(); String type = envelope.type(); return (type == null || type.isBlank()) ? null : type; } @@ -76,14 +83,14 @@ private Class resolveExternalEnvelopeType(String configuredType) { + configuredType + "'. Expected fully-qualified class name (e.g. com.example.ApiResponse)"); } + try { return Class.forName(configuredType); } catch (ClassNotFoundException e) { throw new IllegalStateException( "Configured envelope class not found: '" + configuredType - + "'. " - + "Ensure the class exists and is on the application classpath.", + + "'. Ensure the class exists and is on the application classpath.", e); } } @@ -124,7 +131,6 @@ private TypeVariable validateSingleTypeParameter(Class envelopeType) { private String validateSingleDirectPayloadSlot( Class envelopeType, TypeVariable payloadTypeParameter) { - String payloadPropertyName = null; for (Field field : envelopeType.getDeclaredFields()) { @@ -137,13 +143,16 @@ private String validateSingleDirectPayloadSlot( "contains unsupported nested generic payload slot in field '" + field.getName() + "'"); - } else if (kind == PayloadSlotKind.DIRECT) { + } + + if (kind == PayloadSlotKind.DIRECT) { if (payloadPropertyName != null) { throw invalidEnvelope( envelopeType, "must declare exactly one direct payload field of type " + payloadTypeParameter.getName()); } + payloadPropertyName = field.getName(); } } diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeDescriptor.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeDescriptor.java index 2d6ca683..5d4947b4 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeDescriptor.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeDescriptor.java @@ -1,13 +1,13 @@ package io.github.blueprintplatform.openapi.generics.server.core.introspection; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import java.util.Objects; /** * Describes a supported response shape discovered during introspection. * *

For container responses, {@code dataRefName} follows the OpenAPI schema name produced by - * springdoc, {@code containerType} preserves the Java container identity discovered during + * springdoc, {@code container} preserves the Java container identity discovered during * introspection, and {@code itemRefName} represents the contained item schema name. */ public final class ResponseTypeDescriptor { @@ -15,19 +15,19 @@ public final class ResponseTypeDescriptor { private final Class envelopeType; private final String payloadPropertyName; private final String dataRefName; - private final SupportedContainerType containerType; + private final SupportedContainerDescriptor container; private final String itemRefName; private ResponseTypeDescriptor( Class envelopeType, String payloadPropertyName, String dataRefName, - SupportedContainerType containerType, + SupportedContainerDescriptor container, String itemRefName) { this.envelopeType = envelopeType; this.payloadPropertyName = payloadPropertyName; this.dataRefName = dataRefName; - this.containerType = containerType; + this.container = container; this.itemRefName = itemRefName; } @@ -39,13 +39,13 @@ public static ResponseTypeDescriptor simple( public static ResponseTypeDescriptor container( Class envelopeType, String payloadPropertyName, - SupportedContainerType containerType, + SupportedContainerDescriptor container, String itemRefName) { return new ResponseTypeDescriptor( envelopeType, payloadPropertyName, - containerType.schemaName() + itemRefName, - containerType, + container.schemaName() + itemRefName, + container, itemRefName); } @@ -61,16 +61,16 @@ public String dataRefName() { return dataRefName; } - public SupportedContainerType containerType() { - return containerType; + public SupportedContainerDescriptor container() { + return container; } public String containerName() { - return containerType != null ? containerType.containerName() : null; + return container != null ? container.containerName() : null; } public String containerTypeName() { - return containerType != null ? containerType.containerTypeName() : null; + return container != null ? container.containerTypeName() : null; } public String itemRefName() { @@ -78,7 +78,7 @@ public String itemRefName() { } public boolean isContainer() { - return containerType != null && itemRefName != null; + return container != null && itemRefName != null; } @Override @@ -88,13 +88,13 @@ public boolean equals(Object o) { return Objects.equals(envelopeType, that.envelopeType) && Objects.equals(payloadPropertyName, that.payloadPropertyName) && Objects.equals(dataRefName, that.dataRefName) - && Objects.equals(containerType, that.containerType) + && Objects.equals(container, that.container) && Objects.equals(itemRefName, that.itemRefName); } @Override public int hashCode() { - return Objects.hash(envelopeType, payloadPropertyName, dataRefName, containerType, itemRefName); + return Objects.hash(envelopeType, payloadPropertyName, dataRefName, container, itemRefName); } @Override diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospector.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospector.java index 6f674bf9..4e57bce1 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospector.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospector.java @@ -1,6 +1,6 @@ package io.github.blueprintplatform.openapi.generics.server.core.introspection; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import java.lang.annotation.Annotation; import java.lang.reflect.Method; import java.util.Optional; @@ -15,41 +15,10 @@ import org.springframework.web.context.request.async.WebAsyncTask; /** - * Extracts contract-aware response type metadata from controller return types. + * Introspects generic response types and produces descriptors used during OpenAPI projection. * - *

Unwraps framework-level wrappers (for example {@code ResponseEntity}, {@code CompletionStage}, - * {@code Future}, {@code DeferredResult}, and {@code WebAsyncTask}) before analyzing the actual - * contract response shape. - * - *

Produces a {@link ResponseTypeDescriptor} only for response structures that are explicitly - * supported by the active {@link ResponseIntrospectionPolicy}. - * - *

For the default platform envelope, supported shapes are: - * - *

- * - *

For custom BYOE envelopes, supported shapes are limited to: - * - *

- * - *

Nested container payloads are intentionally unsupported, including: - * - *

- * - *

Enum payloads are supported only when published as reusable OpenAPI schema components (for - * example via {@code @Schema(enumAsRef = true)}). Inline enum schemas are ignored because they do - * not produce stable component identities required by the projection pipeline. + *

Recognizes the configured response envelope, supported generic container contracts, and + * payload types after unwrapping common asynchronous response wrappers. */ public final class ResponseTypeIntrospector { @@ -58,7 +27,7 @@ public final class ResponseTypeIntrospector { private static final String SCHEMA_ANNOTATION = "io.swagger.v3.oas.annotations.media.Schema"; private final Class envelopeType; - private final Set supportedContainers; + private final Set supportedContainers; private final String payloadPropertyName; public ResponseTypeIntrospector(ResponseIntrospectionPolicy policy) { @@ -87,12 +56,14 @@ public Optional extract(ResolvableType type) { descriptorOpt.map(Object::toString).orElse(""), descriptorOpt.map(ResponseTypeDescriptor::dataRefName).orElse("")); } + return descriptorOpt; } private ResolvableType unwrap(ResolvableType type) { for (int i = 0; i < MAX_UNWRAP_DEPTH; i++) { Class raw = type.resolve(); + if (raw == null || envelopeType.isAssignableFrom(raw)) { return type; } @@ -145,13 +116,13 @@ private Optional buildDescriptor(ResolvableType dataType private Optional buildContainerDescriptor( ResolvableType dataType, Class raw) { - - for (SupportedContainerType containerType : supportedContainers) { - if (!containerType.matches(raw)) { + for (SupportedContainerDescriptor container : supportedContainers) { + if (!container.matches(raw)) { continue; } ResolvableType itemType = safeGeneric(dataType); + if (itemType.hasGenerics()) { return Optional.empty(); } @@ -163,7 +134,7 @@ private Optional buildContainerDescriptor( return Optional.of( ResponseTypeDescriptor.container( - envelopeType, payloadPropertyName, containerType, itemRaw.getSimpleName())); + envelopeType, payloadPropertyName, container, itemRaw.getSimpleName())); } return Optional.empty(); @@ -203,6 +174,7 @@ private ResolvableType safeGeneric(ResolvableType type) { if (!type.hasGenerics()) { return ResolvableType.NONE; } + return type.getGeneric(0); } diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/DefaultSupportedContainerTypesResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/DefaultSupportedContainerTypesResolver.java deleted file mode 100644 index c5332fdc..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/DefaultSupportedContainerTypesResolver.java +++ /dev/null @@ -1,23 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.introspection.container; - -import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.ContainerNames.*; - -import io.github.blueprintplatform.openapi.generics.contract.paging.Page; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Set; - -public final class DefaultSupportedContainerTypesResolver - implements SupportedContainerTypesResolver { - - @Override - public Set resolve() { - Set containers = new LinkedHashSet<>(); - - containers.add(new SupportedContainerType(Page.class, PAGE, PAGE)); - containers.add(new SupportedContainerType(List.class, LIST, LIST)); - containers.add(new SupportedContainerType(Set.class, SET, SET)); - - return Set.copyOf(containers); - } -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/SupportedContainerType.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/SupportedContainerType.java deleted file mode 100644 index 14ba9d2a..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/SupportedContainerType.java +++ /dev/null @@ -1,52 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.introspection.container; - -import java.util.Objects; - -/** - * Describes a supported generic container type recognized by the projection pipeline. - * - *

A container definition separates three distinct concerns: - * - *

- * - *

For built-in containers, schema and semantic names are often identical: - * - *

- * Page  -> schemaName=Page, containerName=Page
- * List  -> schemaName=List, containerName=List
- * Set   -> schemaName=Set,  containerName=Set
- * 
- * - *

The Java container type is preserved separately so projection metadata can expose the fully - * qualified container identity without relying on schema names or naming conventions. - * - * @param type raw Java container type discovered during introspection - * @param schemaName canonical schema identifier used during projection - * @param containerName semantic container identifier exposed through vendor extensions - */ -public record SupportedContainerType(Class type, String schemaName, String containerName) { - - public SupportedContainerType { - Objects.requireNonNull(type, "type must not be null"); - - if (schemaName == null || schemaName.isBlank()) { - throw new IllegalArgumentException("schemaName must not be null or blank"); - } - - if (containerName == null || containerName.isBlank()) { - throw new IllegalArgumentException("containerName must not be null or blank"); - } - } - - public String containerTypeName() { - return type.getName(); - } - - public boolean matches(Class candidate) { - return candidate != null && type.isAssignableFrom(candidate); - } -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/SupportedContainerTypesResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/SupportedContainerTypesResolver.java deleted file mode 100644 index 90b3541e..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/SupportedContainerTypesResolver.java +++ /dev/null @@ -1,8 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.introspection.container; - -import java.util.Set; - -public interface SupportedContainerTypesResolver { - - Set resolve(); -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerMatchMode.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerMatchMode.java new file mode 100644 index 00000000..9a9acb77 --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerMatchMode.java @@ -0,0 +1,11 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor; + +/** Defines how discovered Java response types are matched against supported containers. */ +public enum ContainerMatchMode { + + /** Candidate type must be exactly the configured container type. */ + EXACT, + + /** Candidate type may be assignable to the configured container type. */ + ASSIGNABLE +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerShape.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerShape.java new file mode 100644 index 00000000..f62eca74 --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerShape.java @@ -0,0 +1,11 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor; + +/** Describes how a generic container is represented in the OpenAPI schema. */ +public enum ContainerShape { + + /** Container is represented directly as an array, for example List or Set. */ + DIRECT_ARRAY, + + /** Container is represented as an object containing an item collection property. */ + OBJECT_WITH_ITEM_ARRAY +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerSource.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerSource.java new file mode 100644 index 00000000..5b2ffd8a --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/ContainerSource.java @@ -0,0 +1,11 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor; + +/** Identifies where a supported container definition comes from. */ +public enum ContainerSource { + + /** Container provided by OpenAPI Generics itself. */ + BUILT_IN, + + /** Container explicitly configured by the application. */ + CONFIGURED +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/SupportedContainerDescriptor.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/SupportedContainerDescriptor.java new file mode 100644 index 00000000..90f5a944 --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/descriptor/SupportedContainerDescriptor.java @@ -0,0 +1,70 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor; + +import java.util.Objects; + +/** + * Describes a supported generic container contract recognized by the projection pipeline. + * + *

A container descriptor separates Java identity, OpenAPI schema identity, semantic identity, + * and schema-shape behavior. This allows built-in containers and configured BYOC containers to pass + * through the same deterministic introspection and projection pipeline. + * + * @param type raw Java container type discovered during introspection + * @param schemaName canonical schema identifier used during projection + * @param containerName semantic container identifier exposed through vendor extensions + * @param shape OpenAPI schema shape of the container + * @param itemPropertyName JSON property containing the generic item collection for object + * containers + * @param source descriptor source + * @param matchMode Java type matching policy + */ +public record SupportedContainerDescriptor( + Class type, + String schemaName, + String containerName, + ContainerShape shape, + String itemPropertyName, + ContainerSource source, + ContainerMatchMode matchMode) { + + public SupportedContainerDescriptor { + Objects.requireNonNull(type, "type must not be null"); + Objects.requireNonNull(shape, "shape must not be null"); + Objects.requireNonNull(source, "source must not be null"); + Objects.requireNonNull(matchMode, "matchMode must not be null"); + + if (schemaName == null || schemaName.isBlank()) { + throw new IllegalArgumentException("schemaName must not be null or blank"); + } + + if (containerName == null || containerName.isBlank()) { + throw new IllegalArgumentException("containerName must not be null or blank"); + } + + if (shape == ContainerShape.OBJECT_WITH_ITEM_ARRAY + && (itemPropertyName == null || itemPropertyName.isBlank())) { + throw new IllegalArgumentException( + "itemPropertyName must not be null or blank for object containers"); + } + + if (shape == ContainerShape.DIRECT_ARRAY && itemPropertyName != null) { + throw new IllegalArgumentException( + "itemPropertyName must be null for direct array containers"); + } + } + + public String containerTypeName() { + return type.getName(); + } + + public boolean matches(Class candidate) { + if (candidate == null) { + return false; + } + + return switch (matchMode) { + case EXACT -> type.equals(candidate); + case ASSIGNABLE -> type.isAssignableFrom(candidate); + }; + } +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/ConfiguredContainerTypesResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/ConfiguredContainerTypesResolver.java new file mode 100644 index 00000000..39d1ee83 --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/ConfiguredContainerTypesResolver.java @@ -0,0 +1,172 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver; + +import io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties.ContainerProperties; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Resolves application-configured generic container contracts into supported container descriptors. + * + *

Configured containers are validated eagerly so invalid BYOC definitions fail fast during + * application startup. + */ +public final class ConfiguredContainerTypesResolver { + + public static final String ITEM_PROPERTY = "item-property '"; + + public Set resolve(List properties) { + if (properties == null || properties.isEmpty()) { + return Set.of(); + } + + Set containers = new LinkedHashSet<>(); + + for (ContainerProperties property : properties) { + if (property == null) { + continue; + } + + Class containerType = resolveContainerClass(property.type()); + + validateConcreteContainer(containerType); + TypeVariable itemTypeParameter = validateSingleTypeParameter(containerType); + validateItemProperty(containerType, property.itemProperty(), itemTypeParameter); + + String simpleName = containerType.getSimpleName(); + + containers.add( + new SupportedContainerDescriptor( + containerType, + simpleName, + simpleName, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + property.itemProperty(), + ContainerSource.CONFIGURED, + ContainerMatchMode.EXACT)); + } + + return Set.copyOf(containers); + } + + private Class resolveContainerClass(String configuredType) { + if (configuredType == null || configuredType.isBlank()) { + throw new IllegalStateException("Container type must not be null or blank"); + } + + if (!configuredType.contains(".")) { + throw new IllegalStateException( + "Invalid container type '" + + configuredType + + "'. Expected fully-qualified class name (e.g. com.example.Paging)"); + } + + try { + return Class.forName(configuredType); + } catch (ClassNotFoundException e) { + throw new IllegalStateException( + "Configured container class not found: '" + + configuredType + + "'. Ensure the class exists and is on the application classpath.", + e); + } + } + + private void validateConcreteContainer(Class containerType) { + if (containerType.isInterface()) { + throw invalidContainer(containerType, "must be a concrete class or record, not an interface"); + } else if (containerType.isEnum()) { + throw invalidContainer(containerType, "must be a class or record, not an enum"); + } else if (containerType.isAnnotation()) { + throw invalidContainer(containerType, "must be a class or record, not an annotation"); + } else if (containerType.isArray()) { + throw invalidContainer(containerType, "must be a class or record, not an array"); + } else if (containerType.isPrimitive()) { + throw invalidContainer(containerType, "must be a class or record, not a primitive"); + } else if (Modifier.isAbstract(containerType.getModifiers())) { + throw invalidContainer(containerType, "must be concrete, not abstract"); + } + } + + private TypeVariable validateSingleTypeParameter(Class containerType) { + TypeVariable[] typeParameters = containerType.getTypeParameters(); + + if (typeParameters.length != 1) { + throw invalidContainer(containerType, "must declare exactly one type parameter"); + } + + return typeParameters[0]; + } + + private void validateItemProperty( + Class containerType, String itemProperty, TypeVariable itemTypeParameter) { + if (itemProperty == null || itemProperty.isBlank()) { + throw invalidContainer( + containerType, ITEM_PROPERTY + itemProperty + "' must not be null or blank"); + } + + Field field = findDeclaredField(containerType, itemProperty); + + if (field == null) { + throw invalidContainer(containerType, ITEM_PROPERTY + itemProperty + "' does not exist"); + } + + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) { + throw invalidContainer( + containerType, ITEM_PROPERTY + itemProperty + "' must be an instance field"); + } + + Type fieldType = field.getGenericType(); + + if (!(fieldType instanceof ParameterizedType parameterizedType)) { + throw invalidContainer( + containerType, ITEM_PROPERTY + itemProperty + "' must be List or Set"); + } + + Type rawType = parameterizedType.getRawType(); + + if (!(rawType == List.class || rawType == Set.class)) { + throw invalidContainer( + containerType, ITEM_PROPERTY + itemProperty + "' must be List or Set"); + } + + Type[] arguments = parameterizedType.getActualTypeArguments(); + + if (arguments.length != 1 || !sameTypeVariable(arguments[0], itemTypeParameter)) { + throw invalidContainer( + containerType, + ITEM_PROPERTY + itemProperty + "' must use the container type parameter directly"); + } + } + + private Field findDeclaredField(Class type, String name) { + try { + return type.getDeclaredField(name); + } catch (NoSuchFieldException ignored) { + return null; + } + } + + private boolean sameTypeVariable(Type candidate, TypeVariable expected) { + if (!(candidate instanceof TypeVariable typeVariable)) { + return false; + } + + return typeVariable.getGenericDeclaration() == expected.getGenericDeclaration() + && typeVariable.getName().equals(expected.getName()); + } + + private IllegalStateException invalidContainer(Class containerType, String reason) { + return new IllegalStateException( + "Unsupported container type '" + containerType.getName() + "': " + reason); + } +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/DefaultSupportedContainerTypesResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/DefaultSupportedContainerTypesResolver.java new file mode 100644 index 00000000..1cf4dc04 --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/DefaultSupportedContainerTypesResolver.java @@ -0,0 +1,55 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver; + +import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.ContainerNames.*; +import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.SchemaConstants.PROPERTY_CONTENT; + +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** Provides descriptor definitions for the built-in generic container contracts. */ +public final class DefaultSupportedContainerTypesResolver + implements SupportedContainerTypesResolver { + + @Override + public Set resolve() { + Set containers = new LinkedHashSet<>(); + + containers.add( + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + PROPERTY_CONTENT, + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT)); + + containers.add( + new SupportedContainerDescriptor( + List.class, + LIST, + LIST, + ContainerShape.DIRECT_ARRAY, + null, + ContainerSource.BUILT_IN, + ContainerMatchMode.ASSIGNABLE)); + + containers.add( + new SupportedContainerDescriptor( + Set.class, + SET, + SET, + ContainerShape.DIRECT_ARRAY, + null, + ContainerSource.BUILT_IN, + ContainerMatchMode.ASSIGNABLE)); + + return Set.copyOf(containers); + } +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/SupportedContainerTypesResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/SupportedContainerTypesResolver.java new file mode 100644 index 00000000..6ab4356f --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/container/resolver/SupportedContainerTypesResolver.java @@ -0,0 +1,9 @@ +package io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver; + +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; +import java.util.Set; + +public interface SupportedContainerTypesResolver { + + Set resolve(); +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestrator.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestrator.java index c90ac967..eccfb9cb 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestrator.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestrator.java @@ -15,30 +15,12 @@ import org.slf4j.LoggerFactory; /** - * Orchestrates the full OpenAPI projection pipeline for contract-aware responses. + * Orchestrates the OpenAPI projection pipeline for generic response contracts. * - *

This is the single entry point that coordinates all processing steps: + *

Coordinates response type discovery, generic type introspection, wrapper schema processing, + * contract schema exclusion, and final OpenAPI validation. * - *

    - *
  1. Discover response types from the application layer - *
  2. Extract contract-aware descriptors via introspection - *
  3. Generate wrapper schemas (default or BYOE) - *
  4. Mark non-authoritative schemas to be ignored - *
  5. Validate final OpenAPI contract integrity - *
- * - *

The pipeline is executed exactly once per OpenAPI instance. - * - *

Design principles: - * - *

- * - *

Acts as the integration point between discovery, schema generation, control marking, and - * contract validation phases. + *

The pipeline is executed once per OpenAPI instance. */ public class OpenApiPipelineOrchestrator { diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaEnricher.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaEnricher.java deleted file mode 100644 index 71d5a689..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaEnricher.java +++ /dev/null @@ -1,105 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema; - -import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.constant.VendorExtensions; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.ContainerSchemaRegistry; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.ContainerSchemaStrategy; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.media.Schema; -import java.util.Map; - -/** - * Enriches projected wrapper schemas with container metadata required for generic reconstruction. - * - *

This component resolves the container represented by a wrapper payload schema and applies the - * corresponding OpenAPI Generics vendor extensions to the wrapper schema. - * - *

The actual container-specific behavior is delegated to {@link ContainerSchemaStrategy} - * implementations registered in {@link ContainerSchemaRegistry}. - */ -public class WrapperSchemaEnricher { - - private final ContainerSchemaRegistry containerSchemaRegistry; - - public WrapperSchemaEnricher(ContainerSchemaRegistry containerSchemaRegistry) { - this.containerSchemaRegistry = containerSchemaRegistry; - } - - @SuppressWarnings("rawtypes") - public void enrich(OpenAPI openApi, String wrapperName, ResponseTypeDescriptor descriptor) { - Map schemas = getSchemas(openApi); - - if (schemas.isEmpty() - || wrapperName == null - || descriptor == null - || !descriptor.isContainer()) { - return; - } - - ContainerSchemaMetadata metadata = resolveContainerMetadata(schemas, wrapperName, descriptor); - - if (metadata == null) { - return; - } - - applyContainerMetadata(metadata); - } - - @SuppressWarnings("rawtypes") - private ContainerSchemaMetadata resolveContainerMetadata( - Map schemas, String wrapperName, ResponseTypeDescriptor descriptor) { - ContainerSchemaStrategy strategy = - containerSchemaRegistry.findByContainerType(descriptor.containerType()); - - if (strategy == null) { - return null; - } - - Schema containerSchema = - strategy - .resolver() - .resolve( - schemas, descriptor.dataRefName(), wrapperName, descriptor.payloadPropertyName()); - - if (containerSchema == null) { - return null; - } - - String itemName = strategy.extractor().extractItemName(containerSchema); - - if (itemName == null) { - return null; - } - - Schema wrapper = schemas.get(wrapperName); - - if (wrapper == null) { - return null; - } - - return new ContainerSchemaMetadata( - wrapper, strategy.containerName(), descriptor.containerTypeName(), itemName); - } - - private void applyContainerMetadata(ContainerSchemaMetadata metadata) { - metadata.wrapper().addExtension(VendorExtensions.DATA_CONTAINER, metadata.containerName()); - metadata - .wrapper() - .addExtension(VendorExtensions.DATA_CONTAINER_TYPE, metadata.containerTypeName()); - metadata.wrapper().addExtension(VendorExtensions.DATA_ITEM, metadata.itemName()); - } - - @SuppressWarnings("rawtypes") - private Map getSchemas(OpenAPI openApi) { - if (openApi == null - || openApi.getComponents() == null - || openApi.getComponents().getSchemas() == null) { - return Map.of(); - } - - return openApi.getComponents().getSchemas(); - } - - private record ContainerSchemaMetadata( - Schema wrapper, String containerName, String containerTypeName, String itemName) {} -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessor.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessor.java index b1eaaf6f..80583145 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessor.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessor.java @@ -1,6 +1,7 @@ package io.github.blueprintplatform.openapi.generics.server.core.schema; import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.WrapperSchemaEnricher; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.Schema; import java.util.Map; diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/ContainerSchemaMetadata.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/ContainerSchemaMetadata.java new file mode 100644 index 00000000..269d32fa --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/ContainerSchemaMetadata.java @@ -0,0 +1,14 @@ +package io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment; + +import io.swagger.v3.oas.models.media.Schema; + +/** + * Metadata resolved from a container schema and applied to the projected wrapper schema. + * + * @param wrapper wrapper schema receiving vendor extensions + * @param containerName semantic container name + * @param containerTypeName fully-qualified Java container type + * @param itemName contained item schema name + */ +public record ContainerSchemaMetadata( + Schema wrapper, String containerName, String containerTypeName, String itemName) {} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/ContainerSchemaMetadataResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/ContainerSchemaMetadataResolver.java new file mode 100644 index 00000000..fb0abcec --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/ContainerSchemaMetadataResolver.java @@ -0,0 +1,99 @@ +package io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment; + +import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.extraction.ArrayItemReferenceExtractor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.ComponentContainerSchemaResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.WrapperPayloadArraySchemaResolver; +import io.swagger.v3.oas.models.media.Schema; +import java.util.Map; + +/** + * Resolves the container schema metadata required to enrich projected wrapper schemas. + * + *

Extracts the container type, item collection, and payload metadata used for generic client + * reconstruction. + */ +public class ContainerSchemaMetadataResolver { + + private final WrapperPayloadArraySchemaResolver wrapperPayloadArraySchemaResolver; + private final ComponentContainerSchemaResolver componentContainerSchemaResolver; + private final ArrayItemReferenceExtractor arrayItemReferenceExtractor; + + public ContainerSchemaMetadataResolver( + WrapperPayloadArraySchemaResolver wrapperPayloadArraySchemaResolver, + ComponentContainerSchemaResolver componentContainerSchemaResolver, + ArrayItemReferenceExtractor arrayItemReferenceExtractor) { + this.wrapperPayloadArraySchemaResolver = wrapperPayloadArraySchemaResolver; + this.componentContainerSchemaResolver = componentContainerSchemaResolver; + this.arrayItemReferenceExtractor = arrayItemReferenceExtractor; + } + + @SuppressWarnings("rawtypes") + public ContainerSchemaMetadata resolve( + Map schemas, String wrapperName, ResponseTypeDescriptor descriptor) { + if (schemas == null || schemas.isEmpty() || wrapperName == null || descriptor == null) { + return null; + } + + SupportedContainerDescriptor container = descriptor.container(); + if (container == null) { + return null; + } + + Schema wrapper = schemas.get(wrapperName); + if (wrapper == null) { + return null; + } + + Schema containerSchema = resolveContainerSchema(schemas, wrapperName, descriptor, container); + if (containerSchema == null) { + return null; + } + + Schema itemArraySchema = resolveItemArraySchema(containerSchema, container); + String itemName = arrayItemReferenceExtractor.extractItemName(itemArraySchema); + + if (itemName == null) { + return null; + } + + return new ContainerSchemaMetadata( + wrapper, container.containerName(), container.containerTypeName(), itemName); + } + + @SuppressWarnings("rawtypes") + private Schema resolveContainerSchema( + Map schemas, + String wrapperName, + ResponseTypeDescriptor descriptor, + SupportedContainerDescriptor container) { + if (container.shape() == ContainerShape.DIRECT_ARRAY) { + return wrapperPayloadArraySchemaResolver.resolve( + schemas, descriptor.dataRefName(), wrapperName, descriptor.payloadPropertyName()); + } + + if (container.shape() == ContainerShape.OBJECT_WITH_ITEM_ARRAY) { + return componentContainerSchemaResolver.resolve( + schemas, descriptor.dataRefName(), wrapperName, descriptor.payloadPropertyName()); + } + + return null; + } + + @SuppressWarnings("rawtypes") + private Schema resolveItemArraySchema( + Schema containerSchema, SupportedContainerDescriptor container) { + if (container.shape() == ContainerShape.DIRECT_ARRAY) { + return containerSchema; + } + + Map properties = containerSchema.getProperties(); + if (properties == null) { + return null; + } + + return properties.get(container.itemPropertyName()); + } +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/WrapperSchemaEnricher.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/WrapperSchemaEnricher.java new file mode 100644 index 00000000..a07e23be --- /dev/null +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/WrapperSchemaEnricher.java @@ -0,0 +1,59 @@ +package io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment; + +import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.constant.VendorExtensions; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.Schema; +import java.util.Map; + +/** + * Enriches projected wrapper schemas with container metadata for generic client reconstruction. + * + *

Adds vendor extensions describing the generic container contract and its payload item type. + */ +public class WrapperSchemaEnricher { + private final ContainerSchemaMetadataResolver metadataResolver; + + public WrapperSchemaEnricher(ContainerSchemaMetadataResolver metadataResolver) { + this.metadataResolver = metadataResolver; + } + + @SuppressWarnings("rawtypes") + public void enrich(OpenAPI openApi, String wrapperName, ResponseTypeDescriptor descriptor) { + Map schemas = getSchemas(openApi); + + if (schemas.isEmpty() + || wrapperName == null + || descriptor == null + || !descriptor.isContainer()) { + return; + } + + ContainerSchemaMetadata metadata = metadataResolver.resolve(schemas, wrapperName, descriptor); + + if (metadata == null) { + return; + } + + applyContainerMetadata(metadata); + } + + private void applyContainerMetadata(ContainerSchemaMetadata metadata) { + metadata.wrapper().addExtension(VendorExtensions.DATA_CONTAINER, metadata.containerName()); + metadata + .wrapper() + .addExtension(VendorExtensions.DATA_CONTAINER_TYPE, metadata.containerTypeName()); + metadata.wrapper().addExtension(VendorExtensions.DATA_ITEM, metadata.itemName()); + } + + @SuppressWarnings("rawtypes") + private Map getSchemas(OpenAPI openApi) { + if (openApi == null + || openApi.getComponents() == null + || openApi.getComponents().getSchemas() == null) { + return Map.of(); + } + + return openApi.getComponents().getSchemas(); + } +} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/DirectArrayItemExtractor.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extraction/ArrayItemReferenceExtractor.java similarity index 52% rename from openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/DirectArrayItemExtractor.java rename to openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extraction/ArrayItemReferenceExtractor.java index 3c3fbde6..e484bab5 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/DirectArrayItemExtractor.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extraction/ArrayItemReferenceExtractor.java @@ -1,4 +1,4 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.extractor; +package io.github.blueprintplatform.openapi.generics.server.core.schema.extraction; import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.SchemaConstants.COMPONENT_SCHEMA_REF_PREFIX; import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.SchemaConstants.TYPE_ARRAY; @@ -7,31 +7,16 @@ import io.swagger.v3.oas.models.media.JsonSchema; import io.swagger.v3.oas.models.media.Schema; -/** - * Extracts item type from List style schemas. List is usually represented as a direct array - * in OpenAPI. - */ -public class DirectArrayItemExtractor implements ItemExtractor { +/** Extracts the referenced component schema name from OpenAPI array item definitions. */ +public class ArrayItemReferenceExtractor { - @SuppressWarnings("rawtypes") - @Override - public String extractItemName(Schema containerSchema) { - if (containerSchema == null) return null; + public String extractItemName(Schema arraySchema) { + Schema items = resolveItems(arraySchema); - Schema items = null; - - if (containerSchema instanceof ArraySchema arraySchema) { - items = arraySchema.getItems(); - } else if (TYPE_ARRAY.equals(containerSchema.getType())) { - items = containerSchema.getItems(); - } else if (containerSchema instanceof JsonSchema jsonSchema - && jsonSchema.getTypes() != null - && jsonSchema.getTypes().contains(TYPE_ARRAY)) { - items = jsonSchema.getItems(); + if (items == null) { + return null; } - if (items == null) return null; - String itemRef = items.get$ref(); if (itemRef == null || !itemRef.startsWith(COMPONENT_SCHEMA_REF_PREFIX)) { return null; @@ -39,4 +24,26 @@ public String extractItemName(Schema containerSchema) { return itemRef.substring(COMPONENT_SCHEMA_REF_PREFIX.length()); } + + private Schema resolveItems(Schema schema) { + if (schema == null) { + return null; + } + + if (schema instanceof ArraySchema arraySchema) { + return arraySchema.getItems(); + } + + if (TYPE_ARRAY.equals(schema.getType())) { + return schema.getItems(); + } + + if (schema instanceof JsonSchema jsonSchema + && jsonSchema.getTypes() != null + && jsonSchema.getTypes().contains(TYPE_ARRAY)) { + return jsonSchema.getItems(); + } + + return null; + } } diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/ContentArrayItemExtractor.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/ContentArrayItemExtractor.java deleted file mode 100644 index 77c950f0..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/ContentArrayItemExtractor.java +++ /dev/null @@ -1,48 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.extractor; - -import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.SchemaConstants.*; - -import io.swagger.v3.oas.models.media.ArraySchema; -import io.swagger.v3.oas.models.media.JsonSchema; -import io.swagger.v3.oas.models.media.Schema; -import java.util.Map; - -/** - * Extracts item type from Page style schemas. Page schema usually contains a "content" property - * which is an array. - */ -public class ContentArrayItemExtractor implements ItemExtractor { - - @SuppressWarnings("rawtypes") - @Override - public String extractItemName(Schema containerSchema) { - if (containerSchema == null) return null; - - Map properties = containerSchema.getProperties(); - if (properties == null) return null; - - Schema content = properties.get(PROPERTY_CONTENT); - if (content == null) return null; - - Schema items = null; - - if (content instanceof ArraySchema arraySchema) { - items = arraySchema.getItems(); - } else if (TYPE_ARRAY.equals(content.getType())) { - items = content.getItems(); - } else if (content instanceof JsonSchema jsonSchema - && jsonSchema.getTypes() != null - && jsonSchema.getTypes().contains(TYPE_ARRAY)) { - items = jsonSchema.getItems(); - } - - if (items == null) return null; - - String itemRef = items.get$ref(); - if (itemRef == null || !itemRef.startsWith(COMPONENT_SCHEMA_REF_PREFIX)) { - return null; - } - - return itemRef.substring(COMPONENT_SCHEMA_REF_PREFIX.length()); - } -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/ItemExtractor.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/ItemExtractor.java deleted file mode 100644 index 5073ad38..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/extractor/ItemExtractor.java +++ /dev/null @@ -1,19 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.extractor; - -import io.swagger.v3.oas.models.media.Schema; - -/** - * Strategy interface for extracting the item type name from a container schema. Used by - * WrapperSchemaEnricher to support different container types (List, Page, Slice, etc.) in a clean - * and extensible way. - */ -public interface ItemExtractor { - - /** - * Extracts the simple name of the item type inside the container. - * - * @param containerSchema the schema representing the container - * @return the simple name of the item type, or null if it cannot be determined - */ - String extractItemName(Schema containerSchema); -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/ComponentContainerSchemaResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ComponentContainerSchemaResolver.java similarity index 68% rename from openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/ComponentContainerSchemaResolver.java rename to openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ComponentContainerSchemaResolver.java index 9656b83a..ed988801 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/ComponentContainerSchemaResolver.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ComponentContainerSchemaResolver.java @@ -1,12 +1,16 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.resolver; +package io.github.blueprintplatform.openapi.generics.server.core.schema.resolution; import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.SchemaConstants.*; -import io.swagger.v3.oas.models.media.*; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.JsonSchema; +import io.swagger.v3.oas.models.media.Schema; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; +/** Resolves component-based container schemas referenced by projected wrapper payloads. */ public class ComponentContainerSchemaResolver implements ContainerSchemaResolver { @SuppressWarnings("rawtypes") @@ -28,17 +32,25 @@ public Schema resolve( private Schema resolveContainerSchema( Map schemas, Schema schema, Set visited) { - if (schema == null) return null; + if (schema == null) { + return null; + } Schema current = dereferenceIfNeeded(schemas, schema, visited); - if (current == null) return null; + if (current == null) { + return null; + } - if (isContainerLike(current)) return current; + if (isContainerLike(current)) { + return current; + } if (current instanceof ComposedSchema composed && composed.getAllOf() != null) { for (Schema candidate : composed.getAllOf()) { Schema resolved = resolveContainerSchema(schemas, candidate, visited); - if (resolved != null) return resolved; + if (resolved != null) { + return resolved; + } } } @@ -50,23 +62,30 @@ private Schema dereferenceIfNeeded( Map schemas, Schema schema, Set visited) { String ref = schema.get$ref(); - if (ref == null || !ref.startsWith(COMPONENT_SCHEMA_REF_PREFIX)) return schema; + if (ref == null || !ref.startsWith(COMPONENT_SCHEMA_REF_PREFIX)) { + return schema; + } String name = ref.substring(COMPONENT_SCHEMA_REF_PREFIX.length()); - if (!visited.add(name)) return null; + if (!visited.add(name)) { + return null; + } return schemas.get(name); } private boolean isContainerLike(Schema schema) { - return schema instanceof ObjectSchema - || TYPE_OBJECT.equals(schema.getType()) - || (schema.getProperties() != null && !schema.getProperties().isEmpty()) - || isArrayLike(schema); + return hasProperties(schema) || isArrayLike(schema); + } + + private boolean hasProperties(Schema schema) { + return schema.getProperties() != null && !schema.getProperties().isEmpty(); } private boolean isArrayLike(Schema schema) { - if (schema == null) return false; + if (schema == null) { + return false; + } return schema instanceof ArraySchema || TYPE_ARRAY.equals(schema.getType()) diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/ContainerSchemaResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ContainerSchemaResolver.java similarity index 94% rename from openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/ContainerSchemaResolver.java rename to openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ContainerSchemaResolver.java index ec597086..10078027 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/ContainerSchemaResolver.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ContainerSchemaResolver.java @@ -1,4 +1,4 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.resolver; +package io.github.blueprintplatform.openapi.generics.server.core.schema.resolution; import io.swagger.v3.oas.models.media.Schema; import java.util.Map; diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/WrapperPayloadArraySchemaResolver.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/WrapperPayloadArraySchemaResolver.java similarity index 92% rename from openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/WrapperPayloadArraySchemaResolver.java rename to openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/WrapperPayloadArraySchemaResolver.java index 7ffc0206..101bb68a 100644 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolver/WrapperPayloadArraySchemaResolver.java +++ b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/WrapperPayloadArraySchemaResolver.java @@ -1,4 +1,4 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.resolver; +package io.github.blueprintplatform.openapi.generics.server.core.schema.resolution; import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.SchemaConstants.TYPE_ARRAY; @@ -7,6 +7,7 @@ import io.swagger.v3.oas.models.media.Schema; import java.util.Map; +/** Resolves array payload schemas defined directly on projected wrapper properties. */ public class WrapperPayloadArraySchemaResolver implements ContainerSchemaResolver { @SuppressWarnings("rawtypes") diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ContainerSchemaRegistry.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ContainerSchemaRegistry.java deleted file mode 100644 index fb78a299..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ContainerSchemaRegistry.java +++ /dev/null @@ -1,26 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.strategy; - -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; - -public class ContainerSchemaRegistry { - - private final Map strategies = - new LinkedHashMap<>(); - - public ContainerSchemaRegistry(List strategies) { - if (strategies != null) { - strategies.forEach(strategy -> this.strategies.put(strategy.containerType(), strategy)); - } - } - - public ContainerSchemaStrategy findByContainerType(SupportedContainerType containerType) { - if (containerType == null) { - return null; - } - - return strategies.get(containerType); - } -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ContainerSchemaStrategy.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ContainerSchemaStrategy.java deleted file mode 100644 index 74a5fd2c..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ContainerSchemaStrategy.java +++ /dev/null @@ -1,18 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.strategy; - -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.ItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.ContainerSchemaResolver; - -public interface ContainerSchemaStrategy { - - SupportedContainerType containerType(); - - ContainerSchemaResolver resolver(); - - ItemExtractor extractor(); - - default String containerName() { - return containerType().containerName(); - } -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ListContainerSchemaStrategy.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ListContainerSchemaStrategy.java deleted file mode 100644 index 80c26884..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/ListContainerSchemaStrategy.java +++ /dev/null @@ -1,20 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.strategy; - -import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.ContainerNames.LIST; - -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.ItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.ContainerSchemaResolver; -import java.util.List; - -public record ListContainerSchemaStrategy(ContainerSchemaResolver resolver, ItemExtractor extractor) - implements ContainerSchemaStrategy { - - private static final SupportedContainerType CONTAINER_TYPE = - new SupportedContainerType(List.class, LIST, LIST); - - @Override - public SupportedContainerType containerType() { - return CONTAINER_TYPE; - } -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/PageContainerSchemaStrategy.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/PageContainerSchemaStrategy.java deleted file mode 100644 index 8b22433c..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/PageContainerSchemaStrategy.java +++ /dev/null @@ -1,20 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.strategy; - -import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.ContainerNames.PAGE; - -import io.github.blueprintplatform.openapi.generics.contract.paging.Page; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.ItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.ContainerSchemaResolver; - -public record PageContainerSchemaStrategy(ContainerSchemaResolver resolver, ItemExtractor extractor) - implements ContainerSchemaStrategy { - - private static final SupportedContainerType CONTAINER_TYPE = - new SupportedContainerType(Page.class, PAGE, PAGE); - - @Override - public SupportedContainerType containerType() { - return CONTAINER_TYPE; - } -} diff --git a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/SetContainerSchemaStrategy.java b/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/SetContainerSchemaStrategy.java deleted file mode 100644 index dc4c54fb..00000000 --- a/openapi-generics-server-starter/src/main/java/io/github/blueprintplatform/openapi/generics/server/core/schema/strategy/SetContainerSchemaStrategy.java +++ /dev/null @@ -1,20 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema.strategy; - -import static io.github.blueprintplatform.openapi.generics.server.core.schema.constant.ContainerNames.SET; - -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.ItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.ContainerSchemaResolver; -import java.util.Set; - -public record SetContainerSchemaStrategy(ContainerSchemaResolver resolver, ItemExtractor extractor) - implements ContainerSchemaStrategy { - - private static final SupportedContainerType CONTAINER_TYPE = - new SupportedContainerType(Set.class, SET, SET); - - @Override - public SupportedContainerType containerType() { - return CONTAINER_TYPE; - } -} diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfigurationTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfigurationTest.java index c21c6314..5b1e2e19 100644 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfigurationTest.java +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsAutoConfigurationTest.java @@ -8,12 +8,14 @@ import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeIntrospector; import io.github.blueprintplatform.openapi.generics.server.core.pipeline.OpenApiPipelineOrchestrator; import io.github.blueprintplatform.openapi.generics.server.core.schema.ContractSchemaExclusionApplier; -import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaEnricher; import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaProcessor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.ContainerSchemaRegistry; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.ContainerSchemaMetadataResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.WrapperSchemaEnricher; +import io.github.blueprintplatform.openapi.generics.server.core.schema.extraction.ArrayItemReferenceExtractor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.ComponentContainerSchemaResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.WrapperPayloadArraySchemaResolver; import io.github.blueprintplatform.openapi.generics.server.core.validation.OpenApiContractGuard; import io.github.blueprintplatform.openapi.generics.server.mvc.MvcResponseTypeDiscoveryStrategy; -import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -45,7 +47,10 @@ void shouldRegisterAllDefaultBeans() { assertThat(context).hasSingleBean(ResponseIntrospectionPolicy.class); assertThat(context).hasSingleBean(ResponseTypeIntrospector.class); assertThat(context).hasSingleBean(ContractSchemaExclusionApplier.class); - assertThat(context).hasSingleBean(ContainerSchemaRegistry.class); + assertThat(context).hasSingleBean(ArrayItemReferenceExtractor.class); + assertThat(context).hasSingleBean(ComponentContainerSchemaResolver.class); + assertThat(context).hasSingleBean(WrapperPayloadArraySchemaResolver.class); + assertThat(context).hasSingleBean(ContainerSchemaMetadataResolver.class); assertThat(context).hasSingleBean(WrapperSchemaEnricher.class); assertThat(context).hasSingleBean(WrapperSchemaProcessor.class); assertThat(context).hasSingleBean(OpenApiContractGuard.class); @@ -73,7 +78,10 @@ void shouldNotLoadWhenSpringdocMissing() { .withClassLoader(new FilteredClassLoader(OpenApiCustomizer.class)) .run( context -> { - assertThat(context).doesNotHaveBean(ContainerSchemaRegistry.class); + assertThat(context).doesNotHaveBean(ArrayItemReferenceExtractor.class); + assertThat(context).doesNotHaveBean(ComponentContainerSchemaResolver.class); + assertThat(context).doesNotHaveBean(WrapperPayloadArraySchemaResolver.class); + assertThat(context).doesNotHaveBean(ContainerSchemaMetadataResolver.class); assertThat(context).doesNotHaveBean(WrapperSchemaEnricher.class); assertThat(context).doesNotHaveBean(OpenApiPipelineOrchestrator.class); assertThat(context).doesNotHaveBean("openApiGenericsCustomizer"); @@ -143,7 +151,11 @@ OpenApiCustomizer openApiGenericsCustomizer() { static class CustomEnricherConfig { static final WrapperSchemaEnricher CUSTOM_ENRICHER = - new WrapperSchemaEnricher(new ContainerSchemaRegistry(List.of())); + new WrapperSchemaEnricher( + new ContainerSchemaMetadataResolver( + new WrapperPayloadArraySchemaResolver(), + new ComponentContainerSchemaResolver(), + new ArrayItemReferenceExtractor())); @Bean WrapperSchemaEnricher wrapperSchemaEnricher() { diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsSchemaAutoConfigurationTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsSchemaAutoConfigurationTest.java new file mode 100644 index 00000000..faddcf21 --- /dev/null +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/autoconfigure/OpenApiGenericsSchemaAutoConfigurationTest.java @@ -0,0 +1,227 @@ +package io.github.blueprintplatform.openapi.generics.server.autoconfigure; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaProcessor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.ContainerSchemaMetadataResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.WrapperSchemaEnricher; +import io.github.blueprintplatform.openapi.generics.server.core.schema.extraction.ArrayItemReferenceExtractor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.ComponentContainerSchemaResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.WrapperPayloadArraySchemaResolver; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.springdoc.core.customizers.OpenApiCustomizer; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Tag("unit") +@DisplayName("Unit Test: OpenApiGenericsSchemaAutoConfiguration") +class OpenApiGenericsSchemaAutoConfigurationTest { + + private final WebApplicationContextRunner contextRunner = + new WebApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(OpenApiGenericsSchemaAutoConfiguration.class)); + + @Test + @DisplayName("should register schema infrastructure beans when Springdoc is present") + void shouldRegisterSchemaInfrastructureBeans() { + contextRunner.run( + context -> { + assertThat(context).hasSingleBean(ArrayItemReferenceExtractor.class); + assertThat(context).hasSingleBean(ComponentContainerSchemaResolver.class); + assertThat(context).hasSingleBean(WrapperPayloadArraySchemaResolver.class); + assertThat(context).hasSingleBean(ContainerSchemaMetadataResolver.class); + assertThat(context).hasSingleBean(WrapperSchemaEnricher.class); + assertThat(context).hasSingleBean(WrapperSchemaProcessor.class); + }); + } + + @Test + @DisplayName("should not load schema auto-configuration when Springdoc is missing") + void shouldNotLoadWhenSpringdocMissing() { + contextRunner + .withClassLoader(new FilteredClassLoader(OpenApiCustomizer.class)) + .run( + context -> { + assertThat(context).doesNotHaveBean(ArrayItemReferenceExtractor.class); + assertThat(context).doesNotHaveBean(ComponentContainerSchemaResolver.class); + assertThat(context).doesNotHaveBean(WrapperPayloadArraySchemaResolver.class); + assertThat(context).doesNotHaveBean(ContainerSchemaMetadataResolver.class); + assertThat(context).doesNotHaveBean(WrapperSchemaEnricher.class); + assertThat(context).doesNotHaveBean(WrapperSchemaProcessor.class); + }); + } + + @Test + @DisplayName("should back off when user provides custom ArrayItemReferenceExtractor") + void shouldBackOffForCustomArrayItemReferenceExtractor() { + contextRunner + .withUserConfiguration(CustomArrayItemReferenceExtractorConfig.class) + .run( + context -> { + ArrayItemReferenceExtractor extractor = + context.getBean(ArrayItemReferenceExtractor.class); + + assertThat(extractor) + .isSameAs(CustomArrayItemReferenceExtractorConfig.CUSTOM_EXTRACTOR); + }); + } + + @Test + @DisplayName("should back off when user provides custom ComponentContainerSchemaResolver") + void shouldBackOffForCustomComponentContainerSchemaResolver() { + contextRunner + .withUserConfiguration(CustomComponentContainerSchemaResolverConfig.class) + .run( + context -> { + ComponentContainerSchemaResolver resolver = + context.getBean(ComponentContainerSchemaResolver.class); + + assertThat(resolver) + .isSameAs(CustomComponentContainerSchemaResolverConfig.CUSTOM_RESOLVER); + }); + } + + @Test + @DisplayName("should back off when user provides custom WrapperPayloadArraySchemaResolver") + void shouldBackOffForCustomWrapperPayloadArraySchemaResolver() { + contextRunner + .withUserConfiguration(CustomWrapperPayloadArraySchemaResolverConfig.class) + .run( + context -> { + WrapperPayloadArraySchemaResolver resolver = + context.getBean(WrapperPayloadArraySchemaResolver.class); + + assertThat(resolver) + .isSameAs(CustomWrapperPayloadArraySchemaResolverConfig.CUSTOM_RESOLVER); + }); + } + + @Test + @DisplayName("should back off when user provides custom ContainerSchemaMetadataResolver") + void shouldBackOffForCustomContainerSchemaMetadataResolver() { + contextRunner + .withUserConfiguration(CustomContainerSchemaMetadataResolverConfig.class) + .run( + context -> { + ContainerSchemaMetadataResolver resolver = + context.getBean(ContainerSchemaMetadataResolver.class); + + assertThat(resolver) + .isSameAs(CustomContainerSchemaMetadataResolverConfig.CUSTOM_RESOLVER); + }); + } + + @Test + @DisplayName("should back off when user provides custom WrapperSchemaEnricher") + void shouldBackOffForCustomWrapperSchemaEnricher() { + contextRunner + .withUserConfiguration(CustomWrapperSchemaEnricherConfig.class) + .run( + context -> { + WrapperSchemaEnricher enricher = context.getBean(WrapperSchemaEnricher.class); + + assertThat(enricher).isSameAs(CustomWrapperSchemaEnricherConfig.CUSTOM_ENRICHER); + }); + } + + @Test + @DisplayName("should back off when user provides custom WrapperSchemaProcessor") + void shouldBackOffForCustomWrapperSchemaProcessor() { + contextRunner + .withUserConfiguration(CustomWrapperSchemaProcessorConfig.class) + .run( + context -> { + WrapperSchemaProcessor processor = context.getBean(WrapperSchemaProcessor.class); + + assertThat(processor).isSameAs(CustomWrapperSchemaProcessorConfig.CUSTOM_PROCESSOR); + }); + } + + @Configuration + static class CustomArrayItemReferenceExtractorConfig { + + static final ArrayItemReferenceExtractor CUSTOM_EXTRACTOR = new ArrayItemReferenceExtractor(); + + @Bean + ArrayItemReferenceExtractor arrayItemReferenceExtractor() { + return CUSTOM_EXTRACTOR; + } + } + + @Configuration + static class CustomComponentContainerSchemaResolverConfig { + + static final ComponentContainerSchemaResolver CUSTOM_RESOLVER = + new ComponentContainerSchemaResolver(); + + @Bean + ComponentContainerSchemaResolver componentContainerSchemaResolver() { + return CUSTOM_RESOLVER; + } + } + + @Configuration + static class CustomWrapperPayloadArraySchemaResolverConfig { + + static final WrapperPayloadArraySchemaResolver CUSTOM_RESOLVER = + new WrapperPayloadArraySchemaResolver(); + + @Bean + WrapperPayloadArraySchemaResolver wrapperPayloadArraySchemaResolver() { + return CUSTOM_RESOLVER; + } + } + + @Configuration + static class CustomContainerSchemaMetadataResolverConfig { + + static final ContainerSchemaMetadataResolver CUSTOM_RESOLVER = + new ContainerSchemaMetadataResolver( + new WrapperPayloadArraySchemaResolver(), + new ComponentContainerSchemaResolver(), + new ArrayItemReferenceExtractor()); + + @Bean + ContainerSchemaMetadataResolver containerSchemaMetadataResolver() { + return CUSTOM_RESOLVER; + } + } + + @Configuration + static class CustomWrapperSchemaEnricherConfig { + + static final WrapperSchemaEnricher CUSTOM_ENRICHER = + new WrapperSchemaEnricher( + new ContainerSchemaMetadataResolver( + new WrapperPayloadArraySchemaResolver(), + new ComponentContainerSchemaResolver(), + new ArrayItemReferenceExtractor())); + + @Bean + WrapperSchemaEnricher wrapperSchemaEnricher() { + return CUSTOM_ENRICHER; + } + } + + @Configuration + static class CustomWrapperSchemaProcessorConfig { + + static final WrapperSchemaProcessor CUSTOM_PROCESSOR = + new WrapperSchemaProcessor( + new WrapperSchemaEnricher( + new ContainerSchemaMetadataResolver( + new WrapperPayloadArraySchemaResolver(), + new ComponentContainerSchemaResolver(), + new ArrayItemReferenceExtractor()))); + + @Bean + WrapperSchemaProcessor wrapperSchemaProcessor() { + return CUSTOM_PROCESSOR; + } + } +} diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolverTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolverTest.java index 5d151ab0..93226414 100644 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolverTest.java +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseIntrospectionPolicyResolverTest.java @@ -4,10 +4,15 @@ import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties.ContainerProperties; import io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties.EnvelopeProperties; import io.github.blueprintplatform.openapi.generics.server.autoconfigure.properties.OpenApiGenericsProperties; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.DefaultSupportedContainerTypesResolver; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver.ConfiguredContainerTypesResolver; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.resolver.DefaultSupportedContainerTypesResolver; import java.util.List; import java.util.Set; import org.junit.jupiter.api.DisplayName; @@ -19,29 +24,84 @@ class ResponseIntrospectionPolicyResolverTest { private final ResponseIntrospectionPolicyResolver resolver = - new ResponseIntrospectionPolicyResolver(new DefaultSupportedContainerTypesResolver()); + new ResponseIntrospectionPolicyResolver( + new DefaultSupportedContainerTypesResolver(), new ConfiguredContainerTypesResolver()); - private static Set defaultContainers() { + private static Set defaultContainers() { return Set.of( - new SupportedContainerType(Page.class, "Page", "Page"), - new SupportedContainerType(List.class, "List", "List"), - new SupportedContainerType(Set.class, "Set", "Set")); + new SupportedContainerDescriptor( + Page.class, + "Page", + "Page", + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), + new SupportedContainerDescriptor( + List.class, + "List", + "List", + ContainerShape.DIRECT_ARRAY, + null, + ContainerSource.BUILT_IN, + ContainerMatchMode.ASSIGNABLE), + new SupportedContainerDescriptor( + Set.class, + "Set", + "Set", + ContainerShape.DIRECT_ARRAY, + null, + ContainerSource.BUILT_IN, + ContainerMatchMode.ASSIGNABLE)); } @Test @DisplayName("resolve -> should use supported container resolver for default policy") void resolve_shouldUseSupportedContainerResolverForDefaultPolicy() { - - SupportedContainerType pageContainer = new SupportedContainerType(Page.class, "Page", "Page"); + SupportedContainerDescriptor pageContainer = + new SupportedContainerDescriptor( + Page.class, + "Page", + "Page", + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT); ResponseIntrospectionPolicyResolver customResolver = - new ResponseIntrospectionPolicyResolver(() -> Set.of(pageContainer)); + new ResponseIntrospectionPolicyResolver( + () -> Set.of(pageContainer), new ConfiguredContainerTypesResolver()); ResponseIntrospectionPolicy policy = customResolver.resolve(null); assertEquals(Set.of(pageContainer), policy.supportedContainers()); } + @Test + @DisplayName("resolve -> should include configured containers in default policy") + void resolve_shouldIncludeConfiguredContainersInDefaultPolicy() { + OpenApiGenericsProperties properties = + new OpenApiGenericsProperties( + null, List.of(new ContainerProperties(Paging.class.getName(), "content"))); + + ResponseIntrospectionPolicy policy = resolver.resolve(properties); + + SupportedContainerDescriptor expected = + new SupportedContainerDescriptor( + Paging.class, + "Paging", + "Paging", + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.CONFIGURED, + ContainerMatchMode.EXACT); + + assertEquals(ServiceResponse.class, policy.envelopeType()); + assertEquals("data", policy.payloadPropertyName()); + assertTrue(policy.supportedContainers().containsAll(defaultContainers())); + assertTrue(policy.supportedContainers().contains(expected)); + } + @Test @DisplayName("resolve -> should return default policy when properties are null") void resolve_shouldReturnDefaultPolicy_whenPropertiesNull() { @@ -55,7 +115,7 @@ void resolve_shouldReturnDefaultPolicy_whenPropertiesNull() { @Test @DisplayName("resolve -> should return default policy when envelope is missing") void resolve_shouldReturnDefaultPolicy_whenEnvelopeMissing() { - OpenApiGenericsProperties properties = new OpenApiGenericsProperties(null); + OpenApiGenericsProperties properties = new OpenApiGenericsProperties(null, null); ResponseIntrospectionPolicy policy = resolver.resolve(properties); @@ -68,7 +128,7 @@ void resolve_shouldReturnDefaultPolicy_whenEnvelopeMissing() { @DisplayName("resolve -> should return default policy when envelope type is blank") void resolve_shouldReturnDefaultPolicy_whenEnvelopeTypeBlank() { OpenApiGenericsProperties properties = - new OpenApiGenericsProperties(new EnvelopeProperties(" ")); + new OpenApiGenericsProperties(new EnvelopeProperties(" "), null); ResponseIntrospectionPolicy policy = resolver.resolve(properties); @@ -81,7 +141,7 @@ void resolve_shouldReturnDefaultPolicy_whenEnvelopeTypeBlank() { @DisplayName("resolve -> should resolve custom envelope with direct payload field") void resolve_shouldResolveCustomEnvelope_whenValid() { OpenApiGenericsProperties properties = - new OpenApiGenericsProperties(new EnvelopeProperties(ValidEnvelope.class.getName())); + new OpenApiGenericsProperties(new EnvelopeProperties(ValidEnvelope.class.getName()), null); ResponseIntrospectionPolicy policy = resolver.resolve(properties); @@ -90,11 +150,37 @@ void resolve_shouldResolveCustomEnvelope_whenValid() { assertEquals(defaultContainers(), policy.supportedContainers()); } + @Test + @DisplayName("resolve -> should include configured containers in custom envelope policy") + void resolve_shouldIncludeConfiguredContainersInCustomEnvelopePolicy() { + OpenApiGenericsProperties properties = + new OpenApiGenericsProperties( + new EnvelopeProperties(ValidEnvelope.class.getName()), + List.of(new ContainerProperties(Paging.class.getName(), "content"))); + + ResponseIntrospectionPolicy policy = resolver.resolve(properties); + + SupportedContainerDescriptor expected = + new SupportedContainerDescriptor( + Paging.class, + "Paging", + "Paging", + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.CONFIGURED, + ContainerMatchMode.EXACT); + + assertEquals(ValidEnvelope.class, policy.envelopeType()); + assertEquals("payload", policy.payloadPropertyName()); + assertTrue(policy.supportedContainers().containsAll(defaultContainers())); + assertTrue(policy.supportedContainers().contains(expected)); + } + @Test @DisplayName("resolve -> should reject non fqcn envelope type") void resolve_shouldRejectNonFqcnEnvelopeType() { OpenApiGenericsProperties properties = - new OpenApiGenericsProperties(new EnvelopeProperties("ApiResponse")); + new OpenApiGenericsProperties(new EnvelopeProperties("ApiResponse"), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -106,7 +192,7 @@ void resolve_shouldRejectNonFqcnEnvelopeType() { @DisplayName("resolve -> should reject missing envelope class") void resolve_shouldRejectMissingEnvelopeClass() { OpenApiGenericsProperties properties = - new OpenApiGenericsProperties(new EnvelopeProperties("com.example.DoesNotExist")); + new OpenApiGenericsProperties(new EnvelopeProperties("com.example.DoesNotExist"), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -119,7 +205,7 @@ void resolve_shouldRejectMissingEnvelopeClass() { void resolve_shouldRejectInterfaceEnvelope() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeInterface.class.getName())); + new EnvelopeProperties(InvalidEnvelopeInterface.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -131,7 +217,8 @@ void resolve_shouldRejectInterfaceEnvelope() { @DisplayName("resolve -> should reject abstract envelope") void resolve_shouldRejectAbstractEnvelope() { OpenApiGenericsProperties properties = - new OpenApiGenericsProperties(new EnvelopeProperties(AbstractEnvelope.class.getName())); + new OpenApiGenericsProperties( + new EnvelopeProperties(AbstractEnvelope.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -144,7 +231,7 @@ void resolve_shouldRejectAbstractEnvelope() { void resolve_shouldRejectRecordEnvelope() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeRecord.class.getName())); + new EnvelopeProperties(InvalidEnvelopeRecord.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -157,7 +244,7 @@ void resolve_shouldRejectRecordEnvelope() { void resolve_shouldRejectEnvelopeWithMultipleTypeParameters() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeMultipleTypes.class.getName())); + new EnvelopeProperties(InvalidEnvelopeMultipleTypes.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -170,7 +257,7 @@ void resolve_shouldRejectEnvelopeWithMultipleTypeParameters() { void resolve_shouldRejectEnvelopeWithoutDirectPayloadField() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeNoPayload.class.getName())); + new EnvelopeProperties(InvalidEnvelopeNoPayload.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -183,7 +270,7 @@ void resolve_shouldRejectEnvelopeWithoutDirectPayloadField() { void resolve_shouldRejectEnvelopeWithMultipleDirectPayloadFields() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeMultiplePayloads.class.getName())); + new EnvelopeProperties(InvalidEnvelopeMultiplePayloads.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -196,7 +283,7 @@ void resolve_shouldRejectEnvelopeWithMultipleDirectPayloadFields() { void resolve_shouldRejectEnvelopeWithNestedPayloadField() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeNestedPayload.class.getName())); + new EnvelopeProperties(InvalidEnvelopeNestedPayload.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -208,7 +295,8 @@ void resolve_shouldRejectEnvelopeWithNestedPayloadField() { @DisplayName("resolve -> should reject enum envelope") void resolve_shouldRejectEnumEnvelope() { OpenApiGenericsProperties properties = - new OpenApiGenericsProperties(new EnvelopeProperties(InvalidEnvelopeEnum.class.getName())); + new OpenApiGenericsProperties( + new EnvelopeProperties(InvalidEnvelopeEnum.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -221,7 +309,7 @@ void resolve_shouldRejectEnumEnvelope() { void resolve_shouldRejectAnnotationEnvelope() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeAnnotation.class.getName())); + new EnvelopeProperties(InvalidEnvelopeAnnotation.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -234,7 +322,7 @@ void resolve_shouldRejectAnnotationEnvelope() { void resolve_shouldRejectEnvelopeWithZeroTypeParameters() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeNoGenerics.class.getName())); + new EnvelopeProperties(InvalidEnvelopeNoGenerics.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -247,7 +335,7 @@ void resolve_shouldRejectEnvelopeWithZeroTypeParameters() { void resolve_shouldIgnoreStaticFields() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(EnvelopeWithStaticField.class.getName())); + new EnvelopeProperties(EnvelopeWithStaticField.class.getName()), null); ResponseIntrospectionPolicy policy = resolver.resolve(properties); @@ -260,7 +348,7 @@ void resolve_shouldIgnoreStaticFields() { void resolve_shouldDetectNestedPayloadInGenericArray() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeGenericArrayPayload.class.getName())); + new EnvelopeProperties(InvalidEnvelopeGenericArrayPayload.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -273,7 +361,7 @@ void resolve_shouldDetectNestedPayloadInGenericArray() { void resolve_shouldDetectDeeplyNestedPayload() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(InvalidEnvelopeDeeplyNestedPayload.class.getName())); + new EnvelopeProperties(InvalidEnvelopeDeeplyNestedPayload.class.getName()), null); IllegalStateException ex = assertThrows(IllegalStateException.class, () -> resolver.resolve(properties)); @@ -286,7 +374,7 @@ void resolve_shouldDetectDeeplyNestedPayload() { void resolve_shouldAcceptEnvelopeWithUnrelatedGenerics() { OpenApiGenericsProperties properties = new OpenApiGenericsProperties( - new EnvelopeProperties(EnvelopeWithUnrelatedGenericField.class.getName())); + new EnvelopeProperties(EnvelopeWithUnrelatedGenericField.class.getName()), null); ResponseIntrospectionPolicy policy = resolver.resolve(properties); @@ -297,13 +385,22 @@ void resolve_shouldAcceptEnvelopeWithUnrelatedGenerics() { @Test @DisplayName("resolve -> should use supported container resolver for custom envelope policy") void resolve_shouldUseSupportedContainerResolverForCustomEnvelopePolicy() { - SupportedContainerType setContainer = new SupportedContainerType(Set.class, "Set", "Set"); + SupportedContainerDescriptor setContainer = + new SupportedContainerDescriptor( + Set.class, + "Set", + "Set", + ContainerShape.DIRECT_ARRAY, + null, + ContainerSource.BUILT_IN, + ContainerMatchMode.ASSIGNABLE); ResponseIntrospectionPolicyResolver customResolver = - new ResponseIntrospectionPolicyResolver(() -> Set.of(setContainer)); + new ResponseIntrospectionPolicyResolver( + () -> Set.of(setContainer), new ConfiguredContainerTypesResolver()); OpenApiGenericsProperties properties = - new OpenApiGenericsProperties(new EnvelopeProperties(ValidEnvelope.class.getName())); + new OpenApiGenericsProperties(new EnvelopeProperties(ValidEnvelope.class.getName()), null); ResponseIntrospectionPolicy policy = customResolver.resolve(properties); @@ -361,6 +458,7 @@ static final class InvalidEnvelopeNoGenerics { } static final class EnvelopeWithStaticField { + static String ignored; T payload; } @@ -374,6 +472,10 @@ static final class InvalidEnvelopeDeeplyNestedPayload { static final class EnvelopeWithUnrelatedGenericField { T payload; - java.util.List tags; // String is not T, should not be classified as payload + java.util.List tags; + } + + static final class Paging { + List content; } } diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospectorTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospectorTest.java index c119e31b..9d4e922c 100644 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospectorTest.java +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/introspection/ResponseTypeIntrospectorTest.java @@ -4,7 +4,10 @@ import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.Future; @@ -20,11 +23,32 @@ @DisplayName("Unit Test: ResponseTypeIntrospector") class ResponseTypeIntrospectorTest { + private static final SupportedContainerDescriptor PAGE_CONTAINER = + new SupportedContainerDescriptor( + Page.class, + "Page", + "Page", + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT); + + private static final SupportedContainerDescriptor PAGING_CONTAINER = + new SupportedContainerDescriptor( + Paging.class, + "Paging", + "Paging", + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.CONFIGURED, + ContainerMatchMode.EXACT); + private static final ResponseIntrospectionPolicy DEFAULT_POLICY = + new ResponseIntrospectionPolicy(ServiceResponse.class, "data", Set.of(PAGE_CONTAINER)); + + private static final ResponseIntrospectionPolicy CUSTOM_CONTAINER_POLICY = new ResponseIntrospectionPolicy( - ServiceResponse.class, - "data", - Set.of(new SupportedContainerType(Page.class, "Page", "Page"))); + ServiceResponse.class, "data", Set.of(PAGE_CONTAINER, PAGING_CONTAINER)); private final ResponseTypeIntrospector introspector = new ResponseTypeIntrospector(DEFAULT_POLICY); @@ -60,6 +84,30 @@ void extract_shouldReturnContainerDescriptor_forPageEnvelope() { assertEquals("data", descriptor.payloadPropertyName()); assertEquals("PageCustomerDto", descriptor.dataRefName()); assertEquals("Page", descriptor.containerName()); + assertEquals(Page.class.getName(), descriptor.containerTypeName()); + assertEquals("CustomerDto", descriptor.itemRefName()); + assertTrue(descriptor.isContainer()); + } + + @Test + @DisplayName("extract -> should return container descriptor for configured container") + void extract_shouldReturnContainerDescriptor_forConfiguredContainer() { + ResponseTypeIntrospector customIntrospector = + new ResponseTypeIntrospector(CUSTOM_CONTAINER_POLICY); + + ResolvableType pagingType = + ResolvableType.forClassWithGenerics( + Paging.class, ResolvableType.forClass(CustomerDto.class)); + + ResolvableType type = ResolvableType.forClassWithGenerics(ServiceResponse.class, pagingType); + + ResponseTypeDescriptor descriptor = customIntrospector.extract(type).orElseThrow(); + + assertEquals(ServiceResponse.class, descriptor.envelopeType()); + assertEquals("data", descriptor.payloadPropertyName()); + assertEquals("PagingCustomerDto", descriptor.dataRefName()); + assertEquals("Paging", descriptor.containerName()); + assertEquals(Paging.class.getName(), descriptor.containerTypeName()); assertEquals("CustomerDto", descriptor.itemRefName()); assertTrue(descriptor.isContainer()); } @@ -160,6 +208,18 @@ void extract_shouldReturnEmpty_forUnsupportedNestedGenericPayload() { assertTrue(introspector.extract(type).isEmpty()); } + @Test + @DisplayName("extract -> should return empty for unregistered generic container") + void extract_shouldReturnEmpty_forUnregisteredGenericContainer() { + ResolvableType pagingType = + ResolvableType.forClassWithGenerics( + Paging.class, ResolvableType.forClass(CustomerDto.class)); + + ResolvableType type = ResolvableType.forClassWithGenerics(ServiceResponse.class, pagingType); + + assertTrue(introspector.extract(type).isEmpty()); + } + @Test @DisplayName("extract -> should return empty when container item type is unresolved") void extract_shouldReturnEmpty_whenContainerItemTypeUnresolved() { @@ -172,4 +232,8 @@ void extract_shouldReturnEmpty_whenContainerItemTypeUnresolved() { private static final class CustomerDto {} private static final class Wrapper {} + + private static final class Paging { + java.util.List content; + } } diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestratorTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestratorTest.java index 99497267..28dc5dd7 100644 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestratorTest.java +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/pipeline/OpenApiPipelineOrchestratorTest.java @@ -9,7 +9,10 @@ import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDiscoveryStrategy; import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeIntrospector; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import io.github.blueprintplatform.openapi.generics.server.core.schema.ContractSchemaExclusionApplier; import io.github.blueprintplatform.openapi.generics.server.core.schema.WrapperSchemaProcessor; import io.github.blueprintplatform.openapi.generics.server.core.validation.OpenApiContractGuard; @@ -50,7 +53,14 @@ void run_shouldExecuteFullPipeline() { ResponseTypeDescriptor.container( ServiceResponse.class, "data", - new SupportedContainerType(Page.class, PAGE, PAGE), + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "OrderDto"); when(discoveryStrategy.discover()).thenReturn(Set.of(type1, type2)); diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/ContractSchemaExclusionApplierTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/ContractSchemaExclusionApplierTest.java index 63c471da..75cf2b46 100644 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/ContractSchemaExclusionApplierTest.java +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/ContractSchemaExclusionApplierTest.java @@ -6,7 +6,10 @@ import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import io.github.blueprintplatform.openapi.generics.server.core.schema.constant.VendorExtensions; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; @@ -67,7 +70,14 @@ void apply_shouldIgnoreContainerSchema_forContainerResponse() { ResponseTypeDescriptor.container( ServiceResponse.class, "data", - new SupportedContainerType(Page.class, PAGE, PAGE), + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "CustomerDto"); marker.apply(openApi, Set.of(descriptor)); diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaEnricherTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaEnricherTest.java deleted file mode 100644 index 771909f3..00000000 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaEnricherTest.java +++ /dev/null @@ -1,476 +0,0 @@ -package io.github.blueprintplatform.openapi.generics.server.core.schema; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; - -import io.github.blueprintplatform.openapi.generics.contract.paging.Page; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; -import io.github.blueprintplatform.openapi.generics.server.core.schema.constant.VendorExtensions; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.ContentArrayItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.extractor.DirectArrayItemExtractor; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.ComponentContainerSchemaResolver; -import io.github.blueprintplatform.openapi.generics.server.core.schema.resolver.WrapperPayloadArraySchemaResolver; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.ContainerSchemaRegistry; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.ContainerSchemaStrategy; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.ListContainerSchemaStrategy; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.PageContainerSchemaStrategy; -import io.github.blueprintplatform.openapi.generics.server.core.schema.strategy.SetContainerSchemaStrategy; -import io.swagger.v3.oas.models.Components; -import io.swagger.v3.oas.models.OpenAPI; -import io.swagger.v3.oas.models.media.ArraySchema; -import io.swagger.v3.oas.models.media.ComposedSchema; -import io.swagger.v3.oas.models.media.JsonSchema; -import io.swagger.v3.oas.models.media.ObjectSchema; -import io.swagger.v3.oas.models.media.Schema; -import java.util.LinkedHashMap; -import java.util.Map; -import java.util.Set; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Tag; -import org.junit.jupiter.api.Test; - -@Tag("unit") -@DisplayName("Unit Test: WrapperSchemaEnricher") -class WrapperSchemaEnricherTest { - - @Test - @DisplayName("enrich -> should add container extensions for default Page schema") - void enrich_shouldAddContainerExtensions_forPageSchema() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - OpenAPI openApi = - openApi( - schema("PageCustomerDto", pageSchemaWithArrayContentRef("CustomerDto")), - schema("ServiceResponsePageCustomerDto", new ObjectSchema())); - - enricher.enrich(openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponsePageCustomerDto"); - - assertContainerMetadata(wrapper, "Page", Page.class.getName(), "CustomerDto"); - } - - @Test - @DisplayName("enrich -> should support List container") - void enrich_shouldSupportListContainer() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - ArraySchema listSchema = arraySchemaWithItemRef("CustomerDto"); - - ObjectSchema wrapperSchema = new ObjectSchema(); - wrapperSchema.addProperty("data", listSchema); - - OpenAPI openApi = - openApi( - schema("ListCustomerDto", listSchema), - schema("ServiceResponseListCustomerDto", wrapperSchema)); - - enricher.enrich(openApi, "ServiceResponseListCustomerDto", listDescriptor("CustomerDto")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponseListCustomerDto"); - - assertContainerMetadata(wrapper, "List", java.util.List.class.getName(), "CustomerDto"); - } - - @Test - @DisplayName("enrich -> should resolve allOf object-like schema") - void enrich_shouldResolveAllOfSchema() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - ComposedSchema pageComposed = new ComposedSchema(); - pageComposed.addAllOfItem(pageSchemaWithArrayContentRef("CustomerDto")); - - OpenAPI openApi = - openApi( - schema("PageCustomerDto", pageComposed), - schema("ServiceResponsePageCustomerDto", new ObjectSchema())); - - enricher.enrich(openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponsePageCustomerDto"); - - assertContainerMetadata(wrapper, "Page", Page.class.getName(), "CustomerDto"); - } - - @Test - @DisplayName("enrich -> should support custom container strategy") - void enrich_shouldSupportCustomContainerStrategy() { - ContainerSchemaStrategy sliceStrategy = - new ContainerSchemaStrategy() { - - private final SupportedContainerType containerType = - new SupportedContainerType(Slice.class, "Slice", "Slice"); - - private final ComponentContainerSchemaResolver resolver = - new ComponentContainerSchemaResolver(); - - private final DirectArrayItemExtractor extractor = new DirectArrayItemExtractor(); - - @Override - public SupportedContainerType containerType() { - return containerType; - } - - @Override - public ComponentContainerSchemaResolver resolver() { - return resolver; - } - - @Override - public DirectArrayItemExtractor extractor() { - return extractor; - } - }; - - WrapperSchemaEnricher enricher = - new WrapperSchemaEnricher(new ContainerSchemaRegistry(java.util.List.of(sliceStrategy))); - - ArraySchema sliceSchema = arraySchemaWithItemRef("CustomerDto"); - - OpenAPI openApi = - openApi( - schema("SliceCustomerDto", sliceSchema), - schema("ApiResponseSliceCustomerDto", new ObjectSchema())); - - enricher.enrich( - openApi, - "ApiResponseSliceCustomerDto", - containerDescriptor(Slice.class, "Slice", "CustomerDto")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ApiResponseSliceCustomerDto"); - - assertContainerMetadata(wrapper, "Slice", Slice.class.getName(), "CustomerDto"); - } - - @Test - @DisplayName("enrich -> should ignore unsupported container name") - void enrich_shouldIgnoreUnsupportedContainerName() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - ObjectSchema unsupportedSchema = new ObjectSchema(); - unsupportedSchema.addProperty("content", arraySchemaWithItemRef("CustomerDto")); - - OpenAPI openApi = - openApi( - schema("FooBarCustomerDto", unsupportedSchema), - schema("ServiceResponseFooBarCustomerDto", new ObjectSchema())); - - enricher.enrich( - openApi, - "ServiceResponseFooBarCustomerDto", - containerDescriptor(FooBar.class, "FooBar", "CustomerDto")); - - Schema wrapper = - openApi.getComponents().getSchemas().get("ServiceResponseFooBarCustomerDto"); - - assertNull(wrapper.getExtensions()); - } - - @Test - @DisplayName("enrich -> should ignore when wrapper schema is missing") - void enrich_shouldIgnore_whenWrapperSchemaMissing() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - OpenAPI openApi = - openApi(schema("PageCustomerDto", pageSchemaWithArrayContentRef("CustomerDto"))); - - assertDoesNotThrow( - () -> enricher.enrich(openApi, "MissingWrapper", pageDescriptor("CustomerDto"))); - } - - @Test - @DisplayName("enrich -> should ignore when data schema is missing") - void enrich_shouldIgnore_whenDataSchemaMissing() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - OpenAPI openApi = openApi(schema("ServiceResponsePageCustomerDto", new ObjectSchema())); - - assertDoesNotThrow( - () -> - enricher.enrich( - openApi, - "ServiceResponsePageCustomerDto", - containerDescriptor(MissingData.class, "MissingData", "CustomerDto"))); - } - - @Test - @DisplayName("enrich -> should ignore when content property is missing") - void enrich_shouldIgnore_whenContentPropertyMissing() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - ObjectSchema pageSchema = new ObjectSchema(); - pageSchema.addProperty("totalElements", new Schema<>().type("integer")); - - OpenAPI openApi = - openApi( - schema("PageCustomerDto", pageSchema), - schema("ServiceResponsePageCustomerDto", new ObjectSchema())); - - enricher.enrich(openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponsePageCustomerDto"); - - assertNull(wrapper.getExtensions()); - } - - @Test - @DisplayName("enrich -> should extract item type from JsonSchema array content") - void enrich_shouldExtractItemType_fromJsonSchemaArray() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - JsonSchema content = new JsonSchema(); - content.setTypes(Set.of("array")); - content.setItems(new Schema<>().$ref("#/components/schemas/CustomerDto")); - - ObjectSchema pageSchema = new ObjectSchema(); - pageSchema.addProperty("content", content); - - OpenAPI openApi = - openApi( - schema("PageCustomerDto", pageSchema), - schema("ServiceResponsePageCustomerDto", new ObjectSchema())); - - enricher.enrich(openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponsePageCustomerDto"); - - assertContainerMetadata(wrapper, "Page", Page.class.getName(), "CustomerDto"); - } - - @Test - @DisplayName("enrich -> should do nothing for null inputs") - void enrich_shouldDoNothing_forNullInputs() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - assertDoesNotThrow(() -> enricher.enrich(null, "Wrapper", pageDescriptor("CustomerDto"))); - - OpenAPI openApi = - openApi( - schema("PageCustomerDto", pageSchemaWithArrayContentRef("CustomerDto")), - schema("ServiceResponsePageCustomerDto", new ObjectSchema())); - - assertDoesNotThrow(() -> enricher.enrich(openApi, null, pageDescriptor("CustomerDto"))); - assertDoesNotThrow(() -> enricher.enrich(openApi, "ServiceResponsePageCustomerDto", null)); - assertDoesNotThrow( - () -> - enricher.enrich( - openApi, - "ServiceResponsePageCustomerDto", - ResponseTypeDescriptor.simple(ServiceResponse.class, "data", "CustomerDto"))); - } - - @Test - @DisplayName("enrich -> should do nothing when components are null") - void enrich_shouldDoNothing_whenComponentsNull() { - WrapperSchemaEnricher enricher = defaultEnricher(); - OpenAPI openApi = new OpenAPI(); - - assertDoesNotThrow( - () -> - enricher.enrich( - openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto"))); - } - - @Test - @DisplayName("enrich -> should do nothing when schemas map is empty") - void enrich_shouldDoNothing_whenSchemasEmpty() { - WrapperSchemaEnricher enricher = defaultEnricher(); - OpenAPI openApi = new OpenAPI().components(new Components()); - - assertDoesNotThrow( - () -> - enricher.enrich( - openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto"))); - } - - @Test - @DisplayName("constructor -> should work with custom strategy registry") - void constructor_shouldWorkWithCustomStrategyRegistry() { - ContainerSchemaStrategy customListStrategy = - new ContainerSchemaStrategy() { - - private final SupportedContainerType containerType = - new SupportedContainerType(CustomList.class, "CustomList", "CustomList"); - - private final ComponentContainerSchemaResolver resolver = - new ComponentContainerSchemaResolver(); - - private final DirectArrayItemExtractor extractor = new DirectArrayItemExtractor(); - - @Override - public SupportedContainerType containerType() { - return containerType; - } - - @Override - public ComponentContainerSchemaResolver resolver() { - return resolver; - } - - @Override - public DirectArrayItemExtractor extractor() { - return extractor; - } - }; - - WrapperSchemaEnricher enricher = - new WrapperSchemaEnricher( - new ContainerSchemaRegistry(java.util.List.of(customListStrategy))); - - ArraySchema customListSchema = arraySchemaWithItemRef("CustomerDto"); - - OpenAPI openApi = - openApi( - schema("CustomListCustomerDto", customListSchema), - schema("ServiceResponseCustomListCustomerDto", new ObjectSchema())); - - enricher.enrich( - openApi, - "ServiceResponseCustomListCustomerDto", - containerDescriptor(CustomList.class, "CustomList", "CustomerDto")); - - Schema wrapper = - openApi.getComponents().getSchemas().get("ServiceResponseCustomListCustomerDto"); - - assertContainerMetadata(wrapper, "CustomList", CustomList.class.getName(), "CustomerDto"); - } - - @Test - @DisplayName("enrich -> should use custom payload property for array container") - void enrich_shouldUseCustomPayloadProperty_forArrayContainer() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - ArraySchema listSchema = arraySchemaWithItemRef("CustomerDto"); - - ObjectSchema wrapperSchema = new ObjectSchema(); - wrapperSchema.addProperty("payload", listSchema); - - OpenAPI openApi = - openApi( - schema("ListCustomerDto", listSchema), - schema("ApiResponseListCustomerDto", wrapperSchema)); - - enricher.enrich( - openApi, "ApiResponseListCustomerDto", listDescriptor("CustomerDto", "payload")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ApiResponseListCustomerDto"); - - assertContainerMetadata(wrapper, "List", java.util.List.class.getName(), "CustomerDto"); - } - - @Test - @DisplayName("enrich -> should support Set container") - void enrich_shouldSupportSetContainer() { - WrapperSchemaEnricher enricher = defaultEnricher(); - - ArraySchema setSchema = arraySchemaWithItemRef("CustomerDto"); - - ObjectSchema wrapperSchema = new ObjectSchema(); - wrapperSchema.addProperty("data", setSchema); - - OpenAPI openApi = - openApi( - schema("SetCustomerDto", setSchema), - schema("ServiceResponseSetCustomerDto", wrapperSchema)); - - enricher.enrich(openApi, "ServiceResponseSetCustomerDto", setDescriptor("CustomerDto")); - - Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponseSetCustomerDto"); - - assertContainerMetadata(wrapper, "Set", java.util.Set.class.getName(), "CustomerDto"); - } - - private WrapperSchemaEnricher defaultEnricher() { - return new WrapperSchemaEnricher( - new ContainerSchemaRegistry( - java.util.List.of( - new PageContainerSchemaStrategy( - new ComponentContainerSchemaResolver(), new ContentArrayItemExtractor()), - new ListContainerSchemaStrategy( - new WrapperPayloadArraySchemaResolver(), new DirectArrayItemExtractor()), - new SetContainerSchemaStrategy( - new WrapperPayloadArraySchemaResolver(), new DirectArrayItemExtractor())))); - } - - private ResponseTypeDescriptor pageDescriptor(String itemRefName) { - return containerDescriptor(Page.class, "Page", itemRefName); - } - - private ResponseTypeDescriptor listDescriptor(String itemRefName) { - return listDescriptor(itemRefName, "data"); - } - - private ResponseTypeDescriptor listDescriptor(String itemRefName, String payloadPropertyName) { - return containerDescriptor(java.util.List.class, "List", itemRefName, payloadPropertyName); - } - - private ResponseTypeDescriptor setDescriptor(String itemRefName) { - return containerDescriptor(java.util.Set.class, "Set", itemRefName); - } - - private ResponseTypeDescriptor containerDescriptor( - Class containerClass, String containerName, String itemRefName) { - return containerDescriptor(containerClass, containerName, itemRefName, "data"); - } - - private ResponseTypeDescriptor containerDescriptor( - Class containerClass, - String containerName, - String itemRefName, - String payloadPropertyName) { - SupportedContainerType containerType = - new SupportedContainerType(containerClass, containerName, containerName); - - return ResponseTypeDescriptor.container( - ServiceResponse.class, payloadPropertyName, containerType, itemRefName); - } - - private void assertContainerMetadata( - Schema wrapper, String containerName, String containerTypeName, String itemName) { - assertEquals(containerName, wrapper.getExtensions().get(VendorExtensions.DATA_CONTAINER)); - assertEquals( - containerTypeName, wrapper.getExtensions().get(VendorExtensions.DATA_CONTAINER_TYPE)); - assertEquals(itemName, wrapper.getExtensions().get(VendorExtensions.DATA_ITEM)); - } - - private OpenAPI openApi(NamedSchema... namedSchemas) { - Map schemas = new LinkedHashMap<>(); - - for (NamedSchema namedSchema : namedSchemas) { - schemas.put(namedSchema.name, namedSchema.schema); - } - - return new OpenAPI().components(new Components().schemas(schemas)); - } - - private NamedSchema schema(String name, Schema schema) { - schema.setName(name); - return new NamedSchema(name, schema); - } - - private ObjectSchema pageSchemaWithArrayContentRef(String itemRefName) { - ObjectSchema pageSchema = new ObjectSchema(); - pageSchema.addProperty("content", arraySchemaWithItemRef(itemRefName)); - return pageSchema; - } - - private ArraySchema arraySchemaWithItemRef(String itemRefName) { - ArraySchema schema = new ArraySchema(); - schema.setItems(new Schema<>().$ref("#/components/schemas/" + itemRefName)); - return schema; - } - - private record NamedSchema(String name, Schema schema) {} - - private static final class ServiceResponse {} - - private static final class Slice {} - - private static final class FooBar {} - - private static final class MissingData {} - - private static final class CustomList {} -} diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessorTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessorTest.java index eebede95..e1f2bb7d 100644 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessorTest.java +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/WrapperSchemaProcessorTest.java @@ -7,8 +7,12 @@ import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import io.github.blueprintplatform.openapi.generics.server.core.schema.constant.VendorExtensions; +import io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment.WrapperSchemaEnricher; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.Schema; @@ -76,7 +80,14 @@ void process_shouldDelegateContainerEnrichment_forDefaultEnvelopeContainerRespon ResponseTypeDescriptor.container( ServiceResponse.class, "data", - new SupportedContainerType(Page.class, PAGE, PAGE), + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "CustomerDto"); processor.process(openApi, descriptor); @@ -120,8 +131,15 @@ void process_shouldDelegateContainerEnrichment_forCustomEnvelopeContainerRespons ResponseTypeDescriptor descriptor = ResponseTypeDescriptor.container( ApiResponse.class, - "payload", - new SupportedContainerType(Page.class, PAGE, PAGE), + "data", + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "CustomerDto"); processor.process(openApi, descriptor); diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/WrapperSchemaEnricherTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/WrapperSchemaEnricherTest.java new file mode 100644 index 00000000..f2010eac --- /dev/null +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/enrichment/WrapperSchemaEnricherTest.java @@ -0,0 +1,342 @@ +package io.github.blueprintplatform.openapi.generics.server.core.schema.enrichment; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.github.blueprintplatform.openapi.generics.contract.paging.Page; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.constant.VendorExtensions; +import io.github.blueprintplatform.openapi.generics.server.core.schema.extraction.ArrayItemReferenceExtractor; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.ComponentContainerSchemaResolver; +import io.github.blueprintplatform.openapi.generics.server.core.schema.resolution.WrapperPayloadArraySchemaResolver; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.util.LinkedHashMap; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: WrapperSchemaEnricher") +class WrapperSchemaEnricherTest { + + @Test + @DisplayName("enrich -> should do nothing when openApi is null") + void enrich_shouldDoNothing_whenOpenApiIsNull() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + assertDoesNotThrow( + () -> + enricher.enrich(null, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto"))); + } + + @Test + @DisplayName("enrich -> should do nothing when wrapper name is null") + void enrich_shouldDoNothing_whenWrapperNameIsNull() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = + openApi( + schema("PageCustomerDto", objectContainerSchema("content", "CustomerDto")), + schema("ServiceResponsePageCustomerDto", new ObjectSchema())); + + assertDoesNotThrow(() -> enricher.enrich(openApi, null, pageDescriptor("CustomerDto"))); + } + + @Test + @DisplayName("enrich -> should do nothing when descriptor is null") + void enrich_shouldDoNothing_whenDescriptorIsNull() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = + openApi( + schema("PageCustomerDto", objectContainerSchema("content", "CustomerDto")), + schema("ServiceResponsePageCustomerDto", new ObjectSchema())); + + assertDoesNotThrow(() -> enricher.enrich(openApi, "ServiceResponsePageCustomerDto", null)); + } + + @Test + @DisplayName("enrich -> should ignore when wrapper schema is missing") + void enrich_shouldIgnore_whenWrapperSchemaMissing() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = + openApi(schema("PageCustomerDto", objectContainerSchema("content", "CustomerDto"))); + + assertDoesNotThrow( + () -> enricher.enrich(openApi, "MissingWrapper", pageDescriptor("CustomerDto"))); + } + + @Test + @DisplayName("enrich -> should ignore when direct array payload property is missing") + void enrich_shouldIgnore_whenDirectArrayPayloadPropertyMissing() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + ArraySchema listSchema = arraySchemaWithItemRef("CustomerDto"); + + ObjectSchema wrapper = new ObjectSchema(); + wrapper.addProperty("payload", listSchema); + + OpenAPI openApi = + openApi( + schema("ListCustomerDto", listSchema), + schema("ServiceResponseListCustomerDto", wrapper)); + + enricher.enrich(openApi, "ServiceResponseListCustomerDto", listDescriptor("CustomerDto")); + + Schema result = openApi.getComponents().getSchemas().get("ServiceResponseListCustomerDto"); + + assertNull(result.getExtensions()); + } + + @Test + @DisplayName("enrich -> should ignore non-container descriptor") + void enrich_shouldIgnoreNonContainerDescriptor() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = openApi(schema("ServiceResponseCustomerDto", new ObjectSchema())); + + ResponseTypeDescriptor descriptor = + ResponseTypeDescriptor.simple(ServiceResponse.class, "data", "CustomerDto"); + + assertDoesNotThrow(() -> enricher.enrich(openApi, "ServiceResponseCustomerDto", descriptor)); + + Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponseCustomerDto"); + + assertNull(wrapper.getExtensions()); + } + + @Test + @DisplayName("enrich -> should add metadata for object container") + void enrich_shouldAddMetadata_forObjectContainer() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = + openApi( + schema("PageCustomerDto", objectContainerSchema("content", "CustomerDto")), + schema("ServiceResponsePageCustomerDto", new ObjectSchema())); + + enricher.enrich(openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto")); + + Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponsePageCustomerDto"); + + assertContainerMetadata(wrapper, "Page", Page.class.getName(), "CustomerDto"); + } + + @Test + @DisplayName("enrich -> should add metadata for direct array container") + void enrich_shouldAddMetadata_forDirectArrayContainer() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + ArraySchema arraySchema = arraySchemaWithItemRef("CustomerDto"); + + ObjectSchema wrapperSchema = new ObjectSchema(); + wrapperSchema.addProperty("data", arraySchema); + + OpenAPI openApi = + openApi( + schema("ListCustomerDto", arraySchema), + schema("ServiceResponseListCustomerDto", wrapperSchema)); + + enricher.enrich(openApi, "ServiceResponseListCustomerDto", listDescriptor("CustomerDto")); + + Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponseListCustomerDto"); + + assertContainerMetadata(wrapper, "List", java.util.List.class.getName(), "CustomerDto"); + } + + @Test + @DisplayName("enrich -> should add metadata for configured object container") + void enrich_shouldAddMetadata_forConfiguredObjectContainer() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = + openApi( + schema("PagingCustomerDto", objectContainerSchema("items", "CustomerDto")), + schema("ServiceResponsePagingCustomerDto", new ObjectSchema())); + + enricher.enrich( + openApi, + "ServiceResponsePagingCustomerDto", + objectContainerDescriptor(Paging.class, "Paging", "items", "CustomerDto")); + + Schema wrapper = + openApi.getComponents().getSchemas().get("ServiceResponsePagingCustomerDto"); + + assertContainerMetadata(wrapper, "Paging", Paging.class.getName(), "CustomerDto"); + } + + @Test + @DisplayName("enrich -> should ignore when metadata cannot be resolved") + void enrich_shouldIgnore_whenMetadataCannotBeResolved() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = + openApi( + schema("PageCustomerDto", new ObjectSchema()), + schema("ServiceResponsePageCustomerDto", new ObjectSchema())); + + assertDoesNotThrow( + () -> + enricher.enrich( + openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto"))); + + Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponsePageCustomerDto"); + + assertNull(wrapper.getExtensions()); + } + + @Test + @DisplayName("enrich -> should resolve object container through component ref") + void enrich_shouldResolveObjectContainer_throughComponentRef() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + Schema pageRef = new Schema<>().$ref("#/components/schemas/PageCustomerDto"); + + OpenAPI openApi = + openApi( + schema("PageCustomerDto", objectContainerSchema("content", "CustomerDto")), + schema("ServiceResponsePageCustomerDto", pageRef)); + + enricher.enrich(openApi, "ServiceResponsePageCustomerDto", pageDescriptor("CustomerDto")); + + Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponsePageCustomerDto"); + + assertContainerMetadata(wrapper, "Page", Page.class.getName(), "CustomerDto"); + } + + @Test + @DisplayName("enrich -> should ignore when array item is not component ref") + void enrich_shouldIgnore_whenArrayItemIsNotComponentRef() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + ArraySchema arraySchema = new ArraySchema(); + arraySchema.setItems(new Schema<>().type("string")); + + ObjectSchema wrapperSchema = new ObjectSchema(); + wrapperSchema.addProperty("data", arraySchema); + + OpenAPI openApi = + openApi( + schema("ListString", arraySchema), schema("ServiceResponseListString", wrapperSchema)); + + enricher.enrich(openApi, "ServiceResponseListString", listDescriptor("String")); + + Schema wrapper = openApi.getComponents().getSchemas().get("ServiceResponseListString"); + + assertNull(wrapper.getExtensions()); + } + + @Test + @DisplayName("enrich -> should ignore when configured item property does not exist") + void enrich_shouldIgnore_whenConfiguredItemPropertyDoesNotExist() { + WrapperSchemaEnricher enricher = defaultEnricher(); + + OpenAPI openApi = + openApi( + schema("PagingCustomerDto", objectContainerSchema("content", "CustomerDto")), + schema("ServiceResponsePagingCustomerDto", new ObjectSchema())); + + enricher.enrich( + openApi, + "ServiceResponsePagingCustomerDto", + objectContainerDescriptor(Paging.class, "Paging", "items", "CustomerDto")); + + Schema wrapper = + openApi.getComponents().getSchemas().get("ServiceResponsePagingCustomerDto"); + + assertNull(wrapper.getExtensions()); + } + + private WrapperSchemaEnricher defaultEnricher() { + return new WrapperSchemaEnricher( + new ContainerSchemaMetadataResolver( + new WrapperPayloadArraySchemaResolver(), + new ComponentContainerSchemaResolver(), + new ArrayItemReferenceExtractor())); + } + + private ResponseTypeDescriptor pageDescriptor(String itemRefName) { + return objectContainerDescriptor(Page.class, "Page", "content", itemRefName); + } + + private ResponseTypeDescriptor listDescriptor(String itemRefName) { + SupportedContainerDescriptor container = + new SupportedContainerDescriptor( + java.util.List.class, + "List", + "List", + ContainerShape.DIRECT_ARRAY, + null, + ContainerSource.BUILT_IN, + ContainerMatchMode.ASSIGNABLE); + + return ResponseTypeDescriptor.container(ServiceResponse.class, "data", container, itemRefName); + } + + private ResponseTypeDescriptor objectContainerDescriptor( + Class containerClass, String containerName, String itemPropertyName, String itemRefName) { + SupportedContainerDescriptor container = + new SupportedContainerDescriptor( + containerClass, + containerName, + containerName, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + itemPropertyName, + ContainerSource.CONFIGURED, + ContainerMatchMode.EXACT); + + return ResponseTypeDescriptor.container(ServiceResponse.class, "data", container, itemRefName); + } + + private ObjectSchema objectContainerSchema(String itemPropertyName, String itemRefName) { + ObjectSchema schema = new ObjectSchema(); + schema.addProperty(itemPropertyName, arraySchemaWithItemRef(itemRefName)); + return schema; + } + + private ArraySchema arraySchemaWithItemRef(String itemRefName) { + ArraySchema schema = new ArraySchema(); + schema.setItems(new Schema<>().$ref("#/components/schemas/" + itemRefName)); + return schema; + } + + private void assertContainerMetadata( + Schema wrapper, String containerName, String containerTypeName, String itemName) { + assertEquals(containerName, wrapper.getExtensions().get(VendorExtensions.DATA_CONTAINER)); + assertEquals( + containerTypeName, wrapper.getExtensions().get(VendorExtensions.DATA_CONTAINER_TYPE)); + assertEquals(itemName, wrapper.getExtensions().get(VendorExtensions.DATA_ITEM)); + } + + private OpenAPI openApi(NamedSchema... namedSchemas) { + Map schemas = new LinkedHashMap<>(); + + for (NamedSchema namedSchema : namedSchemas) { + schemas.put(namedSchema.name, namedSchema.schema); + } + + return new OpenAPI().components(new Components().schemas(schemas)); + } + + private NamedSchema schema(String name, Schema schema) { + schema.setName(name); + return new NamedSchema(name, schema); + } + + private record NamedSchema(String name, Schema schema) {} + + private static final class ServiceResponse {} + + private static final class Paging {} +} diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ComponentContainerSchemaResolverTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ComponentContainerSchemaResolverTest.java new file mode 100644 index 00000000..7ed9b1fe --- /dev/null +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/schema/resolution/ComponentContainerSchemaResolverTest.java @@ -0,0 +1,211 @@ +package io.github.blueprintplatform.openapi.generics.server.core.schema.resolution; + +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.swagger.v3.oas.models.media.ArraySchema; +import io.swagger.v3.oas.models.media.ComposedSchema; +import io.swagger.v3.oas.models.media.JsonSchema; +import io.swagger.v3.oas.models.media.ObjectSchema; +import io.swagger.v3.oas.models.media.Schema; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; + +@Tag("unit") +@DisplayName("Unit Test: ComponentContainerSchemaResolver") +class ComponentContainerSchemaResolverTest { + + private final ComponentContainerSchemaResolver resolver = new ComponentContainerSchemaResolver(); + + @Test + @DisplayName("resolve -> should return null when data ref name does not exist") + void resolve_shouldReturnNull_whenDataRefNameDoesNotExist() { + Schema result = resolver.resolve(Map.of(), "MissingDto", "WrapperDto", "data"); + + assertNull(result); + } + + @Test + @DisplayName("resolve -> should resolve object container schema") + void resolve_shouldResolveObjectContainerSchema() { + ObjectSchema container = new ObjectSchema(); + container.addProperty("content", new ArraySchema()); + + Schema result = + resolver.resolve( + schemas("PageCustomerDto", container), "PageCustomerDto", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should return null for object type without properties") + void resolve_shouldReturnNull_forObjectTypeWithoutProperties() { + Schema container = new Schema<>().type("object"); + + Schema result = + resolver.resolve( + schemas("PagingCustomerDto", container), "PagingCustomerDto", "WrapperDto", "data"); + + assertNull(result); + } + + @Test + @DisplayName("resolve -> should resolve schema with properties") + void resolve_shouldResolveSchemaWithProperties() { + Schema container = new Schema<>(); + container.addProperty("items", new ArraySchema()); + + Schema result = + resolver.resolve( + schemas("WindowCustomerDto", container), "WindowCustomerDto", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should resolve array schema") + void resolve_shouldResolveArraySchema() { + ArraySchema container = new ArraySchema(); + + Schema result = + resolver.resolve( + schemas("ListCustomerDto", container), "ListCustomerDto", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should resolve schema with array type") + void resolve_shouldResolveSchemaWithArrayType() { + Schema container = new Schema<>().type("array"); + + Schema result = + resolver.resolve( + schemas("ListCustomerDto", container), "ListCustomerDto", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should resolve json schema with array type") + void resolve_shouldResolveJsonSchemaWithArrayType() { + JsonSchema container = new JsonSchema(); + container.setTypes(java.util.Set.of("array")); + + Schema result = + resolver.resolve( + schemas("ListCustomerDto", container), "ListCustomerDto", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should dereference component schema") + void resolve_shouldDereferenceComponentSchema() { + ObjectSchema container = new ObjectSchema(); + container.addProperty("content", new ArraySchema()); + + Schema ref = new Schema<>().$ref("#/components/schemas/PageCustomerDto"); + + Map schemas = + schemas( + "PageCustomerDto", container, + "PageCustomerDtoRef", ref); + + Schema result = resolver.resolve(schemas, "PageCustomerDtoRef", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should resolve container inside allOf") + void resolve_shouldResolveContainerInsideAllOf() { + ObjectSchema container = new ObjectSchema(); + container.addProperty("content", new ArraySchema()); + + ComposedSchema composed = new ComposedSchema(); + composed.setAllOf(List.of(new ObjectSchema(), container)); + + Schema result = + resolver.resolve( + schemas("PageCustomerDto", composed), "PageCustomerDto", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should resolve referenced container inside allOf") + void resolve_shouldResolveReferencedContainerInsideAllOf() { + ObjectSchema container = new ObjectSchema(); + container.addProperty("content", new ArraySchema()); + + ComposedSchema composed = new ComposedSchema(); + composed.setAllOf(List.of(new Schema<>().$ref("#/components/schemas/PageCustomerDto"))); + + Map schemas = + schemas( + "PageCustomerDto", container, + "ComposedPageCustomerDto", composed); + + Schema result = resolver.resolve(schemas, "ComposedPageCustomerDto", "WrapperDto", "data"); + + assertSame(container, result); + } + + @Test + @DisplayName("resolve -> should return null when schema is not container-like") + void resolve_shouldReturnNull_whenSchemaIsNotContainerLike() { + Schema scalar = new Schema<>().type("string"); + + Schema result = + resolver.resolve(schemas("StringDto", scalar), "StringDto", "WrapperDto", "data"); + + assertNull(result); + } + + @Test + @DisplayName("resolve -> should return null when component ref is missing") + void resolve_shouldReturnNull_whenComponentRefIsMissing() { + Schema ref = new Schema<>().$ref("#/components/schemas/MissingDto"); + + Schema result = + resolver.resolve(schemas("BrokenRefDto", ref), "BrokenRefDto", "WrapperDto", "data"); + + assertNull(result); + } + + @Test + @DisplayName("resolve -> should return null when component ref cycle is detected") + void resolve_shouldReturnNull_whenComponentRefCycleIsDetected() { + Schema first = new Schema<>().$ref("#/components/schemas/SecondDto"); + Schema second = new Schema<>().$ref("#/components/schemas/FirstDto"); + + Map schemas = + schemas( + "FirstDto", first, + "SecondDto", second); + + Schema result = resolver.resolve(schemas, "FirstDto", "WrapperDto", "data"); + + assertNull(result); + } + + private Map schemas(String name, Schema schema) { + Map schemas = new LinkedHashMap<>(); + schemas.put(name, schema); + return schemas; + } + + private Map schemas( + String firstName, Schema firstSchema, String secondName, Schema secondSchema) { + Map schemas = new LinkedHashMap<>(); + schemas.put(firstName, firstSchema); + schemas.put(secondName, secondSchema); + return schemas; + } +} diff --git a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/validation/OpenApiContractGuardTest.java b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/validation/OpenApiContractGuardTest.java index 65e92ac0..5702fb82 100644 --- a/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/validation/OpenApiContractGuardTest.java +++ b/openapi-generics-server-starter/src/test/java/io/github/blueprintplatform/openapi/generics/server/core/validation/OpenApiContractGuardTest.java @@ -10,7 +10,10 @@ import io.github.blueprintplatform.openapi.generics.contract.envelope.ServiceResponse; import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.openapi.generics.server.core.introspection.ResponseTypeDescriptor; -import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.SupportedContainerType; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerMatchMode; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerShape; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.ContainerSource; +import io.github.blueprintplatform.openapi.generics.server.core.introspection.container.descriptor.SupportedContainerDescriptor; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.media.ComposedSchema; @@ -51,7 +54,14 @@ void validate_shouldPass_forValidContainerWrapper() { ResponseTypeDescriptor.container( ServiceResponse.class, "data", - new SupportedContainerType(Page.class, PAGE, PAGE), + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "CustomerDto"); Schema wrapper = wrapperWithAllOfPayload("data"); @@ -205,7 +215,14 @@ void validate_shouldFail_whenContainerExtensionsMissing() { ResponseTypeDescriptor.container( ServiceResponse.class, "data", - new SupportedContainerType(Page.class, PAGE, PAGE), + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "CustomerDto"); Schema wrapper = wrapperWithAllOfPayload("data"); @@ -227,7 +244,14 @@ void validate_shouldFail_whenContainerInvalid() { ResponseTypeDescriptor.container( ServiceResponse.class, "data", - new SupportedContainerType(Page.class, PAGE, PAGE), + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "CustomerDto"); Schema wrapper = wrapperWithAllOfPayload("data"); @@ -251,9 +275,15 @@ void validate_shouldFail_whenItemInvalid() { ResponseTypeDescriptor.container( ServiceResponse.class, "data", - new SupportedContainerType(Page.class, PAGE, PAGE), + new SupportedContainerDescriptor( + Page.class, + PAGE, + PAGE, + ContainerShape.OBJECT_WITH_ITEM_ARRAY, + "content", + ContainerSource.BUILT_IN, + ContainerMatchMode.EXACT), "CustomerDto"); - Schema wrapper = wrapperWithAllOfPayload("data"); wrapper.addExtension(API_WRAPPER, true); wrapper.addExtension(API_WRAPPER_DATATYPE, "PageCustomerDto"); diff --git a/samples/type-coverage/byoe-response/README.md b/samples/type-coverage/byoe-response/README.md index f38d4d03..3f91b889 100644 --- a/samples/type-coverage/byoe-response/README.md +++ b/samples/type-coverage/byoe-response/README.md @@ -1,10 +1,10 @@ # byoe-response-type-coverage -> End-to-end verification sample for OpenAPI projection, client generation, and runtime reconstruction using a custom response envelope. +> End-to-end verification sample for OpenAPI projection, client generation, and runtime reconstruction of user-owned `ApiResponse` contracts. This sample demonstrates the **Bring Your Own Envelope (BYOE)** capabilities of OpenAPI Generics. -Instead of using the platform-provided envelope implementation, the producer exposes endpoints using a completely user-owned generic response contract. +Instead of using the platform-provided response envelope, the producer exposes endpoints using a completely application-owned generic contract: ```java ApiResponse @@ -12,95 +12,59 @@ ApiResponse The sample verifies that OpenAPI Generics can: -- project custom generic envelopes into OpenAPI -- reconstruct generic type information during code generation -- generate strongly typed clients -- deserialize responses back into the original contract shape -- support scalar, value, object, collection, and paged payloads +- project user-owned generic response envelopes into OpenAPI +- reconstruct generic type information during client generation +- generate strongly typed Java clients +- deserialize responses back into the original application contract +- support scalar, value, object, collection, built-in page, and application-owned generic container payloads without requiring changes to the original response contract. --- -# Table of Contents +## Table of Contents -- [Purpose](#purpose) -- [Architecture](#architecture) +- [What This Sample Validates](#what-this-sample-validates) - [Modules](#modules) -- [Custom Envelope](#custom-envelope) -- [Supported Response Shapes](#supported-response-shapes) -- [Verification Matrix](#verification-matrix) +- [User-Owned Envelope](#user-owned-envelope) +- [Covered Response Shapes](#covered-response-shapes) - [Running the Sample](#running-the-sample) - [Verification Endpoints](#verification-endpoints) -- [BYOE Projection Flow](#byoe-projection-flow) -- [What This Sample Protects](#what-this-sample-protects) +- [Mental Model](#mental-model) --- -# Purpose +## What This Sample Validates -The goal of this sample is to validate the complete BYOE lifecycle: +The sample exercises the complete OpenAPI Generics flow: ```text User-Owned Contract - ↓ + ↓ Spring Endpoint - ↓ + ↓ OpenAPI Projection - ↓ + ↓ Generated Client - ↓ + ↓ Consumer Runtime ``` The consumer uses only generated client artifacts. -No DTO adapters. +No manual DTO mapping. No manual envelope reconstruction. -No custom deserialization code. - -The generated client must reconstruct the original generic contract automatically. - ---- - -# Architecture - -```text -Producer - │ - ▼ -OpenAPI Projection - │ - ▼ -Generated Client - │ - ▼ -Consumer -``` - -The producer publishes endpoints using: - -```java -ApiResponse -``` +No manual generic container reconstruction. -The OpenAPI document contains projected wrapper schemas. - -The OpenAPI Generics code generator reconstructs the original generic contract. - -The generated client returns: - -```java -ApiResponse -``` +No custom deserialization code. -instead of generated wrapper DTOs. +The generated client automatically reconstructs the original application contract together with any nested generic payload types. --- -# Modules +## Modules ```text byoe-response-type-coverage @@ -111,18 +75,17 @@ byoe-response-type-coverage ``` | Module | Responsibility | -|----------|----------| -| contract | User-owned ApiResponse contract | -| producer | Publishes ApiResponse-based endpoints | +|----------|----------------| +| contract | User-owned `ApiResponse` and generic payload container contracts | +| producer | Publishes `ApiResponse`-based endpoints | | client | Generated Java client | -| consumer | Consumes generated client | -| consumer-api | Verification endpoints for runtime testing | +| consumer | Uses the generated client and exposes verification endpoints | --- -# Custom Envelope +## User-Owned Envelope -The sample uses a completely user-owned envelope. +The sample uses a completely application-owned response envelope. ```java public class ApiResponse { @@ -149,120 +112,81 @@ public record ApiError( These classes are not provided by OpenAPI Generics. -OpenAPI Generics only projects and reconstructs them. - -The ownership of the contract remains entirely with the application. +OpenAPI Generics projects and reconstructs them while preserving the original application contract. --- -# Supported Response Shapes +## Covered Response Shapes -## Scalar Payloads +### Scalar Payloads ```java ApiResponse - ApiResponse - ApiResponse - ApiResponse - ApiResponse ``` ---- - -## Value Payloads +### Value Payloads ```java ApiResponse - ApiResponse - ApiResponse - ApiResponse ``` ---- - -## Object Payloads +### Object Payloads ```java ApiResponse - ApiResponse ``` -Including: - -- nested DTOs -- enums -- collections -- maps -- temporal types - ---- - -## Collection Payloads +### List Payloads ```java ApiResponse> - ApiResponse> +``` -ApiResponse> +### Set Payloads +```java +ApiResponse> ApiResponse> ``` ---- - -## Paged Payloads +### Built-in Page Payloads ```java ApiResponse> - ApiResponse> ``` -Using: +using the built-in platform `Page` contract. + +### Application-owned Generic Container Payloads ```java -io.github.blueprintplatform.openapi.generics.contract.paging.Page -``` +ApiResponse> +ApiResponse> ---- +ApiResponse> +ApiResponse> +``` -# Verification Matrix - -| Category | Verified | -|----------|----------| -| Scalar types | ✓ | -| UUID | ✓ | -| LocalDate | ✓ | -| OffsetDateTime | ✓ | -| Enum payloads | ✓ | -| DTO payloads | ✓ | -| Nested DTO graphs | ✓ | -| List payloads | ✓ | -| Set payloads | ✓ | -| Page payloads | ✓ | -| Generated client reconstruction | ✓ | -| Runtime deserialization | ✓ | -| Consumer integration | ✓ | -| User-owned envelope | ✓ | +These payloads verify that nested application-owned generic containers are projected into OpenAPI and reconstructed by the generated client without requiring custom serialization, mapping, or adapter code. --- -# Running the Sample +## Running the Sample -## Start Producer +### Start Producer ```bash cd producer - mvn spring-boot:run ``` @@ -284,13 +208,10 @@ OpenAPI document: http://localhost:8076/type-coverage/byoe-response/v3/api-docs.yaml ``` ---- - -## Start Consumer +### Start Consumer ```bash cd consumer - mvn spring-boot:run ``` @@ -304,16 +225,14 @@ http://localhost:8077/type-coverage/byoe-response-consumer ## Quick Smoke Test -After starting both producer and consumer, a small subset of endpoints can be called directly to verify that the generated client reconstructs the expected generic response types correctly. +After starting both producer and consumer, a representative subset of endpoints can be called directly to verify that the generated client reconstructs the expected generic response types correctly. -The goal of these requests is not to validate business behavior. - -They provide a fast end-to-end sanity check for: +These requests provide a fast end-to-end verification of: - OpenAPI projection -- custom envelope projection - generated client generation -- generic wrapper reconstruction +- user-owned envelope reconstruction +- nested generic payload reconstruction - runtime deserialization - consumer integration @@ -327,9 +246,13 @@ curl http://localhost:8076/type-coverage/byoe-response/types/lists/summaries curl http://localhost:8076/type-coverage/byoe-response/types/sets/statuses curl http://localhost:8076/type-coverage/byoe-response/types/pages/summaries + +curl http://localhost:8076/type-coverage/byoe-response/types/paging/summaries + +curl http://localhost:8076/type-coverage/byoe-response/types/windows/summaries ``` -Expected generic shapes: +Expected generic response shapes: ```java ApiResponse @@ -339,6 +262,10 @@ ApiResponse> ApiResponse> ApiResponse> + +ApiResponse> + +ApiResponse> ``` ### Consumer Verification @@ -351,154 +278,104 @@ curl http://localhost:8077/type-coverage/byoe-response-consumer/types/lists/summ curl http://localhost:8077/type-coverage/byoe-response-consumer/types/sets/statuses curl http://localhost:8077/type-coverage/byoe-response-consumer/types/pages/summaries -``` - -The consumer uses only generated client artifacts. - -No manual DTO mapping, custom deserialization, or envelope reconstruction exists in the consumer application. -Successful responses verify that generic type information survives the complete pipeline: +curl http://localhost:8077/type-coverage/byoe-response-consumer/types/paging/summaries -```text -User-Owned Contract - ↓ -OpenAPI Projection - ↓ -Generated Client - ↓ -Consumer Runtime +curl http://localhost:8077/type-coverage/byoe-response-consumer/types/windows/summaries ``` -and is reconstructed back into the original contract shape: +The consumer uses only generated client artifacts. -```java -ApiResponse -``` +No manual DTO mapping, custom deserialization, envelope reconstruction, or generic container reconstruction exists in the consumer application. --- -# Verification Endpoints +## Verification Endpoints -## Scalars +### Scalars ```text /types/scalars/string - /types/scalars/boolean - /types/scalars/integer - /types/scalars/long - /types/scalars/decimal ``` ---- - -## Values +### Values ```text /types/values/uuid - /types/values/date - /types/values/datetime - /types/values/enum ``` ---- - -## Objects +### Objects ```text /types/objects/address - /types/objects/profile ``` ---- - -## Lists +### Lists ```text /types/lists/summaries - /types/lists/statuses ``` ---- - -## Sets +### Sets ```text /types/sets/summaries - /types/sets/statuses ``` ---- - -## Pages +### Built-in Pages ```text /types/pages/summaries - /types/pages/statuses ``` ---- - -# BYOE Projection Flow - -The producer starts with: - -```java -ApiResponse> -``` - -Projection phase: +### Application-owned Generic Containers ```text -ApiResponse> - ↓ -ApiResponsePageTypeSummaryDto -``` - -Generated client phase: +/types/paging/summaries +/types/paging/statuses -```text -ApiResponsePageTypeSummaryDto - ↓ -ApiResponse> +/types/windows/summaries +/types/windows/statuses ``` -Consumer phase: +These endpoints verify that the generated client correctly reconstructs: ```java -ApiResponse> +ApiResponse +ApiResponse> +ApiResponse> +ApiResponse> +ApiResponse> +ApiResponse> ``` -The wrapper DTO exists only as a projection artifact. - -The consumer works exclusively with the original generic contract. +across primitive, value, enum, DTO, collection, built-in page, and application-owned generic container payloads. --- -# What This Sample Protects - -This sample serves as a regression suite for BYOE support. - -It validates that future changes do not break: +## Mental Model -- custom envelope projection -- generic payload reconstruction -- collection reconstruction -- page reconstruction -- generated client typing -- runtime deserialization -- consumer integration - -The sample is intentionally designed as an end-to-end verification pipeline rather than a unit-level feature demonstration. +```text +ApiResponse> + ↓ +OpenAPI Projection + ↓ +Generated Client + ↓ +Consumer Deserialization + ↓ +ApiResponse> +``` -Its purpose is to guarantee that user-owned response contracts continue to work without modification across future OpenAPI Generics releases. \ No newline at end of file +The same reconstruction flow applies to every supported response shape, including application-owned generic payload containers nested inside the user-owned response envelope. \ No newline at end of file diff --git a/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/TypeCoverageClientAdapter.java b/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/TypeCoverageClientAdapter.java index 945ea819..c6950d15 100644 --- a/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/TypeCoverageClientAdapter.java +++ b/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/TypeCoverageClientAdapter.java @@ -6,6 +6,8 @@ import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeProfileDto; import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeSummaryDto; import io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse; +import io.github.blueprintplatform.samples.typecoverage.contract.Paging; +import io.github.blueprintplatform.samples.typecoverage.contract.Window; import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; @@ -48,4 +50,12 @@ public interface TypeCoverageClientAdapter { ApiResponse> pagedSummaries(); ApiResponse> pagedStatuses(); + + ApiResponse> pagingSummaries(); + + ApiResponse> pagingStatuses(); + + ApiResponse> windowSummaries(); + + ApiResponse> windowStatuses(); } diff --git a/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/config/TypeCoverageApiClientConfig.java b/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/config/TypeCoverageApiClientConfig.java index 0a8e06fd..da9a9ed0 100644 --- a/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/config/TypeCoverageApiClientConfig.java +++ b/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/config/TypeCoverageApiClientConfig.java @@ -1,11 +1,6 @@ package io.github.blueprintplatform.samples.typecoverage.client.adapter.config; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ListPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ObjectPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.PagedPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ScalarPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.SetPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ValuePayloadControllerApi; +import io.github.blueprintplatform.samples.typecoverage.client.generated.api.*; import io.github.blueprintplatform.samples.typecoverage.client.generated.invoker.ApiClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -55,4 +50,14 @@ SetPayloadControllerApi setPayloadControllerApi(ApiClient typeCoverageApiClient) PagedPayloadControllerApi pagedPayloadControllerApi(ApiClient typeCoverageApiClient) { return new PagedPayloadControllerApi(typeCoverageApiClient); } + + @Bean + PagingPayloadControllerApi pagingPayloadControllerApi(ApiClient typeCoverageApiClient) { + return new PagingPayloadControllerApi(typeCoverageApiClient); + } + + @Bean + WindowPayloadControllerApi windowPayloadControllerApi(ApiClient typeCoverageApiClient) { + return new WindowPayloadControllerApi(typeCoverageApiClient); + } } diff --git a/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/impl/TypeCoverageClientAdapterImpl.java b/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/impl/TypeCoverageClientAdapterImpl.java index 9ea85ef4..954d5d19 100644 --- a/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/impl/TypeCoverageClientAdapterImpl.java +++ b/samples/type-coverage/byoe-response/client/src/main/java/io/github/blueprintplatform/samples/typecoverage/client/adapter/impl/TypeCoverageClientAdapterImpl.java @@ -2,17 +2,14 @@ import io.github.blueprintplatform.openapi.generics.contract.paging.Page; import io.github.blueprintplatform.samples.typecoverage.client.adapter.TypeCoverageClientAdapter; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ListPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ObjectPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.PagedPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ScalarPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.SetPayloadControllerApi; -import io.github.blueprintplatform.samples.typecoverage.client.generated.api.ValuePayloadControllerApi; +import io.github.blueprintplatform.samples.typecoverage.client.generated.api.*; import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.AddressDto; import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.CoverageStatus; import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeProfileDto; import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeSummaryDto; import io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse; +import io.github.blueprintplatform.samples.typecoverage.contract.Paging; +import io.github.blueprintplatform.samples.typecoverage.contract.Window; import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; @@ -30,20 +27,26 @@ public class TypeCoverageClientAdapterImpl implements TypeCoverageClientAdapter private final ListPayloadControllerApi listApi; private final SetPayloadControllerApi setApi; private final PagedPayloadControllerApi pagedApi; + private final PagingPayloadControllerApi pagingApi; + private final WindowPayloadControllerApi windowApi; public TypeCoverageClientAdapterImpl( - ScalarPayloadControllerApi scalarApi, - ValuePayloadControllerApi valueApi, - ObjectPayloadControllerApi objectApi, - ListPayloadControllerApi listApi, - SetPayloadControllerApi setApi, - PagedPayloadControllerApi pagedApi) { + ScalarPayloadControllerApi scalarApi, + ValuePayloadControllerApi valueApi, + ObjectPayloadControllerApi objectApi, + ListPayloadControllerApi listApi, + SetPayloadControllerApi setApi, + PagedPayloadControllerApi pagedApi, + PagingPayloadControllerApi pagingApi, + WindowPayloadControllerApi windowApi) { this.scalarApi = scalarApi; this.valueApi = valueApi; this.objectApi = objectApi; this.listApi = listApi; this.setApi = setApi; this.pagedApi = pagedApi; + this.pagingApi = pagingApi; + this.windowApi = windowApi; } @Override @@ -130,4 +133,24 @@ public ApiResponse> pagedSummaries() { public ApiResponse> pagedStatuses() { return pagedApi.pagedStatuses(); } + + @Override + public ApiResponse> pagingSummaries() { + return pagingApi.pagingSummaries(); + } + + @Override + public ApiResponse> pagingStatuses() { + return pagingApi.pagingStatuses(); + } + + @Override + public ApiResponse> windowSummaries() { + return windowApi.windowSummaries(); + } + + @Override + public ApiResponse> windowStatuses() { + return windowApi.windowStatuses(); + } } diff --git a/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/api/controller/TypeCoverageConsumerController.java b/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/api/controller/TypeCoverageConsumerController.java index c50c9c23..c43a2d2b 100644 --- a/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/api/controller/TypeCoverageConsumerController.java +++ b/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/api/controller/TypeCoverageConsumerController.java @@ -7,6 +7,8 @@ import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeSummaryDto; import io.github.blueprintplatform.samples.typecoverage.consumer.service.TypeCoverageConsumerService; import io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse; +import io.github.blueprintplatform.samples.typecoverage.contract.Paging; +import io.github.blueprintplatform.samples.typecoverage.contract.Window; import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; @@ -113,4 +115,24 @@ public ResponseEntity>> pagedSummaries() { public ResponseEntity>> pagedStatuses() { return ResponseEntity.ok(service.pagedStatuses()); } + + @GetMapping("/paging/summaries") + public ResponseEntity>> pagingSummaries() { + return ResponseEntity.ok(service.pagingSummaries()); + } + + @GetMapping("/paging/statuses") + public ResponseEntity>> pagingStatuses() { + return ResponseEntity.ok(service.pagingStatuses()); + } + + @GetMapping("/windows/summaries") + public ResponseEntity>> windowSummaries() { + return ResponseEntity.ok(service.windowSummaries()); + } + + @GetMapping("/windows/statuses") + public ResponseEntity>> windowStatuses() { + return ResponseEntity.ok(service.windowStatuses()); + } } diff --git a/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/TypeCoverageConsumerService.java b/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/TypeCoverageConsumerService.java index 7adf12d5..f760c344 100644 --- a/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/TypeCoverageConsumerService.java +++ b/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/TypeCoverageConsumerService.java @@ -6,6 +6,8 @@ import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeProfileDto; import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeSummaryDto; import io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse; +import io.github.blueprintplatform.samples.typecoverage.contract.Paging; +import io.github.blueprintplatform.samples.typecoverage.contract.Window; import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; @@ -48,4 +50,12 @@ public interface TypeCoverageConsumerService { ApiResponse> pagedSummaries(); ApiResponse> pagedStatuses(); + + ApiResponse> pagingSummaries(); + + ApiResponse> pagingStatuses(); + + ApiResponse> windowSummaries(); + + ApiResponse> windowStatuses(); } diff --git a/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/impl/TypeCoverageConsumerServiceImpl.java b/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/impl/TypeCoverageConsumerServiceImpl.java index 4ac87372..ada3d2b4 100644 --- a/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/impl/TypeCoverageConsumerServiceImpl.java +++ b/samples/type-coverage/byoe-response/consumer/src/main/java/io/github/blueprintplatform/samples/typecoverage/consumer/service/impl/TypeCoverageConsumerServiceImpl.java @@ -8,6 +8,8 @@ import io.github.blueprintplatform.samples.typecoverage.client.generated.dto.TypeSummaryDto; import io.github.blueprintplatform.samples.typecoverage.consumer.service.TypeCoverageConsumerService; import io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse; +import io.github.blueprintplatform.samples.typecoverage.contract.Paging; +import io.github.blueprintplatform.samples.typecoverage.contract.Window; import java.math.BigDecimal; import java.time.LocalDate; import java.time.OffsetDateTime; @@ -109,4 +111,24 @@ public ApiResponse> pagedSummaries() { public ApiResponse> pagedStatuses() { return adapter.pagedStatuses(); } + + @Override + public ApiResponse> pagingSummaries() { + return adapter.pagingSummaries(); + } + + @Override + public ApiResponse> pagingStatuses() { + return adapter.pagingStatuses(); + } + + @Override + public ApiResponse> windowSummaries() { + return adapter.windowSummaries(); + } + + @Override + public ApiResponse> windowStatuses() { + return adapter.windowStatuses(); + } } diff --git a/samples/type-coverage/byoe-response/contract/src/main/java/io/github/blueprintplatform/samples/typecoverage/contract/Paging.java b/samples/type-coverage/byoe-response/contract/src/main/java/io/github/blueprintplatform/samples/typecoverage/contract/Paging.java new file mode 100644 index 00000000..e27e22ac --- /dev/null +++ b/samples/type-coverage/byoe-response/contract/src/main/java/io/github/blueprintplatform/samples/typecoverage/contract/Paging.java @@ -0,0 +1,35 @@ +package io.github.blueprintplatform.samples.typecoverage.contract; + +import java.util.List; + +/** + * User-owned paged container contract used to verify BYOC container reconstruction. + * + * @param content current page items + * @param page zero-based page index + * @param size requested page size + * @param totalElements total available element count + * @param totalPages total available page count + * @param hasNext whether another page exists + * @param item type + */ +public record Paging( + List content, int page, int size, long totalElements, int totalPages, boolean hasNext) { + + public Paging { + content = content == null ? List.of() : List.copyOf(content); + } + + public static Paging of(List content, int page, int size, long totalElements) { + int safePage = Math.max(page, 0); + int safeSize = Math.max(size, 1); + + long totalPagesLong = totalElements <= 0L ? 0L : ((totalElements + safeSize - 1L) / safeSize); + + int totalPages = totalPagesLong > Integer.MAX_VALUE ? Integer.MAX_VALUE : (int) totalPagesLong; + + boolean hasNext = safePage < totalPages - 1; + + return new Paging<>(content, safePage, safeSize, totalElements, totalPages, hasNext); + } +} diff --git a/samples/type-coverage/byoe-response/contract/src/main/java/io/github/blueprintplatform/samples/typecoverage/contract/Window.java b/samples/type-coverage/byoe-response/contract/src/main/java/io/github/blueprintplatform/samples/typecoverage/contract/Window.java new file mode 100644 index 00000000..97209cea --- /dev/null +++ b/samples/type-coverage/byoe-response/contract/src/main/java/io/github/blueprintplatform/samples/typecoverage/contract/Window.java @@ -0,0 +1,22 @@ +package io.github.blueprintplatform.samples.typecoverage.contract; + +import java.util.List; + +/** + * User-owned cursor/window container contract used to verify BYOC container reconstruction. + * + * @param items current window items + * @param nextCursor cursor for the next window, if available + * @param hasNext whether another window exists + * @param item type + */ +public record Window(List items, String nextCursor, boolean hasNext) { + + public Window { + items = items == null ? List.of() : List.copyOf(items); + } + + public static Window of(List items, String nextCursor, boolean hasNext) { + return new Window<>(items, nextCursor, hasNext); + } +} diff --git a/samples/type-coverage/byoe-response/producer/pom.xml b/samples/type-coverage/byoe-response/producer/pom.xml index b3bd1e45..590aa4f8 100644 --- a/samples/type-coverage/byoe-response/producer/pom.xml +++ b/samples/type-coverage/byoe-response/producer/pom.xml @@ -53,6 +53,11 @@ spring-boot-starter-validation + + org.springframework.data + spring-data-commons + + org.springdoc springdoc-openapi-starter-webmvc-ui diff --git a/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/api/controller/PagingPayloadController.java b/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/api/controller/PagingPayloadController.java new file mode 100644 index 00000000..6430ac83 --- /dev/null +++ b/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/api/controller/PagingPayloadController.java @@ -0,0 +1,42 @@ +package io.github.blueprintplatform.samples.typecoverage.api.controller; + +import io.github.blueprintplatform.samples.typecoverage.api.dto.CoverageStatus; +import io.github.blueprintplatform.samples.typecoverage.api.dto.TypeSummaryDto; +import io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse; +import io.github.blueprintplatform.samples.typecoverage.contract.Paging; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/types/paging", produces = MediaType.APPLICATION_JSON_VALUE) +public class PagingPayloadController { + + @GetMapping("/summaries") + public ResponseEntity>> pagingSummaries() { + var content = + List.of( + new TypeSummaryDto( + UUID.fromString("55555555-5555-5555-5555-555555555555"), + "BYOE-PAGING-001", + CoverageStatus.ACTIVE), + new TypeSummaryDto( + UUID.fromString("66666666-6666-6666-6666-666666666666"), + "BYOE-PAGING-002", + CoverageStatus.EXPERIMENTAL)); + + return ResponseEntity.ok(ApiResponse.ok(Paging.of(content, 0, 2, 2))); + } + + @GetMapping("/statuses") + public ResponseEntity>> pagingStatuses() { + var content = + List.of(CoverageStatus.ACTIVE, CoverageStatus.PASSIVE, CoverageStatus.EXPERIMENTAL); + + return ResponseEntity.ok(ApiResponse.ok(Paging.of(content, 0, 3, 3))); + } +} diff --git a/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/api/controller/WindowPayloadController.java b/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/api/controller/WindowPayloadController.java new file mode 100644 index 00000000..61940685 --- /dev/null +++ b/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/api/controller/WindowPayloadController.java @@ -0,0 +1,41 @@ +package io.github.blueprintplatform.samples.typecoverage.api.controller; + +import io.github.blueprintplatform.samples.typecoverage.api.dto.CoverageStatus; +import io.github.blueprintplatform.samples.typecoverage.api.dto.TypeSummaryDto; +import io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse; +import io.github.blueprintplatform.samples.typecoverage.contract.Window; +import java.util.List; +import java.util.UUID; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping(value = "/types/windows", produces = MediaType.APPLICATION_JSON_VALUE) +public class WindowPayloadController { + + @GetMapping("/summaries") + public ResponseEntity>> windowSummaries() { + var items = + List.of( + new TypeSummaryDto( + UUID.fromString("77777777-7777-7777-7777-777777777777"), + "BYOE-WINDOW-001", + CoverageStatus.ACTIVE), + new TypeSummaryDto( + UUID.fromString("88888888-8888-8888-8888-888888888888"), + "BYOE-WINDOW-002", + CoverageStatus.EXPERIMENTAL)); + + return ResponseEntity.ok(ApiResponse.ok(Window.of(items, "next-window-token", true))); + } + + @GetMapping("/statuses") + public ResponseEntity>> windowStatuses() { + var items = List.of(CoverageStatus.ACTIVE, CoverageStatus.PASSIVE, CoverageStatus.EXPERIMENTAL); + + return ResponseEntity.ok(ApiResponse.ok(Window.of(items, null, false))); + } +} diff --git a/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/openapi/OpenApiConstants.java b/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/openapi/OpenApiConstants.java index 2c93257c..2d873677 100644 --- a/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/openapi/OpenApiConstants.java +++ b/samples/type-coverage/byoe-response/producer/src/main/java/io/github/blueprintplatform/samples/typecoverage/openapi/OpenApiConstants.java @@ -5,9 +5,26 @@ public final class OpenApiConstants { public static final String TITLE = "BYOE Response Type Coverage API"; public static final String DESCRIPTION = - "Type coverage sample validating contract projection and client reconstruction for ApiResponse, ApiResponse>, ApiResponse>, and ApiResponse>."; + """ + Type coverage sample validating OpenAPI projection, generated client reconstruction, + and runtime deserialization for custom response envelopes. + + Verified response shapes include: + + - ApiResponse + - ApiResponse> + - ApiResponse> + - ApiResponse> + - ApiResponse> + - ApiResponse> + + Demonstrates Bring Your Own Envelope (BYOE) and Bring Your Own Container (BYOC) + support by reconstructing both built-in and application-defined generic response + contracts across OpenAPI projection, generated clients, and runtime deserialization. + """; - public static final String SERVER_DESCRIPTION = "Local BYOE response type coverage producer"; + public static final String SERVER_DESCRIPTION = + "Local BYOE/BYOC response type coverage producer"; private OpenApiConstants() {} -} +} \ No newline at end of file diff --git a/samples/type-coverage/byoe-response/producer/src/main/resources/application.yml b/samples/type-coverage/byoe-response/producer/src/main/resources/application.yml index 5148f718..28600990 100644 --- a/samples/type-coverage/byoe-response/producer/src/main/resources/application.yml +++ b/samples/type-coverage/byoe-response/producer/src/main/resources/application.yml @@ -28,6 +28,11 @@ app: openapi-generics: envelope: type: io.github.blueprintplatform.samples.typecoverage.contract.ApiResponse + containers: + - type: io.github.blueprintplatform.samples.typecoverage.contract.Paging + item-property: content + - type: io.github.blueprintplatform.samples.typecoverage.contract.Window + item-property: items springdoc: default-consumes-media-type: application/json diff --git a/samples/type-coverage/byoe-response/spec/byoe-response-coverage.yaml b/samples/type-coverage/byoe-response/spec/byoe-response-coverage.yaml index 3952fe5c..f767b6ca 100644 --- a/samples/type-coverage/byoe-response/spec/byoe-response-coverage.yaml +++ b/samples/type-coverage/byoe-response/spec/byoe-response-coverage.yaml @@ -1,13 +1,51 @@ openapi: 3.1.0 info: title: BYOE Response Type Coverage API - description: "Type coverage sample validating contract projection and client reconstruction\ - \ for ApiResponse, ApiResponse>, ApiResponse>, and ApiResponse>." + description: | + Type coverage sample validating OpenAPI projection, generated client reconstruction, + and runtime deserialization for custom response envelopes. + + Verified response shapes include: + + - ApiResponse + - ApiResponse> + - ApiResponse> + - ApiResponse> + - ApiResponse> + - ApiResponse> + + Demonstrates Bring Your Own Envelope (BYOE) and Bring Your Own Container (BYOC) + support by reconstructing both built-in and application-defined generic response + contracts across OpenAPI projection, generated clients, and runtime deserialization. version: 1.2.0-SNAPSHOT servers: - url: http://localhost:8076/type-coverage/byoe-response - description: Local BYOE response type coverage producer + description: Local BYOE/BYOC response type coverage producer paths: + /types/windows/summaries: + get: + tags: + - window-payload-controller + operationId: windowSummaries + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponseWindowTypeSummaryDto" + /types/windows/statuses: + get: + tags: + - window-payload-controller + operationId: windowStatuses + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponseWindowCoverageStatus" /types/values/uuid: get: tags: @@ -140,6 +178,30 @@ paths: application/json: schema: $ref: "#/components/schemas/ApiResponseBoolean" + /types/paging/summaries: + get: + tags: + - paging-payload-controller + operationId: pagingSummaries + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponsePagingTypeSummaryDto" + /types/paging/statuses: + get: + tags: + - paging-payload-controller + operationId: pagingStatuses + responses: + "200": + description: OK + content: + application/json: + schema: + $ref: "#/components/schemas/ApiResponsePagingCoverageStatus" /types/pages/summaries: get: tags: @@ -222,6 +284,84 @@ components: message: type: string x-ignore-model: true + ApiResponseWindowTypeSummaryDto: + type: object + properties: + status: + type: integer + format: int32 + message: + type: string + data: + $ref: "#/components/schemas/WindowTypeSummaryDto" + errors: + type: array + items: + $ref: "#/components/schemas/ApiError" + x-api-wrapper: true + x-api-wrapper-datatype: WindowTypeSummaryDto + x-data-container: Window + x-data-container-type: io.github.blueprintplatform.samples.typecoverage.contract.Window + x-data-item: TypeSummaryDto + CoverageStatus: + type: string + enum: + - ACTIVE + - PASSIVE + - EXPERIMENTAL + TypeSummaryDto: + type: object + properties: + id: + type: string + format: uuid + code: + type: string + status: + $ref: "#/components/schemas/CoverageStatus" + WindowTypeSummaryDto: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/TypeSummaryDto" + nextCursor: + type: string + hasNext: + type: boolean + x-ignore-model: true + ApiResponseWindowCoverageStatus: + type: object + properties: + status: + type: integer + format: int32 + message: + type: string + data: + $ref: "#/components/schemas/WindowCoverageStatus" + errors: + type: array + items: + $ref: "#/components/schemas/ApiError" + x-api-wrapper: true + x-api-wrapper-datatype: WindowCoverageStatus + x-data-container: Window + x-data-container-type: io.github.blueprintplatform.samples.typecoverage.contract.Window + x-data-item: CoverageStatus + WindowCoverageStatus: + type: object + properties: + items: + type: array + items: + $ref: "#/components/schemas/CoverageStatus" + nextCursor: + type: string + hasNext: + type: boolean + x-ignore-model: true ApiResponseUUID: type: object properties: @@ -255,12 +395,6 @@ components: $ref: "#/components/schemas/ApiError" x-api-wrapper: true x-api-wrapper-datatype: CoverageStatus - CoverageStatus: - type: string - enum: - - ACTIVE - - PASSIVE - - EXPERIMENTAL ApiResponseOffsetDateTime: type: object properties: @@ -317,16 +451,6 @@ components: x-data-container: Set x-data-container-type: java.util.Set x-data-item: TypeSummaryDto - TypeSummaryDto: - type: object - properties: - id: - type: string - format: uuid - code: - type: string - status: - $ref: "#/components/schemas/CoverageStatus" ApiResponseSetCoverageStatus: type: object properties: @@ -431,6 +555,88 @@ components: $ref: "#/components/schemas/ApiError" x-api-wrapper: true x-api-wrapper-datatype: Boolean + ApiResponsePagingTypeSummaryDto: + type: object + properties: + status: + type: integer + format: int32 + message: + type: string + data: + $ref: "#/components/schemas/PagingTypeSummaryDto" + errors: + type: array + items: + $ref: "#/components/schemas/ApiError" + x-api-wrapper: true + x-api-wrapper-datatype: PagingTypeSummaryDto + x-data-container: Paging + x-data-container-type: io.github.blueprintplatform.samples.typecoverage.contract.Paging + x-data-item: TypeSummaryDto + PagingTypeSummaryDto: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/TypeSummaryDto" + page: + type: integer + format: int32 + size: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + hasNext: + type: boolean + x-ignore-model: true + ApiResponsePagingCoverageStatus: + type: object + properties: + status: + type: integer + format: int32 + message: + type: string + data: + $ref: "#/components/schemas/PagingCoverageStatus" + errors: + type: array + items: + $ref: "#/components/schemas/ApiError" + x-api-wrapper: true + x-api-wrapper-datatype: PagingCoverageStatus + x-data-container: Paging + x-data-container-type: io.github.blueprintplatform.samples.typecoverage.contract.Paging + x-data-item: CoverageStatus + PagingCoverageStatus: + type: object + properties: + content: + type: array + items: + $ref: "#/components/schemas/CoverageStatus" + page: + type: integer + format: int32 + size: + type: integer + format: int32 + totalElements: + type: integer + format: int64 + totalPages: + type: integer + format: int32 + hasNext: + type: boolean + x-ignore-model: true ApiResponsePageTypeSummaryDto: type: object properties: diff --git a/samples/type-coverage/service-response/producer/pom.xml b/samples/type-coverage/service-response/producer/pom.xml index 3e21115e..77a55540 100644 --- a/samples/type-coverage/service-response/producer/pom.xml +++ b/samples/type-coverage/service-response/producer/pom.xml @@ -52,11 +52,6 @@ ${springdoc-openapi-starter.version} - - org.springframework.data - spring-data-commons - - org.springframework.boot spring-boot-configuration-processor