diff --git a/all/pom.xml b/all/pom.xml index 8d9c5eb02da..10d54a39059 100644 --- a/all/pom.xml +++ b/all/pom.xml @@ -474,6 +474,14 @@ io.helidon.openapi helidon-openapi + + io.helidon.openapi + helidon-openapi-31 + + + io.helidon.openapi + helidon-openapi-32 + io.helidon.logging helidon-logging-common diff --git a/bom/pom.xml b/bom/pom.xml index 98810318fff..7d66a887a2a 100644 --- a/bom/pom.xml +++ b/bom/pom.xml @@ -647,6 +647,16 @@ helidon-openapi ${helidon.version} + + io.helidon.openapi + helidon-openapi-31 + ${helidon.version} + + + io.helidon.openapi + helidon-openapi-32 + ${helidon.version} + diff --git a/codegen/codegen/src/main/java/io/helidon/codegen/TypeHierarchy.java b/codegen/codegen/src/main/java/io/helidon/codegen/TypeHierarchy.java index 8bb8b274429..501152689c5 100644 --- a/codegen/codegen/src/main/java/io/helidon/codegen/TypeHierarchy.java +++ b/codegen/codegen/src/main/java/io/helidon/codegen/TypeHierarchy.java @@ -24,6 +24,7 @@ import java.util.Objects; import java.util.Optional; import java.util.Set; +import java.util.function.BiConsumer; import java.util.stream.Collectors; import io.helidon.common.Api; @@ -177,6 +178,83 @@ public static List hierarchyAnnotations(CodegenContext ctx, TypeInfo return List.copyOf(annotations.values()); } + /** + * Find all distinct effective occurrences of any of the annotation types on matching methods in a method's type + * hierarchy, grouped by the declaring method. Meta-annotations are included. Unlike + * {@link #hierarchyAnnotations(CodegenContext, TypeInfo, TypedElementInfo)}, this method does not apply + * annotation-type precedence between declarations. + * A candidate-bearing declaration excludes candidates on declarations it overrides. A declaration without matching + * candidates does not exclude candidates inherited from its ancestors. Candidates on unrelated hierarchy branches + * are retained. The provided method itself is excluded. The outer list contains distinct declaration multisets; each + * inner list preserves candidate order and multiplicity. + * + * @param ctx codegen context + * @param type type info owning the method + * @param method method element + * @param annotationTypes annotation types to find + * @return distinct annotation candidates grouped by declaring method + */ + @Api.Internal + public static List> hierarchyAnnotationCandidates(CodegenContext ctx, + TypeInfo type, + TypedElementInfo method, + Set annotationTypes) { + Objects.requireNonNull(ctx, "ctx is null"); + Objects.requireNonNull(type, "type is null"); + Objects.requireNonNull(method, "method is null"); + Set candidateTypes = Set.copyOf(Objects.requireNonNull(annotationTypes, "annotationTypes is null")); + if (method.kind() != ElementKind.METHOD) { + throw new CodegenException("Only method elements have hierarchy annotation candidates: " + method.kind()); + } + + List prototypes = new ArrayList<>(); + BiConsumer collector = (declaringType, inheritedMethod) -> + prototypes.add(new HierarchyMethod(declaringType, inheritedMethod)); + Set processedTypes = new HashSet<>(); + String packageName = type.typeName().packageName(); + type.superTypeInfo().ifPresent(it -> collectInheritedMethods( + processedTypes, + collector, + it, + method, + packageName)); + type.interfaceTypeInfo().forEach(it -> collectInheritedMethods( + processedTypes, + collector, + it, + method, + packageName)); + + List annotationCandidates = new ArrayList<>(); + for (HierarchyMethod prototype : prototypes) { + List candidates = new ArrayList<>(); + prototype.method().annotations() + .forEach(it -> collectAnnotationCandidates(ctx, + candidateTypes, + candidates, + it, + new HashSet<>())); + if (!candidates.isEmpty()) { + annotationCandidates.add(new HierarchyAnnotationCandidate(prototype.declaringType(), + List.copyOf(candidates))); + } + } + + List> result = new ArrayList<>(); + Set> distinctCandidates = new HashSet<>(); + for (HierarchyAnnotationCandidate candidate : annotationCandidates) { + if (isOverriddenCandidate(candidate, annotationCandidates)) { + continue; + } + Map candidateCounts = candidate.annotations().stream() + .collect(Collectors.groupingBy(it -> it, Collectors.counting())); + if (distinctCandidates.add(candidateCounts)) { + result.add(candidate.annotations()); + } + } + return List.copyOf(result); + } + /** * Annotations of a parameter, taken from the full inheritance hierarchy (super type(s), interface(s). * @@ -556,6 +634,30 @@ private static void processMetaAnnotations(CodegenContext ctx, newAnnotations.forEach(it -> annotations.putIfAbsent(it.typeName(), it)); } + private static void collectAnnotationCandidates(CodegenContext ctx, + Set annotationTypes, + List result, + Annotation annotation, + Set path) { + if (!path.add(annotation.typeName())) { + return; + } + if (annotationTypes.contains(annotation.typeName())) { + result.add(annotation); + } + List metaAnnotations = annotation.metaAnnotations(); + if (metaAnnotations.isEmpty()) { + metaAnnotations = ctx.typeInfo(annotation.typeName()) + .map(TypeInfo::annotations) + .orElseGet(List::of); + } + metaAnnotations.forEach(it -> collectAnnotationCandidates(ctx, + annotationTypes, + result, + it, + new HashSet<>(path))); + } + private static void collectMetaAnnotations(CodegenContext ctx, Set processedTypes, List metaAnnotations, @@ -582,6 +684,18 @@ private static void collectInheritedMethods(Set processed, TypeInfo type, TypedElementInfo method, String currentPackage) { + collectInheritedMethods(processed, + (_, inheritedMethod) -> collected.add(inheritedMethod), + type, + method, + currentPackage); + } + + private static void collectInheritedMethods(Set processed, + BiConsumer collector, + TypeInfo type, + TypedElementInfo method, + String currentPackage) { if (!processed.add(type.typeName())) { // already handled this type return; @@ -617,20 +731,29 @@ private static void collectInheritedMethods(Set processed, .collect(Collectors.toUnmodifiableList()), currentPackage, substitutions) - .ifPresent(collected::add); + .ifPresent(it -> collector.accept(type, it)); type.superTypeInfo() .map(it -> substituteTypeParameters(it, substitutions)) - .ifPresent(it -> collectInheritedMethods(processed, collected, it, method, currentPackage)); + .ifPresent(it -> collectInheritedMethods(processed, collector, it, method, currentPackage)); for (TypeInfo typeInfo : type.interfaceTypeInfo()) { collectInheritedMethods(processed, - collected, + collector, substituteTypeParameters(typeInfo, substitutions), method, currentPackage); } } + private static boolean isOverriddenCandidate(HierarchyAnnotationCandidate candidate, + List candidates) { + TypeName candidateType = candidate.declaringType().typeName().genericTypeName(); + return candidates.stream() + .map(HierarchyAnnotationCandidate::declaringType) + .filter(it -> !it.typeName().genericTypeName().equals(candidateType)) + .anyMatch(it -> it.findInHierarchy(candidateType).isPresent()); + } + /** * Check if the provided type declares a method that is overridden. * @@ -807,4 +930,10 @@ private static TypeName substituteTypeParameters(TypeName typeName, Map annotations) { + } + } diff --git a/codegen/codegen/src/test/java/io/helidon/codegen/TypeHierarchyTest.java b/codegen/codegen/src/test/java/io/helidon/codegen/TypeHierarchyTest.java index d532fab033c..19353275b88 100644 --- a/codegen/codegen/src/test/java/io/helidon/codegen/TypeHierarchyTest.java +++ b/codegen/codegen/src/test/java/io/helidon/codegen/TypeHierarchyTest.java @@ -39,6 +39,7 @@ import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; class TypeHierarchyTest { private static final TypeName NOT_BLANK = TypeName.create("io.helidon.validation.Validation.String.NotBlank"); @@ -130,6 +131,108 @@ void methodSignaturesRetainMethodTypeVariableBounds() { not(TypeHierarchy.methodSignature(charSequenceMethod))); } + @Test + void hierarchyAnnotationCandidatesRejectNullArguments() { + TypeName annotationType = TypeName.create("io.helidon.codegen.test.Candidate"); + TypedElementInfo method = TypedElementInfo.builder() + .kind(ElementKind.METHOD) + .elementName("candidate") + .typeName(TypeNames.PRIMITIVE_VOID) + .build(); + TypeInfo type = TypeInfo.builder() + .typeName(TypeName.create("io.helidon.codegen.test.CandidateContract")) + .kind(ElementKind.INTERFACE) + .addElementInfo(method) + .build(); + + NullPointerException nullContext = assertThrows( + NullPointerException.class, + () -> TypeHierarchy.hierarchyAnnotationCandidates(null, type, method, Set.of(annotationType))); + NullPointerException nullType = assertThrows( + NullPointerException.class, + () -> TypeHierarchy.hierarchyAnnotationCandidates(CTX, null, method, Set.of(annotationType))); + NullPointerException nullMethod = assertThrows( + NullPointerException.class, + () -> TypeHierarchy.hierarchyAnnotationCandidates(CTX, type, null, Set.of(annotationType))); + NullPointerException nullAnnotationTypes = assertThrows( + NullPointerException.class, + () -> TypeHierarchy.hierarchyAnnotationCandidates(CTX, type, method, null)); + + assertThat(nullContext.getMessage(), is("ctx is null")); + assertThat(nullType.getMessage(), is("type is null")); + assertThat(nullMethod.getMessage(), is("method is null")); + assertThat(nullAnnotationTypes.getMessage(), is("annotationTypes is null")); + } + + @Test + void hierarchyAnnotationCandidatesUseNearestAnnotatedDeclaration() { + TypeName annotationType = TypeName.create("io.helidon.codegen.test.Candidate"); + Annotation baseCandidate = Annotation.create(annotationType, "base"); + Annotation narrowedCandidate = Annotation.create(annotationType, "narrowed"); + TypedElementInfo baseMethod = candidateMethod(baseCandidate); + TypeInfo base = candidateType("BaseApi", baseMethod); + TypedElementInfo narrowedMethod = candidateMethod(narrowedCandidate); + TypeInfo narrowed = TypeInfo.builder(candidateType("NarrowedApi", narrowedMethod)) + .addInterfaceTypeInfo(base) + .build(); + TypedElementInfo endpointMethod = candidateMethod(); + TypeInfo endpoint = TypeInfo.builder() + .typeName(TypeName.create("io.helidon.codegen.test.Endpoint")) + .kind(ElementKind.CLASS) + .addInterfaceTypeInfo(narrowed) + .addElementInfo(endpointMethod) + .build(); + + List> candidates = TypeHierarchy.hierarchyAnnotationCandidates( + CTX, endpoint, endpointMethod, Set.of(annotationType)); + + assertThat(candidates, is(List.of(List.of(narrowedCandidate)))); + } + + @Test + void hierarchyAnnotationCandidatesFallBackToAnnotatedAncestor() { + TypeName annotationType = TypeName.create("io.helidon.codegen.test.Candidate"); + Annotation baseCandidate = Annotation.create(annotationType, "base"); + TypeInfo base = candidateType("BaseApi", candidateMethod(baseCandidate)); + TypeInfo narrowed = TypeInfo.builder(candidateType("NarrowedApi", candidateMethod())) + .addInterfaceTypeInfo(base) + .build(); + TypedElementInfo endpointMethod = candidateMethod(); + TypeInfo endpoint = TypeInfo.builder() + .typeName(TypeName.create("io.helidon.codegen.test.Endpoint")) + .kind(ElementKind.CLASS) + .addInterfaceTypeInfo(narrowed) + .addElementInfo(endpointMethod) + .build(); + + List> candidates = TypeHierarchy.hierarchyAnnotationCandidates( + CTX, endpoint, endpointMethod, Set.of(annotationType)); + + assertThat(candidates, is(List.of(List.of(baseCandidate)))); + } + + @Test + void hierarchyAnnotationCandidatesKeepUnrelatedDeclarations() { + TypeName annotationType = TypeName.create("io.helidon.codegen.test.Candidate"); + Annotation firstCandidate = Annotation.create(annotationType, "first"); + Annotation secondCandidate = Annotation.create(annotationType, "second"); + TypeInfo first = candidateType("FirstApi", candidateMethod(firstCandidate)); + TypeInfo second = candidateType("SecondApi", candidateMethod(secondCandidate)); + TypedElementInfo endpointMethod = candidateMethod(); + TypeInfo endpoint = TypeInfo.builder() + .typeName(TypeName.create("io.helidon.codegen.test.Endpoint")) + .kind(ElementKind.CLASS) + .addInterfaceTypeInfo(first) + .addInterfaceTypeInfo(second) + .addElementInfo(endpointMethod) + .build(); + + List> candidates = TypeHierarchy.hierarchyAnnotationCandidates( + CTX, endpoint, endpointMethod, Set.of(annotationType)); + + assertThat(candidates, is(List.of(List.of(firstCandidate), List.of(secondCandidate)))); + } + @Test void nestedAnnotationsIncludeDeepGenericTypeArgumentAnnotations() { TypeName mapType = TypeName.builder(TypeNames.MAP) @@ -158,6 +261,24 @@ void nestedAnnotationsIncludeDeepGenericTypeArgumentAnnotations() { assertThat(TypeHierarchy.nestedAnnotations(CTX, typeInfo), hasItem(NOT_BLANK)); } + private static TypeInfo candidateType(String className, TypedElementInfo method) { + return TypeInfo.builder() + .typeName(TypeName.create("io.helidon.codegen.test." + className)) + .kind(ElementKind.INTERFACE) + .addElementInfo(method) + .build(); + } + + private static TypedElementInfo candidateMethod(Annotation... annotations) { + return TypedElementInfo.builder() + .kind(ElementKind.METHOD) + .accessModifier(AccessModifier.PUBLIC) + .elementName("candidate") + .typeName(TypeNames.PRIMITIVE_VOID) + .annotations(List.of(annotations)) + .build(); + } + @Test void typeNameAnnotationsIncludeWildcardBoundsAndArrayComponents() { Annotation notBlank = Annotation.create(NOT_BLANK); diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/ParamProviderHttpEntity.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/ParamProviderHttpEntity.java index d999812ad69..5d202c4b997 100644 --- a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/ParamProviderHttpEntity.java +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/ParamProviderHttpEntity.java @@ -36,9 +36,16 @@ public boolean codegen(ParameterCodegenContext ctx) { } var contentBuilder = ctx.contentBuilder(); + var parameterType = ctx.parameterType(); contentBuilder.addContent(ctx.serverRequestParamName()) - .addContent(".content().as("); - addTypeArgument(ctx, contentBuilder, ctx.parameterType()); + .addContent(".content()."); + if (parameterType.isOptional()) { + contentBuilder.addContent("asOptional("); + addTypeArgument(ctx, contentBuilder, parameterType.typeArguments().getFirst()); + } else { + contentBuilder.addContent("as("); + addTypeArgument(ctx, contentBuilder, parameterType); + } contentBuilder.addContent(");"); return true; diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtension.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtension.java index 1041bbee7ea..0e52e877667 100644 --- a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtension.java +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtension.java @@ -17,21 +17,16 @@ package io.helidon.declarative.codegen.http.webserver; import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Optional; import java.util.ServiceLoader; -import java.util.Set; import java.util.stream.Collectors; import io.helidon.codegen.CodegenException; import io.helidon.codegen.CodegenUtil; -import io.helidon.codegen.ElementInfoPredicates; -import io.helidon.codegen.TypeHierarchy; import io.helidon.codegen.classmodel.ClassModel; import io.helidon.codegen.classmodel.Constructor; import io.helidon.codegen.classmodel.Method; @@ -45,7 +40,6 @@ import io.helidon.common.types.TypeNames; import io.helidon.common.types.TypedElementInfo; import io.helidon.declarative.codegen.DeclarativeTypes; -import io.helidon.declarative.codegen.DeclarativeUtils; import io.helidon.declarative.codegen.http.HttpCodegenValidation; import io.helidon.declarative.codegen.http.HttpFields; import io.helidon.declarative.codegen.http.RestExtensionBase; @@ -66,25 +60,11 @@ import static io.helidon.codegen.CodegenUtil.toConstantName; import static io.helidon.declarative.codegen.DeclarativeTypes.SINGLETON_ANNOTATION; import static io.helidon.declarative.codegen.http.HttpTypes.BAD_REQUEST_EXCEPTION; -import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_CONSUMES_ANNOTATION; import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_ENTITY_ANNOTATION; import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_FORM_PARAM_ANNOTATION; -import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_HEADER_PARAM_ANNOTATION; import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_METHOD; -import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_METHOD_ANNOTATION; -import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_PATH_PARAM_ANNOTATION; -import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_PRODUCES_ANNOTATION; -import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_QUERY_PARAM_ANNOTATION; import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_REQUEST_PARAMS_ANNOTATION; import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_SUPPORT; -import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_COMPUTED_HEADER; -import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_COMPUTED_HEADERS; -import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_ENDPOINT; -import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_HEADER; -import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_HEADERS; -import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_LISTENER; -import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_STATUS; -import static java.util.function.Predicate.not; /* Generates: @@ -99,20 +79,18 @@ class RestServerExtension extends RestExtensionBase implements RegistryCodegenEx private final RegistryCodegenContext ctx; private final List paramProviders; + private final ServerEndpointAnalyzer endpointAnalyzer; RestServerExtension(RegistryCodegenContext ctx) { this.ctx = ctx; this.paramProviders = loadParamProviders(RestServerExtension.class.getClassLoader(), ctx); + this.endpointAnalyzer = ServerEndpointAnalyzer.create(ctx); } @Override public void process(RegistryRoundContext roundContext) { // for each @RestServer.Endpoint generate a service that implements it - Collection serverEndpoints = roundContext.annotatedTypes(REST_SERVER_ENDPOINT); - - List endpoints = serverEndpoints.stream() - .map(this::toEndpoint) - .collect(Collectors.toUnmodifiableList()); + List endpoints = endpointAnalyzer.endpoints(roundContext); for (ServerEndpoint endpoint : endpoints) { process(roundContext, endpoint); @@ -240,134 +218,6 @@ private void addFields(ClassModel.Builder endpointService, TypeName endpointType ); } - private ServerEndpoint toEndpoint(TypeInfo typeInfo) { - var builder = ServerEndpoint.builder() - .type(typeInfo); - - Set typeAnnotations = new HashSet<>(TypeHierarchy.hierarchyAnnotations(ctx, typeInfo)); - builder.annotations(typeAnnotations); - - Annotations.findFirst(REST_SERVER_LISTENER, typeAnnotations) - .flatMap(listener -> listener.stringValue()) - .ifPresent(builder::listener); - builder.listenerRequired(true); - - path(typeAnnotations, builder); - produces(typeAnnotations, builder); - consumes(typeAnnotations, builder); - headers(typeAnnotations, builder, REST_SERVER_HEADERS, REST_SERVER_HEADER); - computedHeaders(typeAnnotations, builder, REST_SERVER_COMPUTED_HEADERS, REST_SERVER_COMPUTED_HEADER); - - typeInfo.elementInfo() - .stream() - .filter(ElementInfoPredicates::isMethod) - .filter(not(ElementInfoPredicates::isPrivate)) - .filter(not(ElementInfoPredicates::isStatic)) - .forEach(it -> toMethod(typeInfo, builder, it)); - - return builder.build(); - } - - private void toMethod(TypeInfo endpoint, - ServerEndpoint.Builder endpointBuilder, - TypedElementInfo method) { - Set annotations = new HashSet<>(TypeHierarchy.hierarchyAnnotations(ctx, endpoint, method)); - - Optional httpMethodAnnotation = DeclarativeUtils.findMetaAnnotated(annotations, HTTP_METHOD_ANNOTATION); - if (httpMethodAnnotation.isEmpty()) { - // this method does not have an Http.Method meta annotation present, we can skip it - return; - } - - String methodName = method.elementName(); - String uniqueName = ctx.uniqueName(endpoint, method); - - var builder = RestMethod.builder() - .returnType(method.typeName()) - .type(endpoint) - .name(methodName) - .uniqueName(uniqueName) - .method(method) - .annotations(annotations) - .httpMethod(httpMethodFromAnnotation(method, httpMethodAnnotation.get())); - - path(annotations, builder); - consumes(annotations, builder); - produces(annotations, builder); - headers(annotations, builder, REST_SERVER_HEADERS, REST_SERVER_HEADER); - computedHeaders(annotations, builder, REST_SERVER_COMPUTED_HEADERS, REST_SERVER_COMPUTED_HEADER); - builder.addHeaders(endpointBuilder.headers()); - builder.addComputedHeaders(endpointBuilder.computedHeaders()); - - Annotations.findFirst(REST_SERVER_STATUS, annotations) - .ifPresent(annotation -> { - int code = annotation.intValue().orElse(200); - Optional reason = annotation - .stringValue("reason") - .filter(not(String::isBlank)); - builder.status(new HttpStatus(code, reason)); - }); - - int index = 0; - for (TypedElementInfo parameterInfo : method.parameterArguments()) { - processEndpointParameter(endpoint, method, parameterInfo, builder, index); - index++; - } - - if (Annotations.findFirst(HTTP_CONSUMES_ANNOTATION, annotations).isEmpty()) { - BodyParameters bodyParameters = bodyParameters(builder.build()); - if (bodyParameters.entityCount() > 0 || bodyParameters.formCount() > 0) { - builder.consumes(endpointBuilder.consumes()); - } - } - if (Annotations.findFirst(HTTP_PRODUCES_ANNOTATION, annotations).isEmpty()) { - builder.produces(endpointBuilder.produces()); - } - - endpointBuilder.addMethod(builder.build()); - } - - private void processEndpointParameter(TypeInfo typeInfo, - TypedElementInfo methodInfo, - TypedElementInfo parameterInfo, - RestMethod.Builder method, - int index) { - Set annotations = new HashSet<>(TypeHierarchy.hierarchyAnnotations(ctx, - typeInfo, - methodInfo, - parameterInfo, - index)); - HttpCodegenValidation.validateMethodParameterAnnotationCount( - annotations, - "Parameter '" + parameterInfo.elementName() + "' of declarative server method " - + typeInfo.typeName().fqName() + "." + methodInfo.elementName() - + "() must have at most one supported request parameter annotation.", - parameterInfo.originatingElementValue()); - var parameter = RestMethodParameter.builder() - .annotations(annotations) - .name(parameterInfo.elementName()) - .typeName(parameterInfo.typeName()) - .index(index) - .method(methodInfo) - .type(typeInfo) - .parameter(parameterInfo) - .build(); - - method.addParameter(parameter); - if (Annotations.findFirst(HTTP_HEADER_PARAM_ANNOTATION, annotations).isPresent()) { - method.addHeaderParameter(parameter); - } - if (Annotations.findFirst(HTTP_QUERY_PARAM_ANNOTATION, annotations).isPresent()) { - method.addQueryParameter(parameter); - } - if (Annotations.findFirst(HTTP_PATH_PARAM_ANNOTATION, annotations).isPresent()) { - method.addPathParameter(parameter); - } - if (Annotations.findFirst(HTTP_ENTITY_ANNOTATION, annotations).isPresent()) { - method.entityParameter(parameter); - } - } - private void process(RegistryRoundContext roundContext, ServerEndpoint endpoint) { TypeInfo type = endpoint.type(); if (type.kind() == ElementKind.INTERFACE) { @@ -499,7 +349,7 @@ private void endpointMethodBody(FieldHandler fieldHandler, int methodIndex) { BodyParameters bodyParameters = bodyParameters(restMethod); validateBodyParameters(restMethod, bodyParameters); - bodyParameters.entityName() + bodyParameters.requiredEntityName() .ifPresent(entity -> method.addContent("if (!") .addContent(REQUEST_PARAM_NAME) .addContentLine(".content().hasEntity()) {") @@ -739,13 +589,15 @@ private void validateBodyParameters(RestMethod restMethod, BodyParameters bodyPa private BodyParameters bodyParameters(RestMethod restMethod) { int entityCount = 0; int formCount = 0; - Optional entityName = Optional.empty(); + Optional requiredEntityName = Optional.empty(); Object firstOriginatingElement = restMethod.method().originatingElementValue(); for (RestMethodParameter parameter : restMethod.parameters()) { if (HttpCodegenValidation.hasAnnotation(parameter.annotations(), HTTP_ENTITY_ANNOTATION)) { entityCount++; - entityName = Optional.of(parameter.name()); + if (!parameter.typeName().isOptional()) { + requiredEntityName = Optional.of(parameter.name()); + } firstOriginatingElement = parameter.parameter().originatingElementValue(); } if (HttpCodegenValidation.hasAnnotation(parameter.annotations(), HTTP_FORM_PARAM_ANNOTATION)) { @@ -761,7 +613,9 @@ private BodyParameters bodyParameters(RestMethod restMethod) { for (TypedElementInfo component : HttpCodegenValidation.requestParamsComponents(requestParamsType)) { if (HttpCodegenValidation.hasAnnotation(component.annotations(), HTTP_ENTITY_ANNOTATION)) { entityCount++; - entityName = Optional.of(component.elementName()); + if (!component.typeName().isOptional()) { + requiredEntityName = Optional.of(component.elementName()); + } firstOriginatingElement = component.originatingElementValue(); } if (HttpCodegenValidation.hasAnnotation(component.annotations(), HTTP_FORM_PARAM_ANNOTATION)) { @@ -772,7 +626,7 @@ private BodyParameters bodyParameters(RestMethod restMethod) { } } - return new BodyParameters(entityCount, formCount, entityName, firstOriginatingElement); + return new BodyParameters(entityCount, formCount, requiredEntityName, firstOriginatingElement); } private void addSimpleRoute(FieldHandler fieldHandler, Method.Builder routing, RestMethod restMethod) { @@ -860,7 +714,7 @@ private void addHttpRoute(FieldHandler fieldHandler, private record BodyParameters(int entityCount, int formCount, - Optional entityName, + Optional requiredEntityName, Object firstOriginatingElement) { } } diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtensionProvider.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtensionProvider.java index 50f02ccb287..2f0e67da7b6 100644 --- a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtensionProvider.java +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/RestServerExtensionProvider.java @@ -26,6 +26,8 @@ import io.helidon.service.codegen.spi.RegistryCodegenExtension; import io.helidon.service.codegen.spi.RegistryCodegenExtensionProvider; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_ENDPOINT; + /** * Java {@link java.util.ServiceLoader} provider implementation of {@link io.helidon.codegen.spi.CodegenExtensionProvider} * to support code generation for WebServer declarative. @@ -42,7 +44,7 @@ public RestServerExtensionProvider() { @Override public Set supportedAnnotations() { - return Set.of(WebServerCodegenTypes.REST_SERVER_ENDPOINT); + return Set.of(REST_SERVER_ENDPOINT); } @Override diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/ServerEndpointAnalyzer.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/ServerEndpointAnalyzer.java new file mode 100644 index 00000000000..c14764fed97 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/http/webserver/ServerEndpointAnalyzer.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.http.webserver; + +import java.util.Collection; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.helidon.codegen.ElementInfoPredicates; +import io.helidon.codegen.TypeHierarchy; +import io.helidon.common.Api; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.Annotations; +import io.helidon.common.types.TypeInfo; +import io.helidon.common.types.TypedElementInfo; +import io.helidon.declarative.codegen.DeclarativeUtils; +import io.helidon.declarative.codegen.http.HttpCodegenValidation; +import io.helidon.declarative.codegen.http.RestExtensionBase; +import io.helidon.declarative.codegen.model.http.HttpStatus; +import io.helidon.declarative.codegen.model.http.RestMethod; +import io.helidon.declarative.codegen.model.http.RestMethodParameter; +import io.helidon.declarative.codegen.model.http.ServerEndpoint; +import io.helidon.service.codegen.RegistryCodegenContext; +import io.helidon.service.codegen.RegistryRoundContext; + +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_CONSUMES_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_ENTITY_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_FORM_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_HEADER_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_METHOD_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_PATH_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_PRODUCES_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_QUERY_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_REQUEST_PARAMS_ANNOTATION; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_COMPUTED_HEADER; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_COMPUTED_HEADERS; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_ENDPOINT; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_HEADER; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_HEADERS; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_LISTENER; +import static io.helidon.declarative.codegen.http.webserver.WebServerCodegenTypes.REST_SERVER_STATUS; +import static java.util.function.Predicate.not; + +/** + * Analyzer for declarative WebServer endpoints. + */ +@Api.Internal +public final class ServerEndpointAnalyzer extends RestExtensionBase { + private final RegistryCodegenContext ctx; + + private ServerEndpointAnalyzer(RegistryCodegenContext ctx) { + this.ctx = ctx; + } + + /** + * Create a new endpoint analyzer. + * + * @param ctx code generation context + * @return endpoint analyzer + */ + public static ServerEndpointAnalyzer create(RegistryCodegenContext ctx) { + return new ServerEndpointAnalyzer(ctx); + } + + /** + * Analyze endpoint types available in the provided round. + * + * @param roundContext codegen round context + * @return analyzed endpoints + */ + public List endpoints(RegistryRoundContext roundContext) { + return endpoints(roundContext.annotatedTypes(REST_SERVER_ENDPOINT)); + } + + /** + * Analyze endpoint types from the provided candidates. + * + * @param types candidate endpoint types + * @return analyzed endpoints + */ + public List endpoints(Collection types) { + return types + .stream() + .filter(it -> Annotations.findFirst(REST_SERVER_ENDPOINT, + TypeHierarchy.hierarchyAnnotations(ctx, it)) + .isPresent()) + .map(this::endpoint) + .toList(); + } + + /** + * Analyze a single endpoint type. + * + * @param typeInfo endpoint type + * @return analyzed endpoint + */ + ServerEndpoint endpoint(TypeInfo typeInfo) { + var builder = ServerEndpoint.builder() + .type(typeInfo); + + Set typeAnnotations = new HashSet<>(TypeHierarchy.hierarchyAnnotations(ctx, typeInfo)); + builder.annotations(typeAnnotations); + + Annotations.findFirst(REST_SERVER_LISTENER, typeAnnotations) + .flatMap(Annotation::stringValue) + .ifPresent(builder::listener); + builder.listenerRequired(true); + + path(typeAnnotations, builder); + produces(typeAnnotations, builder); + consumes(typeAnnotations, builder); + headers(typeAnnotations, builder, REST_SERVER_HEADERS, REST_SERVER_HEADER); + computedHeaders(typeAnnotations, builder, REST_SERVER_COMPUTED_HEADERS, REST_SERVER_COMPUTED_HEADER); + + typeInfo.elementInfo() + .stream() + .filter(ElementInfoPredicates::isMethod) + .filter(not(ElementInfoPredicates::isPrivate)) + .filter(not(ElementInfoPredicates::isStatic)) + .forEach(it -> method(typeInfo, builder, it)); + + return builder.build(); + } + + private void method(TypeInfo endpoint, + ServerEndpoint.Builder endpointBuilder, + TypedElementInfo method) { + Set annotations = new HashSet<>(TypeHierarchy.hierarchyAnnotations(ctx, endpoint, method)); + + Optional httpMethodAnnotation = DeclarativeUtils.findMetaAnnotated(annotations, HTTP_METHOD_ANNOTATION); + if (httpMethodAnnotation.isEmpty()) { + return; + } + + String methodName = method.elementName(); + String uniqueName = ctx.uniqueName(endpoint, method); + + var builder = RestMethod.builder() + .returnType(method.typeName()) + .type(endpoint) + .name(methodName) + .uniqueName(uniqueName) + .method(method) + .annotations(annotations) + .httpMethod(httpMethodFromAnnotation(method, httpMethodAnnotation.get())); + + path(annotations, builder); + consumes(annotations, builder); + produces(annotations, builder); + headers(annotations, builder, REST_SERVER_HEADERS, REST_SERVER_HEADER); + computedHeaders(annotations, builder, REST_SERVER_COMPUTED_HEADERS, REST_SERVER_COMPUTED_HEADER); + + builder.addHeaders(endpointBuilder.headers()); + builder.addComputedHeaders(endpointBuilder.computedHeaders()); + + Annotations.findFirst(REST_SERVER_STATUS, annotations) + .ifPresent(annotation -> { + int code = annotation.intValue().orElse(200); + Optional reason = annotation + .stringValue("reason") + .filter(not(String::isBlank)); + builder.status(new HttpStatus(code, reason)); + }); + + boolean hasBodyParameters = false; + int index = 0; + for (TypedElementInfo parameterInfo : method.parameterArguments()) { + hasBodyParameters |= parameter(endpoint, method, parameterInfo, builder, index); + index++; + } + + if (Annotations.findFirst(HTTP_CONSUMES_ANNOTATION, annotations).isEmpty() && hasBodyParameters) { + builder.consumes(endpointBuilder.consumes()); + } + if (Annotations.findFirst(HTTP_PRODUCES_ANNOTATION, annotations).isEmpty()) { + builder.produces(endpointBuilder.produces()); + } + + endpointBuilder.addMethod(builder.build()); + } + + private boolean parameter(TypeInfo typeInfo, + TypedElementInfo methodInfo, + TypedElementInfo parameterInfo, + RestMethod.Builder method, + int index) { + Set annotations = new HashSet<>(TypeHierarchy.hierarchyAnnotations(ctx, + typeInfo, + methodInfo, + parameterInfo, + index)); + HttpCodegenValidation.validateMethodParameterAnnotationCount( + annotations, + "Parameter '" + parameterInfo.elementName() + "' of declarative server method " + + typeInfo.typeName().fqName() + "." + methodInfo.elementName() + + "() must have at most one supported request parameter annotation.", + parameterInfo.originatingElementValue()); + var parameter = RestMethodParameter.builder() + .annotations(annotations) + .name(parameterInfo.elementName()) + .typeName(parameterInfo.typeName()) + .index(index) + .method(methodInfo) + .type(typeInfo) + .parameter(parameterInfo) + .build(); + + method.addParameter(parameter); + if (Annotations.findFirst(HTTP_HEADER_PARAM_ANNOTATION, annotations).isPresent()) { + method.addHeaderParameter(parameter); + } + if (Annotations.findFirst(HTTP_QUERY_PARAM_ANNOTATION, annotations).isPresent()) { + method.addQueryParameter(parameter); + } + if (Annotations.findFirst(HTTP_PATH_PARAM_ANNOTATION, annotations).isPresent()) { + method.addPathParameter(parameter); + } + if (Annotations.findFirst(HTTP_ENTITY_ANNOTATION, annotations).isPresent()) { + method.entityParameter(parameter); + } + + if (HttpCodegenValidation.hasAnnotation(annotations, HTTP_ENTITY_ANNOTATION) + || HttpCodegenValidation.hasAnnotation(annotations, HTTP_FORM_PARAM_ANNOTATION)) { + return true; + } + if (HttpCodegenValidation.hasAnnotation(annotations, HTTP_REQUEST_PARAMS_ANNOTATION)) { + TypeInfo requestParamsType = HttpCodegenValidation.requestParamsRecordType( + ctx::typeInfo, + parameterInfo.typeName(), + parameterInfo.originatingElementValue()); + HttpCodegenValidation.validateRequestParamsBodyComponents(requestParamsType); + return HttpCodegenValidation.requestParamsComponents(requestParamsType) + .stream() + .anyMatch(component -> { + List componentAnnotations = component.annotations(); + return HttpCodegenValidation.hasAnnotation(componentAnnotations, HTTP_ENTITY_ANNOTATION) + || HttpCodegenValidation.hasAnnotation(componentAnnotations, HTTP_FORM_PARAM_ANNOTATION); + }); + } + return false; + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiAnnotationHierarchy.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiAnnotationHierarchy.java new file mode 100644 index 00000000000..57925a12383 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiAnnotationHierarchy.java @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; + +import io.helidon.codegen.CodegenException; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.TypeInfo; +import io.helidon.common.types.TypeName; + +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION; + +final class OpenApiAnnotationHierarchy { + private static final Set SECURITY_REQUIREMENT_ANNOTATIONS = Set.of(OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION, + OPENAPI_SECURITY_REQUIREMENT_ANNOTATION, + OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION); + + private OpenApiAnnotationHierarchy() { + } + + static List> endpointSecurityAnnotationGroups(TypeInfo endpointType, + Function, ?> semanticKey) { + Collection declared = endpointType.annotations(); + if (clearsSecurity(declared)) { + return List.of(List.copyOf(declared)); + } + List direct = withMetaAnnotations(declared); + if (hasSecurityRequirementAnnotations(direct)) { + return List.of(direct); + } + + List candidates = new ArrayList<>(); + Set processedTypes = new HashSet<>(); + endpointType.superTypeInfo() + .ifPresent(it -> collectEndpointSecurityAnnotations(candidates, processedTypes, it)); + endpointType.interfaceTypeInfo() + .forEach(it -> collectEndpointSecurityAnnotations(candidates, processedTypes, it)); + List applicableCandidates = candidates.stream() + .filter(candidate -> !isOverriddenCandidate(candidate, candidates)) + .toList(); + long clearCandidates = applicableCandidates.stream() + .filter(TypeSecurityAnnotations::clearsSecurity) + .count(); + if (clearCandidates > 0 && clearCandidates < applicableCandidates.size()) { + throw new CodegenException("Conflicting inherited OpenAPI security requirements on " + + endpointType.typeName().fqName()); + } + Set semanticKeys = new HashSet<>(); + Set annotations = new HashSet<>(); + return applicableCandidates.stream() + .filter(candidate -> semanticKeys.add(semanticKey.apply(candidate.annotations()))) + .map(candidate -> candidate.annotations().stream() + .filter(annotations::add) + .toList()) + .filter(it -> !it.isEmpty()) + .toList(); + } + + static List withMetaAnnotations(Collection annotations) { + List result = new ArrayList<>(); + for (Annotation rootAnnotation : annotations) { + Deque>> remaining = new ArrayDeque<>(); + remaining.add(Map.entry(rootAnnotation, new HashSet<>())); + while (!remaining.isEmpty()) { + Map.Entry> current = remaining.removeFirst(); + Annotation annotation = current.getKey(); + Set path = current.getValue(); + if (path.add(annotation.typeName())) { + result.add(annotation); + annotation.metaAnnotations() + .forEach(it -> remaining.add(Map.entry(it, new HashSet<>(path)))); + } + } + } + return result; + } + + private static void collectEndpointSecurityAnnotations(List candidates, + Set processedTypes, + TypeInfo type) { + if (!processedTypes.add(type.typeName().genericTypeName())) { + return; + } + + List annotations = withMetaAnnotations(type.annotations()).stream() + .filter(it -> SECURITY_REQUIREMENT_ANNOTATIONS.contains(it.typeName())) + .toList(); + if (!annotations.isEmpty()) { + candidates.add(new TypeSecurityAnnotations(type, annotations)); + } + type.superTypeInfo().ifPresent(it -> collectEndpointSecurityAnnotations(candidates, processedTypes, it)); + type.interfaceTypeInfo().forEach(it -> collectEndpointSecurityAnnotations(candidates, processedTypes, it)); + } + + private static boolean hasSecurityRequirementAnnotations(Collection annotations) { + return annotations.stream() + .map(Annotation::typeName) + .anyMatch(SECURITY_REQUIREMENT_ANNOTATIONS::contains); + } + + private static boolean clearsSecurity(Collection annotations) { + List securityAnnotations = annotations.stream() + .filter(it -> SECURITY_REQUIREMENT_ANNOTATIONS.contains(it.typeName())) + .toList(); + return !securityAnnotations.isEmpty() + && securityAnnotations.stream() + .allMatch(it -> OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION.equals(it.typeName()) + && it.annotationValues() + .filter(List::isEmpty) + .isPresent()); + } + + private static boolean isOverriddenCandidate(TypeSecurityAnnotations candidate, + List candidates) { + TypeName candidateType = candidate.declaringType().typeName().genericTypeName(); + return candidates.stream() + .map(TypeSecurityAnnotations::declaringType) + .filter(it -> !it.typeName().genericTypeName().equals(candidateType)) + .anyMatch(it -> it.findInHierarchy(candidateType).isPresent()); + } + + private record TypeSecurityAnnotations(TypeInfo declaringType, List annotations) { + private boolean clearsSecurity() { + return OpenApiAnnotationHierarchy.clearsSecurity(annotations); + } + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiAnnotationValidator.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiAnnotationValidator.java new file mode 100644 index 00000000000..fb30092968d --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiAnnotationValidator.java @@ -0,0 +1,650 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import io.helidon.codegen.CodegenException; +import io.helidon.common.types.Annotation; + +import static java.util.function.Predicate.not; + +final class OpenApiAnnotationValidator { + private static final String DEFAULT_MEDIA_TYPE = "application/json"; + private static final Set API_KEY_LOCATIONS = Set.of("query", "header", "cookie"); + private static final Pattern COMPONENT_KEY_PATTERN = Pattern.compile("[a-zA-Z0-9._-]+"); + private static final Pattern CONFIG_REFERENCE_PATTERN = + Pattern.compile("(? tags) { + Set names = new HashSet<>(); + for (Annotation tag : tags) { + String name = stringValue(tag, "value") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Tag value is required")); + validateUnique("@OpenApi.Tag on " + owner, "tag", name, names); + } + } + + void validateRequestBodyRequiredness(String owner, + boolean inferredRequired, + Optional requiredOverride, + String binding) { + if (inferredRequired && requiredOverride.filter(not(Boolean::booleanValue)).isPresent()) { + throw new CodegenException("@OpenApi.RequestBody on " + owner + + " cannot make required " + binding + " optional"); + } + } + + void validateServers(String owner, List servers) { + Set urls = new HashSet<>(); + for (Annotation server : servers) { + String url = server.stringValue() + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Server value is required")); + validateUnique("@OpenApi.Server on " + owner, "server", url, urls); + Set variableNames = new LinkedHashSet<>(); + for (Annotation variable : server.annotationValues("variables").orElseGet(List::of)) { + String location = "@OpenApi.Server on " + owner + " for server " + url; + String name = stringValue(variable, "name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(location + " requires a server variable name")); + if (name.indexOf('{') >= 0 || name.indexOf('}') >= 0) { + throw new CodegenException(location + " has invalid server variable name " + name + + "; names cannot contain braces"); + } + validateUnique(location, "server variable", name, variableNames); + + String defaultValue = variable.stringValue("defaultValue") + .orElseThrow(() -> new CodegenException(location + " for variable " + name + + " requires a default value")); + List enumeration = variable.stringValues("enumeration").orElseGet(List::of); + if (!enumeration.isEmpty()) { + String resolvedDefault = expressionDefaultValue(defaultValue); + List resolvedEnumeration = enumeration.stream() + .map(this::expressionDefaultValue) + .toList(); + boolean defaultIsKnown = !defaultValue.contains("${") || !resolvedDefault.equals(defaultValue); + boolean enumerationIsKnown = enumeration.stream() + .allMatch(value -> !value.contains("${") + || !expressionDefaultValue(value).equals(value)); + if (defaultIsKnown + && enumerationIsKnown + && !resolvedEnumeration.contains(resolvedDefault)) { + throw new CodegenException(location + " for variable " + name + + " must include default value " + resolvedDefault + + " in its enumeration"); + } + } + } + Matcher urlExpression = CONFIG_REFERENCE_PATTERN.matcher(url); + String resolvedUrl; + if (urlExpression.find() + && urlExpression.start() == 0 + && urlExpression.end() == url.length()) { + String expressionDefault = urlExpression.group(2); + if (expressionDefault == null) { + continue; + } + resolvedUrl = expressionDefault.substring(1); + } else { + resolvedUrl = url; + } + Set urlVariableNames = new LinkedHashSet<>(); + Matcher embeddedExpression = CONFIG_REFERENCE_PATTERN.matcher(resolvedUrl); + int expressionStart = -1; + int expressionEnd = -1; + if (embeddedExpression.find()) { + expressionStart = embeddedExpression.start() + 1; + expressionEnd = embeddedExpression.end(); + } + int searchFrom = 0; + while (searchFrom < resolvedUrl.length()) { + int openBrace = resolvedUrl.indexOf('{', searchFrom); + if (openBrace < 0) { + break; + } + if (openBrace == expressionStart) { + searchFrom = expressionEnd; + if (embeddedExpression.find()) { + expressionStart = embeddedExpression.start() + 1; + expressionEnd = embeddedExpression.end(); + } else { + expressionStart = -1; + expressionEnd = -1; + } + continue; + } + int closeBrace = resolvedUrl.indexOf('}', openBrace + 1); + if (closeBrace < 0) { + break; + } + urlVariableNames.add(resolvedUrl.substring(openBrace + 1, closeBrace)); + searchFrom = closeBrace + 1; + } + for (String urlVariableName : urlVariableNames) { + if (!variableNames.contains(urlVariableName)) { + throw new CodegenException("@OpenApi.Server on " + owner + " for server " + url + + " is missing a declaration for URL variable " + + urlVariableName); + } + } + for (String variableName : variableNames) { + if (!urlVariableNames.contains(variableName)) { + throw new CodegenException("@OpenApi.Server on " + owner + " for server " + url + + " declares server variable " + variableName + + " which is not present in the URL"); + } + } + } + } + + void validateOperationTags(String owner, List tags) { + validateUniqueValues("@OpenApi.Operation on " + owner, + "tag", + tags.stream() + .map(this::expressionDefaultValue) + .toList()); + } + + void validateExtensions(String owner, List extensions) { + Set names = new HashSet<>(); + for (Annotation extension : extensions) { + String name = stringValue(extension, "name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Extension name is required")); + validateUnique("@OpenApi.Extension on " + owner, "extension", name, names); + } + } + + void validateSecuritySchemes(String owner, List schemes) { + Set names = new HashSet<>(); + for (OpenApiSecurityScheme scheme : schemes) { + String name = stringValue(scheme, "name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(scheme.annotationName() + " name is required")); + if (!COMPONENT_KEY_PATTERN.matcher(name).matches()) { + throw new CodegenException(scheme.annotationName() + " on " + owner + + " has invalid security scheme name " + name + + "; names can contain only letters, digits, dots, hyphens," + + " and underscores"); + } + validateUnique(scheme.annotationName() + " on " + owner, "security scheme", name, names); + validateSecurityScheme(owner, scheme, name); + } + } + + void validateSecurityRequirements(String owner, List requirements) { + Set signatures = new HashSet<>(); + for (OpenApiSecurityRequirement requirement : requirements) { + Set schemes = new HashSet<>(); + List signature = new ArrayList<>(); + List description = new ArrayList<>(); + for (Annotation schemeRequirement : requirement.schemes()) { + String scheme = stringValue(schemeRequirement, "value") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException( + "@OpenApi.SecuritySchemeRequirement value is required")); + List scopes = stringValues(schemeRequirement, "scopes"); + validateUnique("@OpenApi.SecurityRequirement on " + owner, "scheme", scheme, schemes); + validateUniqueValues("@OpenApi.SecuritySchemeRequirement on " + owner + " for scheme " + scheme, + "scope", + scopes); + signature.add(scheme + "\n" + String.join("\n", scopes.stream().sorted().toList())); + description.add(scheme + (scopes.isEmpty() ? "" : " with scopes " + scopes)); + } + if (!signature.isEmpty()) { + validateUnique("@OpenApi.SecurityRequirement on " + owner, + "security requirement", + description.toString(), + String.join("\n\n", signature.stream().sorted().toList()), + signatures); + } + } + } + + void validateResponses(String restMethodDescription, List responses) { + Set statuses = new HashSet<>(); + for (Annotation response : responses) { + int status = response.intValue("status") + .orElseThrow(() -> new CodegenException("@OpenApi.Response status is required")); + if (status < 100 || status > 599) { + throw new CodegenException("@OpenApi.Response on " + restMethodDescription + + " must define an HTTP response status from 100 to 599: " + + status); + } + if (!statuses.add(status)) { + throw new CodegenException("@OpenApi.Response on " + restMethodDescription + + " cannot define response status " + status + " more than once"); + } + Set linkNames = new HashSet<>(); + for (Annotation link : response.annotationValues("links").orElseGet(List::of)) { + String location = "@OpenApi.Response on " + restMethodDescription + " for status " + status; + String name = stringValue(link, "name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(location + " requires a link name")); + if (!COMPONENT_KEY_PATTERN.matcher(name).matches()) { + throw new CodegenException(location + " has invalid link name " + name + + "; names can contain only letters, digits, dots, hyphens," + + " and underscores"); + } + validateUnique(location, "link", name, linkNames); + + boolean hasOperationRef = hasConfiguredStringValue(link, "operationRef"); + boolean hasOperationId = hasConfiguredStringValue(link, "operationId"); + if (hasOperationRef == hasOperationId) { + throw new CodegenException(location + " link " + name + + " must define exactly one of operationRef or operationId"); + } + + Set parameterNames = new HashSet<>(); + for (Annotation parameter : link.annotationValues("parameters").orElseGet(List::of)) { + String parameterName = stringValue(parameter, "name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(location + " link " + name + + " requires a parameter name")); + validateUnique(location + " link " + name, "parameter", parameterName, parameterNames); + } + } + } + } + + void validateOAuthScopes(String owner, String schemeName, String flowName, List scopes) { + Set names = new HashSet<>(); + for (Annotation scope : scopes) { + String name = stringValue(scope, "value") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.OAuthScope value is required")); + validateUnique("@OpenApi.OAuthFlow on " + owner + " for security scheme " + schemeName + + " " + flowName + " flow", + "scope", + name, + names); + } + } + + void validateOAuthFlow(String owner, String schemeName, String flowName, Annotation flow) { + switch (flowName) { + case "implicit" -> requireString("@OpenApi.OAuthFlow on " + owner + + " for security scheme " + schemeName + " implicit flow", + flow, + "authorizationUrl"); + case "password", "clientCredentials" -> requireString("@OpenApi.OAuthFlow on " + owner + + " for security scheme " + schemeName + " " + + flowName + " flow", + flow, + "tokenUrl"); + case "authorizationCode" -> { + String location = "@OpenApi.OAuthFlow on " + owner + + " for security scheme " + schemeName + " authorizationCode flow"; + requireString(location, flow, "authorizationUrl"); + requireString(location, flow, "tokenUrl"); + } + case "deviceAuthorization" -> { + String location = "@OpenApi.OAuthFlow on " + owner + + " for security scheme " + schemeName + " deviceAuthorization flow"; + requireString(location, flow, "deviceAuthorizationUrl"); + requireString(location, flow, "tokenUrl"); + } + default -> throw new CodegenException("Unsupported OAuth flow " + flowName); + } + } + + void validateContentMediaTypes(String owner, + List contentAnnotations, + List inferredMediaTypes) { + Set mediaTypes = new HashSet<>(); + for (Annotation content : contentAnnotations) { + for (String mediaType : contentMediaTypes(content, inferredMediaTypes)) { + validateUnique(owner, "content media type", mediaType, mediaTypes); + } + validateContentExamples(owner, content); + } + } + + void validateResponseHeaders(String restMethodDescription, + List explicitHeaders, + List inferredHeaderNames) { + Set names = new HashSet<>(); + inferredHeaderNames.forEach(name -> names.add(name.toLowerCase(Locale.ROOT))); + for (Annotation header : explicitHeaders) { + String name = stringValue(header, "name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Header name is required")); + if ("content-type".equals(name.toLowerCase(Locale.ROOT))) { + throw new CodegenException("@OpenApi.Response on " + restMethodDescription + + " cannot define response header " + name + + "; use @OpenApi.Content to define response media types"); + } + if (!names.add(name.toLowerCase(Locale.ROOT))) { + throw new CodegenException("@OpenApi.Response on " + restMethodDescription + + " cannot define response header " + name + + " more than once, including inferred Helidon response headers"); + } + validateResponseHeaderContent(restMethodDescription, + name, + header.annotationValues("content").orElseGet(List::of)); + } + } + + void validateMethodParameters(String restMethodDescription, List methodParameters) { + Set parameterKeys = new HashSet<>(); + for (Annotation methodParameter : methodParameters) { + Optional name = stringValue(methodParameter, "name").filter(not(String::isBlank)); + Optional in = stringValue(methodParameter, "in").filter(not(String::isBlank)); + if (name.isPresent() && in.isPresent()) { + String location = in.get(); + String parameterName = name.get(); + validateUnique("Method-level @OpenApi.Parameter on " + restMethodDescription, + "parameter", + location + " " + parameterName, + parameterKey(location, parameterName), + parameterKeys); + } + } + } + + void validateParameterAnnotations(String restMethodDescription, + String in, + String name, + List parameterAnnotations) { + if (parameterAnnotations.size() > 1) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription + + " cannot define metadata for " + in + + " parameter " + name + " more than once"); + } + } + + void validateParameterExamples(String restMethodDescription, + String in, + String name, + Optional example, + List examples) { + if (example.isPresent() && !examples.isEmpty()) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription + + " cannot define both example and examples for " + in + + " parameter " + name); + } + validateExamples("@OpenApi.Parameter on " + restMethodDescription + + " for " + in + " parameter " + name, + examples); + } + + void validateParameterContent(String restMethodDescription, + String in, + String name, + List contentAnnotations) { + if (contentAnnotations.size() > 1) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription + + " cannot define more than one content entry for " + in + + " parameter " + name); + } + contentAnnotations.forEach(content -> validateContentExamples("@OpenApi.Parameter on " + + restMethodDescription + + " for " + in + " parameter " + name, + content)); + } + + List contentMediaTypes(Annotation content, List inferredMediaTypes) { + return stringValue(content, "value") + .filter(not(String::isBlank)) + .map(List::of) + .orElseGet(() -> inferredMediaTypes.isEmpty() ? List.of(DEFAULT_MEDIA_TYPE) : inferredMediaTypes); + } + + String exampleName(Annotation example, int index) { + return stringValue(example, "name") + .filter(not(String::isBlank)) + .orElse(index == 0 ? "example" : "example" + (index + 1)); + } + + private void validateResponseHeaderContent(String restMethodDescription, + String name, + List contentAnnotations) { + if (contentAnnotations.size() > 1) { + throw new CodegenException("@OpenApi.Header on " + restMethodDescription + + " cannot define more than one content entry for response header " + + name); + } + contentAnnotations.forEach(content -> validateContentExamples("@OpenApi.Header on " + + restMethodDescription + + " for response header " + name, + content)); + } + + private void validateSecurityScheme(String owner, OpenApiSecurityScheme scheme, String name) { + String location = scheme.annotationName() + " on " + owner + " for security scheme " + name; + String type = requireString(location, scheme, "type"); + switch (type) { + case "apiKey" -> { + requireString(location, scheme, "apiKeyName"); + String in = requireString(location, scheme, "in"); + if (!API_KEY_LOCATIONS.contains(in)) { + throw new CodegenException(location + + " apiKey in must be one of query, header, or cookie: " + in); + } + rejectFields(location, type, scheme, "scheme", "bearerFormat", "openIdConnectUrl", "oauth2MetadataUrl"); + rejectFlows(location, type, scheme); + } + case "http" -> { + String securityScheme = requireString(location, scheme, "scheme"); + rejectFields(location, type, scheme, "apiKeyName", "in", "openIdConnectUrl", "oauth2MetadataUrl"); + rejectFlows(location, type, scheme); + if (hasConfiguredStringValue(scheme, "bearerFormat") + && !"bearer".equalsIgnoreCase(securityScheme) + && !securityScheme.startsWith("${")) { + throw new CodegenException(location + " http scheme " + securityScheme + + " cannot define bearerFormat"); + } + } + case "mutualTLS" -> { + rejectFields(location, type, scheme, "apiKeyName", "in", "scheme", "bearerFormat", "openIdConnectUrl", + "oauth2MetadataUrl"); + rejectFlows(location, type, scheme); + } + case "oauth2" -> { + validateOAuth2SecurityScheme(owner, scheme, name, location); + rejectFields(location, type, scheme, "apiKeyName", "in", "scheme", "bearerFormat", "openIdConnectUrl"); + } + case "openIdConnect" -> { + requireString(location, scheme, "openIdConnectUrl"); + rejectFields(location, type, scheme, "apiKeyName", "in", "scheme", "bearerFormat", "oauth2MetadataUrl"); + rejectFlows(location, type, scheme); + } + default -> throw new CodegenException(location + " type must be one of apiKey, http, mutualTLS, oauth2, " + + "or openIdConnect: " + type); + } + } + + private void rejectFields(String location, String type, OpenApiSecurityScheme annotation, String... properties) { + for (String property : properties) { + if (hasConfiguredStringValue(annotation, property)) { + throw new CodegenException(location + " type " + type + " cannot define " + property); + } + } + } + + private void rejectFlows(String location, String type, OpenApiSecurityScheme scheme) { + scheme.annotationValue("flows") + .filter(this::hasOAuthFlowsMetadata) + .ifPresent(_ -> { + throw new CodegenException(location + " type " + type + " cannot define flows"); + }); + } + + private void validateOAuth2SecurityScheme(String owner, + OpenApiSecurityScheme scheme, + String name, + String location) { + Annotation flows = scheme.annotationValue("flows") + .orElseThrow(() -> new CodegenException(location + " requires OAuth flows")); + boolean hasFlow = false; + for (String flowName : List.of("implicit", "password", "clientCredentials", "authorizationCode", + "deviceAuthorization")) { + Optional flow = flows.annotationValue(flowName) + .filter(this::hasOAuthFlowMetadata); + if (flow.isPresent()) { + hasFlow = true; + validateOAuthFlow(owner, name, flowName, flow.get()); + } + } + if (!hasFlow) { + throw new CodegenException(location + " requires at least one OAuth flow"); + } + } + + private boolean hasOAuthFlowMetadata(Annotation flow) { + return hasConfiguredStringValue(flow, "authorizationUrl") + || hasConfiguredStringValue(flow, "deviceAuthorizationUrl") + || hasConfiguredStringValue(flow, "tokenUrl") + || hasConfiguredStringValue(flow, "refreshUrl") + || !flow.annotationValues("scopes").orElseGet(List::of).isEmpty(); + } + + private boolean hasOAuthFlowsMetadata(Annotation flows) { + for (String flowName : List.of("implicit", "password", "clientCredentials", "authorizationCode", + "deviceAuthorization")) { + if (flows.annotationValue(flowName).filter(this::hasOAuthFlowMetadata).isPresent()) { + return true; + } + } + return false; + } + + private String requireString(String location, Annotation annotation, String property) { + return stringValue(annotation, property) + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(location + " requires " + property)); + } + + private String requireString(String location, OpenApiSecurityScheme annotation, String property) { + return stringValue(annotation, property) + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(location + " requires " + property)); + } + + private boolean hasConfiguredStringValue(Annotation annotation, String property) { + Optional value = "value".equals(property) + ? annotation.stringValue() + : annotation.stringValue(property); + return value.filter(not(String::isBlank)).isPresent(); + } + + private boolean hasConfiguredStringValue(OpenApiSecurityScheme annotation, String property) { + return annotation.stringValue(property).filter(not(String::isBlank)).isPresent(); + } + + private Optional stringValue(Annotation annotation, String property) { + Optional value = "value".equals(property) + ? annotation.stringValue() + : annotation.stringValue(property); + return value.map(this::expressionDefaultValue); + } + + private Optional stringValue(OpenApiSecurityScheme annotation, String property) { + return annotation.stringValue(property).map(this::expressionDefaultValue); + } + + private List stringValues(Annotation annotation, String property) { + Optional> values = "value".equals(property) + ? annotation.stringValues() + : annotation.stringValues(property); + return values.orElseGet(List::of) + .stream() + .map(this::expressionDefaultValue) + .toList(); + } + + String expressionDefaultValue(String value) { + if (!value.startsWith("${") || !value.endsWith("}") || value.indexOf("${", 2) >= 0) { + return value; + } + int colon = value.indexOf(':', 2); + if (colon < 0) { + return value; + } + return value.substring(colon + 1, value.length() - 1); + } + + private void validateContentExamples(String owner, Annotation content) { + validateExamples(owner, content.annotationValues("examples").orElseGet(List::of)); + } + + private void validateExamples(String owner, List examples) { + Set names = new HashSet<>(); + for (int i = 0; i < examples.size(); i++) { + Annotation example = examples.get(i); + String name = exampleName(example, i); + validateUnique(owner, "example", name, names); + validateExampleValueFields(owner, name, example); + } + } + + private void validateExampleValueFields(String owner, String name, Annotation example) { + boolean hasValue = hasConfiguredStringValue(example, "value"); + boolean hasDataValue = hasConfiguredStringValue(example, "dataValue"); + boolean hasSerializedValue = hasConfiguredStringValue(example, "serializedValue"); + boolean hasExternalValue = hasConfiguredStringValue(example, "externalValue"); + + if (hasValue && (hasDataValue || hasSerializedValue || hasExternalValue)) { + throw new CodegenException(owner + " example " + name + + " cannot define value with dataValue, serializedValue, or externalValue"); + } + if (hasSerializedValue && hasExternalValue) { + throw new CodegenException(owner + " example " + name + + " cannot define serializedValue and externalValue together"); + } + } + + private void validateUniqueValues(String owner, String valueDescription, List values) { + Set unique = new HashSet<>(); + values.forEach(value -> validateUnique(owner, valueDescription, value, unique)); + } + + private String parameterKey(String in, String name) { + if ("header".equals(in)) { + return in + "\n" + name.toLowerCase(Locale.ROOT); + } + return in + "\n" + name; + } + + private void validateUnique(String owner, String valueDescription, String value, Set values) { + if (!values.add(value)) { + throw new CodegenException(owner + " cannot define " + valueDescription + " " + value + " more than once"); + } + } + + private void validateUnique(String owner, + String valueDescription, + String displayValue, + String uniqueValue, + Set values) { + if (!values.add(uniqueValue)) { + throw new CodegenException(owner + " cannot define " + valueDescription + " " + + displayValue + " more than once"); + } + } + +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiCodegenTypes.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiCodegenTypes.java new file mode 100644 index 00000000000..0a2e4e0f74c --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiCodegenTypes.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import io.helidon.common.types.TypeName; + +final class OpenApiCodegenTypes { + static final TypeName OPENAPI_DOCUMENT_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Document"); + static final TypeName OPENAPI_ENDPOINT_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Endpoint"); + static final TypeName OPENAPI_INFO_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Info"); + static final TypeName OPENAPI_CONTACT_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Contact"); + static final TypeName OPENAPI_LICENSE_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.License"); + static final TypeName OPENAPI_SERVER_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Server"); + static final TypeName OPENAPI_SERVERS_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Servers"); + static final TypeName OPENAPI_TAG_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Tag"); + static final TypeName OPENAPI_TAGS_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Tags"); + static final TypeName OPENAPI_EXTERNAL_DOCS_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.ExternalDocs"); + static final TypeName OPENAPI_EXTENSION_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Extension"); + static final TypeName OPENAPI_EXTENSIONS_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Extensions"); + static final TypeName OPENAPI_SECURITY_SCHEME_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.SecurityScheme"); + static final TypeName OPENAPI_SECURITY_SCHEMES_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.SecuritySchemes"); + static final TypeName OPENAPI_API_KEY_SECURITY_SCHEME_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.ApiKeySecurityScheme"); + static final TypeName OPENAPI_API_KEY_SECURITY_SCHEMES_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.ApiKeySecuritySchemes"); + static final TypeName OPENAPI_HTTP_SECURITY_SCHEME_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.HttpSecurityScheme"); + static final TypeName OPENAPI_HTTP_SECURITY_SCHEMES_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.HttpSecuritySchemes"); + static final TypeName OPENAPI_MUTUAL_TLS_SECURITY_SCHEME_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.MutualTlsSecurityScheme"); + static final TypeName OPENAPI_MUTUAL_TLS_SECURITY_SCHEMES_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.MutualTlsSecuritySchemes"); + static final TypeName OPENAPI_OAUTH2_SECURITY_SCHEME_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.OAuth2SecurityScheme"); + static final TypeName OPENAPI_OAUTH2_SECURITY_SCHEMES_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.OAuth2SecuritySchemes"); + static final TypeName OPENAPI_OIDC_SECURITY_SCHEME_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.OidcSecurityScheme"); + static final TypeName OPENAPI_OIDC_SECURITY_SCHEMES_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.OidcSecuritySchemes"); + static final TypeName OPENAPI_SECURITY_REQUIREMENT_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.SecurityRequirement"); + static final TypeName OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.SecurityRequirements"); + static final TypeName OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION = + TypeName.create("io.helidon.openapi.OpenApi.SecuritySchemeRequirement"); + static final TypeName OPENAPI_OPERATION_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Operation"); + static final TypeName OPENAPI_PARAMETER_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Parameter"); + static final TypeName OPENAPI_PARAMETERS_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Parameters"); + static final TypeName OPENAPI_REQUEST_BODY_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.RequestBody"); + static final TypeName OPENAPI_RESPONSE_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Response"); + static final TypeName OPENAPI_RESPONSES_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Responses"); + static final TypeName OPENAPI_HIDDEN_ANNOTATION = TypeName.create("io.helidon.openapi.OpenApi.Hidden"); + + static final TypeName OPENAPI_SOURCE_BASE = TypeName.create("io.helidon.openapi.OpenApiSourceBase"); + static final TypeName OPENAPI_DOCUMENT_SOURCE = TypeName.create("io.helidon.openapi.spi.OpenApiDocumentSource"); + static final TypeName OPENAPI_DOCUMENT_CONTEXT = TypeName.create("io.helidon.openapi.OpenApiDocumentContext"); + static final TypeName OPENAPI_DOCUMENT_CONTEXT_SUPPORT = + TypeName.create("io.helidon.openapi.OpenApiDocumentContextSupport"); + static final TypeName OPENAPI_DOCUMENT_BUILDER = TypeName.create("io.helidon.openapi.OpenApiDocument.Builder"); + static final TypeName OPENAPI_DOCUMENT_INFO = TypeName.create("io.helidon.openapi.OpenApiDocument.Info"); + static final TypeName OPENAPI_DOCUMENT_SERVER = TypeName.create("io.helidon.openapi.OpenApiDocument.Server"); + static final TypeName OPENAPI_DOCUMENT_TAG = TypeName.create("io.helidon.openapi.OpenApiDocument.Tag"); + static final TypeName OPENAPI_DOCUMENT_OPERATION = TypeName.create("io.helidon.openapi.OpenApiDocument.Operation"); + static final TypeName OPENAPI_DOCUMENT_PARAMETER = TypeName.create("io.helidon.openapi.OpenApiDocument.Parameter"); + static final TypeName OPENAPI_DOCUMENT_REQUEST_BODY = + TypeName.create("io.helidon.openapi.OpenApiDocument.RequestBody"); + static final TypeName OPENAPI_DOCUMENT_RESPONSE = TypeName.create("io.helidon.openapi.OpenApiDocument.Response"); + static final TypeName OPENAPI_DOCUMENT_MEDIA_TYPE_OBJECT = + TypeName.create("io.helidon.openapi.OpenApiDocument.MediaTypeObject"); + static final TypeName OPENAPI_DOCUMENT_EXAMPLE = TypeName.create("io.helidon.openapi.OpenApiDocument.Example"); + + static final TypeName JSON_OBJECT = TypeName.create("io.helidon.json.JsonObject"); + static final TypeName JSON_STRING = TypeName.create("io.helidon.json.JsonString"); + static final TypeName JSON_SCHEMA_PROVIDER = TypeName.create("io.helidon.json.schema.spi.JsonSchemaProvider"); + static final TypeName WEB_SERVER = TypeName.create("io.helidon.webserver.WebServer"); + + private OpenApiCodegenTypes() { + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiExtension.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiExtension.java new file mode 100644 index 00000000000..adac01be570 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiExtension.java @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.Set; + +import io.helidon.common.types.Annotation; +import io.helidon.common.types.TypeName; +import io.helidon.declarative.codegen.http.webserver.ServerEndpointAnalyzer; +import io.helidon.service.codegen.RegistryCodegenContext; +import io.helidon.service.codegen.RegistryRoundContext; +import io.helidon.service.codegen.spi.RegistryCodegenExtension; + +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_DOCUMENT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_ENDPOINT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_EXTENSIONS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_EXTENSION_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_EXTERNAL_DOCS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_HIDDEN_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_OPERATION_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_PARAMETERS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_PARAMETER_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_REQUEST_BODY_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_RESPONSES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_RESPONSE_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SERVERS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SERVER_ANNOTATION; + +final class OpenApiExtension implements RegistryCodegenExtension { + private static final Set TYPE_ANNOTATIONS = Set.of(OPENAPI_DOCUMENT_ANNOTATION, + OPENAPI_ENDPOINT_ANNOTATION, + OPENAPI_HIDDEN_ANNOTATION, + OPENAPI_SECURITY_REQUIREMENT_ANNOTATION, + OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION, + OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION); + private static final Set METHOD_ANNOTATIONS = Set.of(OPENAPI_SERVER_ANNOTATION, + OPENAPI_SERVERS_ANNOTATION, + OPENAPI_EXTERNAL_DOCS_ANNOTATION, + OPENAPI_EXTENSION_ANNOTATION, + OPENAPI_EXTENSIONS_ANNOTATION, + OPENAPI_SECURITY_REQUIREMENT_ANNOTATION, + OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION, + OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION, + OPENAPI_OPERATION_ANNOTATION, + OPENAPI_PARAMETER_ANNOTATION, + OPENAPI_PARAMETERS_ANNOTATION, + OPENAPI_REQUEST_BODY_ANNOTATION, + OPENAPI_RESPONSE_ANNOTATION, + OPENAPI_RESPONSES_ANNOTATION, + OPENAPI_HIDDEN_ANNOTATION); + private static final Set PARAMETER_ANNOTATIONS = Set.of(OPENAPI_PARAMETER_ANNOTATION, + OPENAPI_PARAMETERS_ANNOTATION); + + private final RegistryCodegenContext ctx; + private final ServerEndpointAnalyzer endpointAnalyzer; + private final OpenApiSourceGenerator sourceGenerator; + + OpenApiExtension(RegistryCodegenContext ctx) { + this.ctx = ctx; + this.endpointAnalyzer = ServerEndpointAnalyzer.create(ctx); + this.sourceGenerator = new OpenApiSourceGenerator(ctx); + } + + @Override + public void process(RegistryRoundContext roundContext) { + sourceGenerator.processDocuments(roundContext); + var endpointTypes = roundContext.types() + .stream() + .map(type -> ctx.typeInfo(type.typeName()).orElse(type)) + .toList(); + var endpoints = endpointAnalyzer.endpoints(endpointTypes) + .stream() + .filter(endpoint -> hasAny(endpoint.annotations(), TYPE_ANNOTATIONS) + || endpoint.methods() + .stream() + .anyMatch(method -> hasAny(method.annotations(), METHOD_ANNOTATIONS) + || method.parameters() + .stream() + .anyMatch(parameter -> hasAny(parameter.annotations(), PARAMETER_ANNOTATIONS)))) + .toList(); + sourceGenerator.processEndpoints(roundContext, endpoints); + } + + private static boolean hasAny(Set annotations, Set types) { + return annotations.stream() + .map(Annotation::typeName) + .anyMatch(types::contains); + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiExtensionProvider.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiExtensionProvider.java new file mode 100644 index 00000000000..2a182f33db2 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiExtensionProvider.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.Set; + +import io.helidon.common.Api; +import io.helidon.common.Weight; +import io.helidon.common.Weighted; +import io.helidon.common.types.TypeName; +import io.helidon.service.codegen.RegistryCodegenContext; +import io.helidon.service.codegen.spi.RegistryCodegenExtension; +import io.helidon.service.codegen.spi.RegistryCodegenExtensionProvider; + +/** + * Java {@link java.util.ServiceLoader} provider implementation for declarative OpenAPI code generation. + */ +@Weight(Weighted.DEFAULT_WEIGHT + 20) +public class OpenApiExtensionProvider implements RegistryCodegenExtensionProvider { + /** + * Required public constructor for {@link java.util.ServiceLoader}. + */ + @Api.Internal + public OpenApiExtensionProvider() { + super(); + } + + @Override + public Set supportedAnnotations() { + return Set.of(OpenApiCodegenTypes.OPENAPI_DOCUMENT_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_ENDPOINT_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_SERVER_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_SERVERS_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_EXTERNAL_DOCS_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_EXTENSION_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_EXTENSIONS_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENT_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_OPERATION_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_PARAMETER_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_PARAMETERS_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_REQUEST_BODY_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_RESPONSE_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_RESPONSES_ANNOTATION, + OpenApiCodegenTypes.OPENAPI_HIDDEN_ANNOTATION); + } + + @Override + public boolean supportsServiceContractAnnotations() { + return true; + } + + @Override + public RegistryCodegenExtension create(RegistryCodegenContext ctx) { + return new OpenApiExtension(ctx); + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiFormRequestBodyCodegen.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiFormRequestBodyCodegen.java new file mode 100644 index 00000000000..bb1f47c044f --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiFormRequestBodyCodegen.java @@ -0,0 +1,218 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.classmodel.Method; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.TypeName; +import io.helidon.declarative.codegen.model.http.RestMethodParameter; + +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.JSON_OBJECT; +import static java.util.function.Predicate.not; + +final class OpenApiFormRequestBodyCodegen { + static final String FORM_MEDIA_TYPE = "application/x-www-form-urlencoded"; + + private static final TypeName VOID = TypeName.create(Void.class); + + private final OpenApiAnnotationValidator validator; + private final OpenApiSourceExpressions expressions; + private final OpenApiSchemaCodegen schemas; + private final Function, String> examplesExpression; + private final Predicate requiredPredicate; + private final Function formParameterName; + + OpenApiFormRequestBodyCodegen(OpenApiAnnotationValidator validator, + OpenApiSourceExpressions expressions, + OpenApiSchemaCodegen schemas, + Function, String> examplesExpression, + Predicate requiredPredicate, + Function formParameterName) { + this.validator = validator; + this.expressions = expressions; + this.schemas = schemas; + this.examplesExpression = examplesExpression; + this.requiredPredicate = requiredPredicate; + this.formParameterName = formParameterName; + } + + void addRequestBody(Method.Builder method, + String restMethodDescription, + Annotation requestBody, + List consumes, + List formParameters, + Map componentNames) { + List contentAnnotations = requestBody == null + ? List.of() + : requestBody.annotationValues("content").orElseGet(List::of); + validateFormParameters(restMethodDescription, formParameters); + validateConsumes(restMethodDescription, consumes); + validator.validateContentMediaTypes("@OpenApi.RequestBody on " + restMethodDescription, + contentAnnotations, + List.of(FORM_MEDIA_TYPE)); + validateFormContentMediaTypes(restMethodDescription, contentAnnotations); + method.addContentLine(".requestBody(requestBody -> requestBody") + .increaseContentPadding() + .increaseContentPadding(); + if (requestBody != null) { + requestBody.stringValue() + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + } + Optional requiredOverride = requestBody == null + ? Optional.empty() + : required(requestBody); + boolean inferredRequired = formParameters.stream().anyMatch(requiredPredicate); + validator.validateRequestBodyRequiredness(restMethodDescription, + inferredRequired, + requiredOverride, + "@Http.FormParam parameters"); + boolean required = requiredOverride.orElse(inferredRequired); + if (required || requiredOverride.isPresent()) { + method.addContent(".required(") + .addContent(Boolean.toString(required)) + .addContentLine(")"); + } + if (contentAnnotations.isEmpty()) { + addFormContent(method, formParameters, componentNames, List.of()); + } else { + for (Annotation content : contentAnnotations) { + addFormContent(method, + formParameters, + componentNames, + content.annotationValues("examples").orElseGet(List::of)); + } + } + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void validateFormParameters(String restMethodDescription, List formParameters) { + Set names = new HashSet<>(); + for (RestMethodParameter formParameter : formParameters) { + String name = formParameterName.apply(formParameter); + if (!names.add(name)) { + throw new CodegenException("Generated OpenAPI form request body on " + restMethodDescription + + " cannot define form field " + name + " more than once"); + } + } + } + + private void validateConsumes(String restMethodDescription, List consumes) { + if (consumes.isEmpty() || consumes.equals(List.of(FORM_MEDIA_TYPE))) { + return; + } + throw new CodegenException("Generated OpenAPI form request body on " + restMethodDescription + + " requires @Http.Consumes(\"" + FORM_MEDIA_TYPE + "\")" + + " when consumes is explicitly declared"); + } + + private void addFormContent(Method.Builder method, + List formParameters, + Map componentNames, + List examples) { + method.addContent(".content(") + .addContent(expressions.validatedStringExpression(FORM_MEDIA_TYPE)) + .addContent(", ") + .addContent(formMediaTypeConsumer(formParameters, componentNames, examples)) + .addContentLine(")"); + } + + private void validateFormContentMediaTypes(String restMethodDescription, List contentAnnotations) { + for (Annotation content : contentAnnotations) { + for (String mediaType : validator.contentMediaTypes(content, List.of(FORM_MEDIA_TYPE))) { + if (!FORM_MEDIA_TYPE.equals(mediaType)) { + throw new CodegenException("@OpenApi.RequestBody on " + restMethodDescription + + " for @Http.FormParam parameters must use " + + FORM_MEDIA_TYPE + " content"); + } + } + if (content.typeValue("schema").filter(Predicate.not(VOID::equals)).isPresent() + || content.typeValue("itemSchema").filter(Predicate.not(VOID::equals)).isPresent()) { + throw new CodegenException("@OpenApi.RequestBody on " + restMethodDescription + + " for @Http.FormParam parameters cannot override the inferred" + + " form schema"); + } + } + } + + private String formMediaTypeConsumer(List formParameters, + Map componentNames, + List examples) { + return "content -> content.schema(" + formSchemaExpression(formParameters, componentNames) + ")" + + examplesExpression.apply(examples); + } + + private String formSchemaExpression(List formParameters, Map componentNames) { + StringBuilder result = new StringBuilder(JSON_OBJECT.fqName()) + .append(".builder()") + .append(".set(\"type\", \"object\")") + .append(".set(\"properties\", properties -> properties"); + for (RestMethodParameter formParameter : formParameters) { + TypeName schemaType = schemas.schemaType(formParameter.typeName()); + result.append(".set(") + .append(expressions.validatedStringExpression(formParameterName.apply(formParameter))) + .append(", ") + .append(schemas.schemaExpression(schemaType, componentNames)) + .append(")"); + } + result.append(")"); + List requiredNames = formParameters.stream() + .filter(requiredPredicate) + .map(formParameterName) + .toList(); + if (!requiredNames.isEmpty()) { + result.append(".setStrings(\"required\", java.util.List.of("); + for (int i = 0; i < requiredNames.size(); i++) { + if (i > 0) { + result.append(", "); + } + result.append(expressions.stringLiteral(requiredNames.get(i))); + } + result.append("))"); + } + return result.append(".build()").toString(); + } + + private Optional required(Annotation annotation) { + return annotation.stringValue("required") + .map(this::enumName) + .flatMap(value -> switch (value) { + case "TRUE" -> Optional.of(true); + case "FALSE" -> Optional.of(false); + case "UNSPECIFIED" -> Optional.empty(); + default -> throw new CodegenException("@OpenApi.Required has unsupported value: " + value); + }); + } + + private String enumName(String value) { + int dot = value.lastIndexOf('.'); + return dot == -1 ? value : value.substring(dot + 1); + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiParameterValidation.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiParameterValidation.java new file mode 100644 index 00000000000..616144a38b8 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiParameterValidation.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.function.BiFunction; + +import io.helidon.codegen.CodegenException; +import io.helidon.declarative.codegen.model.http.RestMethodParameter; + +final class OpenApiParameterValidation { + private OpenApiParameterValidation() { + } + + static void validateGeneratedParameters(String restMethodDescription, + List pathParameters, + List queryParameters, + List headerParameters, + List cookieParameters, + BiFunction parameterName) { + Set keys = new HashSet<>(); + validateParameters(restMethodDescription, keys, pathParameters, "path", parameterName); + validateParameters(restMethodDescription, keys, queryParameters, "query", parameterName); + validateParameters(restMethodDescription, keys, headerParameters, "header", parameterName); + validateParameters(restMethodDescription, keys, cookieParameters, "cookie", parameterName); + } + + private static void validateParameters(String restMethodDescription, + Set keys, + List parameters, + String in, + BiFunction parameterName) { + for (RestMethodParameter parameter : parameters) { + String name = parameterName.apply(parameter, in); + if (!keys.add(parameterKey(in, name))) { + throw new CodegenException("Generated OpenAPI parameters on " + restMethodDescription + + " cannot define " + in + " parameter " + name + + " more than once"); + } + } + } + + private static String parameterKey(String in, String name) { + if ("header".equals(in)) { + return in + "\n" + name.toLowerCase(Locale.ROOT); + } + return in + "\n" + name; + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiPathSupport.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiPathSupport.java new file mode 100644 index 00000000000..537d4b58d51 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiPathSupport.java @@ -0,0 +1,255 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.helidon.codegen.CodegenException; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.Annotations; +import io.helidon.declarative.codegen.model.http.RestMethod; +import io.helidon.declarative.codegen.model.http.RestMethodParameter; +import io.helidon.declarative.codegen.model.http.ServerEndpoint; + +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_PATH_PARAM_ANNOTATION; +import static java.util.function.Predicate.not; + +final class OpenApiPathSupport { + private OpenApiPathSupport() { + } + + static String openApiPath(ServerEndpoint endpoint, + RestMethod method, + Optional operation, + List pathParameters) { + String endpointPath = endpoint.path().orElse(""); + String methodPath = method.path().orElse(""); + String joined = joinPath(endpointPath, methodPath); + String path = joined.isBlank() ? "/" : joined; + PathTemplate pathTemplate = operation.flatMap(it -> it.stringValue("path")) + .filter(not(String::isBlank)) + .map(override -> validateOpenApiPathOverride(method, override)) + .orElseGet(() -> validateOpenApiPathTemplate(method, + ensureLeadingSlash(path), + PathValidationMode.HTTP_PATH)); + validatePathParameters(method, pathTemplate, pathParameters); + return pathTemplate.path(); + } + + private static PathTemplate validateOpenApiPathOverride(RestMethod method, String path) { + if (!path.startsWith("/")) { + throw invalidOpenApiPathOverride(method, path, "paths must start with '/'"); + } + return validateOpenApiPathTemplate(method, path, PathValidationMode.OPENAPI_OVERRIDE); + } + + private static PathTemplate validateOpenApiPathTemplate(RestMethod method, String path, PathValidationMode mode) { + StringBuilder result = new StringBuilder(path.length()); + Set pathParameters = new LinkedHashSet<>(); + for (int i = 0; i < path.length(); i++) { + char ch = path.charAt(i); + switch (ch) { + case '{' -> { + PathParameter parameter = pathParameter(method, path, i, mode); + if (!pathParameters.add(parameter.name())) { + throw openApiPathException(method, + path, + "path parameter '" + parameter.name() + "' appears more than once", + mode); + } + result.append('{') + .append(parameter.name()) + .append('}'); + i = parameter.endIndex(); + } + case '}' -> throw openApiPathException(method, path, "unmatched path parameter end", mode); + case '[', ']' -> throw openApiPathException(method, path, "optional path segments are not supported", mode); + case '*' -> throw openApiPathException(method, path, "wildcard path segments are not supported", mode); + case '\\' -> throw openApiPathException(method, path, "escaped path characters are not supported", mode); + case '?', '#' -> throw openApiPathException(method, + path, + "query and fragment characters are not valid in OpenAPI path templates", + mode); + default -> result.append(ch); + } + } + return new PathTemplate(result.toString(), pathParameters); + } + + private static void validatePathParameters(RestMethod method, + PathTemplate pathTemplate, + List pathParameters) { + Set routeParameters = pathParameterNames(pathParameters); + if (routeParameters.equals(pathTemplate.pathParameters())) { + return; + } + + throw invalidOpenApiPathOverride(method, + pathTemplate.path(), + "must declare the same path parameters as the generated route; " + + "generated route parameters: " + routeParameters + + ", OpenAPI path parameters: " + pathTemplate.pathParameters()); + } + + private static Set pathParameterNames(List pathParameters) { + Set result = new LinkedHashSet<>(); + for (RestMethodParameter parameter : pathParameters) { + result.add(pathParameterName(parameter)); + } + return result; + } + + private static String pathParameterName(RestMethodParameter parameter) { + return Annotations.findFirst(HTTP_PATH_PARAM_ANNOTATION, parameter.annotations()) + .flatMap(Annotation::stringValue) + .filter(not(String::isBlank)) + .orElse(parameter.name()); + } + + private static PathParameter pathParameter(RestMethod method, String path, int startIndex, PathValidationMode mode) { + StringBuilder template = new StringBuilder(); + boolean regexp = false; + int regexpNestedBraces = 0; + for (int i = startIndex + 1; i < path.length(); i++) { + char ch = path.charAt(i); + if (regexp) { + switch (ch) { + case '{' -> regexpNestedBraces++; + case '}' -> { + if (regexpNestedBraces == 0) { + return pathParameter(method, path, template.toString(), i, mode); + } + regexpNestedBraces--; + } + default -> { + } + } + } else { + switch (ch) { + case ':' -> regexp = true; + case '}' -> { + return pathParameter(method, path, template.toString(), i, mode); + } + case '{' -> throw openApiPathException(method, path, "nested path parameters are not supported", mode); + default -> { + } + } + } + template.append(ch); + } + throw openApiPathException(method, path, "path parameter is missing a closing '}'", mode); + } + + private static PathParameter pathParameter(RestMethod method, + String path, + String template, + int endIndex, + PathValidationMode mode) { + String trimmed = template.trim(); + if (trimmed.isBlank()) { + throw openApiPathException(method, path, "path parameter name is required", mode); + } + if (trimmed.charAt(0) == '+') { + throw openApiPathException(method, path, "greedy path parameters are not supported", mode); + } + if ("*".equals(trimmed)) { + throw openApiPathException(method, path, "wildcard path parameters are not supported", mode); + } + + int regexStart = trimmed.indexOf(':'); + String name = trimmed; + if (regexStart >= 0) { + if (mode == PathValidationMode.OPENAPI_OVERRIDE) { + throw invalidOpenApiPathOverride(method, path, "path parameters cannot define regex constraints"); + } + name = trimmed.substring(0, regexStart).trim(); + if (name.isBlank()) { + throw openApiPathException(method, path, "unnamed regex path parameters are not supported", mode); + } + } + + validatePathParameterName(method, path, name, mode); + return new PathParameter(name, endIndex); + } + + private static void validatePathParameterName(RestMethod method, String path, String name, PathValidationMode mode) { + for (int i = 0; i < name.length(); i++) { + char ch = name.charAt(i); + switch (ch) { + case '/', '{', '}', '[', ']', ':', '*', '\\' -> + throw openApiPathException(method, path, "path parameter name '" + name + "' is not supported", mode); + default -> { + } + } + } + } + + private static CodegenException openApiPathException(RestMethod method, + String path, + String reason, + PathValidationMode mode) { + return switch (mode) { + case HTTP_PATH -> unsupportedOpenApiPath(method, path, reason); + case OPENAPI_OVERRIDE -> invalidOpenApiPathOverride(method, path, reason); + }; + } + + private static CodegenException unsupportedOpenApiPath(RestMethod method, String path, String reason) { + return new CodegenException("@Http.Path on " + restMethodDescription(method) + + " cannot be represented as an OpenAPI path: " + path + + " (" + reason + "). Use @OpenApi.Operation(path = ...) to provide " + + "the OpenAPI path."); + } + + private static CodegenException invalidOpenApiPathOverride(RestMethod method, String path, String reason) { + return new CodegenException("@OpenApi.Operation path on " + restMethodDescription(method) + + " must be an OpenAPI path template: " + path + + " (" + reason + ")"); + } + + private static String joinPath(String first, String second) { + if (first.isBlank() || "/".equals(first)) { + return second.isBlank() ? "/" : ensureLeadingSlash(second); + } + if (second.isBlank() || "/".equals(second)) { + return ensureLeadingSlash(first); + } + return ensureLeadingSlash(first).replaceAll("/+$", "") + "/" + second.replaceAll("^/+", ""); + } + + private static String ensureLeadingSlash(String path) { + return path.startsWith("/") ? path : "/" + path; + } + + private static String restMethodDescription(RestMethod restMethod) { + return restMethod.type().typeName().fqName() + "." + restMethod.method().signature().text(); + } + + private enum PathValidationMode { + HTTP_PATH, + OPENAPI_OVERRIDE + } + + private record PathParameter(String name, int endIndex) { + } + + private record PathTemplate(String path, Set pathParameters) { + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSchemaBinding.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSchemaBinding.java new file mode 100644 index 00000000000..1acf8aeddaa --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSchemaBinding.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import io.helidon.common.types.TypeName; + +record OpenApiSchemaBinding(TypeName type, String name, String fieldName) { +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSchemaCodegen.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSchemaCodegen.java new file mode 100644 index 00000000000..7e963f63f9e --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSchemaCodegen.java @@ -0,0 +1,223 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Function; +import java.util.function.Predicate; + +import io.helidon.codegen.classmodel.Method; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.TypeName; +import io.helidon.common.types.TypeNames; + +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.JSON_OBJECT; + +final class OpenApiSchemaCodegen { + private static final TypeName VOID = TypeName.create(Void.class); + + private final OpenApiSourceExpressions expressions; + private final Function, String> examplesExpression; + + OpenApiSchemaCodegen(OpenApiSourceExpressions expressions, Function, String> examplesExpression) { + this.expressions = expressions; + this.examplesExpression = examplesExpression; + } + + void addSchemaComponent(Method.Builder method, OpenApiSchemaBinding schemaBinding) { + method.addContent("componentSchema(document, ") + .addContent(schemaBinding.fieldName()) + .addContent(", ") + .addContent(expressions.stringLiteral(schemaBinding.name())) + .addContentLine(");"); + } + + Map componentNames(List schemaBindings) { + Map result = new LinkedHashMap<>(); + for (OpenApiSchemaBinding schemaBinding : schemaBindings) { + result.put(schemaBinding.type(), schemaBinding.name()); + } + return result; + } + + String mediaTypeConsumer(String schemaExpression) { + return "content -> content.schema(" + schemaExpression + ")"; + } + + String mediaTypeConsumer(Annotation content, + TypeName inferredSchemaType, + boolean hasInferredSchema, + Map componentNames) { + Optional explicitSchema = content.typeValue("schema") + .filter(Predicate.not(VOID::equals)); + Optional explicitItemSchema = content.typeValue("itemSchema") + .filter(Predicate.not(VOID::equals)); + TypeName schemaType = explicitSchema.orElse(inferredSchemaType); + boolean hasSchema = explicitSchema.isPresent() || hasInferredSchema; + StringBuilder result = new StringBuilder("content -> content"); + if (explicitSchema.isPresent() || explicitItemSchema.isEmpty()) { + result.append(".schema(") + .append(hasSchema + ? schemaExpression(schemaType, componentNames) + : JSON_OBJECT.fqName() + ".builder().build()") + .append(")"); + } + explicitItemSchema.ifPresent(itemSchema -> result.append(".itemSchema(") + .append(schemaExpression(itemSchema, componentNames)) + .append(")")); + result.append(examplesExpression.apply(content.annotationValues("examples").orElseGet(List::of))); + return result.toString(); + } + + String schemaExpression(TypeName type) { + return schemaExpression(type, Map.of()); + } + + String schemaExpression(TypeName type, Map componentNames) { + TypeName schemaType = schemaType(type); + if (schemaType.isList()) { + TypeName itemType = schemaType.typeArguments().isEmpty() + ? TypeNames.STRING + : schemaType.typeArguments().getFirst(); + return "arraySchema(" + schemaExpression(itemType, componentNames) + ")"; + } + return jsonType(schemaType) + .map(it -> "schema(" + expressions.stringLiteral(it) + ")") + .orElseGet(() -> schemaRefExpression(schemaType, componentNames)); + } + + String stringSchemaWithDefaultExpression(String value) { + return JSON_OBJECT.fqName() + ".builder()" + + ".set(\"type\", \"string\")" + + ".set(\"default\", " + expressions.stringLiteral(value) + ")" + + ".build()"; + } + + TypeName schemaType(TypeName type) { + TypeName unwrapped = type.isOptional() && !type.typeArguments().isEmpty() + ? type.typeArguments().getFirst() + : type; + return unwrapped.boxed(); + } + + TypeName responseType(TypeName type) { + return schemaType(type); + } + + boolean hasResponseEntity(TypeName type) { + TypeName boxed = schemaType(type); + return !boxed.equals(TypeNames.BOXED_VOID); + } + + void collectSchemaComponent(Set schemaTypes, TypeName type) { + TypeName schemaType = schemaType(type); + if (schemaType.isList()) { + if (!schemaType.typeArguments().isEmpty()) { + collectSchemaComponent(schemaTypes, schemaType.typeArguments().getFirst()); + } + return; + } + if (jsonType(schemaType).isPresent()) { + return; + } + schemaTypes.add(schemaType); + } + + String schemaName(TypeName typeName) { + return typeName.classNameWithEnclosingNames().replaceAll("[^a-zA-Z0-9._-]", "_"); + } + + String schemaFieldName(String schemaName) { + StringBuilder result = new StringBuilder(schemaName.length() + "Schema".length()); + for (int i = 0; i < schemaName.length(); i++) { + char ch = schemaName.charAt(i); + if (result.isEmpty()) { + if (Character.isJavaIdentifierStart(ch)) { + result.append(Character.toLowerCase(ch)); + } else if (Character.isJavaIdentifierPart(ch)) { + result.append('_').append(ch); + } else { + result.append('_'); + } + continue; + } + result.append(Character.isJavaIdentifierPart(ch) ? ch : '_'); + } + return result.append("Schema").toString(); + } + + String uniqueSchemaName(String schemaName, Set usedSchemaNames) { + if (usedSchemaNames.add(schemaName)) { + return schemaName; + } + + int index = 2; + String candidate = schemaName + index; + while (!usedSchemaNames.add(candidate)) { + index++; + candidate = schemaName + index; + } + return candidate; + } + + String uniqueFieldName(String fieldName, Set usedFieldNames) { + if (usedFieldNames.add(fieldName)) { + return fieldName; + } + + int index = 2; + String candidate = fieldName + index; + while (!usedFieldNames.add(candidate)) { + index++; + candidate = fieldName + index; + } + return candidate; + } + + private String schemaRefExpression(TypeName type, Map componentNames) { + TypeName schemaType = schemaType(type); + String schemaName = componentNames.getOrDefault(schemaType, schemaName(schemaType)); + return "schemaRef(" + expressions.stringLiteral(schemaName) + ")"; + } + + private Optional jsonType(TypeName type) { + TypeName boxed = type.boxed().genericTypeName(); + if (boxed.equals(TypeNames.STRING)) { + return Optional.of("string"); + } + if (boxed.equals(TypeNames.BOXED_BOOLEAN)) { + return Optional.of("boolean"); + } + if (boxed.equals(TypeNames.BOXED_BYTE) + || boxed.equals(TypeNames.BOXED_SHORT) + || boxed.equals(TypeNames.BOXED_INT) + || boxed.equals(TypeNames.BOXED_LONG) + || boxed.equals(TypeName.create("java.math.BigInteger"))) { + return Optional.of("integer"); + } + if (boxed.equals(TypeNames.BOXED_FLOAT) + || boxed.equals(TypeNames.BOXED_DOUBLE) + || boxed.equals(TypeName.create("java.math.BigDecimal"))) { + return Optional.of("number"); + } + return Optional.empty(); + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecurityRequirement.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecurityRequirement.java new file mode 100644 index 00000000000..5f6e2745c28 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecurityRequirement.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.List; + +import io.helidon.common.types.Annotation; + +record OpenApiSecurityRequirement(List schemes) { + OpenApiSecurityRequirement { + schemes = List.copyOf(schemes); + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecurityScheme.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecurityScheme.java new file mode 100644 index 00000000000..6285a626fe8 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecurityScheme.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.Optional; + +import io.helidon.common.types.Annotation; + +record OpenApiSecurityScheme(String annotationName, Annotation annotation, String type) { + Optional stringValue(String property) { + if ("type".equals(property)) { + return Optional.of(type); + } + return annotation.stringValue(property); + } + + Optional booleanValue(String property) { + return annotation.booleanValue(property); + } + + Optional annotationValue(String property) { + return annotation.annotationValue(property); + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecuritySchemeCodegen.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecuritySchemeCodegen.java new file mode 100644 index 00000000000..95e54973c69 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSecuritySchemeCodegen.java @@ -0,0 +1,298 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.classmodel.Method; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.Annotations; +import io.helidon.common.types.TypeName; + +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.JSON_OBJECT; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_API_KEY_SECURITY_SCHEMES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_API_KEY_SECURITY_SCHEME_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_HTTP_SECURITY_SCHEMES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_HTTP_SECURITY_SCHEME_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_MUTUAL_TLS_SECURITY_SCHEMES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_MUTUAL_TLS_SECURITY_SCHEME_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_OAUTH2_SECURITY_SCHEMES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_OAUTH2_SECURITY_SCHEME_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_OIDC_SECURITY_SCHEMES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_OIDC_SECURITY_SCHEME_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_SCHEMES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_SCHEME_ANNOTATION; +import static java.util.function.Predicate.not; + +final class OpenApiSecuritySchemeCodegen { + private final OpenApiAnnotationValidator validator; + private final OpenApiSourceExpressions expressions; + + OpenApiSecuritySchemeCodegen(OpenApiAnnotationValidator validator, OpenApiSourceExpressions expressions) { + this.validator = validator; + this.expressions = expressions; + } + + List securitySchemes(Set annotations) { + List result = new ArrayList<>(); + repeatableAnnotations(annotations, OPENAPI_SECURITY_SCHEMES_ANNOTATION, OPENAPI_SECURITY_SCHEME_ANNOTATION) + .forEach(annotation -> result.add(new OpenApiSecurityScheme("@OpenApi.SecurityScheme", + annotation, + annotation.stringValue("type") + .orElse("")))); + addSecuritySchemes(result, + annotations, + OPENAPI_API_KEY_SECURITY_SCHEMES_ANNOTATION, + OPENAPI_API_KEY_SECURITY_SCHEME_ANNOTATION, + "@OpenApi.ApiKeySecurityScheme", + "apiKey"); + addSecuritySchemes(result, + annotations, + OPENAPI_HTTP_SECURITY_SCHEMES_ANNOTATION, + OPENAPI_HTTP_SECURITY_SCHEME_ANNOTATION, + "@OpenApi.HttpSecurityScheme", + "http"); + addSecuritySchemes(result, + annotations, + OPENAPI_MUTUAL_TLS_SECURITY_SCHEMES_ANNOTATION, + OPENAPI_MUTUAL_TLS_SECURITY_SCHEME_ANNOTATION, + "@OpenApi.MutualTlsSecurityScheme", + "mutualTLS"); + addSecuritySchemes(result, + annotations, + OPENAPI_OAUTH2_SECURITY_SCHEMES_ANNOTATION, + OPENAPI_OAUTH2_SECURITY_SCHEME_ANNOTATION, + "@OpenApi.OAuth2SecurityScheme", + "oauth2"); + addSecuritySchemes(result, + annotations, + OPENAPI_OIDC_SECURITY_SCHEMES_ANNOTATION, + OPENAPI_OIDC_SECURITY_SCHEME_ANNOTATION, + "@OpenApi.OidcSecurityScheme", + "openIdConnect"); + return result; + } + + void writeSecurityScheme(Method.Builder method, String owner, OpenApiSecurityScheme scheme) { + String name = scheme.stringValue("name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(scheme.annotationName() + " name is required")); + String schemeName = validator.expressionDefaultValue(name); + String type = scheme.stringValue("type") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException(scheme.annotationName() + " type is required")); + String resolvedType = validator.expressionDefaultValue(type); + Optional securityScheme = scheme.stringValue("scheme") + .filter(not(String::isBlank)); + Optional bearerFormat = scheme.stringValue("bearerFormat") + .filter(not(String::isBlank)); + if ("http".equals(resolvedType) + && securityScheme.filter(value -> value.startsWith("${")).isPresent() + && bearerFormat.isPresent()) { + method.addContent("document.components(components -> components.securityScheme(") + .addContent(expressions.validatedStringExpression(name)) + .addContentLine(",") + .increaseContentPadding() + .increaseContentPadding() + .addContentLine("security -> {") + .increaseContentPadding() + .increaseContentPadding() + .addContent("String resolvedScheme = ") + .addContent(expressions.stringExpression(securityScheme.get())) + .addContentLine(";") + .addContent("security.type(") + .addContent(expressions.validatedStringExpression(type)) + .addContentLine(");"); + scheme.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent("security.description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(");")); + method.addContentLine("security.scheme(resolvedScheme);") + .addContentLine("if (\"bearer\".equalsIgnoreCase(resolvedScheme)) {") + .increaseContentPadding() + .increaseContentPadding() + .addContent("security.bearerFormat(") + .addContent(expressions.stringExpression(bearerFormat.get())) + .addContentLine(");") + .decreaseContentPadding() + .decreaseContentPadding() + .addContentLine("}"); + scheme.booleanValue("deprecated") + .filter(Boolean::booleanValue) + .ifPresent(_ -> method.addContentLine("security.deprecated(true);")); + method.decreaseContentPadding() + .decreaseContentPadding() + .addContentLine("}));") + .decreaseContentPadding() + .decreaseContentPadding(); + return; + } + method.addContent("document.components(components -> components.securityScheme(") + .addContent(expressions.validatedStringExpression(name)) + .addContentLine(",") + .increaseContentPadding() + .increaseContentPadding() + .addContent("security -> security.type(") + .addContent(expressions.validatedStringExpression(type)) + .addContentLine(")") + .increaseContentPadding() + .increaseContentPadding(); + scheme.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + scheme.stringValue("apiKeyName") + .filter(not(String::isBlank)) + .ifPresent(apiKeyName -> method.addContent(".name(") + .addContent(expressions.stringExpression(apiKeyName)) + .addContentLine(")")); + securityScheme.ifPresent(value -> method.addContent(".scheme(") + .addContent(expressions.stringExpression(value)) + .addContentLine(")")); + bearerFormat.ifPresent(format -> method.addContent(".bearerFormat(") + .addContent(expressions.stringExpression(format)) + .addContentLine(")")); + scheme.stringValue("in") + .filter(not(String::isBlank)) + .ifPresent(in -> method.addContent(".in(") + .addContent(expressions.validatedStringExpression(in)) + .addContentLine(")")); + scheme.annotationValue("flows") + .flatMap(flows -> oauthFlowsExpression(owner, schemeName, flows)) + .ifPresent(flows -> method.addContent(".flows(") + .addContent(flows) + .addContentLine(")")); + scheme.stringValue("openIdConnectUrl") + .filter(not(String::isBlank)) + .ifPresent(url -> method.addContent(".openIdConnectUrl(") + .addContent(expressions.stringExpression(url)) + .addContentLine(")")); + scheme.stringValue("oauth2MetadataUrl") + .filter(not(String::isBlank)) + .ifPresent(url -> method.addContent(".oauth2MetadataUrl(") + .addContent(expressions.stringExpression(url)) + .addContentLine(")")); + scheme.booleanValue("deprecated") + .filter(Boolean::booleanValue) + .ifPresent(_ -> method.addContentLine(".deprecated(true)")); + method.addContentLine("));") + .decreaseContentPadding() + .decreaseContentPadding() + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addSecuritySchemes(List result, + Set annotations, + TypeName containerType, + TypeName annotationType, + String annotationName, + String type) { + repeatableAnnotations(annotations, containerType, annotationType) + .forEach(annotation -> result.add(new OpenApiSecurityScheme(annotationName, annotation, type))); + } + + private List repeatableAnnotations(Set annotations, + TypeName containerType, + TypeName annotationType) { + List result = new ArrayList<>(); + Annotations.findFirst(containerType, annotations) + .flatMap(Annotation::annotationValues) + .ifPresent(result::addAll); + Annotations.findFirst(annotationType, annotations) + .ifPresent(result::add); + return result; + } + + private Optional oauthFlowsExpression(String owner, String schemeName, Annotation flows) { + List entries = new ArrayList<>(); + addOauthFlow(entries, owner, schemeName, flows, "implicit"); + addOauthFlow(entries, owner, schemeName, flows, "password"); + addOauthFlow(entries, owner, schemeName, flows, "clientCredentials"); + addOauthFlow(entries, owner, schemeName, flows, "authorizationCode"); + addOauthFlow(entries, owner, schemeName, flows, "deviceAuthorization"); + if (entries.isEmpty()) { + return Optional.empty(); + } + return Optional.of(jsonObjectExpression(entries)); + } + + private void addOauthFlow(List entries, + String owner, + String schemeName, + Annotation flows, + String name) { + flows.annotationValue(name) + .flatMap(flow -> oauthFlowExpression(owner, schemeName, name, flow)) + .ifPresent(flow -> entries.add(new JsonObjectEntry(name, flow))); + } + + private Optional oauthFlowExpression(String owner, String schemeName, String flowName, Annotation flow) { + List entries = new ArrayList<>(); + addStringJsonEntry(entries, flow, "authorizationUrl"); + addStringJsonEntry(entries, flow, "deviceAuthorizationUrl"); + addStringJsonEntry(entries, flow, "tokenUrl"); + addStringJsonEntry(entries, flow, "refreshUrl"); + + List scopes = flow.annotationValues("scopes").orElseGet(List::of); + validator.validateOAuthScopes(owner, schemeName, flowName, scopes); + if (entries.isEmpty() && scopes.isEmpty()) { + return Optional.empty(); + } + String scopesExpression = scopes.isEmpty() ? JSON_OBJECT.fqName() + ".empty()" : oauthScopesExpression(scopes); + entries.add(new JsonObjectEntry("scopes", scopesExpression)); + return Optional.of(jsonObjectExpression(entries)); + } + + private void addStringJsonEntry(List entries, Annotation annotation, String name) { + annotation.stringValue(name) + .filter(not(String::isBlank)) + .ifPresent(value -> entries.add(new JsonObjectEntry(name, expressions.stringExpression(value)))); + } + + private String oauthScopesExpression(List scopes) { + List entries = new ArrayList<>(); + scopes.forEach(scope -> { + String name = scope.stringValue() + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.OAuthScope value is required")); + String description = scope.stringValue("description") + .orElse(""); + entries.add(new JsonObjectEntry(name, expressions.stringExpression(description))); + }); + return jsonObjectExpression(entries); + } + + private String jsonObjectExpression(List entries) { + StringBuilder result = new StringBuilder(JSON_OBJECT.fqName()).append(".builder()"); + entries.forEach(entry -> result.append(".set(") + .append(expressions.validatedStringExpression(entry.name())) + .append(", ") + .append(entry.valueExpression()) + .append(")")); + return result.append(".build()").toString(); + } + + private record JsonObjectEntry(String name, String valueExpression) { + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSourceExpressions.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSourceExpressions.java new file mode 100644 index 00000000000..b382da542ba --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSourceExpressions.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.List; + +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_DOCUMENT_CONTEXT_SUPPORT; + +final class OpenApiSourceExpressions { + private final OpenApiAnnotationValidator validator; + + OpenApiSourceExpressions(OpenApiAnnotationValidator validator) { + this.validator = validator; + } + + String stringLiteral(String value) { + StringBuilder result = new StringBuilder("\""); + for (int i = 0; i < value.length(); i++) { + char ch = value.charAt(i); + switch (ch) { + case '\\' -> result.append("\\\\"); + case '"' -> result.append("\\\""); + case '\n' -> result.append("\\n"); + case '\r' -> result.append("\\r"); + case '\t' -> result.append("\\t"); + default -> result.append(ch); + } + } + return result.append('"').toString(); + } + + String stringExpression(String value) { + return OPENAPI_DOCUMENT_CONTEXT_SUPPORT.fqName() + ".resolveExpression(context, " + stringLiteral(value) + ")"; + } + + String validatedStringExpression(String value) { + return stringLiteral(validator.expressionDefaultValue(value)); + } + + String validatedStringListExpression(List values) { + if (values.isEmpty()) { + return "java.util.List.of()"; + } + StringBuilder result = new StringBuilder("java.util.List.of("); + for (int i = 0; i < values.size(); i++) { + if (i > 0) { + result.append(", "); + } + result.append(validatedStringExpression(values.get(i))); + } + return result.append(")").toString(); + } +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSourceGenerator.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSourceGenerator.java new file mode 100644 index 00000000000..cdb24accc17 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/OpenApiSourceGenerator.java @@ -0,0 +1,1985 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.Iterator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import io.helidon.codegen.CodegenException; +import io.helidon.codegen.CodegenUtil; +import io.helidon.codegen.TypeHierarchy; +import io.helidon.codegen.classmodel.ClassModel; +import io.helidon.codegen.classmodel.Method; +import io.helidon.common.types.AccessModifier; +import io.helidon.common.types.Annotation; +import io.helidon.common.types.Annotations; +import io.helidon.common.types.ElementKind; +import io.helidon.common.types.TypeInfo; +import io.helidon.common.types.TypeName; +import io.helidon.common.types.TypeNames; +import io.helidon.common.types.TypedElementInfo; +import io.helidon.declarative.codegen.DeclarativeTypes; +import io.helidon.declarative.codegen.http.HttpCodegenValidation; +import io.helidon.declarative.codegen.model.http.ComputedHeader; +import io.helidon.declarative.codegen.model.http.HeaderValue; +import io.helidon.declarative.codegen.model.http.RestMethod; +import io.helidon.declarative.codegen.model.http.RestMethodParameter; +import io.helidon.declarative.codegen.model.http.ServerEndpoint; +import io.helidon.service.codegen.DefaultsCodegen; +import io.helidon.service.codegen.RegistryCodegenContext; +import io.helidon.service.codegen.RegistryRoundContext; + +import static io.helidon.declarative.codegen.DeclarativeTypes.SINGLETON_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_COOKIE_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_ENTITY_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_FORM_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_HEADER_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_PATH_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_QUERY_PARAM_ANNOTATION; +import static io.helidon.declarative.codegen.http.HttpTypes.HTTP_REQUEST_PARAMS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.JSON_OBJECT; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.JSON_SCHEMA_PROVIDER; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.JSON_STRING; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_CONTACT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_DOCUMENT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_DOCUMENT_BUILDER; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_DOCUMENT_CONTEXT; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_DOCUMENT_CONTEXT_SUPPORT; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_DOCUMENT_EXAMPLE; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_EXTENSIONS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_EXTENSION_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_EXTERNAL_DOCS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_HIDDEN_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_INFO_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_LICENSE_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_OPERATION_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_PARAMETERS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_PARAMETER_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_REQUEST_BODY_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_RESPONSES_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_RESPONSE_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_REQUIREMENT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SERVERS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SERVER_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_SOURCE_BASE; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_TAGS_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.OPENAPI_TAG_ANNOTATION; +import static io.helidon.declarative.codegen.openapi.OpenApiCodegenTypes.WEB_SERVER; +import static io.helidon.service.codegen.ServiceCodegenTypes.SERVICE_ANNOTATION_INJECT; +import static io.helidon.service.codegen.ServiceCodegenTypes.SERVICE_ANNOTATION_NAMED_BY_TYPE; +import static java.util.function.Predicate.not; + +final class OpenApiSourceGenerator { + private static final TypeName GENERATOR = TypeName.create(OpenApiSourceGenerator.class); + private static final TypeName VOID = TypeName.create(Void.class); + private static final String DEFAULT_MEDIA_TYPE = "application/json"; + private static final Set SECURITY_REQUIREMENT_ANNOTATIONS = Set.of(OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION, + OPENAPI_SECURITY_REQUIREMENT_ANNOTATION, OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION); + + private final OpenApiAnnotationValidator validator = new OpenApiAnnotationValidator(); + private final OpenApiSourceExpressions expressions = new OpenApiSourceExpressions(validator); + private final OpenApiSecuritySchemeCodegen securitySchemeCodegen = new OpenApiSecuritySchemeCodegen(validator, + expressions); + private final OpenApiSchemaCodegen schemas = new OpenApiSchemaCodegen(expressions, this::examplesExpression); + private final OpenApiFormRequestBodyCodegen formRequestBodies = new OpenApiFormRequestBodyCodegen( + validator, + expressions, + schemas, + this::examplesExpression, + this::formParameterRequired, + parameter -> parameterName(parameter, "form")); + private final RegistryCodegenContext ctx; + + OpenApiSourceGenerator(RegistryCodegenContext ctx) { + this.ctx = ctx; + } + + void processDocuments(RegistryRoundContext roundContext) { + Collection documents = roundContext.annotatedTypes(OPENAPI_DOCUMENT_ANNOTATION); + for (TypeInfo document : documents) { + processDocument(roundContext, document); + } + } + + void processEndpoints(RegistryRoundContext roundContext, List endpoints) { + for (ServerEndpoint endpoint : endpoints) { + if (endpoint.type().kind() != ElementKind.INTERFACE + && !hasAnnotation(endpoint.annotations(), OPENAPI_HIDDEN_ANNOTATION)) { + processEndpoint(roundContext, endpoint); + } + } + } + + private static boolean hasAnnotation(Collection annotations, TypeName annotationType) { + return Annotations.findFirst(annotationType, annotations).isPresent(); + } + + private static List repeatableAnnotations(Set annotations, + TypeName containerType, + TypeName annotationType) { + List result = new ArrayList<>(); + Annotations.findFirst(containerType, annotations) + .flatMap(Annotation::annotationValues) + .ifPresent(result::addAll); + Annotations.findFirst(annotationType, annotations) + .ifPresent(result::add); + return result; + } + + private void processDocument(RegistryRoundContext roundContext, TypeInfo typeInfo) { + TypeName generatedType = generatedType(typeInfo.typeName(), "OpenApiDocument"); + ClassModel.Builder classModel = sourceClass(typeInfo.typeName(), generatedType); + classModel.addAnnotation(Annotation.builder() + .typeName(SERVICE_ANNOTATION_NAMED_BY_TYPE) + .property("value", typeInfo.typeName()) + .build()); + + classModel.addMethod(method -> method + .accessModifier(AccessModifier.PUBLIC) + .addAnnotation(Annotations.OVERRIDE) + .name("describe") + .addParameter(context -> context + .type(OPENAPI_DOCUMENT_CONTEXT) + .name("context")) + .addParameter(document -> document + .type(OPENAPI_DOCUMENT_BUILDER) + .name("document")) + .update(it -> documentSourceBody(typeInfo, it))); + + roundContext.addGeneratedType(generatedType, + classModel, + typeInfo.typeName(), + typeInfo.originatingElementValue()); + } + + private void processEndpoint(RegistryRoundContext roundContext, ServerEndpoint endpoint) { + TypeInfo typeInfo = endpoint.type(); + TypeName generatedType = generatedType(typeInfo.typeName(), "OpenApiEndpoint"); + List schemaBindings = schemaBindings(endpoint); + ClassModel.Builder classModel = sourceClass(typeInfo.typeName(), generatedType); + addSchemaInjection(classModel, schemaBindings); + addSupports(classModel, endpoint.listener()); + + classModel.addMethod(method -> method + .accessModifier(AccessModifier.PUBLIC) + .addAnnotation(Annotations.OVERRIDE) + .name("describe") + .addParameter(context -> context + .type(OPENAPI_DOCUMENT_CONTEXT) + .name("context")) + .addParameter(document -> document + .type(OPENAPI_DOCUMENT_BUILDER) + .name("document")) + .update(it -> endpointSourceBody(endpoint, schemaBindings, it))); + + roundContext.addGeneratedType(generatedType, + classModel, + typeInfo.typeName(), + typeInfo.originatingElementValue()); + } + + private void addSupports(ClassModel.Builder classModel, Optional listener) { + classModel.addMethod(method -> method + .accessModifier(AccessModifier.PUBLIC) + .addAnnotation(Annotations.OVERRIDE) + .returnType(TypeNames.PRIMITIVE_BOOLEAN) + .name("supports") + .addParameter(context -> context + .type(OPENAPI_DOCUMENT_CONTEXT) + .name("context")) + .addContent("return ") + .update(it -> listener.ifPresentOrElse(explicit -> it.addContent(expressions.stringLiteral(explicit)), + () -> it.addContent(WEB_SERVER) + .addContent(".DEFAULT_SOCKET_NAME"))) + .addContentLine(".equals(context.listener());")); + } + + private ClassModel.Builder sourceClass(TypeName sourceType, TypeName generatedType) { + return ClassModel.builder() + .copyright(CodegenUtil.copyright(GENERATOR, sourceType, generatedType)) + .addAnnotation(CodegenUtil.generatedAnnotation(GENERATOR, + sourceType, + generatedType, + "1", + "")) + .accessModifier(AccessModifier.PACKAGE_PRIVATE) + .type(generatedType) + .addAnnotation(SINGLETON_ANNOTATION) + .addAnnotation(DeclarativeTypes.SUPPRESS_API) + .superType(OPENAPI_SOURCE_BASE); + } + + private void documentSourceBody(TypeInfo typeInfo, Method.Builder method) { + Set annotations = new HashSet<>(TypeHierarchy.hierarchyAnnotations(ctx, typeInfo)); + Annotation document = Annotations.findFirst(OPENAPI_DOCUMENT_ANNOTATION, annotations) + .orElseThrow(() -> new CodegenException("Missing @OpenApi.Document on " + + typeInfo.typeName().fqName())); + + document.stringValue("self") + .filter(not(String::isBlank)) + .ifPresent(self -> method.addContent("document.self(") + .addContent(expressions.stringExpression(self)) + .addContentLine(");")); + document.stringValue("jsonSchemaDialect") + .filter(not(String::isBlank)) + .ifPresent(dialect -> method.addContent("document.jsonSchemaDialect(") + .addContent(expressions.stringExpression(dialect)) + .addContentLine(");")); + + Annotation info = Annotations.findFirst(OPENAPI_INFO_ANNOTATION, annotations) + .orElseThrow(() -> new CodegenException("@OpenApi.Document on " + typeInfo.typeName().fqName() + + " requires @OpenApi.Info")); + addInfo(method, info, annotations); + + String owner = typeInfo.typeName().fqName(); + List servers = repeatableAnnotations(annotations, OPENAPI_SERVERS_ANNOTATION, + OPENAPI_SERVER_ANNOTATION); + validator.validateServers(owner, servers); + servers.forEach(server -> writeServer(method, "document.server", true, server)); + List tags = repeatableAnnotations(annotations, OPENAPI_TAGS_ANNOTATION, OPENAPI_TAG_ANNOTATION); + validator.validateTags(owner, tags); + tags.forEach(tag -> writeTag(method, tag)); + Annotations.findFirst(OPENAPI_EXTERNAL_DOCS_ANNOTATION, annotations) + .ifPresent(externalDocs -> writeExternalDocs(method, "document.externalDocs", true, externalDocs)); + List extensions = repeatableAnnotations(annotations, OPENAPI_EXTENSIONS_ANNOTATION, + OPENAPI_EXTENSION_ANNOTATION); + validator.validateExtensions(owner, extensions); + extensions.forEach(extension -> writeExtension(method, "document.extension", true, extension)); + List securitySchemes = securitySchemeCodegen.securitySchemes(annotations); + validator.validateSecuritySchemes(owner, securitySchemes); + securitySchemes.forEach(scheme -> securitySchemeCodegen.writeSecurityScheme(method, owner, scheme)); + List securityRequirements = securityRequirements(owner, annotations); + validator.validateSecurityRequirements(owner, securityRequirements); + securityRequirements.forEach(requirement -> writeSecurityRequirement(method, + "document.securityRequirement", + true, + requirement)); + } + + private void endpointSourceBody(ServerEndpoint endpoint, + List schemaBindings, + Method.Builder method) { + String endpointTag = endpointName(endpoint.type().typeName()); + Map componentNames = schemas.componentNames(schemaBindings); + String endpointType = endpoint.type().typeName().fqName(); + List> endpointAnnotationGroups = OpenApiAnnotationHierarchy.endpointSecurityAnnotationGroups( + endpoint.type(), + annotations -> securityRequirementCounts(endpointType, annotations)); + boolean endpointClearsSecurity = endpointAnnotationGroups.stream() + .anyMatch(this::hasEmptySecurityRequirements); + List endpointSecurityRequirements = endpointAnnotationGroups.stream() + .flatMap(annotations -> securityRequirements(endpointType, annotations).stream()) + .toList(); + if (!endpointClearsSecurity) { + validator.validateSecurityRequirements(endpointType, endpointSecurityRequirements); + } + for (OpenApiSchemaBinding schemaBinding : schemaBindings) { + schemas.addSchemaComponent(method, schemaBinding); + } + for (RestMethod restMethod : endpoint.methods()) { + if (hasAnnotation(restMethod.annotations(), OPENAPI_HIDDEN_ANNOTATION)) { + continue; + } + addOperation(method, + endpoint, + restMethod, + endpointTag, + componentNames, + endpointSecurityRequirements, + endpointClearsSecurity); + } + } + + private void addInfo(Method.Builder method, Annotation info, Set documentAnnotations) { + String title = info.stringValue("title") + .orElseThrow(() -> new CodegenException("@OpenApi.Info title is required")); + String version = info.stringValue("version") + .orElseThrow(() -> new CodegenException("@OpenApi.Info version is required")); + method.addContent("document.info(info -> info.title(") + .addContent(expressions.stringExpression(title)) + .addContent(")") + .addContentLine() + .increaseContentPadding() + .increaseContentPadding() + .addContent(".version(") + .addContent(expressions.stringExpression(version)) + .addContentLine(")"); + info.stringValue("summary") + .filter(not(String::isBlank)) + .ifPresent(summary -> method.addContent(".summary(") + .addContent(expressions.stringExpression(summary)) + .addContentLine(")")); + info.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + info.stringValue("termsOfService") + .filter(not(String::isBlank)) + .ifPresent(terms -> method.addContent(".termsOfService(") + .addContent(expressions.stringExpression(terms)) + .addContentLine(")")); + Annotations.findFirst(OPENAPI_CONTACT_ANNOTATION, documentAnnotations) + .ifPresent(contact -> addContact(method, contact)); + Annotations.findFirst(OPENAPI_LICENSE_ANNOTATION, documentAnnotations) + .ifPresent(license -> addLicense(method, license)); + method.addContentLine(");") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addContact(Method.Builder method, Annotation contact) { + if (!hasStringValue(contact, "value") && !hasStringValue(contact, "url") && !hasStringValue(contact, "email")) { + method.addContentLine(".contact(contact -> { })"); + return; + } + method.addContent(".contact(contact -> contact") + .addContentLine() + .increaseContentPadding() + .increaseContentPadding(); + contact.stringValue() + .filter(not(String::isBlank)) + .ifPresent(name -> method.addContent(".name(") + .addContent(expressions.stringExpression(name)) + .addContentLine(")")); + contact.stringValue("url") + .filter(not(String::isBlank)) + .ifPresent(url -> method.addContent(".url(") + .addContent(expressions.stringExpression(url)) + .addContentLine(")")); + contact.stringValue("email") + .filter(not(String::isBlank)) + .ifPresent(email -> method.addContent(".email(") + .addContent(expressions.stringExpression(email)) + .addContentLine(")")); + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addLicense(Method.Builder method, Annotation license) { + String name = license.stringValue() + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.License value is required")); + method.addContent(".license(license -> license.name(") + .addContent(expressions.stringExpression(name)) + .addContentLine(")") + .increaseContentPadding() + .increaseContentPadding(); + license.stringValue("url") + .filter(not(String::isBlank)) + .ifPresent(url -> method.addContent(".url(") + .addContent(expressions.stringExpression(url)) + .addContentLine(")")); + license.stringValue("identifier") + .filter(not(String::isBlank)) + .ifPresent(identifier -> method.addContent(".identifier(") + .addContent(expressions.stringExpression(identifier)) + .addContentLine(")")); + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void writeServer(Method.Builder method, String call, boolean statement, Annotation server) { + String url = server.stringValue() + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Server value is required")); + method.addContent(call) + .addContent("(server -> server.url(") + .addContent(expressions.stringExpression(url)) + .addContentLine(")") + .increaseContentPadding() + .increaseContentPadding(); + server.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + server.stringValue("name") + .filter(not(String::isBlank)) + .ifPresent(name -> method.addContent(".name(") + .addContent(expressions.stringExpression(name)) + .addContentLine(")")); + for (Annotation variable : server.annotationValues("variables").orElseGet(List::of)) { + String variableName = variable.stringValue("name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.ServerVariable name is required")); + String defaultValue = variable.stringValue("defaultValue") + .orElseThrow(() -> new CodegenException("@OpenApi.ServerVariable defaultValue is required")); + method.addContent(".variable(") + .addContent(expressions.validatedStringExpression(variableName)) + .addContent(", variable -> variable.value(") + .addContent(expressions.stringExpression(defaultValue)) + .addContentLine(")") + .increaseContentPadding(); + List enumeration = variable.stringValues("enumeration").orElseGet(List::of); + if (!enumeration.isEmpty()) { + method.addContent(".allowedValues(java.util.List.of("); + for (int i = 0; i < enumeration.size(); i++) { + if (i > 0) { + method.addContent(", "); + } + method.addContent(expressions.stringExpression(enumeration.get(i))); + } + method.addContentLine("))"); + } + variable.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + method.addContentLine(")") + .decreaseContentPadding(); + } + method.addContentLine(statement ? ");" : ")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void writeTag(Method.Builder method, Annotation tag) { + String name = tag.stringValue() + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Tag value is required")); + method.addContent("document.tag(tag -> tag.name(") + .addContent(expressions.validatedStringExpression(name)) + .addContentLine(")") + .increaseContentPadding() + .increaseContentPadding(); + tag.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + tag.stringValue("summary") + .filter(not(String::isBlank)) + .ifPresent(summary -> method.addContent(".summary(") + .addContent(expressions.stringExpression(summary)) + .addContentLine(")")); + tag.stringValue("parent") + .filter(not(String::isBlank)) + .ifPresent(parent -> method.addContent(".parent(") + .addContent(expressions.stringExpression(parent)) + .addContentLine(")")); + tag.stringValue("kind") + .filter(not(String::isBlank)) + .ifPresent(kind -> method.addContent(".kind(") + .addContent(expressions.stringExpression(kind)) + .addContentLine(")")); + method.addContentLine(");") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void writeExternalDocs(Method.Builder method, String call, boolean statement, Annotation externalDocs) { + String url = externalDocs.stringValue() + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.ExternalDocs value is required")); + method.addContent(call) + .addContent("(externalDocs -> externalDocs.url(") + .addContent(expressions.stringExpression(url)) + .addContentLine(")") + .increaseContentPadding() + .increaseContentPadding(); + externalDocs.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + method.addContentLine(statement ? ");" : ")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void writeExtension(Method.Builder method, String call, boolean statement, Annotation extension) { + String name = extension.stringValue("name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Extension name is required")); + String extensionName = validator.expressionDefaultValue(name); + if (!extensionName.startsWith("x-")) { + throw new CodegenException("@OpenApi.Extension name must start with x-: " + extensionName); + } + String value = extension.stringValue("value") + .orElseThrow(() -> new CodegenException("@OpenApi.Extension value is required")); + boolean parseValue = extension.booleanValue("parseValue").orElse(false); + method.addContent(call) + .addContent("(") + .addContent(expressions.validatedStringExpression(name)) + .addContent(", extensionValue(") + .addContent(expressions.validatedStringExpression(name)) + .addContent(", ") + .addContent(expressions.stringExpression(value)) + .addContent(", ") + .addContent(Boolean.toString(parseValue)) + .addContentLine(statement ? "));" : "))"); + } + + private void writeSecurityRequirement(Method.Builder method, + String call, + boolean statement, + OpenApiSecurityRequirement requirement) { + List schemes = requirement.schemes(); + if (schemes.isEmpty()) { + method.addContent(call) + .addContentLine(statement ? "(security -> { });" : "(security -> { })"); + return; + } + method.addContent(call) + .addContent("(security -> security") + .addContentLine() + .increaseContentPadding() + .increaseContentPadding(); + schemes.forEach(scheme -> { + List scopes = scheme.stringValues("scopes").orElseGet(List::of); + method.addContent(".scheme(") + .addContent(expressions.validatedStringExpression(scheme.stringValue().orElseThrow())) + .addContent(", ") + .addContent(expressions.validatedStringListExpression(scopes)) + .addContentLine(")"); + }); + method.addContentLine(statement ? ");" : ")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addOperation(Method.Builder method, + ServerEndpoint endpoint, + RestMethod restMethod, + String endpointTag, + Map componentNames, + List endpointSecurityRequirements, + boolean endpointClearsSecurity) { + Optional operation = Annotations.findFirst(OPENAPI_OPERATION_ANNOTATION, + restMethod.annotations()); + List pathParameters = pathParameters(restMethod); + method.addContent("document.path(") + .addContent(expressions.stringLiteral(OpenApiPathSupport.openApiPath(endpoint, + restMethod, + operation, + pathParameters))) + .addContentLine(",") + .increaseContentPadding() + .increaseContentPadding() + .addContent("path -> path.operation(") + .addContent(expressions.stringLiteral(restMethod.httpMethod().name())) + .addContentLine(",") + .increaseContentPadding() + .increaseContentPadding() + .addContentLine("operation -> operation") + .increaseContentPadding() + .increaseContentPadding(); + + operation.ifPresentOrElse( + operationAnnotation -> addExplicitOperation(method, operationAnnotation, restMethod, endpointTag), + () -> addInferredOperation(method, restMethod, endpointTag)); + addOperationMetadata(method, restMethod, endpointSecurityRequirements, endpointClearsSecurity); + addParameters(method, restMethod, componentNames); + addRequestBody(method, restMethod, componentNames); + addResponses(method, restMethod, componentNames); + + method.addContentLine("));") + .decreaseContentPadding() + .decreaseContentPadding() + .decreaseContentPadding() + .decreaseContentPadding() + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addExplicitOperation(Method.Builder method, + Annotation operation, + RestMethod restMethod, + String endpointTag) { + operation.stringValue() + .filter(not(String::isBlank)) + .ifPresent(summary -> method.addContent(".summary(") + .addContent(expressions.stringExpression(summary)) + .addContentLine(")")); + operation.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + Optional operationId = operation.stringValue("operationId") + .filter(not(String::isBlank)) + .or(() -> Optional.of(operationId(restMethod, endpointTag))); + addOperationId(method, restMethod, operationId.orElseThrow()); + List tags = operation.stringValues("tags").orElseGet(List::of); + if (tags.isEmpty()) { + method.addContent(".tag(") + .addContent(expressions.stringLiteral(endpointTag)) + .addContentLine(")"); + } else { + validator.validateOperationTags(restMethodDescription(restMethod), tags); + tags.forEach(tag -> method.addContent(".tag(") + .addContent(expressions.validatedStringExpression(tag)) + .addContentLine(")")); + } + operation.booleanValue("deprecated") + .filter(Boolean::booleanValue) + .ifPresent(deprecated -> method.addContentLine(".deprecated(true)")); + } + + private void addOperationMetadata(Method.Builder method, + RestMethod restMethod, + List endpointSecurityRequirements, + boolean endpointClearsSecurity) { + Set annotations = restMethod.annotations(); + List servers = repeatableAnnotations(annotations, + OPENAPI_SERVERS_ANNOTATION, + OPENAPI_SERVER_ANNOTATION); + validator.validateServers(restMethodDescription(restMethod), servers); + servers.forEach(server -> writeServer(method, ".server", false, server)); + Annotations.findFirst(OPENAPI_EXTERNAL_DOCS_ANNOTATION, annotations) + .ifPresent(externalDocs -> writeExternalDocs(method, ".externalDocs", false, externalDocs)); + List extensions = repeatableAnnotations(annotations, + OPENAPI_EXTENSIONS_ANNOTATION, + OPENAPI_EXTENSION_ANNOTATION); + validator.validateExtensions(restMethodDescription(restMethod), extensions); + extensions.forEach(extension -> writeExtension(method, ".extension", false, extension)); + Collection securityAnnotations = operationSecurityAnnotations(restMethod); + if (hasEmptySecurityRequirements(securityAnnotations)) { + method.addContentLine(".security(java.util.List.of())"); + return; + } + List securityRequirements = securityRequirements(restMethodDescription(restMethod), + securityAnnotations); + if (!securityRequirements.isEmpty()) { + validator.validateSecurityRequirements(restMethodDescription(restMethod), securityRequirements); + securityRequirements.forEach(requirement -> writeSecurityRequirement( + method, ".securityRequirement", false, requirement)); + return; + } + if (endpointClearsSecurity) { + method.addContentLine(".security(java.util.List.of())"); + return; + } + endpointSecurityRequirements.forEach(requirement -> writeSecurityRequirement( + method, ".securityRequirement", false, requirement)); + } + + private Collection operationSecurityAnnotations(RestMethod restMethod) { + var direct = restMethod.method().annotations(); + if (hasEmptySecurityRequirements(direct)) { + return direct; + } + var securityAnnotations = OpenApiAnnotationHierarchy.withMetaAnnotations(direct); + if (hasSecurityRequirementAnnotations(securityAnnotations)) { + return securityAnnotations; + } + var inherited = restMethod.annotations(); + var candidates = TypeHierarchy.hierarchyAnnotationCandidates( + ctx, restMethod.type(), restMethod.method(), SECURITY_REQUIREMENT_ANNOTATIONS); + if (candidates.stream() + .map(it -> securityRequirementCounts(restMethodDescription(restMethod), it)).distinct().limit(2).count() > 1) { + boolean onlySchemes = candidates.stream().flatMap(List::stream) + .allMatch(it -> OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION.equals(it.typeName())); + String message = onlySchemes ? "Conflicting inherited @OpenApi.SecuritySchemeRequirement annotations on " + : "Conflicting inherited OpenAPI security requirements on "; + throw new CodegenException(message + restMethodDescription(restMethod)); + } + return candidates.isEmpty() ? inherited : Stream.concat( + inherited.stream().filter(it -> !SECURITY_REQUIREMENT_ANNOTATIONS.contains(it.typeName())), + candidates.getFirst().stream()).toList(); + } + + private Map, Long>, Long> securityRequirementCounts(String owner, Collection annotations) { + return securityRequirements(owner, annotations).stream() + .map(requirement -> requirement.schemes().stream() + .map(scheme -> Stream.concat(Stream.of(validator.expressionDefaultValue( + scheme.stringValue().orElse(""))), + scheme.stringValues("scopes").orElseGet(List::of).stream() + .map(validator::expressionDefaultValue).sorted()).toList()) + .collect(Collectors.groupingBy(it -> it, Collectors.counting()))) + .collect(Collectors.groupingBy(it -> it, Collectors.counting())); + } + + private boolean hasSecurityRequirementAnnotations(Collection annotations) { + return hasAnnotation(annotations, OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION) + || hasAnnotation(annotations, OPENAPI_SECURITY_REQUIREMENT_ANNOTATION) + || hasAnnotation(annotations, OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION); + } + + private void addInferredOperation(Method.Builder method, RestMethod restMethod, String endpointTag) { + addOperationId(method, restMethod, operationId(restMethod, endpointTag)); + method.addContent(".tag(") + .addContent(expressions.stringLiteral(endpointTag)) + .addContentLine(")"); + } + + private void addOperationId(Method.Builder method, RestMethod restMethod, String operationId) { + method.addContent(".operationId(") + .addContent(OPENAPI_DOCUMENT_CONTEXT_SUPPORT) + .addContent(".operationId(context, ") + .addContent(expressions.stringLiteral(restMethod.type().typeName().fqName() + + "#" + + restMethod.method().signature().text())) + .addContent(", ") + .addContent(expressions.stringExpression(operationId)) + .addContentLine("))"); + } + + private String operationId(RestMethod restMethod, String endpointTag) { + return endpointTag + + CodegenUtil.capitalize(restMethod.httpMethod().name().toLowerCase(Locale.ROOT)) + + CodegenUtil.capitalize(restMethod.uniqueName()); + } + + private void addParameters(Method.Builder method, RestMethod restMethod, Map componentNames) { + List methodParameters = new ArrayList<>(methodParameterAnnotations(restMethod)); + List pathParameters = pathParameters(restMethod); + List queryParameters = queryParameters(restMethod); + List headerParameters = headerParameters(restMethod) + .stream() + .filter(parameter -> !isSpecialHeader(parameterName(parameter, "header"))) + .toList(); + List cookieParameters = cookieParameters(restMethod); + validator.validateMethodParameters(restMethodDescription(restMethod), methodParameters); + OpenApiParameterValidation.validateGeneratedParameters(restMethodDescription(restMethod), + pathParameters, + queryParameters, + headerParameters, + cookieParameters, + this::parameterName); + for (RestMethodParameter parameter : pathParameters) { + addParameter(method, + restMethod, + parameter, + "path", + matchingMethodParameters(methodParameters, parameter, "path"), + componentNames); + } + for (RestMethodParameter parameter : queryParameters) { + addParameter(method, + restMethod, + parameter, + "query", + matchingMethodParameters(methodParameters, parameter, "query"), + componentNames); + } + for (RestMethodParameter parameter : headerParameters) { + addParameter(method, + restMethod, + parameter, + "header", + matchingMethodParameters(methodParameters, parameter, "header"), + componentNames); + } + for (RestMethodParameter parameter : cookieParameters) { + addParameter(method, + restMethod, + parameter, + "cookie", + matchingMethodParameters(methodParameters, parameter, "cookie"), + componentNames); + } + if (!methodParameters.isEmpty()) { + throw unmatchedMethodParameter(restMethod, methodParameters.getFirst()); + } + } + + private void addParameter(Method.Builder method, + RestMethod restMethod, + RestMethodParameter parameter, + String in, + List methodAnnotations, + Map componentNames) { + TypeName type = parameter.typeName(); + TypeName schemaType = schemas.schemaType(type); + String parameterName = parameterName(parameter, in); + List parameterAnnotations = repeatableAnnotations(parameter.annotations(), + OPENAPI_PARAMETERS_ANNOTATION, + OPENAPI_PARAMETER_ANNOTATION); + validator.validateParameterAnnotations(restMethodDescription(restMethod), + in, + parameterName, + parameterAnnotations); + List annotations = new ArrayList<>(methodAnnotations); + annotations.addAll(parameterAnnotations); + Optional configuredLocation = validatedExplicitStringValue(annotations, "in"); + validateParameterLocation(restMethod, in, configuredLocation); + String location = configuredLocation.orElse(in); + Optional configuredName = validatedExplicitStringValue(annotations, "name"); + validateParameterName(restMethod, in, parameterName, configuredName); + String name = configuredName.orElse(parameterName); + List contentAnnotations = annotationValues(annotations, "content"); + boolean hasExplicitContent = !contentAnnotations.isEmpty(); + Optional configuredStyle = style(annotations); + Optional configuredExplode = explode(annotations); + validator.validateParameterContent(restMethodDescription(restMethod), in, name, contentAnnotations); + validateParameterContentSerialization(restMethod, in, name, hasExplicitContent, configuredStyle, configuredExplode); + validateParameterSerialization(restMethod, in, schemaType, configuredStyle, configuredExplode); + boolean allowReserved = booleanFlag(annotations, "allowReserved"); + validateParameterAllowReserved(restMethod, in, allowReserved); + Optional example = explicitStringValue(annotations, "example"); + List examples = annotationValues(annotations, "examples"); + validator.validateParameterExamples(restMethodDescription(restMethod), in, name, example, examples); + + method.addContent(".parameter(parameter -> parameter.name(") + .addContent(expressions.validatedStringExpression(name)) + .addContent(")") + .addContentLine() + .increaseContentPadding() + .increaseContentPadding() + .addContent(".in(") + .addContent(expressions.validatedStringExpression(location)) + .addContentLine(")") + .addContent(".required(") + .addContent(Boolean.toString(required(restMethod, parameter, in, type, schemaType, annotations))) + .addContentLine(")"); + if (hasExplicitContent) { + for (Annotation content : contentAnnotations) { + addContent(method, List.of(DEFAULT_MEDIA_TYPE), content, schemaType, true, componentNames); + } + } else { + method.addContent(".schema(") + .addContent(schemas.schemaExpression(schemaType, componentNames)) + .addContentLine(")"); + } + + explicitStringValue(annotations, "value") + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + configuredStyle + .or(() -> hasExplicitContent ? Optional.empty() : inferredStyle(schemaType, location)) + .ifPresent(style -> { + method.addContent(".style("); + if ("cookie".equals(style)) { + method.addContent("\"3.2\".equals(context.openApiVersion().type()) ? \"cookie\" : \"form\""); + } else { + method.addContent(expressions.stringExpression(style)); + } + method.addContentLine(")"); + }); + configuredExplode + .or(() -> hasExplicitContent ? Optional.empty() : inferredExplode(schemaType, location)) + .ifPresent(explode -> method.addContent(".explode(") + .addContent(Boolean.toString(explode)) + .addContentLine(")")); + if (allowReserved) { + method.addContentLine(".allowReserved(true)"); + } + if (booleanFlag(annotations, "deprecated")) { + method.addContentLine(".deprecated(true)"); + } + example.ifPresent(it -> method.addContent(".example(") + .addContent("exampleValue(") + .addContent(expressions.stringExpression(it)) + .addContentLine("))")); + addExamples(method, examples); + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addRequestBody(Method.Builder method, RestMethod restMethod, Map componentNames) { + Optional requestBodyMetadata = requestBodyAnnotation(restMethod); + Optional entityParameter = entityParameter(restMethod); + List formParameters = formParameters(restMethod); + if (!formParameters.isEmpty()) { + if (entityParameter.isPresent()) { + throw new CodegenException("@Http.Entity and @Http.FormParam cannot be combined on declarative" + + " OpenAPI method " + restMethodDescription(restMethod)); + } + formRequestBodies.addRequestBody(method, + restMethodDescription(restMethod), + requestBodyMetadata.orElse(null), + restMethod.consumes(), + formParameters, + componentNames); + return; + } + if (entityParameter.isEmpty()) { + if (requestBodyMetadata.isPresent()) { + throw new CodegenException("@OpenApi.RequestBody on " + restMethodDescription(restMethod) + + " requires an @Http.Entity parameter or @Http.FormParam" + + " parameters"); + } + return; + } + + entityParameter.ifPresent(parameter -> { + Annotation requestBody = requestBodyMetadata.orElse(null); + TypeName entityType = schemas.schemaType(parameter.typeName()); + List contentAnnotations = requestBody == null + ? List.of() + : requestBody.annotationValues("content").orElseGet(List::of); + validator.validateContentMediaTypes("@OpenApi.RequestBody on " + restMethodDescription(restMethod), + contentAnnotations, + restMethod.consumes()); + method.addContentLine(".requestBody(requestBody -> requestBody") + .increaseContentPadding() + .increaseContentPadding(); + if (requestBody != null) { + requestBody.stringValue() + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + } + Optional requiredOverride = requestBody == null ? Optional.empty() : required(requestBody); + validator.validateRequestBodyRequiredness(restMethodDescription(restMethod), !parameter.typeName().isOptional(), + requiredOverride, "@Http.Entity parameter"); + boolean required = requiredOverride.orElse(!parameter.typeName().isOptional()); + if (required || requiredOverride.isPresent()) { + method.addContent(".required(") + .addContent(Boolean.toString(required)) + .addContentLine(")"); + } + if (contentAnnotations.isEmpty()) { + for (String mediaType : mediaTypes(restMethod.consumes())) { + method.addContent(".content(") + .addContent(expressions.validatedStringExpression(mediaType)) + .addContent(", ") + .addContent(schemas.mediaTypeConsumer(schemas.schemaExpression(entityType, componentNames))) + .addContentLine(")"); + } + } else { + for (Annotation content : contentAnnotations) { + addContent(method, restMethod.consumes(), content, entityType, true, componentNames); + } + } + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + }); + } + + private void addResponses(Method.Builder method, RestMethod restMethod, Map componentNames) { + List explicitResponses = repeatableAnnotations(restMethod.annotations(), + OPENAPI_RESPONSES_ANNOTATION, + OPENAPI_RESPONSE_ANNOTATION); + if (explicitResponses.isEmpty()) { + addInferredResponses(method, restMethod, componentNames); + return; + } + validator.validateResponses(restMethodDescription(restMethod), explicitResponses); + for (Annotation response : explicitResponses) { + addResponse(method, restMethod, response, componentNames); + } + if (restMethod.returnType().isOptional() && !hasExplicitResponse(explicitResponses, 404)) { + addNotFoundResponse(method); + } + } + + private void addResponse(Method.Builder method, + RestMethod restMethod, + Annotation response, + Map componentNames) { + int status = response.intValue("status") + .orElseThrow(() -> new CodegenException("@OpenApi.Response status is required")); + TypeName responseType = schemas.responseType(restMethod.returnType()); + boolean hasEntity = schemas.hasResponseEntity(restMethod.returnType()); + List contentAnnotations = response.annotationValues("content").orElseGet(List::of); + validator.validateContentMediaTypes("@OpenApi.Response on " + restMethodDescription(restMethod) + + " for status " + status, + contentAnnotations, + restMethod.produces()); + method.addContent(".response(") + .addContent(expressions.stringLiteral(String.valueOf(status))) + .addContent(", ") + .addContent("response -> response.description(") + .addContent(expressions.stringExpression(response.stringValue("description").orElse(statusDescription(status)))) + .addContentLine(")") + .increaseContentPadding() + .increaseContentPadding(); + response.stringValue("summary") + .filter(not(String::isBlank)) + .ifPresent(summary -> method.addContent(".summary(") + .addContent(expressions.stringExpression(summary)) + .addContentLine(")")); + addResponseHeaders(method, restMethod, response, componentNames); + for (Annotation link : response.annotationValues("links").orElseGet(List::of)) { + String linkName = link.stringValue("name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Link name is required")); + method.addContent(".link(") + .addContent(expressions.validatedStringExpression(linkName)) + .addContentLine(", link -> link") + .increaseContentPadding() + .increaseContentPadding(); + link.stringValue("operationRef") + .filter(not(String::isBlank)) + .ifPresent(operationRef -> method.addContent(".operationRef(") + .addContent(expressions.stringExpression(operationRef)) + .addContentLine(")")); + link.stringValue("operationId") + .filter(not(String::isBlank)) + .ifPresent(operationId -> method.addContent(".operationId(") + .addContent(expressions.stringExpression(operationId)) + .addContentLine(")")); + List linkParameters = link.annotationValues("parameters").orElseGet(List::of); + if (!linkParameters.isEmpty()) { + method.addContent(".parameters(") + .addContent(JSON_OBJECT) + .addContent(".builder()"); + for (Annotation parameter : linkParameters) { + String name = parameter.stringValue("name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.LinkParameter name is required")); + String value = parameter.stringValue() + .orElseThrow(() -> new CodegenException("@OpenApi.LinkParameter value is required")); + method.addContent(".set(") + .addContent(expressions.validatedStringExpression(name)) + .addContent(", ") + .addContent(expressions.stringExpression(value)) + .addContent(")"); + } + method.addContentLine(".build())"); + } + link.stringValue("requestBody") + .filter(not(String::isBlank)) + .ifPresent(requestBody -> method.addContent(".requestBody(") + .addContent(JSON_STRING) + .addContent(".create(") + .addContent(expressions.stringExpression(requestBody)) + .addContentLine("))")); + link.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + for (Annotation content : contentAnnotations) { + addContent(method, restMethod.produces(), content, responseType, hasEntity, componentNames); + } + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addInferredResponses(Method.Builder method, RestMethod restMethod, Map componentNames) { + TypeName returnType = restMethod.returnType(); + int status = restMethod.status() + .map(it -> it.code()) + .orElseGet(() -> returnType.boxed().equals(TypeNames.BOXED_VOID) ? 204 : 200); + TypeName responseType = schemas.responseType(returnType); + boolean hasEntity = schemas.hasResponseEntity(returnType); + method.addContent(".response(") + .addContent(expressions.stringLiteral(String.valueOf(status))) + .addContent(", ") + .addContent("response -> response.description(") + .addContent(expressions.stringLiteral(restMethod.status() + .flatMap(it -> it.reason()) + .orElse(statusDescription(status)))) + .addContentLine(")") + .increaseContentPadding() + .increaseContentPadding(); + addInferredResponseHeaders(method, restMethod); + if (hasEntity) { + for (String mediaType : mediaTypes(restMethod.produces())) { + method.addContent(".content(") + .addContent(expressions.validatedStringExpression(mediaType)) + .addContent(", ") + .addContent(schemas.mediaTypeConsumer(schemas.schemaExpression(responseType, componentNames))) + .addContentLine(")"); + } + } + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + if (returnType.isOptional()) { + addNotFoundResponse(method); + } + } + + private boolean hasExplicitResponse(List explicitResponses, int status) { + for (Annotation response : explicitResponses) { + if (response.intValue("status").orElse(-1) == status) { + return true; + } + } + return false; + } + + private void addNotFoundResponse(Method.Builder method) { + method.addContentLine(".response(\"404\", response -> response.description(\"Not Found\"))"); + } + + private void addResponseHeaders(Method.Builder method, + RestMethod restMethod, + Annotation response, + Map componentNames) { + List explicitHeaders = response.annotationValues("headers").orElseGet(List::of); + validator.validateResponseHeaders(restMethodDescription(restMethod), + explicitHeaders, + inferredResponseHeaderNames(restMethod)); + addInferredResponseHeaders(method, restMethod); + explicitHeaders.forEach(header -> addResponseHeader(method, restMethod, header, componentNames)); + } + + private List inferredResponseHeaderNames(RestMethod restMethod) { + List names = new ArrayList<>(); + restMethod.headers() + .stream() + .map(HeaderValue::name) + .filter(header -> !isContentTypeHeader(header)) + .forEach(names::add); + restMethod.computedHeaders() + .stream() + .map(ComputedHeader::headerName) + .filter(header -> !isContentTypeHeader(header)) + .forEach(names::add); + return names; + } + + private void addInferredResponseHeaders(Method.Builder method, RestMethod restMethod) { + restMethod.headers() + .stream() + .filter(header -> !isContentTypeHeader(header.name())) + .forEach(header -> addResponseHeader(method, header)); + restMethod.computedHeaders() + .stream() + .filter(header -> !isContentTypeHeader(header.headerName())) + .forEach(header -> addResponseHeader(method, header)); + } + + private void addResponseHeader(Method.Builder method, HeaderValue header) { + method.addContent(".header(") + .addContent(expressions.stringLiteral(header.name())) + .addContent(", header -> header.required(true).schema(") + .addContent(schemas.stringSchemaWithDefaultExpression(header.value())) + .addContentLine("))"); + } + + private void addResponseHeader(Method.Builder method, ComputedHeader header) { + method.addContent(".header(") + .addContent(expressions.stringLiteral(header.headerName())) + .addContent(", header -> header.schema(") + .addContent(schemas.schemaExpression(TypeNames.STRING)) + .addContentLine("))"); + } + + private void addResponseHeader(Method.Builder method, + RestMethod restMethod, + Annotation header, + Map componentNames) { + String name = header.stringValue("name") + .filter(not(String::isBlank)) + .orElseThrow(() -> new CodegenException("@OpenApi.Header name is required")); + List contentAnnotations = header.annotationValues("content").orElseGet(List::of); + TypeName schemaType = header.typeValue("schema") + .filter(Predicate.not(VOID::equals)) + .orElse(TypeNames.STRING); + + method.addContent(".header(") + .addContent(expressions.validatedStringExpression(name)) + .addContentLine(", header -> header") + .increaseContentPadding() + .increaseContentPadding(); + header.stringValue() + .filter(not(String::isBlank)) + .ifPresent(description -> method.addContent(".description(") + .addContent(expressions.stringExpression(description)) + .addContentLine(")")); + required(header) + .ifPresent(required -> method.addContent(".required(") + .addContent(Boolean.toString(required)) + .addContentLine(")")); + header.booleanValue("deprecated") + .filter(Boolean::booleanValue) + .ifPresent(deprecated -> method.addContentLine(".deprecated(true)")); + if (contentAnnotations.isEmpty()) { + method.addContent(".schema(") + .addContent(schemas.schemaExpression(schemaType, componentNames)) + .addContentLine(")"); + } else { + for (Annotation content : contentAnnotations) { + addContent(method, List.of(DEFAULT_MEDIA_TYPE), content, schemaType, true, componentNames); + } + } + method.addContentLine(")") + .decreaseContentPadding() + .decreaseContentPadding(); + } + + private void addContent(Method.Builder method, + List inferredMediaTypes, + Annotation content, + TypeName inferredSchemaType, + boolean hasInferredSchema, + Map componentNames) { + List mediaTypes = validator.contentMediaTypes(content, inferredMediaTypes); + for (String mediaType : mediaTypes) { + method.addContent(".content(") + .addContent(expressions.validatedStringExpression(mediaType)) + .addContent(", ") + .addContent(schemas.mediaTypeConsumer(content, inferredSchemaType, hasInferredSchema, componentNames)) + .addContentLine(")"); + } + } + + private List schemaBindings(ServerEndpoint endpoint) { + Set schemaTypes = new LinkedHashSet<>(); + for (RestMethod restMethod : endpoint.methods()) { + if (hasAnnotation(restMethod.annotations(), OPENAPI_HIDDEN_ANNOTATION)) { + continue; + } + collectOperationSchemaComponents(schemaTypes, restMethod); + } + + List result = new ArrayList<>(); + Set usedSchemaNames = new HashSet<>(); + Set usedFieldNames = new HashSet<>(); + for (TypeName schemaType : schemaTypes) { + String schemaName = schemas.uniqueSchemaName(schemas.schemaName(schemaType), usedSchemaNames); + result.add(new OpenApiSchemaBinding(schemaType, + schemaName, + schemas.uniqueFieldName(schemas.schemaFieldName(schemaName), usedFieldNames))); + } + return result; + } + + private void collectOperationSchemaComponents(Set schemaTypes, RestMethod restMethod) { + List methodParameters = methodParameterAnnotations(restMethod); + pathParameters(restMethod) + .forEach(parameter -> collectParameterSchemaComponents(schemaTypes, + parameter, + "path", + methodParameters)); + queryParameters(restMethod) + .forEach(parameter -> collectParameterSchemaComponents(schemaTypes, + parameter, + "query", + methodParameters)); + headerParameters(restMethod).stream() + .filter(parameter -> !isSpecialHeader(parameterName(parameter, "header"))) + .forEach(parameter -> collectParameterSchemaComponents(schemaTypes, + parameter, + "header", + methodParameters)); + cookieParameters(restMethod) + .forEach(parameter -> collectParameterSchemaComponents(schemaTypes, + parameter, + "cookie", + methodParameters)); + formParameters(restMethod) + .forEach(parameter -> schemas.collectSchemaComponent(schemaTypes, parameter.typeName())); + entityParameter(restMethod).ifPresent(parameter -> { + TypeName inferredSchemaType = schemas.schemaType(parameter.typeName()); + List contentAnnotations = requestBodyAnnotation(restMethod) + .flatMap(requestBody -> requestBody.annotationValues("content")) + .orElseGet(List::of); + if (contentAnnotations.isEmpty()) { + schemas.collectSchemaComponent(schemaTypes, inferredSchemaType); + } else { + contentAnnotations.forEach(content -> collectContentSchemaComponent(schemaTypes, + content, + inferredSchemaType, + true)); + } + }); + + TypeName responseType = schemas.responseType(restMethod.returnType()); + boolean hasResponseEntity = schemas.hasResponseEntity(restMethod.returnType()); + List explicitResponses = repeatableAnnotations(restMethod.annotations(), + OPENAPI_RESPONSES_ANNOTATION, + OPENAPI_RESPONSE_ANNOTATION); + if (explicitResponses.isEmpty()) { + if (hasResponseEntity) { + schemas.collectSchemaComponent(schemaTypes, responseType); + } + return; + } + + for (Annotation response : explicitResponses) { + List contentAnnotations = response.annotationValues("content").orElseGet(List::of); + for (Annotation content : contentAnnotations) { + collectContentSchemaComponent(schemaTypes, content, responseType, hasResponseEntity); + } + response.annotationValues("headers") + .orElseGet(List::of) + .forEach(header -> collectHeaderSchemaComponent(schemaTypes, header)); + } + } + + private void collectHeaderSchemaComponent(Set schemaTypes, Annotation header) { + Optional explicitSchema = header.typeValue("schema") + .filter(Predicate.not(VOID::equals)); + explicitSchema.ifPresent(schemaType -> schemas.collectSchemaComponent(schemaTypes, schemaType)); + TypeName inferredSchemaType = explicitSchema.orElse(TypeNames.STRING); + header.annotationValues("content") + .orElseGet(List::of) + .forEach(content -> collectContentSchemaComponent(schemaTypes, content, inferredSchemaType, true)); + } + + private void collectParameterSchemaComponents(Set schemaTypes, + RestMethodParameter parameter, + String in, + List methodParameters) { + List annotations = new ArrayList<>(matchingMethodParameters(methodParameters, parameter, in, false)); + annotations.addAll(repeatableAnnotations(parameter.annotations(), + OPENAPI_PARAMETERS_ANNOTATION, + OPENAPI_PARAMETER_ANNOTATION)); + TypeName inferredSchemaType = schemas.schemaType(parameter.typeName()); + List contentAnnotations = annotationValues(annotations, "content"); + if (contentAnnotations.isEmpty()) { + schemas.collectSchemaComponent(schemaTypes, inferredSchemaType); + } else { + contentAnnotations.forEach(content -> collectContentSchemaComponent(schemaTypes, + content, + inferredSchemaType, + true)); + } + } + + private void collectContentSchemaComponent(Set schemaTypes, + Annotation content, + TypeName inferredSchemaType, + boolean hasInferredSchema) { + Optional explicitSchema = content.typeValue("schema") + .filter(Predicate.not(VOID::equals)); + Optional explicitItemSchema = content.typeValue("itemSchema") + .filter(Predicate.not(VOID::equals)); + if (explicitSchema.isPresent() || (explicitItemSchema.isEmpty() && hasInferredSchema)) { + schemas.collectSchemaComponent(schemaTypes, explicitSchema.orElse(inferredSchemaType)); + } + explicitItemSchema.ifPresent(itemSchema -> schemas.collectSchemaComponent(schemaTypes, itemSchema)); + } + + private List pathParameters(RestMethod restMethod) { + return parameters(restMethod.pathParameters(), restMethod, HTTP_PATH_PARAM_ANNOTATION); + } + + private List queryParameters(RestMethod restMethod) { + return parameters(restMethod.queryParameters(), restMethod, HTTP_QUERY_PARAM_ANNOTATION); + } + + private List headerParameters(RestMethod restMethod) { + return parameters(restMethod.headerParameters(), restMethod, HTTP_HEADER_PARAM_ANNOTATION); + } + + private List cookieParameters(RestMethod restMethod) { + return parameters(annotatedParameters(restMethod, HTTP_COOKIE_PARAM_ANNOTATION), + restMethod, + HTTP_COOKIE_PARAM_ANNOTATION); + } + + private List formParameters(RestMethod restMethod) { + return parameters(annotatedParameters(restMethod, HTTP_FORM_PARAM_ANNOTATION), + restMethod, + HTTP_FORM_PARAM_ANNOTATION); + } + + private Optional entityParameter(RestMethod restMethod) { + return restMethod.entityParameter() + .or(() -> requestParamsParameters(restMethod, HTTP_ENTITY_ANNOTATION) + .stream() + .findFirst()); + } + + private List annotatedParameters(RestMethod restMethod, TypeName annotation) { + return restMethod.parameters() + .stream() + .filter(parameter -> Annotations.findFirst(annotation, parameter.annotations()).isPresent()) + .toList(); + } + + private List parameters(List directParameters, + RestMethod restMethod, + TypeName annotation) { + List result = new ArrayList<>(directParameters); + result.addAll(requestParamsParameters(restMethod, annotation)); + return result; + } + + private List requestParamsParameters(RestMethod restMethod, TypeName annotation) { + List result = new ArrayList<>(); + for (RestMethodParameter parameter : restMethod.parameters()) { + if (Annotations.findFirst(HTTP_REQUEST_PARAMS_ANNOTATION, parameter.annotations()).isEmpty()) { + continue; + } + TypeInfo requestParamsType = HttpCodegenValidation.requestParamsRecordType( + ctx::typeInfo, + parameter.typeName(), + parameter.parameter().originatingElementValue()); + HttpCodegenValidation.validateRequestParamsBodyComponents(requestParamsType); + for (TypedElementInfo component : HttpCodegenValidation.requestParamsComponents(requestParamsType)) { + if (Annotations.findFirst(annotation, component.annotations()).isPresent()) { + result.add(RestMethodParameter.builder() + .annotations(new HashSet<>(component.annotations())) + .name(component.elementName()) + .typeName(component.typeName()) + .index(parameter.index()) + .method(restMethod.method()) + .type(restMethod.type()) + .parameter(component) + .build()); + } + } + } + return result; + } + + private void addSchemaInjection(ClassModel.Builder classModel, List schemaBindings) { + if (schemaBindings.isEmpty()) { + return; + } + for (OpenApiSchemaBinding schemaBinding : schemaBindings) { + classModel.addField(field -> field + .accessModifier(AccessModifier.PRIVATE) + .isFinal(true) + .type(JSON_SCHEMA_PROVIDER) + .name(schemaBinding.fieldName())); + } + + classModel.addConstructor(ctr -> { + ctr.accessModifier(AccessModifier.PACKAGE_PRIVATE) + .addAnnotation(Annotation.create(SERVICE_ANNOTATION_INJECT)); + for (OpenApiSchemaBinding schemaBinding : schemaBindings) { + ctr.addParameter(parameter -> parameter + .type(JSON_SCHEMA_PROVIDER) + .addAnnotation(Annotation.builder() + .typeName(SERVICE_ANNOTATION_NAMED_BY_TYPE) + .property("value", schemaBinding.type()) + .build()) + .name(schemaBinding.fieldName())) + .addContent("this.") + .addContent(schemaBinding.fieldName()) + .addContent(" = ") + .addContent(schemaBinding.fieldName()) + .addContentLine(";"); + } + }); + } + + private String parameterName(RestMethodParameter parameter, String in) { + TypeName annotationType = switch (in) { + case "path" -> HTTP_PATH_PARAM_ANNOTATION; + case "query" -> HTTP_QUERY_PARAM_ANNOTATION; + case "header" -> HTTP_HEADER_PARAM_ANNOTATION; + case "cookie" -> HTTP_COOKIE_PARAM_ANNOTATION; + case "form" -> HTTP_FORM_PARAM_ANNOTATION; + default -> throw new CodegenException("Unsupported OpenAPI parameter location: " + in); + }; + return Annotations.findFirst(annotationType, parameter.annotations()) + .flatMap(Annotation::stringValue) + .filter(not(String::isBlank)) + .orElse(parameter.name()); + } + + private boolean required(RestMethod restMethod, + RestMethodParameter parameter, + String in, + TypeName type, + TypeName schemaType, + List annotations) { + Optional explicit = required(annotations); + if ("path".equals(in)) { + if (explicit.filter(Predicate.not(Boolean::booleanValue)).isPresent()) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot make a path parameter optional"); + } + return true; + } + boolean required = parameterRequired(type, parameter.annotations()); + if (required && explicit.filter(Predicate.not(Boolean::booleanValue)).isPresent() + && ("query".equals(in) || "header".equals(in) || "cookie".equals(in))) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot make a required " + in + " parameter optional"); + } + return explicit.orElse(required); + } + + private boolean formParameterRequired(RestMethodParameter parameter) { + return parameterRequired(parameter.typeName(), parameter.annotations()); + } + + private boolean parameterRequired(TypeName type, Collection annotations) { + if (type.isOptional()) { + return false; + } + + return DefaultsCodegen.findDefault(new HashSet<>(annotations), type).isEmpty(); + } + + private void validateParameterLocation(RestMethod restMethod, String in, Optional location) { + location.filter(not(in::equals)) + .ifPresent(it -> { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot document a " + in + " parameter as " + it); + }); + } + + private void validateParameterName(RestMethod restMethod, + String in, + String parameterName, + Optional configuredName) { + configuredName.filter(not(parameterName::equals)) + .ifPresent(it -> { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot document a " + in + " parameter named " + + parameterName + " as " + it); + }); + } + + private void validateParameterAllowReserved(RestMethod restMethod, String in, boolean allowReserved) { + if (allowReserved && !"query".equals(in)) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot use allowReserved=true for a " + in + " parameter"); + } + } + + private void validateParameterContentSerialization(RestMethod restMethod, + String in, + String name, + boolean hasExplicitContent, + Optional configuredStyle, + Optional configuredExplode) { + if (!hasExplicitContent) { + return; + } + if (configuredStyle.isPresent()) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot define style when content is defined for " + in + + " parameter " + name); + } + if (configuredExplode.isPresent()) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot define explode when content is defined for " + in + + " parameter " + name); + } + } + + private void validateParameterSerialization(RestMethod restMethod, + String in, + TypeName schemaType, + Optional style, + Optional explode) { + style.ifPresent(it -> validateParameterStyle(restMethod, in, schemaType, it)); + if ("header".equals(in) && explode.filter(Boolean::booleanValue).isPresent()) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot use explode=true for a header parameter"); + } + if ("query".equals(in) + && style.filter(it -> "pipeDelimited".equals(it) || "spaceDelimited".equals(it)).isPresent() + && explode.filter(Boolean::booleanValue).isPresent()) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot use explode=true with " + style.orElseThrow() + + " style for a query parameter"); + } + } + + private void validateParameterStyle(RestMethod restMethod, String in, TypeName schemaType, String style) { + switch (in) { + case "path" -> { + if (!"simple".equals(style)) { + throw unsupportedStyle(restMethod, in, style); + } + } + case "query" -> { + switch (style) { + case "pipeDelimited", "spaceDelimited" -> { + if (!schemaType.isList()) { + throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot use " + style + " style for a scalar query parameter"); + } + } + case "deepObject" -> throw new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot use deepObject style because declarative" + + " HTTP parameters do not support deep object binding"); + default -> { + if (!"form".equals(style)) { + throw unsupportedStyle(restMethod, in, style); + } + } + } + } + case "header" -> { + if (!"simple".equals(style)) { + throw unsupportedStyle(restMethod, in, style); + } + } + case "cookie" -> { + if (!"form".equals(style) && !"cookie".equals(style)) { + throw unsupportedStyle(restMethod, in, style); + } + } + default -> throw new CodegenException("Unsupported OpenAPI parameter location: " + in); + } + } + + private CodegenException unsupportedStyle(RestMethod restMethod, String in, String style) { + return new CodegenException("@OpenApi.Parameter on " + restMethodDescription(restMethod) + + " cannot use " + style + " style for a " + in + " parameter"); + } + + private List mediaTypes(List mediaTypes) { + return mediaTypes.isEmpty() ? List.of(DEFAULT_MEDIA_TYPE) : mediaTypes; + } + + private boolean isSpecialHeader(String name) { + String normalized = name.toLowerCase(Locale.ROOT); + return "accept".equals(normalized) + || "content-type".equals(normalized) + || "authorization".equals(normalized); + } + + private boolean isContentTypeHeader(String name) { + return "content-type".equals(name.toLowerCase(Locale.ROOT)); + } + + private Optional requestBodyAnnotation(RestMethod method) { + return Annotations.findFirst(OPENAPI_REQUEST_BODY_ANNOTATION, method.annotations()); + } + + private List methodParameterAnnotations(RestMethod method) { + return repeatableAnnotations(method.annotations(), OPENAPI_PARAMETERS_ANNOTATION, OPENAPI_PARAMETER_ANNOTATION); + } + + private List matchingMethodParameters(List methodParameters, + RestMethodParameter parameter, + String in) { + return matchingMethodParameters(methodParameters, parameter, in, true); + } + + private List matchingMethodParameters(List methodParameters, + RestMethodParameter parameter, + String in, + boolean remove) { + List result = new ArrayList<>(); + String name = parameterName(parameter, in); + for (Iterator iterator = methodParameters.iterator(); iterator.hasNext();) { + Annotation annotation = iterator.next(); + Optional annotationName = validatedStringValue(annotation, "name"); + Optional annotationIn = validatedStringValue(annotation, "in"); + if (annotationName.filter(name::equals).isPresent() && annotationIn.filter(in::equals).isPresent()) { + result.add(annotation); + if (remove) { + iterator.remove(); + } + } + } + return result; + } + + private CodegenException unmatchedMethodParameter(RestMethod restMethod, Annotation annotation) { + Optional name = validatedStringValue(annotation, "name"); + Optional in = validatedStringValue(annotation, "in"); + if (name.isEmpty() || in.isEmpty()) { + return new CodegenException("Method-level @OpenApi.Parameter on " + restMethodDescription(restMethod) + + " must declare non-blank name and in values"); + } + return new CodegenException("Method-level @OpenApi.Parameter on " + restMethodDescription(restMethod) + + " does not match a generated parameter: " + in.get() + " " + name.get()); + } + + private String restMethodDescription(RestMethod restMethod) { + return restMethod.type().typeName().fqName() + "." + restMethod.name(); + } + + private Optional explicitStringValue(List annotations, String property) { + for (int i = annotations.size() - 1; i >= 0; i--) { + Optional value = "value".equals(property) + ? annotations.get(i).stringValue() + : annotations.get(i).stringValue(property); + if (value.filter(not(String::isBlank)).isPresent()) { + return value; + } + } + return Optional.empty(); + } + + private Optional validatedExplicitStringValue(List annotations, String property) { + for (int i = annotations.size() - 1; i >= 0; i--) { + Optional value = validatedStringValue(annotations.get(i), property); + if (value.isPresent()) { + return value; + } + } + return Optional.empty(); + } + + private Optional validatedStringValue(Annotation annotation, String property) { + Optional value = "value".equals(property) + ? annotation.stringValue() + : annotation.stringValue(property); + return value.map(validator::expressionDefaultValue) + .filter(not(String::isBlank)); + } + + private List annotationValues(List annotations, String property) { + List result = new ArrayList<>(); + annotations.forEach(annotation -> annotation.annotationValues(property).ifPresent(result::addAll)); + return result; + } + + private boolean booleanFlag(List annotations, String property) { + return annotations.stream() + .flatMap(annotation -> annotation.booleanValue(property).stream()) + .anyMatch(Boolean::booleanValue); + } + + private Optional required(List annotations) { + for (int i = annotations.size() - 1; i >= 0; i--) { + Optional required = required(annotations.get(i)); + if (required.isPresent()) { + return required; + } + } + return Optional.empty(); + } + + private Optional required(Annotation annotation) { + return triState(annotation, "required", "Required"); + } + + private Optional explode(List annotations) { + for (int i = annotations.size() - 1; i >= 0; i--) { + Optional explode = triState(annotations.get(i), "explode", "Explode"); + if (explode.isPresent()) { + return explode; + } + } + return Optional.empty(); + } + + private Optional triState(Annotation annotation, String property, String enumName) { + return annotation.stringValue(property) + .map(this::enumName) + .flatMap(value -> switch (value) { + case "UNSPECIFIED" -> Optional.empty(); + case "TRUE" -> Optional.of(true); + case "FALSE" -> Optional.of(false); + default -> throw new CodegenException("@OpenApi." + enumName + " has unsupported value: " + value); + }); + } + + private Optional style(List annotations) { + for (int i = annotations.size() - 1; i >= 0; i--) { + Optional style = annotations.get(i).stringValue("style") + .map(this::enumName) + .filter(value -> !"UNSPECIFIED".equals(value)) + .map(this::styleName); + if (style.isPresent()) { + return style; + } + } + return Optional.empty(); + } + + private String enumName(String value) { + int dot = value.lastIndexOf('.'); + return dot == -1 ? value : value.substring(dot + 1); + } + + private String styleName(String style) { + return switch (style) { + case "MATRIX" -> "matrix"; + case "LABEL" -> "label"; + case "FORM" -> "form"; + case "COOKIE" -> "cookie"; + case "SIMPLE" -> "simple"; + case "SPACE_DELIMITED" -> "spaceDelimited"; + case "PIPE_DELIMITED" -> "pipeDelimited"; + case "DEEP_OBJECT" -> "deepObject"; + default -> throw new CodegenException("@OpenApi.Style has unsupported value: " + style); + }; + } + + private Optional inferredStyle(TypeName schemaType, String in) { + if ("cookie".equals(in)) { + return Optional.of("cookie"); + } + if (!schemaType.isList()) { + return Optional.empty(); + } + return switch (in) { + case "query" -> Optional.of("form"); + case "header" -> Optional.of("simple"); + default -> Optional.empty(); + }; + } + + private Optional inferredExplode(TypeName schemaType, String in) { + if (!schemaType.isList()) { + return Optional.empty(); + } + return switch (in) { + case "query", "cookie" -> Optional.of(true); + case "header" -> Optional.of(false); + default -> Optional.empty(); + }; + } + + private void addExamples(Method.Builder method, List examples) { + for (int i = 0; i < examples.size(); i++) { + Annotation example = examples.get(i); + method.addContent(".example(") + .addContent(expressions.validatedStringExpression(validator.exampleName(example, i))) + .addContent(", ") + .addContent(exampleExpression(example)) + .addContentLine(")"); + } + } + + private String examplesExpression(List examples) { + StringBuilder result = new StringBuilder(); + addExamples(result, examples); + return result.toString(); + } + + private void addExamples(StringBuilder builder, List examples) { + for (int i = 0; i < examples.size(); i++) { + Annotation example = examples.get(i); + builder.append(".example(") + .append(expressions.validatedStringExpression(validator.exampleName(example, i))) + .append(", ") + .append(exampleExpression(example)) + .append(")"); + } + } + + private String exampleExpression(Annotation example) { + StringBuilder result = new StringBuilder(OPENAPI_DOCUMENT_EXAMPLE.fqName()).append(".builder()"); + example.stringValue("summary") + .filter(not(String::isBlank)) + .ifPresent(summary -> result.append(".summary(") + .append(expressions.stringExpression(summary)) + .append(")")); + example.stringValue("description") + .filter(not(String::isBlank)) + .ifPresent(description -> result.append(".description(") + .append(expressions.stringExpression(description)) + .append(")")); + example.stringValue("value") + .filter(not(String::isBlank)) + .ifPresent(value -> result.append(".value(") + .append("exampleValue(") + .append(expressions.stringExpression(value)) + .append("))")); + example.stringValue("dataValue") + .filter(not(String::isBlank)) + .ifPresent(value -> result.append(".dataValue(") + .append("exampleValue(") + .append(expressions.stringExpression(value)) + .append("))")); + example.stringValue("serializedValue") + .filter(not(String::isBlank)) + .ifPresent(value -> result.append(".serializedValue(") + .append(expressions.stringExpression(value)) + .append(")")); + example.stringValue("externalValue") + .filter(not(String::isBlank)) + .ifPresent(value -> result.append(".externalValue(") + .append(expressions.stringExpression(value)) + .append(")")); + return result.append(".build()").toString(); + } + + private boolean hasEmptySecurityRequirements(Collection annotations) { + List containers = annotations.stream() + .filter(it -> it.typeName().name().equals(OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION.name())) + .toList(); + return !containers.isEmpty() + && containers.stream() + .allMatch(it -> it.annotationValues() + .filter(List::isEmpty) + .isPresent()) + && Annotations.findFirst(OPENAPI_SECURITY_REQUIREMENT_ANNOTATION, annotations).isEmpty() + && Annotations.findFirst(OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION, annotations).isEmpty(); + } + + private List securityRequirements(String owner, Collection annotations) { + List direct = annotations.stream() + .filter(it -> it.typeName().name().equals(OPENAPI_SECURITY_SCHEME_REQUIREMENT_ANNOTATION.name())) + .toList(); + List containers = annotations.stream() + .filter(it -> it.typeName().name().equals(OPENAPI_SECURITY_REQUIREMENTS_ANNOTATION.name())) + .toList(); + List requirements = annotations.stream() + .filter(it -> it.typeName().name().equals(OPENAPI_SECURITY_REQUIREMENT_ANNOTATION.name())) + .toList(); + if (!direct.isEmpty()) { + if (!containers.isEmpty() || !requirements.isEmpty()) { + throw new CodegenException("@OpenApi.SecuritySchemeRequirement on " + owner + + " cannot be combined with @OpenApi.SecurityRequirement or " + + "@OpenApi.SecurityRequirements"); + } + return direct.stream().map(it -> new OpenApiSecurityRequirement(List.of(it))).toList(); + } + + List result = new ArrayList<>(); + containers.forEach(container -> container + .annotationValues() + .orElseGet(List::of) + .forEach(it -> result.add(new OpenApiSecurityRequirement(it.annotationValues() + .orElseGet(List::of))))); + requirements.forEach(it -> result.add(new OpenApiSecurityRequirement(it.annotationValues() + .orElseGet(List::of)))); + return result; + } + + private boolean hasStringValue(Annotation annotation, String property) { + Optional value = "value".equals(property) + ? annotation.stringValue() + : annotation.stringValue(property); + return value.filter(not(String::isBlank)).isPresent(); + } + + private TypeName generatedType(TypeName sourceType, String suffix) { + return TypeName.builder() + .packageName(sourceType.packageName()) + .className(sourceType.classNameWithEnclosingNames().replace('.', '_') + "__" + suffix + "Source") + .build(); + } + + private String endpointName(TypeName typeName) { + String className = typeName.className(); + if (className.endsWith("Endpoint") && className.length() > "Endpoint".length()) { + className = className.substring(0, className.length() - "Endpoint".length()); + } + if (className.isEmpty()) { + return className; + } + return Character.toLowerCase(className.charAt(0)) + className.substring(1); + } + + private String statusDescription(int status) { + return switch (status) { + case 200 -> "OK"; + case 201 -> "Created"; + case 202 -> "Accepted"; + case 204 -> "No Content"; + case 400 -> "Bad Request"; + case 401 -> "Unauthorized"; + case 403 -> "Forbidden"; + case 404 -> "Not Found"; + case 409 -> "Conflict"; + case 500 -> "Internal Server Error"; + default -> "HTTP " + status; + }; + } + +} diff --git a/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/package-info.java b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/package-info.java new file mode 100644 index 00000000000..51e79e168d0 --- /dev/null +++ b/declarative/codegen/src/main/java/io/helidon/declarative/codegen/openapi/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Code generation support for declarative OpenAPI. + */ +package io.helidon.declarative.codegen.openapi; diff --git a/declarative/codegen/src/main/java/module-info.java b/declarative/codegen/src/main/java/module-info.java index 1fd1f3bec53..c5354c718c9 100644 --- a/declarative/codegen/src/main/java/module-info.java +++ b/declarative/codegen/src/main/java/module-info.java @@ -59,6 +59,7 @@ with io.helidon.declarative.codegen.faulttolerance.FtExtensionProvider, io.helidon.declarative.codegen.scheduling.SchedulingExtensionProvider, io.helidon.declarative.codegen.graphql.server.GraphQlServerExtensionProvider, + io.helidon.declarative.codegen.openapi.OpenApiExtensionProvider, io.helidon.declarative.codegen.http.restclient.RestClientExtensionProvider, io.helidon.declarative.codegen.grpc.client.GrpcClientExtensionProvider, io.helidon.declarative.codegen.http.webserver.RestServerExtensionProvider, diff --git a/declarative/tests/codegen/pom.xml b/declarative/tests/codegen/pom.xml index 14d37467018..878eca34bb7 100644 --- a/declarative/tests/codegen/pom.xml +++ b/declarative/tests/codegen/pom.xml @@ -46,6 +46,11 @@ helidon-http test + + io.helidon.openapi + helidon-openapi + test + io.helidon.graphql helidon-graphql diff --git a/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiDuplicateValuesCodegenTest.java b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiDuplicateValuesCodegenTest.java new file mode 100644 index 00000000000..9cf70fce7f0 --- /dev/null +++ b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiDuplicateValuesCodegenTest.java @@ -0,0 +1,1939 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import io.helidon.codegen.apt.AptProcessor; +import io.helidon.codegen.testing.TestCompiler; +import io.helidon.common.Api; +import io.helidon.common.Default; +import io.helidon.common.Generated; +import io.helidon.common.GenericType; +import io.helidon.common.LazyValue; +import io.helidon.common.mapper.Mappers; +import io.helidon.common.parameters.Parameters; +import io.helidon.common.types.Annotation; +import io.helidon.common.uri.UriQuery; +import io.helidon.config.Config; +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Dependency; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.HttpEntryPoint; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRoute; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.RestServer; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiDuplicateValuesCodegenTest { + private static final List> CLASSPATH = List.of( + Annotation.class, + Api.class, + Config.class, + Default.class, + Dependency.class, + Generated.class, + GenericType.class, + Handler.class, + Http.class, + HttpEntryPoint.class, + HttpFeature.class, + HttpRoute.class, + HttpRouting.class, + HttpRules.class, + LazyValue.class, + Mappers.class, + OpenApi.class, + Parameters.class, + RestServer.class, + ServerRequest.class, + ServerResponse.class, + Service.class, + ServiceDescriptor.class, + UriQuery.class, + WebServer.class + ); + + @Test + void infoAllowsEmptyStrings() throws IOException { + var result = compile("openapi-empty-info-strings", """ + @OpenApi.Document + @OpenApi.Info(title = "", version = " ") + class EmptyInfoDocument { + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString("info.title(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"\"))")); + assertThat(generated, containsString(".version(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \" \"))")); + } + + @Test + void documentCannotDeclareDuplicateServers() { + var result = compile("openapi-duplicate-document-servers", """ + @OpenApi.Server("https://api.example.com") + @OpenApi.Server("https://api.example.com") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Server on com.example.InvalidOpenApiEndpoint", + "cannot define server https://api.example.com more than once"); + } + + @Test + void serverCannotDeclareDuplicateVariables() { + var result = compile("openapi-duplicate-server-variables", """ + @OpenApi.Server( + value = "https://{region}.api.example.com", + variables = { + @OpenApi.ServerVariable(name = "region", defaultValue = "us"), + @OpenApi.ServerVariable(name = "region", defaultValue = "eu") + }) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Server on com.example.InvalidOpenApiEndpoint " + + "for server https://{region}.api.example.com", + "cannot define server variable region more than once"); + } + + @Test + void serverMustDeclareUrlVariables() { + var result = compile("openapi-server-missing-variable", """ + @OpenApi.Server("https://{region}.api.example.com") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Server on com.example.InvalidOpenApiEndpoint " + + "for server https://{region}.api.example.com", + "is missing a declaration for URL variable region"); + } + + @Test + void serverValidatesKnownConfigurationExpressionDefault() { + var result = compile("openapi-server-config-expression-default-unused-variable", """ + @OpenApi.Server( + value = "${openapi.server.url:https://api.example.com}", + variables = @OpenApi.ServerVariable(name = "region", defaultValue = "us")) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Server on com.example.InvalidOpenApiEndpoint " + + "for server ${openapi.server.url:https://api.example.com}", + "declares server variable region which is not present in the URL"); + } + + @Test + void serverCannotDeclareUnusedVariables() { + var result = compile("openapi-server-unused-variable", """ + @OpenApi.Server( + value = "https://api.example.com", + variables = @OpenApi.ServerVariable(name = "tenant", defaultValue = "acme")) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Server on com.example.InvalidOpenApiEndpoint " + + "for server https://api.example.com", + "declares server variable tenant which is not present in the URL"); + } + + @Test + void serverVariableEnumerationMustContainDefault() { + var result = compile("openapi-server-variable-invalid-enumeration", """ + @OpenApi.Server( + value = "https://{region}.api.example.com", + variables = @OpenApi.ServerVariable( + name = "region", + defaultValue = "apac", + enumeration = {"us", "eu"})) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Server on com.example.InvalidOpenApiEndpoint " + + "for server https://{region}.api.example.com for variable region", + "must include default value apac in its enumeration"); + } + + @Test + void documentCannotDeclareDuplicateTags() { + var result = compile("openapi-duplicate-document-tags", """ + @OpenApi.Tag("greeting") + @OpenApi.Tag("greeting") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Tag on com.example.InvalidOpenApiEndpoint", + "cannot define tag greeting more than once"); + } + + @Test + void documentCannotDeclareDuplicateExtensions() { + var result = compile("openapi-duplicate-document-extensions", """ + @OpenApi.Extension(name = "x-test", value = "one") + @OpenApi.Extension(name = "x-test", value = "two") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Extension on com.example.InvalidOpenApiEndpoint", + "cannot define extension x-test more than once"); + } + + @Test + void parsedExtensionDelegatesToGeneratedSourceBase() throws IOException { + var result = compile("openapi-parsed-extension", """ + @OpenApi.Extension(name = "x-test", + value = "${test.extension:[true,42]}", + parseValue = true) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString("document.extension(\"x-test\", extensionValue(\"x-test\", ")); + assertThat(generated, containsString("resolveExpression(context, \"${test.extension:[true,42]}\"), true));")); + } + + @Test + void operationCannotDeclareDuplicateServers() { + var result = compile("openapi-duplicate-operation-servers", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Server("https://api.example.com") + @OpenApi.Server("https://api.example.com") + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Server on com.example.InvalidOpenApiEndpoint.get", + "cannot define server https://api.example.com more than once"); + } + + @Test + void operationCannotDeclareDuplicateTags() { + var result = compile("openapi-duplicate-operation-tags", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Operation(tags = {"${openapi.tag:greeting}", "greeting"}) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Operation on com.example.InvalidOpenApiEndpoint.get", + "cannot define tag greeting more than once"); + } + + @Test + void operationTagsUseConfigurationExpressionDefaults() throws IOException { + var result = compile("openapi-operation-tag-config-expression-defaults", """ + @OpenApi.Tag("${openapi.tag:greeting}") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Operation(tags = "${openapi.tag:greeting}") + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".tag(tag -> tag.name(\"greeting\")")); + assertThat(generated, containsString(".tag(\"greeting\")")); + } + + @Test + void securitySchemeCannotUseDuplicateName() { + var result = compile("openapi-duplicate-security-scheme", """ + @OpenApi.SecurityScheme(name = "bearerAuth", type = "http", scheme = "bearer") + @OpenApi.SecurityScheme(name = "bearerAuth", + type = "apiKey", + in = "header", + apiKeyName = "Authorization") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint", + "cannot define security scheme bearerAuth more than once"); + } + + @Test + void securitySchemeRejectsInvalidComponentName() { + List invalidNames = List.of("auth/name", "${security.name:auth/name}"); + for (int i = 0; i < invalidNames.size(); i++) { + var result = compile("openapi-invalid-security-scheme-name-" + i, """ + @OpenApi.HttpSecurityScheme(name = "%s", scheme = "basic") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + class InvalidSecuritySchemeDocument { + } + """.formatted(invalidNames.get(i))); + + assertCompilationFails(result, + "invalid security scheme name auth/name", + "names can contain only letters, digits, dots, hyphens, and underscores"); + } + } + + @Test + void securitySchemeContainerAndDirectAnnotationAreCombined() throws IOException { + var result = compile("openapi-combined-security-schemes", """ + @OpenApi.SecuritySchemes({ + @OpenApi.SecurityScheme(name = "bearerAuth", type = "http", scheme = "bearer") + }) + @OpenApi.SecurityScheme(name = "apiKeyAuth", + type = "apiKey", + in = "header", + apiKeyName = "Authorization") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertThat(String.join("\n", result.diagnostics()), result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".securityScheme(\"bearerAuth\"")); + assertThat(generated, containsString(".securityScheme(\"apiKeyAuth\"")); + } + + @Test + void securitySchemeCannotUseDuplicateNameAcrossTypedAndGenericAnnotations() { + var result = compile("openapi-duplicate-typed-security-scheme", """ + @OpenApi.SecurityScheme(name = "bearerAuth", type = "http", scheme = "bearer") + @OpenApi.HttpSecurityScheme(name = "bearerAuth", scheme = "basic") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.HttpSecurityScheme on com.example.InvalidOpenApiEndpoint", + "cannot define security scheme bearerAuth more than once"); + } + + @Test + void securitySchemeAllowsConfigurationExpressionDefaults() throws IOException { + var result = compile("openapi-security-scheme-config-expression-defaults", """ + @OpenApi.Server( + value = "https://${openapi.host:api.example.com}/{region}", + variables = @OpenApi.ServerVariable( + name = "region", + defaultValue = "${openapi.region:us}", + enumeration = {"${openapi.region.us:us}", "eu"})) + @OpenApi.SecurityScheme(name = "apiKeyAuth", + type = "apiKey", + in = "${openapi.api-key.in:header}", + apiKeyName = "${openapi.api-key.name:X-API-Key}") + @OpenApi.Document + @OpenApi.Info(title = "${openapi.title:Test}", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString("OpenApiDocumentContextSupport.resolveExpression(context, " + + "\"https://${openapi.host:api.example.com}/{region}\")")); + assertThat(generated, containsString(".variable(\"region\", variable -> variable.value(" + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"${openapi.region:us}\"))")); + assertThat(generated, containsString(".allowedValues(java.util.List.of(" + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"${openapi.region.us:us}\"), " + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"eu\")))")); + assertThat(generated, containsString("OpenApiDocumentContextSupport.resolveExpression(context, " + + "\"${openapi.title:Test}\")")); + assertThat(generated, containsString(".type(\"apiKey\")")); + assertThat(generated, containsString(".in(\"header\")")); + assertThat(generated, containsString("OpenApiDocumentContextSupport.resolveExpression(context, " + + "\"${openapi.api-key.name:X-API-Key}\")")); + } + + @Test + void serverVariableValidationDefersUnknownConfigurationExpression() { + var result = compile("openapi-server-unknown-config-expression", """ + @OpenApi.Server( + value = "${openapi.server.url}", + variables = @OpenApi.ServerVariable(name = "region", defaultValue = "us")) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + } + + @Test + void serverVariableValidationAllowsLeadingEmbeddedConfigurationExpression() { + var result = compile("openapi-server-leading-config-expression", """ + @OpenApi.Server( + value = "${openapi.scheme:https}://api.example.com/{region}", + variables = @OpenApi.ServerVariable(name = "region", defaultValue = "us")) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + } + + @Test + void httpSecuritySchemeWithConfiguredSchemeGuardsBearerFormat() throws IOException { + var result = compile("openapi-http-security-scheme-configured-scheme-bearer-format", """ + @OpenApi.HttpSecurityScheme(name = "bearerAuth", + scheme = "${auth.scheme:bearer}", + bearerFormat = "JWT") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString("String resolvedScheme = io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"${auth.scheme:bearer}\");")); + assertThat(generated, containsString("security.scheme(resolvedScheme);")); + assertThat(generated, containsString("if (\"bearer\".equalsIgnoreCase(resolvedScheme)) {")); + assertThat(generated, containsString("security.bearerFormat(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"JWT\"));")); + } + + @Test + void genericHttpSecuritySchemeWithConfiguredTypeAndSchemeGuardsBearerFormat() throws IOException { + var result = compile("openapi-generic-http-security-scheme-configured-type-scheme-bearer-format", """ + @OpenApi.SecurityScheme(name = "bearerAuth", + type = "${security.type:http}", + scheme = "${auth.scheme}", + bearerFormat = "JWT") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString("String resolvedScheme = io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"${auth.scheme}\");")); + assertThat(generated, containsString("security.type(\"http\");")); + assertThat(generated, containsString("security.scheme(resolvedScheme);")); + assertThat(generated, containsString("if (\"bearer\".equalsIgnoreCase(resolvedScheme)) {")); + assertThat(generated, containsString("security.bearerFormat(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"JWT\"));")); + } + + @Test + void typedSecuritySchemesGenerateComponents() throws IOException { + var result = compile("openapi-typed-security-schemes", """ + @OpenApi.ApiKeySecurityScheme(name = "apiKeyAuth", + apiKeyName = "X-API-Key", + in = "header") + @OpenApi.HttpSecurityScheme(name = "bearerAuth", + scheme = "bearer", + bearerFormat = "JWT") + @OpenApi.MutualTlsSecurityScheme(name = "mtls") + @OpenApi.OAuth2SecurityScheme( + name = "oauth2", + flows = @OpenApi.OAuthFlows( + clientCredentials = @OpenApi.OAuthFlow( + tokenUrl = "https://api.example.com/token", + scopes = @OpenApi.OAuthScope(value = "read", description = "Read"))), + oauth2MetadataUrl = "https://api.example.com/.well-known/oauth-authorization-server") + @OpenApi.OidcSecurityScheme( + name = "oidc", + openIdConnectUrl = "https://id.example.com/.well-known/openid-configuration") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".securityScheme(\"apiKeyAuth\",")); + assertThat(generated, containsString(".type(\"apiKey\")")); + assertThat(generated, containsString(".name(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"X-API-Key\"))")); + assertThat(generated, containsString(".in(\"header\")")); + assertThat(generated, containsString(".securityScheme(\"bearerAuth\",")); + assertThat(generated, containsString(".type(\"http\")")); + assertThat(generated, containsString(".scheme(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"bearer\"))")); + assertThat(generated, containsString(".bearerFormat(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"JWT\"))")); + assertThat(generated, containsString(".securityScheme(\"mtls\",")); + assertThat(generated, containsString(".type(\"mutualTLS\")")); + assertThat(generated, containsString(".securityScheme(\"oauth2\",")); + assertThat(generated, containsString(".type(\"oauth2\")")); + assertThat(generated, containsString(".flows(")); + assertThat(generated, containsString(".oauth2MetadataUrl(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"https://api.example.com/" + + ".well-known/oauth-authorization-server\"))")); + assertThat(generated, containsString(".securityScheme(\"oidc\",")); + assertThat(generated, containsString(".type(\"openIdConnect\")")); + assertThat(generated, containsString(".openIdConnectUrl(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"https://id.example.com/" + + ".well-known/openid-configuration\"))")); + } + + @Test + void apiKeySecuritySchemeRequiresNameAndLocation() { + var result = compile("openapi-security-scheme-api-key-missing-name", """ + @OpenApi.SecurityScheme(name = "apiKeyAuth", type = "apiKey", in = "header") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme apiKeyAuth", + "requires apiKeyName"); + } + + @Test + void apiKeySecuritySchemeRejectsInvalidLocationDefault() { + var result = compile("openapi-security-scheme-api-key-invalid-location", """ + @OpenApi.SecurityScheme(name = "apiKeyAuth", + type = "apiKey", + in = "${openapi.api-key.in:body}", + apiKeyName = "X-API-Key") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme apiKeyAuth", + "apiKey in must be one of query, header, or cookie: body"); + } + + @Test + void httpSecuritySchemeRequiresScheme() { + var result = compile("openapi-security-scheme-http-missing-scheme", """ + @OpenApi.SecurityScheme(name = "httpAuth", type = "http") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme httpAuth", + "requires scheme"); + } + + @Test + void httpSecuritySchemeRejectsApiKeyFields() { + var result = compile("openapi-security-scheme-http-api-key-fields", """ + @OpenApi.SecurityScheme(name = "httpAuth", + type = "http", + scheme = "bearer", + apiKeyName = "X-API-Key", + in = "header") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme httpAuth", + "type http cannot define apiKeyName"); + } + + @Test + void securitySchemeRejectsInvalidFieldWithBlankExpressionDefault() { + var result = compile("openapi-security-scheme-invalid-field-blank-expression-default", """ + @OpenApi.SecurityScheme(name = "apiKeyAuth", + type = "apiKey", + in = "header", + apiKeyName = "X-API-Key", + scheme = "${openapi.http.scheme:}") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme apiKeyAuth", + "type apiKey cannot define scheme"); + } + + @Test + void securitySchemeRejectsInvalidFlowWithBlankExpressionDefault() { + var result = compile("openapi-security-scheme-invalid-flow-blank-expression-default", """ + @OpenApi.SecurityScheme(name = "mtls", + type = "mutualTLS", + flows = @OpenApi.OAuthFlows( + clientCredentials = @OpenApi.OAuthFlow( + tokenUrl = "${openapi.oauth.token:}"))) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme mtls", + "type mutualTLS cannot define flows"); + } + + @Test + void apiKeySecuritySchemeRejectsHttpFields() { + var result = compile("openapi-security-scheme-api-key-http-fields", """ + @OpenApi.SecurityScheme(name = "apiKeyAuth", + type = "apiKey", + apiKeyName = "X-API-Key", + in = "header", + scheme = "bearer") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme apiKeyAuth", + "type apiKey cannot define scheme"); + } + + @Test + void oauth2SecuritySchemeRequiresFlow() { + var result = compile("openapi-security-scheme-oauth2-missing-flow", """ + @OpenApi.SecurityScheme(name = "oauth2", type = "oauth2") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme oauth2", + "requires at least one OAuth flow"); + } + + @Test + void oauth2AuthorizationCodeFlowRequiresTokenUrl() { + var result = compile("openapi-security-scheme-oauth2-missing-token-url", """ + @OpenApi.SecurityScheme(name = "oauth2", + type = "oauth2", + flows = @OpenApi.OAuthFlows( + authorizationCode = @OpenApi.OAuthFlow( + authorizationUrl = "https://id.example.com/authorize"))) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.OAuthFlow on com.example.InvalidOpenApiEndpoint " + + "for security scheme oauth2 authorizationCode flow", + "requires tokenUrl"); + } + + @Test + void openIdConnectSecuritySchemeRequiresUrl() { + var result = compile("openapi-security-scheme-openid-missing-url", """ + @OpenApi.SecurityScheme(name = "oidc", type = "openIdConnect") + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityScheme on com.example.InvalidOpenApiEndpoint " + + "for security scheme oidc", + "requires openIdConnectUrl"); + } + + @Test + void oauthFlowCannotDeclareDuplicateScopes() { + var result = compile("openapi-duplicate-oauth-scopes", """ + @OpenApi.SecurityScheme( + name = "oauth2", + type = "oauth2", + flows = @OpenApi.OAuthFlows( + clientCredentials = @OpenApi.OAuthFlow( + tokenUrl = "https://api.example.com/token", + scopes = { + @OpenApi.OAuthScope(value = "read", description = "Read"), + @OpenApi.OAuthScope(value = "read", description = "Read again") + }))) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.OAuthFlow on com.example.InvalidOpenApiEndpoint", + "cannot define scope read more than once"); + } + + @Test + void securityRequirementCannotDeclareDuplicateSchemes() { + var result = compile("openapi-duplicate-security-requirement-schemes", """ + @OpenApi.SecurityRequirement({ + @OpenApi.SecuritySchemeRequirement("bearerAuth"), + @OpenApi.SecuritySchemeRequirement("bearerAuth") + }) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityRequirement on com.example.InvalidOpenApiEndpoint", + "cannot define scheme bearerAuth more than once"); + } + + @Test + void securityRequirementCannotRepeatSameRequirement() { + var result = compile("openapi-duplicate-security-requirement", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityRequirement on com.example.InvalidOpenApiEndpoint", + "cannot define security requirement [bearerAuth] more than once"); + } + + @Test + void securityRequirementCannotRepeatSameRequirementInDifferentOrder() { + var result = compile("openapi-duplicate-security-requirement-order", """ + @OpenApi.SecurityRequirement({ + @OpenApi.SecuritySchemeRequirement("bearerAuth"), + @OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = {"write", "read"}) + }) + @OpenApi.SecurityRequirement({ + @OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = {"read", "write"}), + @OpenApi.SecuritySchemeRequirement("bearerAuth") + }) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityRequirement on com.example.InvalidOpenApiEndpoint", + "cannot define security requirement"); + } + + @Test + void securitySchemeRequirementCannotUseDuplicateScopes() { + var result = compile("openapi-duplicate-security-requirement-scopes", """ + @OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = {"read", "read"}) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecuritySchemeRequirement on com.example.InvalidOpenApiEndpoint" + + " for scheme oauth2", + "cannot define scope read more than once"); + } + + @Test + void securitySchemeRequirementCannotCombineWithSecurityRequirement() { + var result = compile("openapi-mixed-security-requirements", """ + @OpenApi.SecuritySchemeRequirement("bearerAuth") + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = "read")) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecuritySchemeRequirement on com.example.InvalidOpenApiEndpoint", + "cannot be combined with @OpenApi.SecurityRequirement or @OpenApi.SecurityRequirements"); + } + + @Test + void conflictingInheritedSecurityRequirementFormsAreRejected() { + var result = compile("openapi-inherited-mixed-security-requirements", """ + interface DirectSecurityApi { + @Http.GET + @OpenApi.SecuritySchemeRequirement("bearerAuth") + String get(); + } + + interface StructuredSecurityApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = "read")) + String get(); + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint implements DirectSecurityApi, StructuredSecurityApi { + @Override + public String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "Conflicting inherited OpenAPI security requirements on " + + "com.example.InvalidOpenApiEndpoint.get"); + } + + @Test + void equivalentInheritedSecurityRequirementFormsAreAccepted() throws IOException { + var result = compile("openapi-equivalent-inherited-mixed-security-requirements", """ + interface DirectSecurityApi { + @Http.GET + @OpenApi.SecuritySchemeRequirement("apiKey") + String get(); + } + + interface StructuredSecurityApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("apiKey")) + String get(); + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint implements DirectSecurityApi, StructuredSecurityApi { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"apiKey\", java.util.List.of())")); + } + + @Test + void securityRequirementScopesApplyOnlyToTheirSchemes() throws IOException { + var result = compile("openapi-security-requirement-scopes", """ + @OpenApi.SecurityRequirement({ + @OpenApi.SecuritySchemeRequirement("bearerAuth"), + @OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = "read") + }) + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class InvalidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"oauth2\", java.util.List.of(\"read\"))")); + } + + @Test + void methodSecurityRequirementOverridesInheritedRequirement() throws IOException { + var result = compile("openapi-method-security-requirement-overrides-inherited-requirement", """ + interface SecuredApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("oauth2")) + String get(); + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint implements SecuredApi { + @Override + @OpenApi.SecuritySchemeRequirement("bearerAuth") + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"oauth2\", java.util.List.of())"))); + } + + @Test + void directAndComposedMethodSecurityRequirementsArePreserved() throws IOException { + var result = compile("openapi-direct-and-composed-method-security-requirements", """ + @OpenApi.SecuritySchemeRequirement("meta") + @interface MetaAuth { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + @OpenApi.SecuritySchemeRequirement("direct") + @MetaAuth + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"direct\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"meta\", java.util.List.of())")); + } + + @Test + void directMethodSecurityRequirementOverridesComposedSecurityClear() throws IOException { + var result = compile("openapi-direct-method-security-requirement-overrides-composed-security-clear", """ + @OpenApi.SecurityRequirements({}) + @interface PublicEndpoint { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("admin")) + @PublicEndpoint + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"admin\", java.util.List.of())")); + } + + @Test + void composedMethodSecurityRequirementOverridesInheritedRequirements() throws IOException { + var result = compile("openapi-composed-method-security-requirement-overrides-inherited-requirements", """ + interface SecuredApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("contractOne")) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("contractTwo")) + String get(); + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @interface BearerAuth { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint implements SecuredApi { + @Override + @BearerAuth + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"contractOne\", java.util.List.of())"))); + assertThat(generated, not(containsString(".scheme(\"contractTwo\", java.util.List.of())"))); + } + + @Test + void multipleComposedMethodSecurityRequirementsArePreserved() throws IOException { + var result = compile("openapi-multiple-composed-method-security-requirements", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @interface BearerAuth { + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("oauth2")) + @interface OAuth2 { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + @BearerAuth + @OAuth2 + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"oauth2\", java.util.List.of())")); + } + + @Test + void composedSecurityRequirementsContainerAndStandaloneRequirementArePreserved() throws IOException { + var result = compile("openapi-composed-security-requirements-container-and-standalone-requirement", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("oauth2")) + @interface MultiAuth { + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("apiKey")) + @interface ApiKeyAuth { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + @MultiAuth + @ApiKeyAuth + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"oauth2\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"apiKey\", java.util.List.of())")); + } + + @Test + void multipleComposedSecurityRequirementsContainersArePreserved() throws IOException { + var result = compile("openapi-multiple-composed-security-requirements-containers", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("oauth2")) + @interface UserAuth { + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("apiKey")) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("admin")) + @interface ServiceAuth { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + @UserAuth + @ServiceAuth + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"oauth2\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"apiKey\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"admin\", java.util.List.of())")); + } + + @Test + void composedMethodSecurityRequirementCannotRepeatSameRequirement() { + var result = compile("openapi-duplicate-composed-method-security-requirement", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @interface BearerAuth { + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @interface AlternateBearerAuth { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @BearerAuth + @AlternateBearerAuth + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityRequirement on com.example.InvalidOpenApiEndpoint.get", + "cannot define security requirement [bearerAuth] more than once"); + } + + @Test + void inheritedComposedMethodSecuritySchemeRequirementCannotRepeatSameRequirement() { + var result = compile("openapi-duplicate-inherited-composed-method-security-scheme-requirement", """ + @OpenApi.SecuritySchemeRequirement("bearerAuth") + @interface BearerAuth { + } + + @OpenApi.SecuritySchemeRequirement("bearerAuth") + @interface AlternateBearerAuth { + } + + interface SecuredApi { + @Http.GET + @BearerAuth + @AlternateBearerAuth + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/invalid") + class InvalidOpenApiEndpoint implements SecuredApi { + @Override + public String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityRequirement on com.example.InvalidOpenApiEndpoint.get", + "cannot define security requirement [bearerAuth] more than once"); + } + + @Test + void composedMethodSecurityRequirementCannotRepeatThroughDiamond() { + var result = compile("openapi-duplicate-composed-method-security-requirement-diamond", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @interface BearerAuth { + } + + @BearerAuth + @interface UserAuth { + } + + @BearerAuth + @interface ServiceAuth { + } + + @UserAuth + @ServiceAuth + @interface CorporateAuth { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @CorporateAuth + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.SecurityRequirement on com.example.InvalidOpenApiEndpoint.get", + "cannot define security requirement [bearerAuth] more than once"); + } + + @Test + void recursivelyComposedMethodSecurityRequirementOverridesInheritedRequirements() throws IOException { + var result = compile("openapi-recursively-composed-method-security-requirement-overrides-inherited-requirements", """ + interface SecuredApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("contractOne")) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("contractTwo")) + String get(); + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @interface BearerAuth { + } + + @BearerAuth + @interface CorporateAuth { + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint implements SecuredApi { + @Override + @CorporateAuth + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"contractOne\", java.util.List.of())"))); + assertThat(generated, not(containsString(".scheme(\"contractTwo\", java.util.List.of())"))); + } + + @Test + void methodParameterCannotRepeatSameLocationAndName() { + var result = compile("openapi-duplicate-method-parameter", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Parameter(name = "${openapi.param:value}", in = "${openapi.in:query}", value = "First") + @OpenApi.Parameter(name = "value", in = "query", value = "Second") + String get(@Http.QueryParam("value") String value) { + return value; + } + } + """); + + assertCompilationFails(result, + "Method-level @OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot define parameter query value more than once"); + } + + @Test + void parameterAnnotationCannotRepeatOnSameParameter() { + var result = compile("openapi-duplicate-parameter-annotation", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter("First") + @OpenApi.Parameter("Second") + @Http.QueryParam("value") String value) { + return value; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot define metadata for query parameter value more than once"); + } + + @Test + void parameterExamplesCannotUseDuplicateNames() { + var result = compile("openapi-duplicate-parameter-examples", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(examples = { + @OpenApi.Example(name = "${openapi.example:sample}", value = "one"), + @OpenApi.Example(name = "sample", value = "two") + }) + @Http.QueryParam("value") String value) { + return value; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot define example sample more than once"); + } + + @Test + void requestBodyCannotDeclareDuplicateContentMediaTypes() { + var result = compile("openapi-duplicate-request-body-content", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.POST + @OpenApi.RequestBody(content = { + @OpenApi.Content("${openapi.content:application/json}"), + @OpenApi.Content("application/json") + }) + String post(@Http.Entity String value) { + return value; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.RequestBody on com.example.InvalidOpenApiEndpoint.post", + "cannot define content media type application/json more than once"); + } + + @Test + void responseCannotDeclareDuplicateContentMediaTypes() { + var result = compile("openapi-duplicate-response-content", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + content = { + @OpenApi.Content("${openapi.content:application/json}"), + @OpenApi.Content("application/json") + }) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "cannot define content media type application/json more than once"); + } + + @Test + void contentExamplesCannotUseDuplicateNames() { + var result = compile("openapi-duplicate-content-examples", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + content = @OpenApi.Content( + examples = { + @OpenApi.Example(name = "${openapi.example:sample}", value = "one"), + @OpenApi.Example(name = "sample", value = "two") + })) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "cannot define example sample more than once"); + } + + @Test + void contentExampleCannotUseValueWithDataValue() { + var result = compile("openapi-example-value-and-data-value", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + content = @OpenApi.Content( + examples = @OpenApi.Example(name = "sample", + value = "one", + dataValue = "two"))) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "example sample cannot define value with dataValue, serializedValue, or externalValue"); + } + + @Test + void contentExampleCannotUseValueWithExternalValue() { + var result = compile("openapi-example-value-and-external-value", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + content = @OpenApi.Content( + examples = @OpenApi.Example(name = "sample", + value = "one", + externalValue = "examples/sample.json"))) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "example sample cannot define value with dataValue, serializedValue, or externalValue"); + } + + @Test + void contentExampleCannotUseValueWithSerializedValue() { + var result = compile("openapi-example-value-and-serialized-value", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + content = @OpenApi.Content( + examples = @OpenApi.Example(name = "sample", + value = "one", + serializedValue = "two"))) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "example sample cannot define value with dataValue, serializedValue, or externalValue"); + } + + @Test + void contentExampleCannotUseSerializedValueWithExternalValue() { + var result = compile("openapi-example-serialized-value-and-external-value", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + content = @OpenApi.Content( + examples = @OpenApi.Example(name = "sample", + dataValue = "{\\"value\\":\\"one\\"}", + serializedValue = "{\\"value\\":\\"one\\"}", + externalValue = "examples/sample.json"))) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "example sample cannot define serializedValue and externalValue together"); + } + + @Test + void responseCannotDeclareDuplicateHeaderNames() { + var result = compile("openapi-duplicate-response-headers", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + headers = { + @OpenApi.Header(name = "X-Value", value = "First"), + @OpenApi.Header(name = "x-value", value = "Second") + }) + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "cannot define response header x-value more than once"); + } + + private static TestCompiler.Result compile(String workDir, String source) { + return TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/" + workDir)) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + %s + """.formatted(source)) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + } + + private static String generatedSource(TestCompiler.Result result) throws IOException { + StringBuilder generatedContent = new StringBuilder(); + var generatedSources = Files.walk(result.sourceOutput()) + .filter(it -> it.getFileName().toString().endsWith(".java")) + .toList(); + for (Path generatedSource : generatedSources) { + generatedContent.append(Files.readString(generatedSource, StandardCharsets.UTF_8)); + generatedContent.append('\n'); + } + return generatedContent.toString(); + } + + private static void assertCompilationFails(TestCompiler.Result result, String... diagnosticParts) { + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + for (String diagnosticPart : diagnosticParts) { + assertThat(diagnostics, containsString(diagnosticPart)); + } + } +} diff --git a/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiExplicitContentSchemaCodegenTest.java b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiExplicitContentSchemaCodegenTest.java new file mode 100644 index 00000000000..3c10901c90a --- /dev/null +++ b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiExplicitContentSchemaCodegenTest.java @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import io.helidon.codegen.apt.AptProcessor; +import io.helidon.codegen.testing.TestCompiler; +import io.helidon.common.Api; +import io.helidon.common.Default; +import io.helidon.common.Generated; +import io.helidon.common.GenericType; +import io.helidon.common.LazyValue; +import io.helidon.common.mapper.Mappers; +import io.helidon.common.parameters.Parameters; +import io.helidon.common.types.Annotation; +import io.helidon.common.uri.UriQuery; +import io.helidon.config.Config; +import io.helidon.http.Http; +import io.helidon.json.schema.spi.JsonSchemaProvider; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Dependency; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.HttpEntryPoint; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRoute; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.RestServer; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiExplicitContentSchemaCodegenTest { + private static final List> CLASSPATH = List.of( + Annotation.class, + Api.class, + Config.class, + Default.class, + Dependency.class, + Generated.class, + GenericType.class, + Handler.class, + Http.class, + HttpEntryPoint.class, + HttpFeature.class, + HttpRoute.class, + HttpRouting.class, + HttpRules.class, + LazyValue.class, + Mappers.class, + JsonSchemaProvider.class, + OpenApi.class, + Parameters.class, + RestServer.class, + ServerRequest.class, + ServerResponse.class, + Service.class, + ServiceDescriptor.class, + UriQuery.class, + WebServer.class + ); + + @Test + void explicitContentSchemaControlsCollectedParameterAndRequestBodySchemas() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-explicit-content-schema")) + .addSource("ExplicitSchemaEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/explicit") + class ExplicitSchemaEndpoint { + @Http.GET + @OpenApi.Parameter(name = "filter", + in = "query", + content = @OpenApi.Content(schema = MessageRequest.class)) + String get(@Http.QueryParam("filter") InternalPayload filter) { + return filter.value(); + } + + @Http.POST + @OpenApi.RequestBody(content = @OpenApi.Content(schema = MessageRequest.class)) + String post(@Http.Entity InternalPayload request) { + return request.value(); + } + } + + record InternalPayload(String value) { + } + + record MessageRequest(String prefix, String name) { + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString("class ExplicitSchemaEndpoint__OpenApiDocumentSource")); + assertThat(generated, containsString("class ExplicitSchemaEndpoint__OpenApiEndpointSource")); + assertThat(generated, containsString("OpenApiDocumentContextSupport.operationId(context, " + + "\"com.example.ExplicitSchemaEndpoint" + + "#get(com.example.InternalPayload)\", " + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"explicitSchemaGetGet\"))")); + assertThat(generated, containsString("@Service.NamedByType(ExplicitSchemaEndpoint.class)")); + assertThat(generated, not(containsString("@Service.NamedByType(OpenApi.Document.class)"))); + assertThat(generated, containsString("@Service.NamedByType(MessageRequest.class)")); + assertThat(generated, not(containsString("@Service.NamedByType(InternalPayload.class)"))); + assertThat(generated, containsString("content -> content.schema(schemaRef(\"MessageRequest\"))")); + } + + @Test + void itemSchemaSuppressesInferredResponseSchema() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-item-schema")) + .addSource("ItemSchemaEndpoint.java", """ + package com.example; + + import java.util.List; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/items") + class ItemSchemaEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + content = @OpenApi.Content(value = "application/json-seq", + itemSchema = Item.class)) + Envelope get() { + return new Envelope(List.of()); + } + } + + record Envelope(List items) { + } + + record Item(String value) { + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString("content -> content.itemSchema(schemaRef(\"Item\"))")); + assertThat(generated, not(containsString(".schema(schemaRef(\"Envelope\"))"))); + assertThat(generated, containsString("@Service.NamedByType(Item.class)")); + assertThat(generated, not(containsString("@Service.NamedByType(Envelope.class)"))); + } + + private static String generatedSource(TestCompiler.Result result) throws IOException { + StringBuilder generatedContent = new StringBuilder(); + var generatedSources = Files.walk(result.sourceOutput()) + .filter(it -> it.getFileName().toString().endsWith(".java")) + .toList(); + for (Path generatedSource : generatedSources) { + generatedContent.append(Files.readString(generatedSource, StandardCharsets.UTF_8)); + generatedContent.append('\n'); + } + return generatedContent.toString(); + } +} diff --git a/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiParameterRequirednessCodegenTest.java b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiParameterRequirednessCodegenTest.java new file mode 100644 index 00000000000..b67f3dcba22 --- /dev/null +++ b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiParameterRequirednessCodegenTest.java @@ -0,0 +1,398 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import io.helidon.codegen.apt.AptProcessor; +import io.helidon.codegen.testing.TestCompiler; +import io.helidon.common.Api; +import io.helidon.common.Default; +import io.helidon.common.Generated; +import io.helidon.common.GenericType; +import io.helidon.common.LazyValue; +import io.helidon.common.mapper.Mappers; +import io.helidon.common.parameters.Parameters; +import io.helidon.common.types.Annotation; +import io.helidon.common.uri.UriQuery; +import io.helidon.config.Config; +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Dependency; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.HttpEntryPoint; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRoute; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.RestServer; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiParameterRequirednessCodegenTest { + private static final List> CLASSPATH = List.of( + Annotation.class, + Api.class, + Config.class, + Default.class, + Dependency.class, + Generated.class, + GenericType.class, + Handler.class, + Http.class, + HttpEntryPoint.class, + HttpFeature.class, + HttpRoute.class, + HttpRouting.class, + HttpRules.class, + LazyValue.class, + Mappers.class, + OpenApi.class, + Parameters.class, + RestServer.class, + ServerRequest.class, + ServerResponse.class, + Service.class, + ServiceDescriptor.class, + UriQuery.class, + WebServer.class + ); + + @Test + void nonOptionalQueryParameterCannotBeDocumentedAsOptional() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-required-query")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(required = OpenApi.Required.FALSE) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + assertThat(diagnostics, containsString("@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get")); + assertThat(diagnostics, containsString("cannot make a required query parameter optional")); + } + + @Test + void nonOptionalQueryListParameterCannotBeDocumentedAsOptional() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-required-query-list")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import java.util.List; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(required = OpenApi.Required.FALSE) + @Http.QueryParam("include") List include) { + return include.toString(); + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + assertThat(diagnostics, containsString("@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get")); + assertThat(diagnostics, containsString("cannot make a required query parameter optional")); + } + + @Test + void nonOptionalHeaderParameterCannotBeDocumentedAsOptional() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-required-header")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(required = OpenApi.Required.FALSE) + @Http.HeaderParam("X-Value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + assertThat(diagnostics, containsString("@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get")); + assertThat(diagnostics, containsString("cannot make a required header parameter optional")); + } + + @Test + void defaultedQueryParameterCanBeDocumentedAsOptional() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-default-query")) + .addSource("DefaultOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.common.Default; + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/default") + class DefaultOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(required = OpenApi.Required.FALSE) + @Http.QueryParam("limit") @Default.Value("13") Integer limit) { + return Integer.toString(limit); + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + } + + @Test + void nonOptionalEntityCannotBeDocumentedAsOptional() { + var result = compileRequestBody("openapi-required-entity", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.POST + @OpenApi.RequestBody(required = OpenApi.Required.FALSE) + String post(@Http.Entity String value) { + return value; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + assertThat(diagnostics, containsString("@OpenApi.RequestBody on com.example.InvalidOpenApiEndpoint.post")); + assertThat(diagnostics, containsString("cannot make required @Http.Entity parameter optional")); + } + + @Test + void requiredFormParameterCannotBeDocumentedAsOptionalBody() { + var result = compileRequestBody("openapi-required-form", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.POST + @OpenApi.RequestBody(required = OpenApi.Required.FALSE) + String post(@Http.FormParam("value") String value) { + return value; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + assertThat(diagnostics, containsString("@OpenApi.RequestBody on com.example.InvalidOpenApiEndpoint.post")); + assertThat(diagnostics, containsString("cannot make required @Http.FormParam parameters optional")); + } + + @Test + void optionalRequestBindingsCanBeDocumentedAsOptional() throws IOException { + var result = compileRequestBody("openapi-optional-request-bindings", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/optional") + class OptionalOpenApiEndpoint { + @Http.POST + @Http.Path("/entity") + @OpenApi.RequestBody(required = OpenApi.Required.FALSE) + String entity(@Http.Entity Optional value) { + return value.orElse("none"); + } + + @Http.POST + @Http.Path("/form") + @OpenApi.RequestBody(required = OpenApi.Required.FALSE) + String form(@Http.FormParam("value") Optional value) { + return value.orElse("none"); + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".required(false)")); + assertThat(generated, containsString("req.content().asOptional(String.class)")); + assertThat(generated, not(containsString("Entity value is not present in the request."))); + } + + private static TestCompiler.Result compileRequestBody(String workDir, String endpointSource) { + return TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/" + workDir)) + .addSource("OpenApiEndpoint.java", """ + package com.example; + + import java.util.Optional; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + %s + """.formatted(endpointSource)) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + } + + private static String generatedSource(TestCompiler.Result result) throws IOException { + StringBuilder generatedContent = new StringBuilder(); + try (var sources = Files.walk(result.sourceOutput())) { + for (Path generatedSource : sources.filter(it -> it.getFileName().toString().endsWith(".java")).toList()) { + generatedContent.append(Files.readString(generatedSource)); + generatedContent.append('\n'); + } + } + return generatedContent.toString(); + } +} diff --git a/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiParameterSerializationCodegenTest.java b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiParameterSerializationCodegenTest.java new file mode 100644 index 00000000000..7e44b44d8a3 --- /dev/null +++ b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiParameterSerializationCodegenTest.java @@ -0,0 +1,1257 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import io.helidon.codegen.apt.AptProcessor; +import io.helidon.codegen.testing.TestCompiler; +import io.helidon.common.Api; +import io.helidon.common.Default; +import io.helidon.common.Generated; +import io.helidon.common.GenericType; +import io.helidon.common.LazyValue; +import io.helidon.common.mapper.Mappers; +import io.helidon.common.parameters.Parameters; +import io.helidon.common.types.Annotation; +import io.helidon.common.uri.UriQuery; +import io.helidon.config.Config; +import io.helidon.http.Http; +import io.helidon.json.schema.spi.JsonSchemaProvider; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Dependency; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.HttpEntryPoint; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRoute; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.RestServer; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiParameterSerializationCodegenTest { + private static final List> CLASSPATH = List.of( + Annotation.class, + Api.class, + Config.class, + Default.class, + Dependency.class, + Generated.class, + GenericType.class, + Handler.class, + Http.class, + HttpEntryPoint.class, + HttpFeature.class, + HttpRoute.class, + HttpRouting.class, + HttpRules.class, + LazyValue.class, + Mappers.class, + JsonSchemaProvider.class, + OpenApi.class, + Parameters.class, + RestServer.class, + ServerRequest.class, + ServerResponse.class, + Service.class, + ServiceDescriptor.class, + UriQuery.class, + WebServer.class + ); + + @Test + void queryParameterCannotOverrideLocation() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-location-override")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(in = "header") + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot document a query parameter as header"); + } + + @Test + void queryParameterCannotOverrideName() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-name-override")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(name = "documented") + @Http.QueryParam("actual") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot document a query parameter named actual as documented"); + } + + @Test + void queryParameterCannotUseExampleAndExamples() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-example-and-examples")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(example = "one", + examples = @OpenApi.Example(name = "two", + value = "two")) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot define both example and examples for query parameter value"); + } + + @Test + void queryParameterCannotUseContentAndStyle() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-content-and-style")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(content = @OpenApi.Content, + style = OpenApi.Style.FORM) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot define style when content is defined for query parameter value"); + } + + @Test + void queryParameterCannotUseContentAndExplode() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-content-and-explode")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(content = @OpenApi.Content, + explode = OpenApi.Explode.FALSE) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot define explode when content is defined for query parameter value"); + } + + @Test + void queryParameterCannotUseMultipleContentEntries() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-multiple-content")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(content = {@OpenApi.Content("application/json"), + @OpenApi.Content("text/plain")}) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot define more than one content entry for query parameter value"); + } + + @Test + void headerParameterCannotUseQueryStyle() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-header-query-style")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import java.util.List; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(style = OpenApi.Style.PIPE_DELIMITED) + @Http.HeaderParam("X-Value") List values) { + return values.toString(); + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot use pipeDelimited style for a header parameter"); + } + + @Test + void scalarQueryParameterCannotUseArrayStyle() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-scalar-query-array-style")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(style = OpenApi.Style.PIPE_DELIMITED) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot use pipeDelimited style for a scalar query parameter"); + } + + @Test + void pathParameterCannotUseQueryStyle() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-path-query-style")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/{id}") + String get(@OpenApi.Parameter(style = OpenApi.Style.FORM) + @Http.PathParam("id") String id) { + return id; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot use form style for a path parameter"); + } + + @Test + void pathParameterCannotUseAllowReserved() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-path-allow-reserved")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/{id}") + String get(@OpenApi.Parameter(allowReserved = true) + @Http.PathParam("id") String id) { + return id; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot use allowReserved=true for a path parameter"); + } + + @Test + void listQueryParameterCannotUseDelimitedStyleWithExplodeTrue() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-list-query-delimited-explode")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import java.util.List; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(style = OpenApi.Style.PIPE_DELIMITED, + explode = OpenApi.Explode.TRUE) + @Http.QueryParam("value") List values) { + return values.toString(); + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot use explode=true with pipeDelimited style for a query parameter"); + } + + @Test + void headerParameterCannotUseExplodeTrue() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-header-explode")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(explode = OpenApi.Explode.TRUE) + @Http.HeaderParam("X-Value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot use explode=true for a header parameter"); + } + + @Test + void headerParameterCannotUseAllowReserved() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-header-allow-reserved")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(allowReserved = true) + @Http.HeaderParam("X-Value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Parameter on com.example.InvalidOpenApiEndpoint.get", + "cannot use allowReserved=true for a header parameter"); + } + + @Test + void queryParameterCanUseAllowReserved() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-allow-reserved")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(allowReserved = true) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + } + + @Test + void queryParameterCanUseMatchingName() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-matching-name")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(name = "value") + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + } + + @Test + void generatedParameterUsesValidatedStaticNameAndLocation() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-static-name-location")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + @OpenApi.Parameter(name = "${openapi.param:value}", + in = "${openapi.in:query}", + examples = @OpenApi.Example( + name = "${openapi.example:sample}", + value = "one")) + String get(@Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".parameter(parameter -> parameter.name(\"value\")")); + assertThat(generated, containsString(".in(\"query\")")); + assertThat(generated, containsString(".example(\"sample\", ")); + } + + @Test + void requestParamsRecordComponentsAreGeneratedAsOpenApiParameters() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-request-params-components")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.PUT + @Http.Path("/{id}") + @OpenApi.Parameter(name = "q", in = "query", value = "Search query") + @OpenApi.RequestBody("Body doc") + String put(@Http.RequestParams Params params) { + return params.id() + params.query() + params.trace() + params.body().value(); + } + } + + record Params(@Http.PathParam("id") String id, + @Http.QueryParam("q") String query, + @Http.HeaderParam("X-Trace") String trace, + @Http.Entity MessageRequest body) { + } + + record MessageRequest(String value) { + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString("document.path(\"/valid/{id}\"")); + assertThat(generated, containsString(".parameter(parameter -> parameter.name(\"id\")")); + assertThat(generated, containsString(".in(\"path\")")); + assertThat(generated, containsString(".parameter(parameter -> parameter.name(\"q\")")); + assertThat(generated, containsString("Search query")); + assertThat(generated, containsString(".parameter(parameter -> parameter.name(\"X-Trace\")")); + assertThat(generated, containsString(".in(\"header\")")); + assertThat(generated, containsString(".requestBody(requestBody -> requestBody")); + assertThat(generated, containsString("Body doc")); + assertThat(generated, containsString("NamedByType(MessageRequest.class)")); + assertThat(generated, containsString(".content(\"application/json\", content -> content.schema(schemaRef(")); + assertThat(generated, not(containsString("NamedByType(Params.class)"))); + } + + @Test + void cookieAndFormParametersAreGeneratedAsOpenApiInputs() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-cookie-form-params")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import java.util.List; + import java.util.Optional; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.POST + @OpenApi.Parameter(name = "session", + in = "cookie", + value = "Session cookie", + style = OpenApi.Style.COOKIE) + @OpenApi.RequestBody(value = "Form doc", + content = @OpenApi.Content( + value = "application/x-www-form-urlencoded", + examples = @OpenApi.Example(name = "form-example", + value = "{\\"first\\":\\"one\\"}"))) + String post(@Http.CookieParam("session") String session, + @OpenApi.Parameter(style = OpenApi.Style.FORM) + @Http.CookieParam("legacy") String legacy, + @Http.FormParam("first") String first, + @Http.RequestParams Params params) { + return session + legacy + first + params.second() + params.tags(); + } + } + + record Params(@Http.CookieParam("tracking") Optional tracking, + @Http.FormParam("second") String second, + @Http.FormParam("tags") Optional> tags) { + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".parameter(parameter -> parameter.name(\"session\")")); + assertThat(generated, containsString("Session cookie")); + assertThat(generated, containsString(".in(\"cookie\")")); + assertThat(generated, containsString(".parameter(parameter -> parameter.name(\"tracking\")")); + assertThat(generated, containsString(".parameter(parameter -> parameter.name(\"legacy\")")); + String cookieStyle = ".style(\"3.2\".equals(context.openApiVersion().type()) ? \"cookie\" : \"form\")"; + int explicitCookieStyle = generated.indexOf(cookieStyle); + int inferredCookieStyle = generated.indexOf(cookieStyle, explicitCookieStyle + 1); + assertThat("explicit cookie style", explicitCookieStyle, not(is(-1))); + assertThat("inferred cookie style", inferredCookieStyle, not(is(-1))); + assertThat(generated.indexOf(cookieStyle, inferredCookieStyle + 1), is(-1)); + assertThat(generated, + containsString(".style(io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"form\"))")); + assertThat(generated, containsString(".content(\"application/x-www-form-urlencoded\", content -> content.schema(")); + assertThat(generated, containsString(".set(\"properties\", properties -> properties")); + assertThat(generated, containsString(".set(\"first\", schema(\"string\"))")); + assertThat(generated, containsString(".set(\"second\", schema(\"string\"))")); + assertThat(generated, containsString(".set(\"tags\", arraySchema(schema(\"string\")))")); + assertThat(generated, containsString(".setStrings(\"required\", java.util.List.of(\"first\", \"second\"))")); + assertThat(generated, containsString(".example(\"form-example\", ")); + assertThat(generated, not(containsString("NamedByType(Params.class)"))); + } + + @Test + void duplicateCookieParametersAreRejected() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-duplicate-cookie")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + String get(@Http.CookieParam("session") String session, + @Http.RequestParams Params params) { + return session + params.duplicate(); + } + } + + record Params(@Http.CookieParam("session") String duplicate) { + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "Generated OpenAPI parameters on com.example.InvalidOpenApiEndpoint.get", + "cannot define cookie parameter session more than once"); + } + + @Test + void duplicateFormParametersAreRejected() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-duplicate-form")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.POST + @Http.Consumes("application/x-www-form-urlencoded") + String post(@Http.FormParam("field") String first, + @Http.RequestParams Params params) { + return first + params.second(); + } + } + + record Params(@Http.FormParam("field") String second) { + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "Generated OpenAPI form request body on com.example.InvalidOpenApiEndpoint.post", + "cannot define form field field more than once"); + } + + @Test + void formParametersRequireCompatibleConsumes() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-form-json-consumes")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.POST + @Http.Consumes("application/json") + String post(@Http.FormParam("field") String field) { + return field; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "Generated OpenAPI form request body on com.example.InvalidOpenApiEndpoint.post", + "requires @Http.Consumes(\"application/x-www-form-urlencoded\")"); + } + + @Test + void queryParameterCanUseContent() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-query-content")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(content = @OpenApi.Content) + @Http.QueryParam("value") String value) { + return value; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + } + + @Test + void listQueryParameterCanUseDelimitedStyle() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-list-query-delimited-style")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import java.util.List; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter(style = OpenApi.Style.PIPE_DELIMITED, + explode = OpenApi.Explode.FALSE) + @Http.QueryParam("value") List values) { + return values.toString(); + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + } + + private static void assertCompilationFails(TestCompiler.Result result, String... diagnosticParts) { + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + for (String diagnosticPart : diagnosticParts) { + assertThat(diagnostics, containsString(diagnosticPart)); + } + } + + private static String generatedSource(TestCompiler.Result result) throws IOException { + StringBuilder generatedContent = new StringBuilder(); + var generatedSources = Files.walk(result.sourceOutput()) + .filter(it -> it.getFileName().toString().endsWith(".java")) + .toList(); + for (Path generatedSource : generatedSources) { + generatedContent.append(Files.readString(generatedSource)); + generatedContent.append('\n'); + } + return generatedContent.toString(); + } +} diff --git a/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiPathCodegenTest.java b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiPathCodegenTest.java new file mode 100644 index 00000000000..17da094f149 --- /dev/null +++ b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiPathCodegenTest.java @@ -0,0 +1,1457 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import io.helidon.codegen.apt.AptProcessor; +import io.helidon.codegen.testing.TestCompiler; +import io.helidon.common.Api; +import io.helidon.common.Default; +import io.helidon.common.Generated; +import io.helidon.common.GenericType; +import io.helidon.common.LazyValue; +import io.helidon.common.mapper.Mappers; +import io.helidon.common.parameters.Parameters; +import io.helidon.common.types.Annotation; +import io.helidon.common.uri.UriQuery; +import io.helidon.config.Config; +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Dependency; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.HttpEntryPoint; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRoute; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.RestServer; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiPathCodegenTest { + private static final List> CLASSPATH = List.of( + Annotation.class, + Api.class, + Config.class, + Default.class, + Dependency.class, + Generated.class, + GenericType.class, + Handler.class, + Http.class, + HttpEntryPoint.class, + HttpFeature.class, + HttpRoute.class, + HttpRouting.class, + HttpRules.class, + LazyValue.class, + Mappers.class, + OpenApi.class, + Parameters.class, + RestServer.class, + ServerRequest.class, + ServerResponse.class, + Service.class, + ServiceDescriptor.class, + UriQuery.class, + WebServer.class + ); + + @TempDir + private Path workDirRoot; + + @Test + void openApiAnnotationOnMethodTriggersEndpointGeneration() throws IOException { + var result = compile("openapi-operation-endpoint", """ + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/cross-module") + class CrossModuleEndpoint { + @Http.GET + @OpenApi.Operation + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/cross-module\"")); + } + + @Test + void endpointMarkerGeneratesMetadataFromSignature() throws IOException { + var result = compile("openapi-marker-endpoint", """ + @OpenApi.Endpoint + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/defaults") + class DefaultOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/defaults\"")); + } + + @Test + void endpointMarkerIsInheritedWithEndpointContract() throws IOException { + var result = compile("inherited-openapi-marker-endpoint", """ + @OpenApi.Endpoint + @RestServer.Endpoint + interface OpenApiEndpointContract { + @Http.GET + String get(); + } + + @Service.Singleton + @Http.Path("/inherited") + class InheritedOpenApiEndpoint implements OpenApiEndpointContract { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/inherited\"")); + } + + @Test + void methodAnnotationOnEndpointContractTriggersGeneration() throws IOException { + var result = compile("contract-method-openapi-endpoint", """ + @RestServer.Endpoint + interface OpenApiEndpointContract { + @Http.GET + @OpenApi.Operation + String get(); + } + + @Service.Singleton + @Http.Path("/contract-method") + class ContractMethodOpenApiEndpoint implements OpenApiEndpointContract { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/contract-method\"")); + } + + @Test + void securityAnnotationOnEndpointContractTriggersGeneration() throws IOException { + var result = compile("contract-security-openapi-endpoint", """ + @RestServer.Endpoint + @OpenApi.SecuritySchemeRequirement("bearerAuth") + interface SecuredOpenApiEndpointContract { + @Http.GET + String get(); + } + + @Service.Singleton + @Http.Path("/contract-security") + class ContractSecurityOpenApiEndpoint implements SecuredOpenApiEndpointContract { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString("document.path(\"/contract-security\"")); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + } + + @Test + void securityAnnotationsOnUnrelatedEndpointContractsArePreserved() throws IOException { + for (String interfaces : List.of("FirstApi, SecondApi", "SecondApi, FirstApi")) { + var result = compile("unrelated-endpoint-security-" + + interfaces.substring(0, interfaces.indexOf(',')), """ + @OpenApi.SecuritySchemeRequirement("firstAuth") + interface FirstApi { + } + + @OpenApi.SecuritySchemeRequirement("secondAuth") + interface SecondApi { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/unrelated-endpoint-security") + class SecuredEndpoint implements %s { + @Http.GET + public String get() { + return "ok"; + } + } + """.formatted(interfaces)); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String first = ".scheme(\"firstAuth\", java.util.List.of())"; + assertThat(generated, containsString(first)); + assertThat(generated.lastIndexOf(first), is(generated.indexOf(first))); + String second = ".scheme(\"secondAuth\", java.util.List.of())"; + assertThat(generated, containsString(second)); + assertThat(generated.lastIndexOf(second), is(generated.indexOf(second))); + } + } + + @Test + void mixedSecurityAnnotationFormsOnUnrelatedEndpointContractsArePreserved() throws IOException { + for (String interfaces : List.of("FirstApi, SecondApi", "SecondApi, FirstApi")) { + var result = compile("mixed-unrelated-endpoint-security-" + + interfaces.substring(0, interfaces.indexOf(',')), """ + @OpenApi.SecuritySchemeRequirement("firstAuth") + interface FirstApi { + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("secondAuth")) + interface SecondApi { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/mixed-unrelated-endpoint-security") + class SecuredEndpoint implements %s { + @Http.GET + public String get() { + return "ok"; + } + } + """.formatted(interfaces)); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String first = ".scheme(\"firstAuth\", java.util.List.of())"; + assertThat(generated, containsString(first)); + assertThat(generated.lastIndexOf(first), is(generated.indexOf(first))); + String second = ".scheme(\"secondAuth\", java.util.List.of())"; + assertThat(generated, containsString(second)); + assertThat(generated.lastIndexOf(second), is(generated.indexOf(second))); + } + } + + @Test + void equivalentInheritedEndpointSecurityRequirementFormsAreAccepted() throws IOException { + for (String interfaces : List.of("DirectSecurityContract, StructuredSecurityContract", + "StructuredSecurityContract, DirectSecurityContract")) { + var result = compile("equivalent-inherited-endpoint-security-" + + interfaces.substring(0, interfaces.indexOf(',')), """ + @OpenApi.SecuritySchemeRequirement("apiKey") + interface DirectSecurityContract { + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("apiKey")) + interface StructuredSecurityContract { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/equivalent-endpoint-security") + class SecuredEndpoint implements %s { + @Http.GET + String get() { + return "ok"; + } + } + """.formatted(interfaces)); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String requirement = ".scheme(\"apiKey\", java.util.List.of())"; + assertThat(generated, containsString(requirement)); + assertThat(generated.lastIndexOf(requirement), is(generated.indexOf(requirement))); + } + } + + @Test + void conflictingInheritedEndpointSecurityClearFails() { + for (String interfaces : List.of("PublicContract, SecuredContract", "SecuredContract, PublicContract")) { + var result = compile("conflicting-inherited-endpoint-security-clear-" + + interfaces.substring(0, interfaces.indexOf(',')), """ + @OpenApi.SecurityRequirements({}) + interface PublicContract { + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("requiredAuth")) + interface SecuredContract { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/conflicting-endpoint-security") + class ConflictingSecurityEndpoint implements %s { + @Http.GET + String get() { + return "ok"; + } + } + """.formatted(interfaces)); + + assertCompilationFails(result, + "Conflicting inherited OpenAPI security requirements on " + + "com.example.ConflictingSecurityEndpoint"); + } + } + + @Test + void identicalInheritedEndpointSecurityClearsAreDeduplicated() throws IOException { + for (String interfaces : List.of("FirstPublicContract, SecondPublicContract", + "SecondPublicContract, FirstPublicContract")) { + var result = compile("identical-inherited-endpoint-security-clears-" + + interfaces.substring(0, interfaces.indexOf(',')), """ + @OpenApi.SecurityRequirements({}) + interface FirstPublicContract { + } + + @OpenApi.SecurityRequirements({}) + interface SecondPublicContract { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/public-endpoint") + class PublicEndpoint implements %s { + @Http.GET + String get() { + return "ok"; + } + } + """.formatted(interfaces)); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String securityClear = ".security(java.util.List.of())"; + assertThat(generated, containsString(securityClear)); + assertThat(generated.lastIndexOf(securityClear), is(generated.indexOf(securityClear))); + } + } + + @Test + void overridingEndpointContractSecurityRequirementReplacesBaseRequirement() throws IOException { + var result = compile("overriding-endpoint-contract-security", """ + @OpenApi.SecuritySchemeRequirement("baseAuth") + interface BaseApi { + } + + @OpenApi.SecuritySchemeRequirement("narrowedAuth") + interface NarrowedApi extends BaseApi { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/overriding-endpoint-security") + class SecuredEndpoint implements NarrowedApi { + @Http.GET + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"narrowedAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"baseAuth\", java.util.List.of())"))); + } + + @Test + void unannotatedEndpointContractInheritsBaseSecurityRequirement() throws IOException { + var result = compile("unannotated-endpoint-contract-security", """ + @OpenApi.SecuritySchemeRequirement("baseAuth") + interface BaseApi { + } + + interface NarrowedApi extends BaseApi { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/inherited-endpoint-security") + class SecuredEndpoint implements NarrowedApi { + @Http.GET + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString(".scheme(\"baseAuth\", java.util.List.of())")); + } + + @Test + void composedEndpointSecurityRequirementOverridesInheritedRequirements() throws IOException { + var result = compile("endpoint-security-overrides-inherited-requirements", """ + @RestServer.Endpoint + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("contractOne")) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("contractTwo")) + interface SecuredOpenApiEndpointContract { + @Http.GET + String get(); + } + + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) + @interface BearerAuth { + } + + @Service.Singleton + @Http.Path("/endpoint-security-override") + @BearerAuth + class ContractSecurityOpenApiEndpoint implements SecuredOpenApiEndpointContract { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString("document.path(\"/endpoint-security-override\"")); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"contractOne\", java.util.List.of())"))); + assertThat(generated, not(containsString(".scheme(\"contractTwo\", java.util.List.of())"))); + } + + @Test + void directAndComposedEndpointSecurityRequirementsArePreserved() throws IOException { + var result = compile("direct-and-composed-endpoint-security-requirements", """ + @OpenApi.SecuritySchemeRequirement("meta") + @interface MetaAuth { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/mixed-endpoint-security") + @OpenApi.SecuritySchemeRequirement("direct") + @MetaAuth + class MixedEndpointSecurityEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String direct = ".scheme(\"direct\", java.util.List.of())"; + assertThat(generated, containsString(direct)); + assertThat(generated.lastIndexOf(direct), is(generated.indexOf(direct))); + String meta = ".scheme(\"meta\", java.util.List.of())"; + assertThat(generated, containsString(meta)); + assertThat(generated.lastIndexOf(meta), is(generated.indexOf(meta))); + } + + @Test + void directEndpointSecurityClearOverridesComposedRequirement() throws IOException { + var result = compile("direct-endpoint-security-clear", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("metaAuth")) + @interface Secured { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @OpenApi.SecurityRequirements({}) + @Secured + @Http.Path("/public-endpoint") + class PublicEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".security(java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"metaAuth\", java.util.List.of())"))); + } + + @Test + void composedMethodSecuritySchemeRequirementsAreAllPreserved() throws IOException { + var result = compile("composed-method-security-scheme-requirements", """ + @OpenApi.SecuritySchemeRequirement("bearerAuth") + @interface BearerAuth { + } + + @OpenApi.SecuritySchemeRequirement("apiKey") + @interface ApiKeyAuth { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/composed-method-security") + class ComposedMethodSecurityEndpoint { + @Http.GET + @BearerAuth + @ApiKeyAuth + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"apiKey\", java.util.List.of())")); + } + + @Test + void directMethodSecurityClearsOverrideComposedRequirements() throws IOException { + var result = compile("direct-method-security-clears", """ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("structuredAuth")) + @interface StructuredAuth { + } + + @OpenApi.SecuritySchemeRequirement("schemeAuth") + @interface SchemeAuth { + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/public-methods") + class PublicMethodsEndpoint { + @Http.GET + @OpenApi.SecurityRequirements({}) + @StructuredAuth + String get() { + return "ok"; + } + + @Http.POST + @OpenApi.SecurityRequirements({}) + @SchemeAuth + String post() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String securityClear = ".security(java.util.List.of())"; + int firstClear = generated.indexOf(securityClear); + assertThat(firstClear, not(is(-1))); + assertThat(generated.indexOf(securityClear, firstClear + 1), not(is(-1))); + assertThat(generated, not(containsString(".scheme(\"structuredAuth\", java.util.List.of())"))); + assertThat(generated, not(containsString(".scheme(\"schemeAuth\", java.util.List.of())"))); + } + + @Test + void inheritedComposedMethodSecuritySchemeRequirementsAreAllPreserved() throws IOException { + var result = compile("inherited-composed-method-security-scheme-requirements", """ + @OpenApi.SecuritySchemeRequirement("bearerAuth") + @interface BearerAuth { + } + + @OpenApi.SecuritySchemeRequirement("apiKey") + @interface ApiKeyAuth { + } + + interface SecuredApi { + @Http.GET + @BearerAuth + @ApiKeyAuth + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/inherited-composed-method-security") + class InheritedComposedMethodSecurityEndpoint implements SecuredApi { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"bearerAuth\", java.util.List.of())")); + assertThat(generated, containsString(".scheme(\"apiKey\", java.util.List.of())")); + } + + @Test + void conflictingInheritedMethodSecuritySchemeRequirementsFail() { + var result = compile("conflicting-inherited-method-security-scheme-requirements", """ + interface FirstApi { + @Http.GET + @OpenApi.SecuritySchemeRequirement("firstAuth") + String get(); + } + + interface SecondApi { + @Http.GET + @OpenApi.SecuritySchemeRequirement("secondAuth") + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/conflicting-inherited-security") + class ConflictingSecurityEndpoint implements FirstApi, SecondApi { + @Override + public String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "Conflicting inherited @OpenApi.SecuritySchemeRequirement annotations", + "com.example.ConflictingSecurityEndpoint.get"); + } + + @Test + void overridingInheritedMethodSecurityRequirementReplacesBaseRequirement() throws IOException { + var result = compile("overriding-inherited-method-security-requirement", """ + interface BaseApi { + @Http.GET + @OpenApi.SecuritySchemeRequirement("baseAuth") + String get(); + } + + interface NarrowedApi extends BaseApi { + @Override + @OpenApi.SecuritySchemeRequirement("narrowedAuth") + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/overriding-inherited-security") + class NarrowedSecurityEndpoint implements NarrowedApi { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"narrowedAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"baseAuth\", java.util.List.of())"))); + } + + @Test + void conflictingInheritedMethodSecurityRequirementsFail() { + for (String interfaces : List.of("FirstApi, SecondApi", "SecondApi, FirstApi")) { + var result = compile("conflicting-inherited-method-security-requirements-" + + interfaces.substring(0, interfaces.indexOf(',')), """ + interface FirstApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("firstAuth")) + String get(); + } + + interface SecondApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("secondAuth")) + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/conflicting-inherited-security") + class ConflictingSecurityEndpoint implements %s { + @Override + public String get() { + return "ok"; + } + } + """.formatted(interfaces)); + + assertCompilationFails(result, + "Conflicting inherited OpenAPI security requirements", + "com.example.ConflictingSecurityEndpoint.get"); + } + } + + @Test + void conflictingInheritedMethodSecurityRequirementContainersFail() { + var result = compile("conflicting-inherited-method-security-requirement-containers", """ + interface FirstApi { + @Http.GET + @OpenApi.SecurityRequirements({ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("firstAuth")) + }) + String get(); + } + + interface SecondApi { + @Http.GET + @OpenApi.SecurityRequirements({ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("secondAuth")) + }) + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/conflicting-inherited-security") + class ConflictingSecurityEndpoint implements FirstApi, SecondApi { + @Override + public String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "Conflicting inherited OpenAPI security requirements", + "com.example.ConflictingSecurityEndpoint.get"); + } + + @Test + void identicalInheritedMethodSecuritySchemeRequirementsAreDeduplicated() throws IOException { + var result = compile("identical-inherited-method-security-scheme-requirements", """ + @OpenApi.SecuritySchemeRequirement("sharedAuth") + @interface SharedAuth { + } + + @OpenApi.SecuritySchemeRequirement("apiKey") + @interface ApiKeyAuth { + } + + interface FirstApi { + @Http.GET + @SharedAuth + @ApiKeyAuth + String get(); + } + + interface SecondApi { + @Http.GET + @ApiKeyAuth + @SharedAuth + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/identical-inherited-security") + class SharedSecurityEndpoint implements FirstApi, SecondApi { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String scheme = ".scheme(\"sharedAuth\", java.util.List.of())"; + assertThat(generated, containsString(scheme)); + assertThat(generated.lastIndexOf(scheme), is(generated.indexOf(scheme))); + String apiKey = ".scheme(\"apiKey\", java.util.List.of())"; + assertThat(generated, containsString(apiKey)); + assertThat(generated.lastIndexOf(apiKey), is(generated.indexOf(apiKey))); + } + + @Test + void identicalInheritedMethodSecurityRequirementsAreDeduplicated() throws IOException { + var result = compile("identical-inherited-method-security-requirements", """ + interface FirstApi { + @Http.GET + @OpenApi.SecurityRequirement( + @OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = {"read", "write"})) + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("apiKey")) + String get(); + } + + interface SecondApi { + @Http.GET + @OpenApi.SecurityRequirements({ + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("apiKey")), + @OpenApi.SecurityRequirement( + @OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = {"write", "read"})) + }) + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/identical-inherited-security") + class SharedSecurityEndpoint implements FirstApi, SecondApi { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + String oauth2 = ".scheme(\"oauth2\", java.util.List.of(\"read\", \"write\"))"; + assertThat(generated, containsString(oauth2)); + assertThat(generated.lastIndexOf(oauth2), is(generated.indexOf(oauth2))); + String apiKey = ".scheme(\"apiKey\", java.util.List.of())"; + assertThat(generated, containsString(apiKey)); + assertThat(generated.lastIndexOf(apiKey), is(generated.indexOf(apiKey))); + } + + @Test + void concreteMethodSecurityRequirementOverridesConflictingInheritedRequirements() throws IOException { + var result = compile("concrete-security-overrides-conflicting-inherited-requirements", """ + interface FirstApi { + @Http.GET + @OpenApi.SecuritySchemeRequirement("firstAuth") + String get(); + } + + interface SecondApi { + @Http.GET + @OpenApi.SecuritySchemeRequirement("secondAuth") + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/concrete-security-override") + class ConcreteSecurityEndpoint implements FirstApi, SecondApi { + @Override + @OpenApi.SecuritySchemeRequirement("methodAuth") + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"methodAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"firstAuth\", java.util.List.of())"))); + assertThat(generated, not(containsString(".scheme(\"secondAuth\", java.util.List.of())"))); + } + + @Test + void concreteStructuredSecurityRequirementOverridesConflictingInheritedRequirements() throws IOException { + var result = compile("concrete-structured-security-overrides-conflicting-inherited-requirements", """ + interface FirstApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("firstAuth")) + String get(); + } + + interface SecondApi { + @Http.GET + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("secondAuth")) + String get(); + } + + @RestServer.Endpoint + @Service.Singleton + @OpenApi.Endpoint + @Http.Path("/concrete-structured-security-override") + class ConcreteSecurityEndpoint implements FirstApi, SecondApi { + @Override + @OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("methodAuth")) + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".scheme(\"methodAuth\", java.util.List.of())")); + assertThat(generated, not(containsString(".scheme(\"firstAuth\", java.util.List.of())"))); + assertThat(generated, not(containsString(".scheme(\"secondAuth\", java.util.List.of())"))); + } + + @Test + void hiddenAnnotationOnEndpointContractHidesImplementation() throws IOException { + var result = compile("contract-hidden-openapi-endpoint", """ + @OpenApi.Endpoint + @OpenApi.Hidden + @RestServer.Endpoint + interface HiddenOpenApiEndpointContract { + @Http.GET + String get(); + } + + @Service.Singleton + @Http.Path("/contract-hidden") + class ContractHiddenOpenApiEndpoint implements HiddenOpenApiEndpointContract { + @Override + public String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(Files.exists(result.sourceOutput() + .resolve("com/example/ContractHiddenOpenApiEndpoint__OpenApiEndpointSource.java")), + is(false)); + } + + @Test + void unannotatedEndpointDoesNotTriggerOpenApiGeneration() throws IOException { + var result = compile("unannotated-endpoint", """ + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/without-openapi") + class EndpointWithoutOpenApi { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), not(containsString("OpenApiEndpoint"))); + } + + @Test + void documentOnlyAnnotationDoesNotTriggerEndpointGeneration() throws IOException { + var result = compile("document-only-annotation-endpoint", """ + @OpenApi.Info(title = "Not a document", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/document-only") + class EndpointWithDocumentOnlyAnnotation { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), not(containsString("OpenApiEndpoint"))); + } + + @Test + void documentOnlyTypePlacementDoesNotTriggerEndpointGeneration() throws IOException { + var result = compile("document-only-type-placement-endpoint", """ + @OpenApi.Server("https://example.test") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/document-only-type-placement") + class EndpointWithDocumentOnlyTypePlacement { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), not(containsString("OpenApiEndpoint"))); + } + + @Test + void methodLevelAnnotationTriggersEndpointGeneration() throws IOException { + var result = compile("method-level-openapi-endpoint", """ + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/method-level") + class MethodLevelOpenApiEndpoint { + @Http.GET + @OpenApi.Server("https://example.test") + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/method-level\"")); + } + + @Test + void parameterAnnotationTriggersEndpointGeneration() throws IOException { + var result = compile("parameter-openapi-endpoint", """ + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/parameter") + class ParameterOpenApiEndpoint { + @Http.GET + String get(@OpenApi.Parameter("Search term") + @Http.QueryParam("q") String query) { + return query; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/parameter\"")); + } + + @Test + void annotatedEndpointDoesNotOptInOtherEndpoint() throws IOException { + var result = compile("mixed-openapi-endpoints", """ + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/annotated") + class AnnotatedEndpoint { + @Http.GET + @OpenApi.Operation + String get() { + return "ok"; + } + } + + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/unannotated") + class UnannotatedEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(Files.exists(result.sourceOutput() + .resolve("com/example/AnnotatedEndpoint__OpenApiEndpointSource.java")), + is(true)); + assertThat(Files.exists(result.sourceOutput() + .resolve("com/example/UnannotatedEndpoint__OpenApiEndpointSource.java")), + is(false)); + } + + @Test + void restEndpointCompilesWithoutOpenApiOnClasspath() throws IOException { + var result = compile("rest-endpoint-without-openapi", """ + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/without-openapi") + class EndpointWithoutOpenApi { + @Http.GET + String get() { + return "ok"; + } + } + """, CLASSPATH.stream() + .filter(it -> it != OpenApi.class) + .toList()); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString("EndpointWithoutOpenApi__HttpFeature")); + assertThat(generated, not(containsString("OpenApiEndpoint"))); + } + + @Test + void interfaceEndpointIsNotDocumented() throws IOException { + var result = compile("openapi-interface-endpoint", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + String get() { + return "ok"; + } + } + + @RestServer.Endpoint + @Http.Path("/ghost") + interface GhostEndpoint { + @Http.GET + String get(); + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString("document.path(\"/valid\"")); + assertThat(generated, not(containsString("document.path(\"/ghost\""))); + } + + @Test + void repeatedHttpPathParameterCannotBeRepresented() { + var result = compile("openapi-repeated-http-path-parameter", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/items") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/{id}/{id}") + String get(@Http.PathParam("id") String id) { + return id; + } + } + """); + + assertCompilationFails(result, + "@Http.Path on com.example.InvalidOpenApiEndpoint.get", + "cannot be represented as an OpenAPI path: /items/{id}/{id}", + "path parameter 'id' appears more than once"); + } + + @Test + void unsupportedHttpPathRequiresOpenApiPathOverride() { + var result = compile("openapi-unsupported-http-path", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/files/{+}") + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@Http.Path on com.example.InvalidOpenApiEndpoint.get", + "cannot be represented as an OpenAPI path: /invalid/files/{+}", + "Use @OpenApi.Operation(path = ...) to provide the OpenAPI path"); + } + + @Test + void operationPathOverrideCannotRepeatPathParameter() { + var result = compile("openapi-repeated-operation-path-parameter", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/items") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/{id}") + @OpenApi.Operation(path = "/items/{id}/{id}") + String get(@Http.PathParam("id") String id) { + return id; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Operation path on com.example.InvalidOpenApiEndpoint.get", + "must be an OpenAPI path template: /items/{id}/{id}", + "path parameter 'id' appears more than once"); + } + + @Test + void operationPathOverrideMustBeOpenApiPathTemplate() { + var result = compile("openapi-invalid-operation-path", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/files/{+}") + @OpenApi.Operation(path = "/invalid/files/{id:\\\\d+}") + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Operation path on com.example.InvalidOpenApiEndpoint.get", + "must be an OpenAPI path template: /invalid/files/{id:\\d+}", + "path parameters cannot define regex constraints"); + } + + @Test + void operationPathOverrideCannotContainQuery() { + var result = compile("openapi-operation-path-query", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/items") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Operation(path = "/items?mode=full") + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Operation path on com.example.InvalidOpenApiEndpoint.get", + "must be an OpenAPI path template: /items?mode=full", + "query and fragment characters are not valid in OpenAPI path templates"); + } + + @Test + void operationPathOverrideCannotContainFragment() { + var result = compile("openapi-operation-path-fragment", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/items") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Operation(path = "/items#details") + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Operation path on com.example.InvalidOpenApiEndpoint.get", + "must be an OpenAPI path template: /items#details", + "query and fragment characters are not valid in OpenAPI path templates"); + } + + @Test + void operationPathParameterNameCanContainQuery() throws IOException { + var result = compile("openapi-operation-path-parameter-query", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/items") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/{id?mode}") + @OpenApi.Operation(path = "/items/{id?mode}") + String get(@Http.PathParam("id?mode") String id) { + return id; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/items/{id?mode}\"")); + } + + @Test + void operationPathParameterNameCanContainFragment() throws IOException { + var result = compile("openapi-operation-path-parameter-fragment", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/items") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/{id#fragment}") + @OpenApi.Operation(path = "/items/{id#fragment}") + String get(@Http.PathParam("id#fragment") String id) { + return id; + } + } + """); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + assertThat(generatedSource(result), containsString("document.path(\"/items/{id#fragment}\"")); + } + + @Test + void operationPathOverrideCannotAddPathParameter() { + var result = compile("openapi-extra-operation-path-parameter", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/files") + @OpenApi.Operation(path = "/invalid/files/{id}") + String get() { + return "ok"; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Operation path on com.example.InvalidOpenApiEndpoint.get", + "must declare the same path parameters as the generated route", + "generated route parameters: []", + "OpenAPI path parameters: [id]"); + } + + @Test + void operationPathOverrideMustUseGeneratedPathParameterNames() { + var result = compile("openapi-renamed-operation-path-parameter", """ + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @Http.Path("/files/{name}") + @OpenApi.Operation(path = "/invalid/files/{id}") + String get(@Http.PathParam("name") String name) { + return name; + } + } + """); + + assertCompilationFails(result, + "@OpenApi.Operation path on com.example.InvalidOpenApiEndpoint.get", + "must declare the same path parameters as the generated route", + "generated route parameters: [name]", + "OpenAPI path parameters: [id]"); + } + + private TestCompiler.Result compile(String workDir, String source) { + return compile(workDir, source, CLASSPATH); + } + + private TestCompiler.Result compile(String workDir, String source, List> classpath) { + return TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(classpath) + .addProcessor(AptProcessor::new) + .workDir(workDirRoot.resolve(workDir)) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + %s + %s + """.formatted(classpath.contains(OpenApi.class) + ? "import io.helidon.openapi.OpenApi;" + : "", + source)) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + } + + private static String generatedSource(TestCompiler.Result result) throws IOException { + StringBuilder generatedContent = new StringBuilder(); + var generatedSources = Files.walk(result.sourceOutput()) + .filter(it -> it.getFileName().toString().endsWith(".java")) + .toList(); + for (Path generatedSource : generatedSources) { + generatedContent.append(Files.readString(generatedSource, StandardCharsets.UTF_8)); + generatedContent.append('\n'); + } + return generatedContent.toString(); + } + + private static void assertCompilationFails(TestCompiler.Result result, String... diagnosticParts) { + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + for (String diagnosticPart : diagnosticParts) { + assertThat(diagnostics, containsString(diagnosticPart)); + } + } +} diff --git a/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiResponseCodegenTest.java b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiResponseCodegenTest.java new file mode 100644 index 00000000000..946a8f2e337 --- /dev/null +++ b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiResponseCodegenTest.java @@ -0,0 +1,643 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import io.helidon.codegen.apt.AptProcessor; +import io.helidon.codegen.testing.TestCompiler; +import io.helidon.common.Api; +import io.helidon.common.Default; +import io.helidon.common.Generated; +import io.helidon.common.GenericType; +import io.helidon.common.LazyValue; +import io.helidon.common.mapper.Mappers; +import io.helidon.common.parameters.Parameters; +import io.helidon.common.types.Annotation; +import io.helidon.common.uri.UriQuery; +import io.helidon.config.Config; +import io.helidon.http.Http; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Dependency; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.HttpEntryPoint; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRoute; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.RestServer; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.not; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiResponseCodegenTest { + private static final List> CLASSPATH = List.of( + Annotation.class, + Api.class, + Config.class, + Default.class, + Dependency.class, + Generated.class, + GenericType.class, + Handler.class, + Http.class, + HttpEntryPoint.class, + HttpFeature.class, + HttpRoute.class, + HttpRouting.class, + HttpRules.class, + JsonObject.class, + JsonString.class, + LazyValue.class, + Mappers.class, + OpenApi.class, + Parameters.class, + RestServer.class, + ServerRequest.class, + ServerResponse.class, + Service.class, + ServiceDescriptor.class, + UriQuery.class, + WebServer.class + ); + + @Test + void optionalResponseWithExplicitSuccessKeepsNotFoundResponse() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-optional-explicit-success")) + .addSource("OptionalOpenApiEndpoint.java", """ + package com.example; + + import java.util.Optional; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/optional") + class OptionalOpenApiEndpoint { + @Http.GET + @Http.Path("/{name}") + @OpenApi.Response(status = 200, + description = "Greeting found", + content = @OpenApi.Content) + Optional get(@Http.PathParam("name") String name) { + return Optional.of(name); + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".response(\"200\", response -> response.description(" + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"Greeting found\"))")); + assertThat(generated, containsString(".response(\"404\", response -> response.description(\"Not Found\"))")); + } + + @Test + void directResponseCombinesWithInheritedResponses() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-inherited-and-direct-responses")) + .addSource("CombinedResponseOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + interface ResponseContract { + @Http.GET + @OpenApi.Responses(@OpenApi.Response(status = 200, description = "Inherited OK")) + String get(); + } + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/responses") + class CombinedResponseOpenApiEndpoint implements ResponseContract { + @Override + @OpenApi.Response(status = 201, description = "Local Created") + public String get() { + return "ok"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".response(\"200\", response -> response.description(" + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"Inherited OK\"))")); + assertThat(generated, containsString(".response(\"201\", response -> response.description(" + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"Local Created\"))")); + } + + @Test + void responseLinksAreGenerated() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-response-links")) + .addSource("LinkedOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/linked") + class LinkedOpenApiEndpoint { + @Http.GET + @OpenApi.Response( + status = 200, + description = "Linked", + links = { + @OpenApi.Link( + name = "follow", + operationId = "getGreeting", + parameters = @OpenApi.LinkParameter( + name = "id", + value = "$response.body#/id"), + requestBody = "$response.body", + description = "Follow the greeting"), + @OpenApi.Link( + name = "relative", + operationRef = "#/paths/~1greetings~1{id}/get") + }) + String get() { + return "ok"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".link(\"follow\", link -> link")); + assertThat(generated, containsString(".operationId(")); + assertThat(generated, containsString("\"getGreeting\"")); + assertThat(generated, containsString(".parameters(JsonObject.builder().set(\"id\",")); + assertThat(generated, containsString("\"$response.body#/id\"")); + assertThat(generated, containsString(".requestBody(JsonString.create(")); + assertThat(generated, containsString("\"$response.body\"")); + assertThat(generated, containsString("\"Follow the greeting\"")); + assertThat(generated, containsString(".link(\"relative\", link -> link")); + assertThat(generated, containsString(".operationRef(")); + assertThat(generated, containsString("\"#/paths/~1greetings~1{id}/get\"")); + } + + @Test + void responseCannotDeclareDuplicateLinkNames() { + var result = compileInvalidResponseLinks( + "duplicate-names", + """ + @OpenApi.Link(name = "duplicate", operationId = "first"), + @OpenApi.Link(name = "duplicate", operationId = "second") + """); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get for status 200", + "cannot define link duplicate more than once"); + } + + @Test + void responseLinkRequiresOperationTarget() { + var result = compileInvalidResponseLinks( + "missing-target", + """ + @OpenApi.Link(name = "invalid") + """); + + assertCompilationFails(result, + "link invalid must define exactly one of operationRef or operationId"); + } + + @Test + void responseLinkRejectsMultipleOperationTargets() { + var result = compileInvalidResponseLinks( + "multiple-targets", + """ + @OpenApi.Link(name = "invalid", + operationRef = "${link.ref:}", + operationId = "getGreeting") + """); + + assertCompilationFails(result, + "link invalid must define exactly one of operationRef or operationId"); + } + + @Test + void responseLinkRejectsDuplicateParameterNames() { + var result = compileInvalidResponseLinks( + "duplicate-parameter-names", + """ + @OpenApi.Link( + name = "next", + operationId = "getGreeting", + parameters = { + @OpenApi.LinkParameter(name = "id", value = "$response.body#/id"), + @OpenApi.LinkParameter(name = "id", value = "$request.path.id") + }) + """); + + assertCompilationFails(result, + "link next cannot define parameter id more than once"); + } + + @Test + void responseLinkRejectsInvalidName() { + var result = compileInvalidResponseLinks( + "invalid-name", + """ + @OpenApi.Link(name = "invalid/name", operationId = "getGreeting") + """); + + assertCompilationFails(result, + "has invalid link name invalid/name"); + } + + @Test + void responseLinkRequiresParameterName() { + var result = compileInvalidResponseLinks( + "missing-parameter-name", + """ + @OpenApi.Link( + name = "next", + operationId = "getGreeting", + parameters = @OpenApi.LinkParameter(name = "", value = "$response.body#/id")) + """); + + assertCompilationFails(result, + "link next requires a parameter name"); + } + + @Test + void explicitResponseWithoutContentDoesNotInferMethodReturnContent() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-explicit-bodyless-response")) + .addSource("BodylessOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/bodyless") + class BodylessOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 204, description = "Deleted") + String delete() { + return "deleted"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, containsString(".response(\"204\", response -> response.description(" + + "io.helidon.openapi.OpenApiDocumentContextSupport" + + ".resolveExpression(context, \"Deleted\"))")); + assertThat(generated, is(not(containsString(".content(")))); + assertThat(generated, is(not(containsString("JsonSchemaProvider")))); + } + + @Test + void bigIntegerResponseUsesBuiltInIntegerSchema() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-big-integer-response")) + .addSource("BigIntegerOpenApiEndpoint.java", """ + package com.example; + + import java.math.BigInteger; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/big-integer") + class BigIntegerOpenApiEndpoint { + @Http.GET + @Http.Produces("application/json") + BigInteger get() { + return BigInteger.ONE; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + + String generated = generatedSource(result); + assertThat(generated, + containsString(".content(\"application/json\", content -> content.schema(schema(\"integer\")))")); + assertThat(generated, not(containsString("JsonSchemaProvider"))); + assertThat(generated, not(containsString("NamedByType(BigInteger.class)"))); + assertThat(generated, not(containsString("schemaRef(\"BigInteger\")"))); + } + + @Test + void responseCannotDeclareDuplicateStatus() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-duplicate-response-status")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, description = "First") + @OpenApi.Response(status = 200, description = "Second") + String get() { + return "ok"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "cannot define response status 200 more than once"); + } + + @Test + void responseCannotDeclareLowStatus() { + var result = compileInvalidResponseStatus(99); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "must define an HTTP response status from 100 to 599: 99"); + } + + @Test + void responseCannotDeclareHighStatus() { + var result = compileInvalidResponseStatus(600); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "must define an HTTP response status from 100 to 599: 600"); + } + + private static TestCompiler.Result compileInvalidResponseStatus(int status) { + return TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-invalid-response-status-" + status)) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = %d, description = "Invalid") + String get() { + return "ok"; + } + } + """.formatted(status)) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + } + + private static TestCompiler.Result compileInvalidResponseLinks(String testName, String links) { + return TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-invalid-response-link-" + testName)) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response( + status = 200, + description = "Invalid", + links = { + %s + }) + String get() { + return "ok"; + } + } + """.formatted(links)) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + } + + private static String generatedSource(TestCompiler.Result result) throws IOException { + StringBuilder generatedContent = new StringBuilder(); + var generatedSources = Files.walk(result.sourceOutput()) + .filter(it -> it.getFileName().toString().endsWith(".java")) + .toList(); + for (Path generatedSource : generatedSources) { + generatedContent.append(Files.readString(generatedSource, StandardCharsets.UTF_8)); + generatedContent.append('\n'); + } + return generatedContent.toString(); + } + + private static void assertCompilationFails(TestCompiler.Result result, String... diagnosticParts) { + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + for (String diagnosticPart : diagnosticParts) { + assertThat(diagnostics, containsString(diagnosticPart)); + } + } +} diff --git a/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiResponseHeaderCodegenTest.java b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiResponseHeaderCodegenTest.java new file mode 100644 index 00000000000..a4a789fdb1e --- /dev/null +++ b/declarative/tests/codegen/src/test/java/io/helidon/declarative/codegen/openapi/OpenApiResponseHeaderCodegenTest.java @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.codegen.openapi; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import io.helidon.codegen.apt.AptProcessor; +import io.helidon.codegen.testing.TestCompiler; +import io.helidon.common.Api; +import io.helidon.common.Default; +import io.helidon.common.Generated; +import io.helidon.common.GenericType; +import io.helidon.common.LazyValue; +import io.helidon.common.mapper.Mappers; +import io.helidon.common.parameters.Parameters; +import io.helidon.common.types.Annotation; +import io.helidon.common.uri.UriQuery; +import io.helidon.config.Config; +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Dependency; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.webserver.WebServer; +import io.helidon.webserver.http.Handler; +import io.helidon.webserver.http.HttpEntryPoint; +import io.helidon.webserver.http.HttpFeature; +import io.helidon.webserver.http.HttpRoute; +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.http.HttpRules; +import io.helidon.webserver.http.RestServer; +import io.helidon.webserver.http.ServerRequest; +import io.helidon.webserver.http.ServerResponse; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiResponseHeaderCodegenTest { + private static final List> CLASSPATH = List.of( + Annotation.class, + Api.class, + Config.class, + Default.class, + Dependency.class, + Generated.class, + GenericType.class, + Handler.class, + Http.class, + HttpEntryPoint.class, + HttpFeature.class, + HttpRoute.class, + HttpRouting.class, + HttpRules.class, + LazyValue.class, + Mappers.class, + OpenApi.class, + Parameters.class, + RestServer.class, + ServerRequest.class, + ServerResponse.class, + Service.class, + ServiceDescriptor.class, + UriQuery.class, + WebServer.class + ); + + @Test + void responseHeaderCannotUseMultipleContentEntries() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-response-header-multiple-content")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + headers = @OpenApi.Header( + name = "X-Value", + content = {@OpenApi.Content("application/json"), + @OpenApi.Content("text/plain")})) + String get() { + return "ok"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Header on com.example.InvalidOpenApiEndpoint.get", + "cannot define more than one content entry for response header X-Value"); + } + + @Test + void responseHeaderCanUseSingleContentEntry() throws IOException { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-response-header-single-content")) + .addSource("ValidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/valid") + class ValidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + headers = @OpenApi.Header( + name = "${openapi.header:X-Value}", + content = @OpenApi.Content("${openapi.content:application/json}"))) + String get() { + return "ok"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + String diagnostics = String.join("\n", result.diagnostics()); + assertThat(diagnostics, result.success(), is(true)); + String generated = generatedSource(result); + assertThat(generated, containsString(".header(\"X-Value\", header -> header")); + assertThat(generated, containsString(".content(\"application/json\", content -> content.schema(schema(\"string\")))")); + } + + @Test + void responseHeaderCannotUseContentTypeName() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-response-header-content-type")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @OpenApi.Response(status = 200, + description = "OK", + headers = @OpenApi.Header(name = "${openapi.header:Content-Type}")) + String get() { + return "ok"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "cannot define response header Content-Type", + "use @OpenApi.Content to define response media types"); + } + + @Test + void responseHeaderCannotDuplicateInferredHeaderName() { + var result = TestCompiler.builder() + .currentRelease() + .procOnly() + .addClasspath(CLASSPATH) + .addProcessor(AptProcessor::new) + .workDir(Path.of("target/test-compiler/openapi-response-header-duplicates-inferred")) + .addSource("InvalidOpenApiEndpoint.java", """ + package com.example; + + import io.helidon.http.Http; + import io.helidon.openapi.OpenApi; + import io.helidon.service.registry.Service; + import io.helidon.webserver.http.RestServer; + + @OpenApi.Document + @OpenApi.Info(title = "Test", version = "1.0") + @RestServer.Endpoint + @Service.Singleton + @Http.Path("/invalid") + class InvalidOpenApiEndpoint { + @Http.GET + @RestServer.Header(name = "X-Trace", value = "static") + @RestServer.ComputedHeader(name = "X-Computed", function = "computed") + @OpenApi.Response(status = 200, + description = "OK", + headers = { + @OpenApi.Header(name = "x-trace"), + @OpenApi.Header(name = "x-computed") + }) + String get() { + return "ok"; + } + } + """) + .addSource("Main.java", """ + package com.example; + + import io.helidon.service.registry.Service; + + @Service.GenerateBinding + class Main { + } + """) + .build() + .compile(); + + assertCompilationFails(result, + "@OpenApi.Response on com.example.InvalidOpenApiEndpoint.get", + "cannot define response header x-trace more than once", + "including inferred Helidon response headers"); + } + + private static void assertCompilationFails(TestCompiler.Result result, String... diagnosticParts) { + String diagnostics = String.join("\n", result.diagnostics()); + assertThat("Build should fail", result.success(), is(false)); + for (String diagnosticPart : diagnosticParts) { + assertThat(diagnostics, containsString(diagnosticPart)); + } + } + + private static String generatedSource(TestCompiler.Result result) throws IOException { + StringBuilder generatedContent = new StringBuilder(); + var generatedSources = Files.walk(result.sourceOutput()) + .filter(it -> it.getFileName().toString().endsWith(".java")) + .toList(); + for (Path generatedSource : generatedSources) { + generatedContent.append(Files.readString(generatedSource, StandardCharsets.UTF_8)); + generatedContent.append('\n'); + } + return generatedContent.toString(); + } +} diff --git a/declarative/tests/http/src/main/java/io/helidon/declarative/tests/http/GreetServiceEndpoint.java b/declarative/tests/http/src/main/java/io/helidon/declarative/tests/http/GreetServiceEndpoint.java index 904b2d413c2..7fe574effaf 100644 --- a/declarative/tests/http/src/main/java/io/helidon/declarative/tests/http/GreetServiceEndpoint.java +++ b/declarative/tests/http/src/main/java/io/helidon/declarative/tests/http/GreetServiceEndpoint.java @@ -336,6 +336,30 @@ String inputStreamEntity(@Http.Entity InputStream inputStream) throws IOExceptio } } + @Http.POST + @Http.Path("/optional-entity") + String optionalEntity(@Http.Entity Optional entity) { + return entity.orElse("none"); + } + + @Http.POST + @Http.Path("/optional-input-stream") + String optionalInputStreamEntity(@Http.Entity Optional entity) throws IOException { + if (entity.isEmpty()) { + return "none"; + } + try (InputStream inputStream = entity.get()) { + return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); + } + } + + @Http.POST + @Http.Path("/optional-byte-array") + String optionalByteArrayEntity(@Http.Entity Optional entity) { + return entity.map(bytes -> new String(bytes, StandardCharsets.UTF_8)) + .orElse("none"); + } + private JsonObject response(String name) { return JsonObject.builder() .set("message", stringResponse(name)) diff --git a/declarative/tests/http/src/test/java/io/helidon/declarative/tests/http/DeclarativeHttpTest.java b/declarative/tests/http/src/test/java/io/helidon/declarative/tests/http/DeclarativeHttpTest.java index 90be2bbc426..70717df14ba 100644 --- a/declarative/tests/http/src/test/java/io/helidon/declarative/tests/http/DeclarativeHttpTest.java +++ b/declarative/tests/http/src/test/java/io/helidon/declarative/tests/http/DeclarativeHttpTest.java @@ -818,4 +818,43 @@ void testInputStreamEntityEmptyContentLengthFailure() { assertThat(response.status(), is(Status.BAD_REQUEST_400)); assertThat(response.entity(), is("Entity inputStream is not present in the request.")); } + + @Test + void testOptionalEntityEmptyContent() { + var response = client.post("/greet/optional-entity") + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), is("none")); + } + + @Test + void testOptionalInputStreamEntity() { + var presentResponse = client.post("/greet/optional-input-stream") + .submit("hello", String.class); + + assertThat(presentResponse.status(), is(Status.OK_200)); + assertThat(presentResponse.entity(), is("hello")); + + var emptyResponse = client.post("/greet/optional-input-stream") + .request(String.class); + + assertThat(emptyResponse.status(), is(Status.OK_200)); + assertThat(emptyResponse.entity(), is("none")); + } + + @Test + void testOptionalByteArrayEntity() { + var presentResponse = client.post("/greet/optional-byte-array") + .submit("hello", String.class); + + assertThat(presentResponse.status(), is(Status.OK_200)); + assertThat(presentResponse.entity(), is("hello")); + + var emptyResponse = client.post("/greet/optional-byte-array") + .request(String.class); + + assertThat(emptyResponse.status(), is(Status.OK_200)); + assertThat(emptyResponse.entity(), is("none")); + } } diff --git a/declarative/tests/openapi/pom.xml b/declarative/tests/openapi/pom.xml new file mode 100644 index 00000000000..55d346239b9 --- /dev/null +++ b/declarative/tests/openapi/pom.xml @@ -0,0 +1,158 @@ + + + + + + io.helidon.declarative.tests + helidon-declarative-tests-project + 27.0.0-SNAPSHOT + ../pom.xml + + 4.0.0 + + helidon-declarative-tests-openapi + Helidon Declarative Tests OpenAPI + Tests for Declarative OpenAPI generation + + + + io.helidon.webserver + helidon-webserver + + + io.helidon.webserver + helidon-webserver-context + + + io.helidon.openapi + helidon-openapi + + + io.helidon.openapi + helidon-openapi-31 + + + io.helidon.openapi + helidon-openapi-32 + + + io.helidon.http.media + helidon-http-media-json + + + io.helidon.http.media + helidon-http-media-json-binding + + + io.helidon.json.schema + helidon-json-schema + + + io.helidon.config + helidon-config-yaml + + + io.helidon.logging + helidon-logging-jul + runtime + + + org.slf4j + slf4j-jdk14 + runtime + + + io.helidon.service + helidon-service-registry + + + io.helidon.webclient + helidon-webclient + + + org.junit.jupiter + junit-jupiter-api + test + + + io.helidon.webserver.testing.junit5 + helidon-webserver-testing-junit5 + test + + + org.hamcrest + hamcrest-all + test + + + org.yaml + snakeyaml + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + + + copy-libs + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.bundles + helidon-bundles-apt + ${helidon.version} + + + + + + + io.helidon.bundles + helidon-bundles-apt + ${helidon.version} + + + + + io.helidon.service + helidon-service-maven-plugin + ${helidon.version} + + + create-application + + create-application + + + + + + + diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/AdminEndpoint.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/AdminEndpoint.java new file mode 100644 index 00000000000..f34291a6c22 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/AdminEndpoint.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.webserver.http.RestServer; + +/** + * Admin endpoint used to verify explicit-listener OpenAPI generation. + */ +@RestServer.Endpoint +@OpenApi.Endpoint +@RestServer.Listener("admin") +@Http.Path("/admin") +class AdminEndpoint { + + @Http.GET + @Http.Path("/status") + String status() { + return "admin"; + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/AlternateDocument.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/AlternateDocument.java new file mode 100644 index 00000000000..5c36e6e0e82 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/AlternateDocument.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.openapi.OpenApi; + +/** + * Alternate document metadata source used to verify generated document source selection. + */ +@OpenApi.Document +@OpenApi.Info(title = "Alternate Declarative OpenAPI Test", version = "9.9.9") +final class AlternateDocument { + private AlternateDocument() { + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/CollisionEndpoint.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/CollisionEndpoint.java new file mode 100644 index 00000000000..b0efff19fa3 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/CollisionEndpoint.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.webserver.http.RestServer; + +/** + * Endpoint with colliding schema simple names. + */ +@RestServer.Endpoint +@OpenApi.Endpoint +@Http.Path("/collisions") +class CollisionEndpoint { + + @Http.POST + @Http.Consumes(MediaTypes.APPLICATION_JSON_VALUE) + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + Message create(@Http.Entity io.helidon.declarative.tests.openapi.other.Message request) { + return new Message(request.text()); + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/ExternalMessageEndpoint.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/ExternalMessageEndpoint.java new file mode 100644 index 00000000000..5a51f869c22 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/ExternalMessageEndpoint.java @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.declarative.tests.openapi.external.Message; +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.webserver.http.RestServer; + +/** + * Endpoint with a schema type whose simple name collides with schemas used by other endpoint sources. + */ +@RestServer.Endpoint +@OpenApi.Endpoint +@Http.Path("/external-message") +class ExternalMessageEndpoint { + + @Http.GET + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + Message get() { + return new Message("external"); + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/FarewellEndpoint.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/FarewellEndpoint.java new file mode 100644 index 00000000000..dd279f3a866 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/FarewellEndpoint.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.Http; +import io.helidon.openapi.OpenApi; +import io.helidon.webserver.http.RestServer; + +/** + * Farewell endpoint used to verify operation ids across endpoint classes with the same method names. + */ +@RestServer.Endpoint +@Http.Path("/farewells") +@OpenApi.SecurityRequirements({}) +class FarewellEndpoint { + + @Http.GET + @Http.Path("/{name}") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + Message find(@Http.PathParam("name") String name) { + return new Message("Goodbye " + name); + } + + @Http.POST + @Http.Consumes(MediaTypes.APPLICATION_JSON_VALUE) + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + Message create(@Http.Entity MessageRequest request) { + return new Message("Goodbye " + request.name()); + } + + @Http.POST + @Http.Path("/plain") + @Http.Consumes(MediaTypes.TEXT_PLAIN_VALUE) + @Http.Produces(MediaTypes.TEXT_PLAIN_VALUE) + String createPlain(@Http.Entity String message) { + return "Goodbye " + message; + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/GreetingEndpoint.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/GreetingEndpoint.java new file mode 100644 index 00000000000..445df839a51 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/GreetingEndpoint.java @@ -0,0 +1,269 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import java.math.BigInteger; +import java.util.List; +import java.util.Optional; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.http.Http; +import io.helidon.http.Status; +import io.helidon.openapi.OpenApi; +import io.helidon.webserver.http.RestServer; + +/** + * Greeting endpoint used by OpenAPI generation tests. + */ +@RestServer.Endpoint +@Http.Path("/greetings") +@OpenApi.SecuritySchemeRequirement("bearerAuth") +class GreetingEndpoint { + private static final String ACCEPTED_MEDIA_TYPE = "application/vnd.greeting+json"; + + @Http.GET + @Http.Path("/{name}") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + Message find(@Http.PathParam("name") String name, + @Http.QueryParam("language") Optional language, + @Http.QueryParam("include") List include) { + return new Message(message("Hello", name, language)); + } + + @Http.GET + @Http.Path("/documented/{name}") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.Operation(value = "Find a greeting", + operationId = "findDocumentedGreeting", + description = "Returns a documented greeting.", + tags = {"greeting", "documented"}, + deprecated = true) + @OpenApi.Server(value = "https://{region}.api.example.com/greetings", + description = "Operation server", + variables = @OpenApi.ServerVariable(name = "region", + defaultValue = "us", + enumeration = {"us", "eu"})) + @OpenApi.ExternalDocs(value = "https://helidon.io/docs/openapi", description = "Operation documentation") + @OpenApi.Extension(name = "x-test-operation", value = "documented-greeting") + @OpenApi.SecurityRequirement({ + @OpenApi.SecuritySchemeRequirement("bearerAuth"), + @OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = "greeting:read") + }) + @OpenApi.Response(status = Status.OK_200_CODE, + description = "Greeting found", + content = @OpenApi.Content) + Message documented(@OpenApi.Parameter(value = "Greeting recipient", example = "Tomas") + @Http.PathParam("name") String name) { + return new Message("Hello " + name); + } + + @Http.GET + @Http.Path("/public") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.SecurityRequirements({}) + Message publicGreeting() { + return new Message("Hello everybody"); + } + + @Http.GET + @Http.Path("/responses") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @RestServer.Status(Status.ACCEPTED_202_CODE) + @RestServer.Header(name = "Content-Type", value = ACCEPTED_MEDIA_TYPE) + @RestServer.Header(name = "X-Static", value = "static") + @RestServer.ComputedHeader(name = "Content-Type", function = ResponseHeaderFunction.SERVICE_NAME) + @RestServer.ComputedHeader(name = ResponseHeaderFunction.HEADER_NAME, function = ResponseHeaderFunction.SERVICE_NAME) + @OpenApi.Response(status = Status.ACCEPTED_202_CODE, + description = "Accepted greeting", + summary = "Accepted response", + headers = @OpenApi.Header(name = "X-Documented", + value = "Documented response header", + required = OpenApi.Required.TRUE, + deprecated = true), + links = @OpenApi.Link(name = "documentedGreeting", + operationId = "findDocumentedGreeting", + parameters = @OpenApi.LinkParameter(name = "name", + value = "$response.body#/message"), + requestBody = "$response.body", + description = "Follow the documented greeting"), + content = @OpenApi.Content(value = ACCEPTED_MEDIA_TYPE, + schema = Message.class, + examples = @OpenApi.Example(name = "accepted-response", + summary = "Accepted example", + value = "{\"message\":\"Accepted\"}"))) + Message responses() { + return new Message("Accepted"); + } + + @Http.GET + @Http.Path("/parameters/{id}") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.Parameter(name = "search", + in = "query", + value = "Search text", + required = OpenApi.Required.FALSE, + style = OpenApi.Style.FORM, + explode = OpenApi.Explode.FALSE, + allowReserved = true, + deprecated = true, + examples = @OpenApi.Example(name = "search-example", + summary = "Search example", + value = "hi")) + @OpenApi.Parameter(name = "filter", + in = "query", + style = OpenApi.Style.PIPE_DELIMITED, + explode = OpenApi.Explode.FALSE) + @OpenApi.Parameter(name = "packed", + in = "query", + value = "Packed JSON filter", + content = @OpenApi.Content(value = MediaTypes.APPLICATION_JSON_VALUE, + schema = MessageRequest.class, + examples = @OpenApi.Example(name = "packed-example", + value = "{\"prefix\":\"Hello\"," + + "\"name\":\"Ada\"}"))) + Message parameters(@OpenApi.Parameter(value = "Greeting identifier", example = "42") + @Http.PathParam("id") String id, + @Http.QueryParam("search") Optional search, + @Http.QueryParam("filter") List filter, + @Http.QueryParam("packed") String packed, + @OpenApi.Parameter(value = "Trace header", + required = OpenApi.Required.FALSE, + style = OpenApi.Style.SIMPLE, + explode = OpenApi.Explode.FALSE, + examples = @OpenApi.Example(name = "trace-example", value = "abc-123")) + @Http.HeaderParam("X-Trace") Optional trace, + @Http.HeaderParam("X-Modes") List modes) { + return new Message("Hello " + id); + } + + @Http.POST + @Http.Consumes(MediaTypes.APPLICATION_JSON_VALUE) + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @RestServer.Status(Status.CREATED_201_CODE) + @OpenApi.RequestBody(value = "Greeting payload", + required = OpenApi.Required.TRUE, + content = @OpenApi.Content(value = MediaTypes.APPLICATION_JSON_VALUE, + examples = @OpenApi.Example(name = "create-request", + value = "{\"prefix\":\"Hello\"," + + "\"name\":\"Ada\"}"))) + Message create(@Http.Entity MessageRequest request) { + return new Message(request.prefix() + " " + request.name()); + } + + @Http.PUT + @Http.Path("/inferred-body") + @Http.Consumes(MediaTypes.APPLICATION_JSON_VALUE) + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.RequestBody("Inferred greeting payload") + Message inferredBody(@Http.Entity MessageRequest request) { + return new Message(request.prefix() + " " + request.name()); + } + + @Http.POST + @Http.Path("/explicit-request-schema") + @Http.Consumes(MediaTypes.APPLICATION_JSON_VALUE) + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.RequestBody(value = "Explicit request schema", + content = @OpenApi.Content(value = MediaTypes.APPLICATION_JSON_VALUE, + schema = MessageRequest.class)) + Message explicitRequestSchema(@Http.Entity InternalPayload request) { + return new Message(request.value()); + } + + @Http.PUT + @Http.Path("/request-params/{id}") + @Http.Consumes(MediaTypes.APPLICATION_JSON_VALUE) + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.Parameter(name = "search", in = "query", value = "Request params search") + @OpenApi.RequestBody("Request params body") + Message requestParams(@Http.RequestParams GreetingRequestParams params) { + return new Message(params.id() + params.search() + params.trace() + params.request().name()); + } + + @Http.POST + @Http.Path("/form-cookie") + @Http.Consumes(MediaTypes.APPLICATION_FORM_URLENCODED_VALUE) + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.Parameter(name = "session", in = "cookie", value = "Session cookie") + @OpenApi.RequestBody(value = "Greeting form", + content = @OpenApi.Content(value = MediaTypes.APPLICATION_FORM_URLENCODED_VALUE, + examples = @OpenApi.Example(name = "form-example", + value = "{\"prefix\":\"Hello\"," + + "\"name\":\"Ada\"}"))) + Message formCookie(@Http.CookieParam("session") String session, + @Http.FormParam("prefix") String prefix, + @Http.RequestParams GreetingFormParams params) { + return new Message(session + prefix + params.name() + params.tags()); + } + + @Http.GET + @Http.Path("/constrained/{id:[0-9]+}") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + Message constrained(@Http.PathParam("id") String id) { + return new Message("Hello " + id); + } + + @Http.GET + @Http.Path("/override[/{id}]") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + @OpenApi.Operation(path = "/greetings/override/{id}") + Message overridePath(@Http.PathParam("id") String id) { + return new Message("Hello " + id); + } + + @Http.GET + @Http.Path("/big-integer") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + BigInteger bigInteger() { + return BigInteger.ONE; + } + + @Http.GET + @Http.Path("/optional/{name}") + @Http.Produces(MediaTypes.APPLICATION_JSON_VALUE) + Optional maybeFind(@Http.PathParam("name") String name) { + return name.isBlank() ? Optional.empty() : Optional.of(new Message("Hello " + name)); + } + + @Http.DELETE + @Http.Path("/{name}") + void remove(@Http.PathParam("name") String name) { + } + + @Http.GET + @Http.Path("/internal") + @OpenApi.Hidden + String internal() { + return "internal"; + } + + private static String message(String prefix, String name, Optional language) { + return language.map(it -> prefix + " " + name + " in " + it) + .orElse(prefix + " " + name); + } + + record GreetingRequestParams(@Http.PathParam("id") String id, + @Http.QueryParam("search") String search, + @Http.HeaderParam("X-Trace") String trace, + @Http.Entity MessageRequest request) { + } + + record GreetingFormParams(@Http.CookieParam("tracking") Optional tracking, + @Http.FormParam("name") String name, + @Http.FormParam("tag") Optional> tags) { + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/InternalPayload.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/InternalPayload.java new file mode 100644 index 00000000000..7c11405977c --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/InternalPayload.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.json.binding.Json; +import io.helidon.json.schema.JsonSchema; + +@Json.Entity +@JsonSchema.Schema +record InternalPayload(@JsonSchema.Required String value) { +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/Main.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/Main.java new file mode 100644 index 00000000000..82af26a48da --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/Main.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.logging.common.LogConfig; +import io.helidon.openapi.OpenApi; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceRegistryManager; +import io.helidon.service.registry.Services; +import io.helidon.webserver.WebServer; + +/** + * Custom application main class. + */ +@OpenApi.Document +@OpenApi.Info(title = "Declarative OpenAPI Test", version = "1.0.0") +@OpenApi.Contact(value = "Helidon Team", url = "https://helidon.io", email = "helidon@example.com") +@OpenApi.License(value = "Apache License 2.0", + identifier = "Apache-2.0", + url = "https://www.apache.org/licenses/LICENSE-2.0") +@OpenApi.Server(value = "${test.openapi.server-url}:{port}{basePath}", + description = "Test server", + variables = { + @OpenApi.ServerVariable(name = "port", + defaultValue = "8443", + enumeration = {"8443", "443"}, + description = "HTTPS port"), + @OpenApi.ServerVariable(name = "basePath", + defaultValue = "", + description = "Optional base path") + }) +@OpenApi.Tag(value = "greeting", description = "Greeting operations") +@OpenApi.Tag(value = "farewell", description = "Farewell operations") +@OpenApi.ExternalDocs(value = "https://helidon.io/docs", description = "Helidon documentation") +@OpenApi.Extension(name = "x-test-document", value = "declarative-openapi") +@OpenApi.Extension(name = "x-test-typed", value = "${test.openapi.extension-value}", parseValue = true) +@OpenApi.SecurityScheme(name = "bearerAuth", + type = "http", + description = "Bearer token authentication", + scheme = "bearer", + bearerFormat = "JWT") +@OpenApi.SecurityScheme(name = "oauth2", + type = "oauth2", + description = "OAuth2 client credentials", + flows = @OpenApi.OAuthFlows(clientCredentials = @OpenApi.OAuthFlow( + tokenUrl = "https://id.example.com/oauth2/token", + scopes = @OpenApi.OAuthScope(value = "greeting:read", description = "Read greetings")))) +@OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement("bearerAuth")) +@OpenApi.SecurityRequirement(@OpenApi.SecuritySchemeRequirement(value = "oauth2", scopes = "greeting:read")) +@Service.GenerateBinding // annotation is required to generate application binding +public final class Main { + static { + // used when building with GraalVM native image to configure logging during build + LogConfig.initClass(); + } + + private Main() { + } + + /** + * Application main entry point. + * + * @param args command line arguments. + */ + public static void main(String[] args) { + // used to configure logging + LogConfig.configureRuntime(); + + ServiceRegistryManager.start(ApplicationBinding.create()); + + WebServer webServer = Services.get(WebServer.class); + System.out.println("Server started on: http://localhost:" + webServer.port() + "/openapi"); + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/Message.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/Message.java new file mode 100644 index 00000000000..e43bacdefff --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/Message.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.json.binding.Json; +import io.helidon.json.schema.JsonSchema; + +@Json.Entity +@JsonSchema.Schema +@JsonSchema.Description("Message response entity") +record Message(@JsonSchema.Description("Message text") + @JsonSchema.Required String message) { +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/MessageRequest.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/MessageRequest.java new file mode 100644 index 00000000000..a078ed51fe9 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/MessageRequest.java @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.json.binding.Json; +import io.helidon.json.schema.JsonSchema; + +@Json.Entity +@JsonSchema.Schema +@JsonSchema.Description("Message request entity") +record MessageRequest(@JsonSchema.Description("Greeting prefix") + @JsonSchema.Required String prefix, + @JsonSchema.Description("Recipient name") + @JsonSchema.Required String name) { +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/ResponseHeaderFunction.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/ResponseHeaderFunction.java new file mode 100644 index 00000000000..4c3c820b8ae --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/ResponseHeaderFunction.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import java.util.Optional; + +import io.helidon.http.Header; +import io.helidon.http.HeaderName; +import io.helidon.http.HeaderValues; +import io.helidon.http.Http; +import io.helidon.service.registry.Service; + +@Service.Singleton +@Service.Named(ResponseHeaderFunction.SERVICE_NAME) +class ResponseHeaderFunction implements Http.HeaderFunction { + static final String HEADER_NAME = "X-Computed"; + static final String SERVICE_NAME = "openapi-response-header"; + + private static final Header HEADER = HeaderValues.create(HEADER_NAME, "computed"); + + @Override + public Optional
apply(HeaderName name) { + return Optional.of(HEADER); + } +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/external/Message.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/external/Message.java new file mode 100644 index 00000000000..7f119536d7e --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/external/Message.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi.external; + +import io.helidon.json.binding.Json; +import io.helidon.json.schema.JsonSchema; + +/** + * Message type used by a separate endpoint to verify global OpenAPI component name uniqueness. + * + * @param value message value + */ +@Json.Entity +@JsonSchema.Schema +@JsonSchema.Description("External package message entity") +public record Message(@JsonSchema.Description("External message value") + @JsonSchema.Required String value) { +} diff --git a/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/other/Message.java b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/other/Message.java new file mode 100644 index 00000000000..bf843b4f97f --- /dev/null +++ b/declarative/tests/openapi/src/main/java/io/helidon/declarative/tests/openapi/other/Message.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi.other; + +import io.helidon.json.binding.Json; +import io.helidon.json.schema.JsonSchema; + +/** + * Message type in another package to test OpenAPI component name collisions. + * + * @param text text value + */ +@Json.Entity +@JsonSchema.Schema +@JsonSchema.Description("Other package message entity") +public record Message(@JsonSchema.Description("Other message text") + @JsonSchema.Required String text) { +} diff --git a/declarative/tests/openapi/src/main/java/module-info.java b/declarative/tests/openapi/src/main/java/module-info.java new file mode 100644 index 00000000000..6a9a2e11f42 --- /dev/null +++ b/declarative/tests/openapi/src/main/java/module-info.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@SuppressWarnings({"helidon:api:incubating", "helidon:api:preview"}) +module io.helidon.declarative.tests.openapi { + requires io.helidon.common.media.type; + requires io.helidon.config.yaml; + requires io.helidon.http; + requires io.helidon.json.binding; + requires io.helidon.json.schema; + requires io.helidon.logging.common; + requires io.helidon.openapi; + requires io.helidon.openapi.v31; + requires io.helidon.openapi.v32; + requires io.helidon.service.registry; + requires io.helidon.webserver; + requires io.helidon.webclient.api; + + // required for generated binding + requires io.helidon.webserver.context; + + exports io.helidon.declarative.tests.openapi; +} diff --git a/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApi31Test.java b/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApi31Test.java new file mode 100644 index 00000000000..4a22f289fe0 --- /dev/null +++ b/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApi31Test.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webserver.WebServerConfig; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpServer; +import io.helidon.webserver.testing.junit5.Socket; + +@ServerTest +class DeclarativeOpenApi31Test extends DeclarativeOpenApiTest { + DeclarativeOpenApi31Test(Http1Client client, @Socket("admin") Http1Client adminClient) { + super(client, adminClient); + } + + @SetUpServer + static void configureServer(WebServerConfig.Builder builder) { + configureServer(builder, "application-openapi-31.yaml"); + } + + @Override + String expectedOpenApiVersion() { + return "3.1.1"; + } + + @Override + boolean supportsLicenseIdentifier() { + return true; + } +} diff --git a/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApi32Test.java b/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApi32Test.java new file mode 100644 index 00000000000..884f90172bc --- /dev/null +++ b/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApi32Test.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webserver.WebServerConfig; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.SetUpServer; +import io.helidon.webserver.testing.junit5.Socket; + +@ServerTest +class DeclarativeOpenApi32Test extends DeclarativeOpenApiTest { + DeclarativeOpenApi32Test(Http1Client client, @Socket("admin") Http1Client adminClient) { + super(client, adminClient); + } + + @SetUpServer + static void configureServer(WebServerConfig.Builder builder) { + configureServer(builder, "application-openapi-32.yaml"); + } + + @Override + String expectedOpenApiVersion() { + return "3.2.0"; + } + + @Override + boolean supportsLicenseIdentifier() { + return true; + } +} diff --git a/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApiTest.java b/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApiTest.java new file mode 100644 index 00000000000..9bdfb26d7e1 --- /dev/null +++ b/declarative/tests/openapi/src/test/java/io/helidon/declarative/tests/openapi/DeclarativeOpenApiTest.java @@ -0,0 +1,687 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.declarative.tests.openapi; + +import java.util.List; +import java.util.Map; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; +import io.helidon.http.Status; +import io.helidon.openapi.spi.OpenApiDocumentSource; +import io.helidon.service.registry.Services; +import io.helidon.webclient.http1.Http1Client; +import io.helidon.webclient.http1.Http1ClientResponse; +import io.helidon.webserver.WebServerConfig; +import io.helidon.webserver.testing.junit5.ServerTest; +import io.helidon.webserver.testing.junit5.Socket; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.instanceOf; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.hasItems; +import static org.hamcrest.Matchers.hasKey; +import static org.hamcrest.Matchers.not; + +@ServerTest +@SuppressWarnings("helidon:api:preview") +class DeclarativeOpenApiTest { + private static final String CONFIGURED_GREETING_FIND_OPERATION_ID = "configuredGreetingFind"; + private static final String SCHEMA_REF_PREFIX = "#/components/schemas/"; + private static final String MESSAGE_SCHEMA_DESCRIPTION = "Message response entity"; + private static final String MESSAGE_REQUEST_SCHEMA_DESCRIPTION = "Message request entity"; + private static final String OTHER_MESSAGE_SCHEMA_DESCRIPTION = "Other package message entity"; + private static final String EXTERNAL_MESSAGE_SCHEMA_DESCRIPTION = "External package message entity"; + + private final Http1Client client; + private final Http1Client adminClient; + + DeclarativeOpenApiTest(Http1Client client, @Socket("admin") Http1Client adminClient) { + this.client = client; + this.adminClient = adminClient; + } + + @Test + void generatedDocumentUsesEndpointNamesForTagsAndOperationIds() { + Map document = document(); + + assertThat(document.get("openapi"), is(expectedOpenApiVersion())); + assertThat(object(document, "info").get("title"), is("Declarative OpenAPI Test")); + assertThat(object(document, "info").get("version"), is("1.0.0")); + + Map greetingFind = operation(document, "/greetings/{name}", "get"); + assertThat(greetingFind.get("operationId"), is(CONFIGURED_GREETING_FIND_OPERATION_ID)); + assertThat(list(greetingFind, "tags"), contains("greeting")); + + Map farewellFind = operation(document, "/farewells/{name}", "get"); + assertThat(farewellFind.get("operationId"), is("farewellGetFind")); + assertThat(list(farewellFind, "tags"), contains("farewell")); + assertThat(list(farewellFind, "security"), is(List.of())); + + Map greetingCreate = operation(document, "/greetings", "post"); + assertThat(greetingCreate.get("operationId"), is("greetingPostCreate")); + assertThat(list(greetingCreate, "tags"), contains("greeting")); + + Map farewellCreate = operation(document, "/farewells", "post"); + assertThat(farewellCreate.get("operationId"), is("farewellPostCreate")); + assertThat(list(farewellCreate, "tags"), contains("farewell")); + } + + @Test + void generatedDocumentIncludesRoundTripAlignmentEdges() { + Map document = document(); + + Map noContent = operation(document, "/greetings/{name}", "delete"); + assertThat(noContent.get("operationId"), is("greetingDeleteRemove")); + Map removeResponse = response(noContent, "204"); + assertThat(removeResponse.get("description"), is("No Content")); + assertThat(removeResponse, not(hasKey("content"))); + + Map plain = operation(document, "/farewells/plain", "post"); + assertThat(plain.get("operationId"), is("farewellPostCreatePlain")); + Map plainBody = object(plain, "requestBody"); + assertThat(object(content(plainBody, MediaTypes.TEXT_PLAIN_VALUE)).get("type"), is("string")); + Map plainResponse = response(plain, "200"); + assertThat(plainResponse.get("description"), is("OK")); + assertThat(object(content(plainResponse, MediaTypes.TEXT_PLAIN_VALUE)).get("type"), is("string")); + } + + @Test + void generatedDocumentUsesBuiltInBigIntegerSchema() { + Map operation = operation(document(), "/greetings/big-integer", "get"); + Map schema = content(response(operation, "200"), MediaTypes.APPLICATION_JSON_VALUE); + + assertThat(schema.get("type"), is("integer")); + assertThat(schema, not(hasKey("$ref"))); + } + + @Test + void generatedDocumentIncludesApplicationMetadata() { + Map document = document(); + Map info = object(document, "info"); + + Map contact = object(info, "contact"); + assertThat(contact.get("name"), is("Helidon Team")); + assertThat(contact.get("url"), is("https://helidon.io")); + assertThat(contact.get("email"), is("helidon@example.com")); + + Map license = object(info, "license"); + assertThat(license.get("name"), is("Apache License 2.0")); + if (supportsLicenseIdentifier()) { + assertThat(license.get("identifier"), is("Apache-2.0")); + assertThat(license, not(hasKey("url"))); + } else { + assertThat(license, not(hasKey("identifier"))); + assertThat(license.get("url"), is("https://www.apache.org/licenses/LICENSE-2.0")); + } + + Map externalDocs = object(document, "externalDocs"); + assertThat(externalDocs.get("url"), is("https://helidon.io/docs")); + assertThat(externalDocs.get("description"), is("Helidon documentation")); + assertThat(document.get("x-test-document"), is("declarative-openapi")); + Map typedExtension = object(document, "x-test-typed"); + assertThat(typedExtension.get("enabled"), is(true)); + assertThat(typedExtension.get("retries"), instanceOf(Number.class)); + assertThat(((Number) typedExtension.get("retries")).intValue(), is(3)); + assertThat(list(typedExtension, "tags"), contains("generated", "openapi")); + assertThat(typedExtension, hasKey("none")); + assertThat(typedExtension.get("none"), is(nullValue())); + + Map server = object(list(document, "servers").getFirst()); + assertThat(server.get("url"), is("https://openapi.example.test:{port}{basePath}")); + assertThat(server.get("description"), is("Test server")); + Map serverVariables = object(server, "variables"); + Map port = object(serverVariables, "port"); + assertThat(port.get("default"), is("8443")); + assertThat(list(port, "enum"), contains("8443", "443")); + assertThat(port.get("description"), is("HTTPS port")); + Map basePath = object(serverVariables, "basePath"); + assertThat(basePath.get("default"), is("")); + assertThat(basePath.get("description"), is("Optional base path")); + assertThat(basePath, not(hasKey("enum"))); + + Map securitySchemes = object(object(document, "components"), "securitySchemes"); + Map bearerAuth = object(securitySchemes, "bearerAuth"); + assertThat(bearerAuth.get("type"), is("http")); + assertThat(bearerAuth.get("description"), is("Bearer token authentication")); + assertThat(bearerAuth.get("scheme"), is("bearer")); + assertThat(bearerAuth.get("bearerFormat"), is("JWT")); + + Map oauth2 = object(securitySchemes, "oauth2"); + assertThat(oauth2.get("type"), is("oauth2")); + Map clientCredentials = object(object(oauth2, "flows"), "clientCredentials"); + assertThat(clientCredentials.get("tokenUrl"), is("https://id.example.com/oauth2/token")); + assertThat(object(clientCredentials, "scopes").get("greeting:read"), is("Read greetings")); + + List security = list(document, "security"); + assertThat(list(object(security.getFirst()), "bearerAuth"), is(List.of())); + assertThat(list(object(security.get(1)), "oauth2"), contains("greeting:read")); + } + + @Test + void generatedDocumentInfersMinimalOperationShapeFromDeclarativeHttp() { + Map document = document(); + Map operation = operation(document, "/greetings/{name}", "get"); + + assertThat(list(operation, "tags"), contains("greeting")); + List security = list(operation, "security"); + assertThat(security.size(), is(1)); + assertThat(list(object(security.getFirst()), "bearerAuth"), is(List.of())); + + Map name = parameter(operation, "name", "path"); + assertThat(name.get("required"), is(true)); + assertThat(object(name, "schema").get("type"), is("string")); + + Map language = parameter(operation, "language", "query"); + assertThat(language.get("required"), is(false)); + assertThat(object(language, "schema").get("type"), is("string")); + + Map include = parameter(operation, "include", "query"); + assertThat(include.get("required"), is(true)); + assertThat(include.get("style"), is("form")); + assertThat(include.get("explode"), is(true)); + assertThat(object(object(include, "schema"), "items").get("type"), is("string")); + + Map response = response(operation, "200"); + assertThat(response.get("description"), is("OK")); + assertThat(ref(content(response, MediaTypes.APPLICATION_JSON_VALUE)), is(messageRef(document))); + } + + @Test + void generatedDocumentUsesExplicitAnnotationsWhenPresent() { + Map document = document(); + Map operation = operation(document, "/greetings/documented/{name}", "get"); + + assertThat(operation.get("summary"), is("Find a greeting")); + assertThat(operation.get("description"), is("Returns a documented greeting.")); + assertThat(operation.get("operationId"), is("findDocumentedGreeting")); + assertThat(list(operation, "tags"), contains("greeting", "documented")); + assertThat(operation.get("deprecated"), is(true)); + + Map parameter = parameter(operation, "name", "path"); + assertThat(parameter.get("description"), is("Greeting recipient")); + assertThat(parameter.get("example"), is("Tomas")); + + Map response = response(operation, "200"); + assertThat(response.get("description"), is("Greeting found")); + assertThat(ref(content(response, MediaTypes.APPLICATION_JSON_VALUE)), is(messageRef(document))); + } + + @Test + void generatedDocumentIncludesRichResponseMetadata() { + Map document = document(); + Map operation = operation(document, "/greetings/responses", "get"); + + Map response = response(operation, "202"); + assertThat(response.get("description"), is("Accepted greeting")); + assertThat(ref(content(response, "application/vnd.greeting+json")), is(messageRef(document))); + Map responseExample = example(mediaTypeObject(response, "application/vnd.greeting+json"), + "accepted-response"); + assertThat(responseExample.get("summary"), is("Accepted example")); + assertThat(object(responseExample, "value").get("message"), is("Accepted")); + + Map headers = object(response, "headers"); + assertThat(headers, not(hasKey("Content-Type"))); + Map staticHeader = object(headers, "X-Static"); + assertThat(staticHeader.get("required"), is(true)); + Map staticHeaderSchema = object(staticHeader, "schema"); + assertThat(staticHeaderSchema.get("type"), is("string")); + assertThat(staticHeaderSchema.get("default"), is("static")); + Map computedHeader = object(headers, "X-Computed"); + assertThat(computedHeader, not(hasKey("required"))); + Map computedHeaderSchema = object(computedHeader, "schema"); + assertThat(computedHeaderSchema.get("type"), is("string")); + assertThat(computedHeaderSchema, not(hasKey("default"))); + Map documentedHeader = object(headers, "X-Documented"); + assertThat(documentedHeader.get("description"), is("Documented response header")); + assertThat(documentedHeader.get("required"), is(true)); + assertThat(documentedHeader.get("deprecated"), is(true)); + assertThat(object(documentedHeader, "schema").get("type"), is("string")); + + Map link = object(object(response, "links"), "documentedGreeting"); + assertThat(link.get("operationId"), is("findDocumentedGreeting")); + assertThat(object(link, "parameters").get("name"), is("$response.body#/message")); + assertThat(link.get("requestBody"), is("$response.body")); + assertThat(link.get("description"), is("Follow the documented greeting")); + } + + @Test + void generatedDocumentMergesExplicitParameterMetadata() { + Map document = document(); + Map operation = operation(document, "/greetings/parameters/{id}", "get"); + + Map id = parameter(operation, "id", "path"); + assertThat(id.get("required"), is(true)); + assertThat(id.get("description"), is("Greeting identifier")); + Object idExample = id.get("example"); + assertThat(idExample, instanceOf(Number.class)); + assertThat(((Number) idExample).doubleValue(), is(42.0)); + + Map search = parameter(operation, "search", "query"); + assertThat(search.get("required"), is(false)); + assertThat(search.get("description"), is("Search text")); + assertThat(search.get("style"), is("form")); + assertThat(search.get("explode"), is(false)); + assertThat(search.get("allowReserved"), is(true)); + assertThat(search.get("deprecated"), is(true)); + assertThat(search.containsKey("example"), is(false)); + Map searchExample = example(search, "search-example"); + assertThat(searchExample.get("summary"), is("Search example")); + assertThat(searchExample.get("value"), is("hi")); + + Map filter = parameter(operation, "filter", "query"); + assertThat(filter.get("required"), is(true)); + assertThat(filter.get("style"), is("pipeDelimited")); + assertThat(filter.get("explode"), is(false)); + assertThat(object(object(filter, "schema"), "items").get("type"), is("string")); + + Map packed = parameter(operation, "packed", "query"); + assertThat(packed.get("description"), is("Packed JSON filter")); + assertThat(packed, not(hasKey("schema"))); + assertThat(ref(content(packed, MediaTypes.APPLICATION_JSON_VALUE)), is(messageRequestRef(document))); + Map packedExample = object(example(mediaTypeObject(packed, MediaTypes.APPLICATION_JSON_VALUE), + "packed-example"), + "value"); + assertThat(packedExample.get("prefix"), is("Hello")); + assertThat(packedExample.get("name"), is("Ada")); + + Map trace = parameter(operation, "X-Trace", "header"); + assertThat(trace.get("required"), is(false)); + assertThat(trace.get("description"), is("Trace header")); + assertThat(trace.get("style"), is("simple")); + assertThat(trace.get("explode"), is(false)); + assertThat(example(trace, "trace-example").get("value"), is("abc-123")); + + Map modes = parameter(operation, "X-Modes", "header"); + assertThat(modes.get("required"), is(true)); + assertThat(modes.get("style"), is("simple")); + assertThat(modes.get("explode"), is(false)); + assertThat(object(object(modes, "schema"), "items").get("type"), is("string")); + } + + @Test + void generatedDocumentIncludesOperationLevelMetadata() { + Map document = document(); + Map operation = operation(document, "/greetings/documented/{name}", "get"); + + Map server = object(list(operation, "servers").getFirst()); + assertThat(server.get("url"), is("https://{region}.api.example.com/greetings")); + assertThat(server.get("description"), is("Operation server")); + Map region = object(object(server, "variables"), "region"); + assertThat(region.get("default"), is("us")); + assertThat(list(region, "enum"), contains("us", "eu")); + + Map externalDocs = object(operation, "externalDocs"); + assertThat(externalDocs.get("url"), is("https://helidon.io/docs/openapi")); + assertThat(externalDocs.get("description"), is("Operation documentation")); + assertThat(operation.get("x-test-operation"), is("documented-greeting")); + + List security = list(operation, "security"); + assertThat(security.size(), is(1)); + Map allRequired = object(security.getFirst()); + assertThat(list(allRequired, "bearerAuth"), is(List.of())); + assertThat(list(allRequired, "oauth2"), contains("greeting:read")); + } + + @Test + void generatedDocumentCanClearOperationSecurity() { + Map operation = operation(document(), "/greetings/public", "get"); + + assertThat(list(operation, "security"), is(List.of())); + } + + @Test + void generatedDocumentInfersRequestBodyStatusAndOptionalResponses() { + Map document = document(); + + Map create = operation(document, "/greetings", "post"); + assertThat(create.get("operationId"), is("greetingPostCreate")); + Map createBody = object(create, "requestBody"); + assertThat(createBody.get("description"), is("Greeting payload")); + assertThat(createBody.get("required"), is(true)); + assertThat(ref(content(createBody, MediaTypes.APPLICATION_JSON_VALUE)), is(messageRequestRef(document))); + Map createExample = object(example(mediaTypeObject(createBody, MediaTypes.APPLICATION_JSON_VALUE), + "create-request"), + "value"); + assertThat(createExample.get("prefix"), is("Hello")); + assertThat(createExample.get("name"), is("Ada")); + assertThat(response(create, "201").get("description"), is("Created")); + assertThat(ref(content(response(create, "201"), MediaTypes.APPLICATION_JSON_VALUE)), + is(messageRef(document))); + + Map inferredBody = object(operation(document, "/greetings/inferred-body", "put"), "requestBody"); + assertThat(inferredBody.get("description"), is("Inferred greeting payload")); + assertThat(inferredBody.get("required"), is(true)); + assertThat(ref(content(inferredBody, MediaTypes.APPLICATION_JSON_VALUE)), is(messageRequestRef(document))); + + Map explicitBody = object(operation(document, "/greetings/explicit-request-schema", "post"), + "requestBody"); + assertThat(explicitBody.get("description"), is("Explicit request schema")); + assertThat(ref(content(explicitBody, MediaTypes.APPLICATION_JSON_VALUE)), + is(messageRequestRef(document))); + assertThat(object(object(document, "components"), "schemas"), not(hasKey("InternalPayload"))); + + Map requestParams = operation(document, "/greetings/request-params/{id}", "put"); + Map requestParamsId = parameter(requestParams, "id", "path"); + assertThat(requestParamsId.get("required"), is(true)); + assertThat(object(requestParamsId, "schema").get("type"), is("string")); + Map requestParamsSearch = parameter(requestParams, "search", "query"); + assertThat(requestParamsSearch.get("description"), is("Request params search")); + assertThat(object(requestParamsSearch, "schema").get("type"), is("string")); + Map requestParamsTrace = parameter(requestParams, "X-Trace", "header"); + assertThat(object(requestParamsTrace, "schema").get("type"), is("string")); + Map requestParamsBody = object(requestParams, "requestBody"); + assertThat(requestParamsBody.get("description"), is("Request params body")); + assertThat(ref(content(requestParamsBody, MediaTypes.APPLICATION_JSON_VALUE)), is(messageRequestRef(document))); + + Map formCookie = operation(document, "/greetings/form-cookie", "post"); + String expectedCookieStyle = expectedOpenApiVersion().startsWith("3.2") ? "cookie" : "form"; + Map session = parameter(formCookie, "session", "cookie"); + assertThat(session.get("description"), is("Session cookie")); + assertThat(session.get("required"), is(true)); + assertThat(session.get("style"), is(expectedCookieStyle)); + assertThat(object(session, "schema").get("type"), is("string")); + Map tracking = parameter(formCookie, "tracking", "cookie"); + assertThat(tracking.get("required"), is(false)); + assertThat(tracking.get("style"), is(expectedCookieStyle)); + assertThat(object(tracking, "schema").get("type"), is("string")); + Map formBody = object(formCookie, "requestBody"); + assertThat(formBody.get("description"), is("Greeting form")); + assertThat(formBody.get("required"), is(true)); + Map formContent = mediaTypeObject(formBody, MediaTypes.APPLICATION_FORM_URLENCODED_VALUE); + Map formSchema = object(formContent, "schema"); + assertThat(formSchema.get("type"), is("object")); + Map formProperties = object(formSchema, "properties"); + assertThat(object(formProperties, "prefix").get("type"), is("string")); + assertThat(object(formProperties, "name").get("type"), is("string")); + assertThat(object(object(formProperties, "tag"), "items").get("type"), is("string")); + assertThat(list(formSchema, "required"), contains("prefix", "name")); + Map formExample = object(example(formContent, "form-example"), "value"); + assertThat(formExample.get("prefix"), is("Hello")); + assertThat(formExample.get("name"), is("Ada")); + + Map optional = operation(document, "/greetings/optional/{name}", "get"); + assertThat(optional.get("operationId"), is("greetingGetMaybeFind")); + Map optionalFound = response(optional, "200"); + assertThat(optionalFound.get("description"), is("OK")); + assertThat(ref(content(optionalFound, MediaTypes.APPLICATION_JSON_VALUE)), is(messageRef(document))); + Map optionalMissing = response(optional, "404"); + assertThat(optionalMissing.get("description"), is("Not Found")); + assertThat(optionalMissing, not(hasKey("content"))); + } + + @Test + void generatedDocumentUsesJsonSchemaComponentsForEntityTypes() { + Map document = document(); + Map schemas = schemas(document); + + Map message = object(schemas, messageSchema(document)); + assertThat(message.get("type"), is("object")); + assertThat(message.get("description"), is(MESSAGE_SCHEMA_DESCRIPTION)); + assertThat(list(message, "required"), contains("message")); + Map messageText = object(object(message, "properties"), "message"); + assertThat(messageText.get("type"), is("string")); + assertThat(messageText.get("description"), is("Message text")); + + Map request = object(schemas, messageRequestSchema(document)); + assertThat(request.get("type"), is("object")); + assertThat(request.get("description"), is(MESSAGE_REQUEST_SCHEMA_DESCRIPTION)); + assertThat(list(request, "required"), contains("prefix", "name")); + Map prefix = object(object(request, "properties"), "prefix"); + assertThat(prefix.get("type"), is("string")); + assertThat(prefix.get("description"), is("Greeting prefix")); + Map name = object(object(request, "properties"), "name"); + assertThat(name.get("type"), is("string")); + assertThat(name.get("description"), is("Recipient name")); + assertThat(schemas, not(hasKey("GreetingRequestParams"))); + assertThat(schemas, not(hasKey("GreetingFormParams"))); + } + + @Test + void generatedDocumentDisambiguatesCollidingSchemaNames() { + Map document = document(); + Map schemas = schemas(document); + String messageSchema = messageSchema(document); + String otherMessageSchema = schemaWithDescription(document, OTHER_MESSAGE_SCHEMA_DESCRIPTION); + + assertThat(schemas, hasKey(messageSchema)); + assertThat(schemas, hasKey(otherMessageSchema)); + assertThat(otherMessageSchema, not(is(messageSchema))); + + Map create = operation(document, "/collisions", "post"); + assertThat(ref(content(object(create, "requestBody"), MediaTypes.APPLICATION_JSON_VALUE)), + is(schemaRef(otherMessageSchema))); + assertThat(ref(content(response(create, "200"), MediaTypes.APPLICATION_JSON_VALUE)), + is(schemaRef(messageSchema))); + } + + @Test + void generatedDocumentDisambiguatesSchemaNamesAcrossEndpointSources() { + Map document = document(); + Map schemas = schemas(document); + String messageSchema = messageSchema(document); + String externalMessageSchema = schemaWithDescription(document, EXTERNAL_MESSAGE_SCHEMA_DESCRIPTION); + + assertThat(schemas, hasKey(messageSchema)); + assertThat(schemas, hasKey(externalMessageSchema)); + assertThat(externalMessageSchema, not(is(messageSchema))); + + Map get = operation(document, "/external-message", "get"); + assertThat(ref(content(response(get, "200"), MediaTypes.APPLICATION_JSON_VALUE)), + is(schemaRef(externalMessageSchema))); + } + + @Test + void generatedDocumentDoesNotExposeJavaPackageNamesInSchemaNamesOrRefs() { + Map document = document(); + schemas(document).keySet() + .forEach(name -> assertThat(name, not(containsString("io.helidon")))); + assertSchemaRefsDoNotExposeJavaPackageNames(document); + } + + @Test + void generatedDocumentAndEndpointSourcesAreDiscovered() { + List sourceNames = Services.all(OpenApiDocumentSource.class) + .stream() + .map(source -> source.getClass().getSimpleName()) + .toList(); + + assertThat(sourceNames, + hasItems("Main__OpenApiDocumentSource", + "AlternateDocument__OpenApiDocumentSource", + "CollisionEndpoint__OpenApiEndpointSource")); + } + + @Test + void generatedDocumentExcludesHiddenOperations() { + assertThat(object(document(), "paths"), not(hasKey("/greetings/internal"))); + } + + @Test + void generatedDocumentNormalizesHelidonPathTemplatesAndUsesOperationPathOverride() { + Map document = document(); + Map paths = object(document, "paths"); + + Map constrained = operation(document, "/greetings/constrained/{id}", "get"); + assertThat(parameter(constrained, "id", "path").get("required"), is(true)); + assertThat(paths, not(hasKey("/greetings/constrained/{id:[0-9]+}"))); + + Map override = operation(document, "/greetings/override/{id}", "get"); + assertThat(parameter(override, "id", "path").get("required"), is(true)); + assertThat(paths, not(hasKey("/greetings/override[/{id}]"))); + } + + @Test + void staticDocumentIsMergedWithGeneratedDocument() { + Map document = document(); + + assertThat(operation(document, "/static/status", "get").get("operationId"), is("staticGetStatus")); + assertThat(operation(document, "/greetings/{name}", "get").get("operationId"), + is(CONFIGURED_GREETING_FIND_OPERATION_ID)); + } + + @Test + void generatedEndpointsContributeOnlyToDefaultListenerDocument() { + Map defaultDocument = document(); + Map adminDocument = document(adminClient); + + assertThat(object(defaultDocument, "paths"), hasKey("/greetings/{name}")); + assertThat(object(defaultDocument, "paths"), not(hasKey("/admin/status"))); + assertThat(object(adminDocument, "paths"), hasKey("/static/status")); + assertThat(object(adminDocument, "paths"), hasKey("/admin/status")); + assertThat(object(adminDocument, "paths"), not(hasKey("/greetings/{name}"))); + assertThat(object(adminDocument, "paths"), not(hasKey("/farewells/{name}"))); + assertThat(object(adminDocument, "paths"), not(hasKey("/collisions"))); + } + + String expectedOpenApiVersion() { + return "3.0.3"; + } + + boolean supportsLicenseIdentifier() { + return false; + } + + static void configureServer(WebServerConfig.Builder builder, String configResource) { + Config config = Config.just(ConfigSources.classpath(configResource)); + builder.clearFeatures() + .config(config.get("server")); + } + + private Map document() { + return document(client); + } + + private static Map document(Http1Client client) { + try (Http1ClientResponse response = client.get("/openapi") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request()) { + + assertThat(response.status(), is(Status.OK_200)); + return parse(response.as(String.class)); + } + } + + private static Map parse(String yaml) { + return object(new Yaml().load(yaml)); + } + + private static Map schemas(Map document) { + return object(object(document, "components"), "schemas"); + } + + private static String messageSchema(Map document) { + return schemaWithDescription(document, MESSAGE_SCHEMA_DESCRIPTION); + } + + private static String messageRequestSchema(Map document) { + return schemaWithDescription(document, MESSAGE_REQUEST_SCHEMA_DESCRIPTION); + } + + private static String messageRef(Map document) { + return schemaRef(messageSchema(document)); + } + + private static String messageRequestRef(Map document) { + return schemaRef(messageRequestSchema(document)); + } + + private static String schemaRef(String name) { + return SCHEMA_REF_PREFIX + name; + } + + private static String schemaWithDescription(Map document, String description) { + List names = schemas(document) + .entrySet() + .stream() + .filter(entry -> description.equals(object(entry.getValue()).get("description"))) + .map(Map.Entry::getKey) + .toList(); + assertThat("schema count for " + description, names.size(), is(1)); + return names.getFirst(); + } + + private static void assertSchemaRefsDoNotExposeJavaPackageNames(Object value) { + if (value instanceof Map map) { + Object ref = map.get("$ref"); + if (ref instanceof String refValue && refValue.startsWith(SCHEMA_REF_PREFIX)) { + assertThat(refValue, not(containsString("io.helidon"))); + } + map.values().forEach(DeclarativeOpenApiTest::assertSchemaRefsDoNotExposeJavaPackageNames); + } else if (value instanceof List list) { + list.forEach(DeclarativeOpenApiTest::assertSchemaRefsDoNotExposeJavaPackageNames); + } + } + + private static Map operation(Map document, String path, String method) { + return object(object(object(document, "paths"), path), method); + } + + private static Map response(Map operation, String status) { + return object(object(operation, "responses"), status); + } + + private static Map content(Map owner, String mediaType) { + return object(mediaTypeObject(owner, mediaType), "schema"); + } + + private static Map mediaTypeObject(Map owner, String mediaType) { + return object(object(owner, "content"), mediaType); + } + + private static Map example(Map owner, String name) { + return object(object(owner, "examples"), name); + } + + private static String ref(Map schema) { + return (String) schema.get("$ref"); + } + + private static Map parameter(Map operation, String name, String in) { + for (Object parameter : list(operation, "parameters")) { + Map parameterObject = object(parameter); + if (name.equals(parameterObject.get("name")) && in.equals(parameterObject.get("in"))) { + return parameterObject; + } + } + throw new AssertionError("Parameter not found: " + in + " " + name); + } + + private static Map object(Map owner, String key) { + return object(owner.get(key)); + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + assertThat(value, instanceOf(Map.class)); + return (Map) value; + } + + @SuppressWarnings("unchecked") + private static List list(Map owner, String key) { + Object value = owner.get(key); + assertThat(value, instanceOf(List.class)); + return (List) value; + } +} diff --git a/declarative/tests/openapi/src/test/resources/application-openapi-31.yaml b/declarative/tests/openapi/src/test/resources/application-openapi-31.yaml new file mode 100644 index 00000000000..d9f3564de5b --- /dev/null +++ b/declarative/tests/openapi/src/test/resources/application-openapi-31.yaml @@ -0,0 +1,40 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +test: + openapi: + server-url: "https://openapi.example.test" + extension-value: '{"enabled":true,"retries":3,"tags":["generated","openapi"],"none":null}' + +server: + port: 0 + sockets: + - name: "admin" + port: 0 + features: + declarative-openapi: + type: "openapi" + web-context: "/openapi" + static-file: "static-openapi.yaml" + document: + - type: "3.1" + generated: + mode: "MERGE" + resolve-config-expressions: true + document-sources: + - "io.helidon.declarative.tests.openapi.Main" + operation-ids: + "io.helidon.declarative.tests.openapi.GreetingEndpoint#find(java.lang.String,java.util.Optional,java.util.List)": "configuredGreetingFind" diff --git a/declarative/tests/openapi/src/test/resources/application-openapi-32.yaml b/declarative/tests/openapi/src/test/resources/application-openapi-32.yaml new file mode 100644 index 00000000000..4324b19d9a6 --- /dev/null +++ b/declarative/tests/openapi/src/test/resources/application-openapi-32.yaml @@ -0,0 +1,40 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +test: + openapi: + server-url: "https://openapi.example.test" + extension-value: '{"enabled":true,"retries":3,"tags":["generated","openapi"],"none":null}' + +server: + port: 0 + sockets: + - name: "admin" + port: 0 + features: + declarative-openapi: + type: "openapi" + web-context: "/openapi" + static-file: "static-openapi.yaml" + document: + - type: "3.2" + generated: + mode: "MERGE" + resolve-config-expressions: true + document-sources: + - "io.helidon.declarative.tests.openapi.Main" + operation-ids: + "io.helidon.declarative.tests.openapi.GreetingEndpoint#find(java.lang.String,java.util.Optional,java.util.List)": "configuredGreetingFind" diff --git a/declarative/tests/openapi/src/test/resources/application.yaml b/declarative/tests/openapi/src/test/resources/application.yaml new file mode 100644 index 00000000000..895e554a15c --- /dev/null +++ b/declarative/tests/openapi/src/test/resources/application.yaml @@ -0,0 +1,40 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +test: + openapi: + server-url: "https://openapi.example.test" + extension-value: '{"enabled":true,"retries":3,"tags":["generated","openapi"],"none":null}' + +server: + port: 0 + sockets: + - name: "admin" + port: 0 + features: + declarative-openapi: + type: "openapi" + web-context: "/openapi" + static-file: "static-openapi.yaml" + document: + - type: "3.0" + generated: + mode: "MERGE" + resolve-config-expressions: true + document-sources: + - "io.helidon.declarative.tests.openapi.Main" + operation-ids: + "io.helidon.declarative.tests.openapi.GreetingEndpoint#find(java.lang.String,java.util.Optional,java.util.List)": "configuredGreetingFind" diff --git a/declarative/tests/openapi/src/test/resources/static-openapi.yaml b/declarative/tests/openapi/src/test/resources/static-openapi.yaml new file mode 100644 index 00000000000..70285388d15 --- /dev/null +++ b/declarative/tests/openapi/src/test/resources/static-openapi.yaml @@ -0,0 +1,28 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +openapi: 3.0.3 +info: + title: Declarative OpenAPI Test + version: 1.0.0 +paths: + /static/status: + get: + operationId: staticGetStatus + summary: Static status + responses: + "200": + description: Static status response diff --git a/declarative/tests/pom.xml b/declarative/tests/pom.xml index 3b3c1f52181..6a5b3ea9629 100644 --- a/declarative/tests/pom.xml +++ b/declarative/tests/pom.xml @@ -48,6 +48,7 @@ http scheduling metrics + openapi tracing graphql websocket diff --git a/docs/config/io.helidon.openapi.GeneratedConfig.md b/docs/config/io.helidon.openapi.GeneratedConfig.md new file mode 100644 index 00000000000..ce5e59bee4a --- /dev/null +++ b/docs/config/io.helidon.openapi.GeneratedConfig.md @@ -0,0 +1,80 @@ +# io.helidon.openapi.GeneratedConfig + +## Description + +Configuration for openapi.generated + +## Configuration options + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTypeDefaultDescription
+document-sources + +List<String> + +Named generated document metadata sources to use, in the order configured
+ + +mode + + +OpenApiGeneratedMode + +STATIC_FIRST +Generated document source handling mode
+operation-ids + +Map<String, String> + +Operation ids to use for generated Java methods
+resolve-config-expressions + +Boolean + +false +Whether generated document sources resolve annotation string values as Helidon config expressions at runtime
+ + + +## Usages + +- openapi.generated + +--- + +See the [manifest](manifest.md) for all available types. diff --git a/docs/config/io.helidon.openapi.OpenApiFeature.md b/docs/config/io.helidon.openapi.OpenApiFeature.md index 41f24918387..2e0c62d1db1 100644 --- a/docs/config/io.helidon.openapi.OpenApiFeature.md +++ b/docs/config/io.helidon.openapi.OpenApiFeature.md @@ -2,7 +2,7 @@ ## Description -OpenApiFeature prototype +OpenAPI feature configuration ## Configuration options @@ -19,33 +19,33 @@ -web-context + + +document + -String +Map<String, OpenApiVersion> or List<OpenApiVersion> -/openapi -Web context path for the OpenAPI endpoint +OpenAPI version implementation for rendered generated or merged documents - - -manager - +document-discover-services -Map<String, OpenApiManager> or List<OpenApiManager> +Boolean +true -OpenAPI manager +Whether to enable automatic service discovery for document -services-discover-services +enabled Boolean @@ -53,42 +53,70 @@ true -Whether to enable automatic service discovery for services +Sets whether the feature should be enabled -roles + + +generated + -List<String> -openapi -Hints for role names the user is expected to be in +Configuration for generated -static-file + + +manager + -String +Map<String, OpenApiManager> or List<OpenApiManager> -Path of the static OpenAPI document file +OpenAPI manager -weight +manager-discover-services -Double +Boolean -90.0 +false -Weight of the OpenAPI feature +Whether to enable automatic service discovery for manager + + + +permit-all + + +Boolean + + +true + +Whether to allow anybody to access the endpoint + + + +roles + + +List<String> + + +openapi + +Hints for role names the user is expected to be in @@ -106,6 +134,18 @@ +services-discover-services + + +Boolean + + +true + +Whether to enable automatic service discovery for services + + + sockets @@ -117,39 +157,38 @@ -manager-discover-services +static-file -Boolean +String -false -Whether to enable automatic service discovery for manager +Path of the static OpenAPI document file -permit-all +web-context -Boolean +String -true +/openapi -Whether to allow anybody to access the endpoint +Web context path for the OpenAPI endpoint -enabled +weight -Boolean +Double -true +90.0 -Sets whether the feature should be enabled +Weight of the OpenAPI feature diff --git a/docs/config/io.helidon.openapi.OpenApiGeneratedMode.md b/docs/config/io.helidon.openapi.OpenApiGeneratedMode.md new file mode 100644 index 00000000000..1ac9a46c3ad --- /dev/null +++ b/docs/config/io.helidon.openapi.OpenApiGeneratedMode.md @@ -0,0 +1,43 @@ +# io.helidon.openapi.OpenApiGeneratedMode + +## Description + +This type is an enumeration. + +## Allowed Values + + + + + + + + + + + + + + + + + + + + + + + + + + +
ValueDescription
STATIC_FIRSTUse a static document when present, otherwise use generated document sources
STATIC_ONLYUse only a static document and ignore generated document sources
MERGEStrictly merge generated document sources into a static document
GENERATED_ONLYUse only generated document sources, even when a static document is present
+ +## Usages + +- openapi.generated.mode +- server.features.openapi.generated.mode + +--- + +See the [manifest](manifest.md) for all available types. diff --git a/docs/config/io.helidon.openapi.spi.OpenApiVersion.md b/docs/config/io.helidon.openapi.spi.OpenApiVersion.md new file mode 100644 index 00000000000..b5da745f3cf --- /dev/null +++ b/docs/config/io.helidon.openapi.spi.OpenApiVersion.md @@ -0,0 +1,58 @@ +# io.helidon.openapi.spi.OpenApiVersion + +## Description + +This type is a provider contract. + +## Implementations + + + + + + + + + + + + + + + + + + + + + + + + +
KeyDescription
+ + +3.0 + +OpenAPI 3.0 version configuration
+ + +3.1 + +OpenAPI 3.1 version configuration
+ + +3.2 + +OpenAPI 3.2 version configuration
+ + + +## Usages + +- openapi.document +- server.features.openapi.document + +--- + +See the [manifest](manifest.md) for all available types. diff --git a/docs/config/io.helidon.openapi.v30.OpenApi30Version.md b/docs/config/io.helidon.openapi.v30.OpenApi30Version.md new file mode 100644 index 00000000000..168763171f2 --- /dev/null +++ b/docs/config/io.helidon.openapi.v30.OpenApi30Version.md @@ -0,0 +1,44 @@ +# io.helidon.openapi.v30.OpenApi30Version + +## Description + +OpenAPI 3.0 version configuration + +## Configuration options + + + + + + + + + + + + + + + + + + + +
KeyTypeDefaultDescription
+version + +String + +3.0.3 +Exact OpenAPI 3.0 document version to produce
+ + + +## Usages + +- openapi.document.3.0 +- server.features.openapi.document.3.0 + +--- + +See the [manifest](manifest.md) for all available types. diff --git a/docs/config/io.helidon.openapi.v31.OpenApi31Version.md b/docs/config/io.helidon.openapi.v31.OpenApi31Version.md new file mode 100644 index 00000000000..66be84e2413 --- /dev/null +++ b/docs/config/io.helidon.openapi.v31.OpenApi31Version.md @@ -0,0 +1,44 @@ +# io.helidon.openapi.v31.OpenApi31Version + +## Description + +OpenAPI 3.1 version configuration + +## Configuration options + + + + + + + + + + + + + + + + + + + +
KeyTypeDefaultDescription
+version + +String + +3.1.1 +Exact OpenAPI 3.1 document version to produce
+ + + +## Usages + +- openapi.document.3.1 +- server.features.openapi.document.3.1 + +--- + +See the [manifest](manifest.md) for all available types. diff --git a/docs/config/io.helidon.openapi.v32.OpenApi32Version.md b/docs/config/io.helidon.openapi.v32.OpenApi32Version.md new file mode 100644 index 00000000000..bbcddde5b51 --- /dev/null +++ b/docs/config/io.helidon.openapi.v32.OpenApi32Version.md @@ -0,0 +1,44 @@ +# io.helidon.openapi.v32.OpenApi32Version + +## Description + +OpenAPI 3.2 version configuration + +## Configuration options + + + + + + + + + + + + + + + + + + + +
KeyTypeDefaultDescription
+version + +String + +3.2.0 +Exact OpenAPI 3.2 document version to produce
+ + + +## Usages + +- openapi.document.3.2 +- server.features.openapi.document.3.2 + +--- + +See the [manifest](manifest.md) for all available types. diff --git a/docs/config/io.helidon.server.features.openapi.GeneratedConfig.md b/docs/config/io.helidon.server.features.openapi.GeneratedConfig.md new file mode 100644 index 00000000000..fe314cb9825 --- /dev/null +++ b/docs/config/io.helidon.server.features.openapi.GeneratedConfig.md @@ -0,0 +1,80 @@ +# io.helidon.server.features.openapi.GeneratedConfig + +## Description + +Configuration for server.features.openapi.generated + +## Configuration options + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
KeyTypeDefaultDescription
+document-sources + +List<String> + +Named generated document metadata sources to use, in the order configured
+ + +mode + + +OpenApiGeneratedMode + +STATIC_FIRST +Generated document source handling mode
+operation-ids + +Map<String, String> + +Operation ids to use for generated Java methods
+resolve-config-expressions + +Boolean + +false +Whether generated document sources resolve annotation string values as Helidon config expressions at runtime
+ + + +## Usages + +- server.features.openapi.generated + +--- + +See the [manifest](manifest.md) for all available types. diff --git a/docs/config/io.helidon.webserver.spi.ServerFeature.md b/docs/config/io.helidon.webserver.spi.ServerFeature.md index 992bb3daa60..f04e3db5543 100644 --- a/docs/config/io.helidon.webserver.spi.ServerFeature.md +++ b/docs/config/io.helidon.webserver.spi.ServerFeature.md @@ -86,7 +86,7 @@ This type is a provider contract. openapi -OpenApiFeature prototype +OpenAPI feature configuration diff --git a/docs/config/manifest.md b/docs/config/manifest.md index 155059516e0..cb0bdeb2b34 100644 --- a/docs/config/manifest.md +++ b/docs/config/manifest.md @@ -50,6 +50,9 @@ See the [root type](config_reference.md). - [io.helidon.metrics.providers.micrometer.OtlpPublisher](io.helidon.metrics.providers.micrometer.OtlpPublisher.md) - [io.helidon.metrics.providers.micrometer.PrometheusPublisher](io.helidon.metrics.providers.micrometer.PrometheusPublisher.md) - [io.helidon.openapi.OpenApiFeature](io.helidon.openapi.OpenApiFeature.md) +- [io.helidon.openapi.v30.OpenApi30Version](io.helidon.openapi.v30.OpenApi30Version.md) +- [io.helidon.openapi.v31.OpenApi31Version](io.helidon.openapi.v31.OpenApi31Version.md) +- [io.helidon.openapi.v32.OpenApi32Version](io.helidon.openapi.v32.OpenApi32Version.md) - [io.helidon.scheduling.Cron](io.helidon.scheduling.Cron.md) - [io.helidon.scheduling.FixedRate](io.helidon.scheduling.FixedRate.md) - [io.helidon.scheduling.TaskConfig](io.helidon.scheduling.TaskConfig.md) @@ -176,6 +179,7 @@ See the [root type](config_reference.md). - [io.helidon.metrics.api.MetricsPublisher](io.helidon.metrics.api.MetricsPublisher.md) - [io.helidon.openapi.OpenApiManager](io.helidon.openapi.OpenApiManager.md) - [io.helidon.openapi.OpenApiService](io.helidon.openapi.OpenApiService.md) +- [io.helidon.openapi.spi.OpenApiVersion](io.helidon.openapi.spi.OpenApiVersion.md) - [io.helidon.security.SecretsProviderConfig](io.helidon.security.SecretsProviderConfig.md) - [io.helidon.security.spi.SecurityProvider](io.helidon.security.spi.SecurityProvider.md) - [io.helidon.webclient.grpc.spi.GrpcClientService](io.helidon.webclient.grpc.spi.GrpcClientService.md) @@ -196,6 +200,7 @@ See the [root type](config_reference.md). - [io.helidon.http.RequestedUriDiscoveryContext.RequestedUriDiscoveryType](io.helidon.http.RequestedUriDiscoveryContext.RequestedUriDiscoveryType.md) - [io.helidon.http.SetCookie.SameSite](io.helidon.http.SetCookie.SameSite.md) - [io.helidon.metrics.api.BuiltInMeterNameFormat](io.helidon.metrics.api.BuiltInMeterNameFormat.md) +- [io.helidon.openapi.OpenApiGeneratedMode](io.helidon.openapi.OpenApiGeneratedMode.md) - [io.helidon.scheduling.FixedRate.DelayType](io.helidon.scheduling.FixedRate.DelayType.md) - [io.helidon.security.AuditEvent.AuditSeverity](io.helidon.security.AuditEvent.AuditSeverity.md) - [io.helidon.security.ProviderSelectionPolicyType](io.helidon.security.ProviderSelectionPolicyType.md) @@ -254,6 +259,7 @@ See the [root type](config_reference.md). - [io.helidon.metrics.keyPerformanceIndicators.LongRunningRequestsConfig](io.helidon.metrics.keyPerformanceIndicators.LongRunningRequestsConfig.md) - [io.helidon.metrics.scoping.scopes.FilterConfig](io.helidon.metrics.scoping.scopes.FilterConfig.md) - [io.helidon.metrics.virtualThreads.PinnedConfig](io.helidon.metrics.virtualThreads.PinnedConfig.md) +- [io.helidon.openapi.GeneratedConfig](io.helidon.openapi.GeneratedConfig.md) - [io.helidon.security.EnvironmentConfig](io.helidon.security.EnvironmentConfig.md) - [io.helidon.security.ProviderPolicyConfig](io.helidon.security.ProviderPolicyConfig.md) - [io.helidon.security.SecretsConfig](io.helidon.security.SecretsConfig.md) @@ -305,6 +311,7 @@ See the [root type](config_reference.md). - [io.helidon.server.features.observe.observers.metrics.keyPerformanceIndicators.LongRunningRequestsConfig](io.helidon.server.features.observe.observers.metrics.keyPerformanceIndicators.LongRunningRequestsConfig.md) - [io.helidon.server.features.observe.observers.metrics.scoping.scopes.FilterConfig](io.helidon.server.features.observe.observers.metrics.scoping.scopes.FilterConfig.md) - [io.helidon.server.features.observe.observers.metrics.virtualThreads.PinnedConfig](io.helidon.server.features.observe.observers.metrics.virtualThreads.PinnedConfig.md) +- [io.helidon.server.features.openapi.GeneratedConfig](io.helidon.server.features.openapi.GeneratedConfig.md) - [io.helidon.server.features.security.security.EnvironmentConfig](io.helidon.server.features.security.security.EnvironmentConfig.md) - [io.helidon.server.features.security.security.ProviderPolicyConfig](io.helidon.server.features.security.security.ProviderPolicyConfig.md) - [io.helidon.server.features.security.security.SecretsConfig](io.helidon.server.features.security.security.SecretsConfig.md) diff --git a/docs/modules/injection/declarative.md b/docs/modules/injection/declarative.md index 487a4f41dcd..c0e1eb57404 100644 --- a/docs/modules/injection/declarative.md +++ b/docs/modules/injection/declarative.md @@ -225,6 +225,12 @@ combine supported request parameter annotations. At most one `Http.Entity` component is supported, and `Http.Entity` cannot be combined with `Http.FormParam` components. +For declarative server endpoints, `Http.Entity` supports both a direct entity +type and `Optional`. A direct entity is mandatory, and the request fails if +the entity is missing. An optional entity is `Optional.empty()` when the +request has no entity. This behavior applies both to endpoint method parameters +and to `Http.RequestParams` record components. + The named value annotations include `Http.HeaderParam`, `Http.CookieParam`, `Http.QueryParam`, `Http.FormParam`, and `Http.PathParam`. They support scalar values, `Optional`, `List`, and `Optional>`. For server endpoints, diff --git a/docs/modules/openapi/openapi.md b/docs/modules/openapi/openapi.md index ffecd5314f1..ccdeb80fb6a 100644 --- a/docs/modules/openapi/openapi.md +++ b/docs/modules/openapi/openapi.md @@ -12,17 +12,18 @@ The [MicroProfile OpenAPI spec][microprofile-ope] explains how MicroProfile embraces OpenAPI, adding annotations, configuration, and a service provider interface (SPI). -OpenAPI support in Helidon draws its inspiration from MicroProfile OpenAPI -but does not implement the spec because Helidon Core does not support -annotations. +OpenAPI support in Helidon draws its inspiration from MicroProfile OpenAPI but +does not implement the specification. Helidon focuses on serving an OpenAPI +document and exposing it through the `/openapi` endpoint. The OpenAPI support in Helidon performs two main tasks: - Build an in-memory model of the REST API your service implements. - Expose the model in text format (YAML or JSON) via the `/openapi` endpoint. -To construct the model, Helidon gathers information about the service API from a -static OpenAPI document file packaged as part of your service. +To construct the model, Helidon gathers information about the service API from +a static OpenAPI document file packaged as part of your service, generated +OpenAPI document sources, or both. ## Maven Coordinates @@ -60,7 +61,8 @@ below][example-below] illustrates one way to do this. #### Furnish OpenAPI information about your endpoints Your application supplies data for the OpenAPI model using a static OpenAPI -file. +file. When you use Helidon Declarative endpoints, Helidon can also generate +OpenAPI data from annotations at build time. **Provide a static OpenAPI file** @@ -69,6 +71,119 @@ Add a static file at `META-INF/openapi.yml`, `META-INF/openapi.yaml`, or and they then generate an OpenAPI document file which you can include in your application so OpenAPI can use it. +**Generate OpenAPI data from declarative endpoints** + +> [!NOTE] +> Declarative OpenAPI generation and its model, annotation, version SPI, and +> generated-document configuration APIs are preview and may change. + +For classes annotated with +[`@RestServer.Endpoint`][restserver-endpoint], Helidon generates OpenAPI data +when the endpoint type, one of its methods, or one of its method parameters uses +an endpoint-applicable `OpenApi` annotation. To opt in without adding +OpenAPI-specific metadata, annotate the endpoint with `@OpenApi.Endpoint`. +Helidon derives the generated data from the HTTP method, path, media type, +parameter, status, and response metadata and from the Java signatures. + +Annotate non-built-in request and response model types with +[`@JsonSchema.Schema`][jsonschema-schema] so Helidon can generate their +component schemas. See the [JSON Schema documentation](../json/schema.md) for +details. + +The following annotation placements opt an endpoint into OpenAPI processing: + +- On the endpoint type: `Document`, `Hidden`, + `SecuritySchemeRequirement`, `SecurityRequirement`, or + `SecurityRequirements`. +- On an endpoint method: `Operation`, `Hidden`, `Server`, `Servers`, + `ExternalDocs`, `Extension`, `Extensions`, `SecuritySchemeRequirement`, + `SecurityRequirement`, `SecurityRequirements`, `Parameter`, `Parameters`, + `RequestBody`, `Response`, or `Responses`. +- On a method parameter: `Parameter` or `Parameters`. + +Document-only companion annotations such as `Info`, `Contact`, `License`, +`Tag`, and security scheme declarations do not opt an endpoint into generation +by themselves. + +`OpenApi.Endpoint` and endpoint-level `Hidden` and security requirement +annotations declared on a declarative REST endpoint contract also apply to its +implementations. `OpenApi.Document` describes only its declaring document +metadata type and is not inherited by endpoint implementations. An +endpoint-level security requirement declared directly on an implementation +replaces the security requirements inherited from its endpoint contract. + +Security requirements inherited from unrelated endpoint contracts are +combined. If one such contract clears endpoint security with an empty +`OpenApi.SecurityRequirements` container while another declares a requirement, +code generation fails because the contracts conflict. If matching methods +inherited from multiple endpoint contracts declare +`OpenApi.SecuritySchemeRequirement` annotations, each inherited method must +declare the same requirements, including repeated occurrences, although the +annotation order can differ. Helidon emits the inherited requirements once. +Different inherited declarations cause code generation to fail. A scheme +requirement declared directly on the endpoint implementation method replaces +all inherited method-level requirements. + +The final composed OpenAPI document must contain Info metadata. Supply it using +`@OpenApi.Document` and `@OpenApi.Info` on an application type, from a custom +[`OpenApiDocumentSource`][openapi-document-source], or from a static OpenAPI +document when static and generated content are merged. In a multi-module +application, annotation processing must generate OpenAPI metadata while +compiling every module that declares opted-in declarative endpoints. Adding +`@OpenApi.Document` only in the final application module does not include +unannotated endpoints from previously compiled modules. Use +[`io.helidon.openapi.OpenApi`][openapi-annotations] annotations to add +OpenAPI-specific details such as document info, operation descriptions, +parameters, responses, schemas, security, tags, servers, external docs, and +extensions. + +For OpenAPI 3.2, an application can set the document `$self` identity using +`OpenApi.Document.self` or `OpenApiDocument.Builder.self`. Helidon resolves a +relative `$self` against the configured OpenAPI web context for relative and +origin-relative references. Document composition does not have the request +scheme or authority, so an absolute reference cannot be recognized as a +same-document reference when `$self` is relative. This combination is not +supported. Use an absolute `$self` or relative references instead. The web +context is used only as the document retrieval-location base; Helidon does not +add the OpenAPI endpoint itself to the document's Paths Object. + +Annotation string values that become OpenAPI text values, such as document +info, descriptions, server URLs, example values, and external documentation +URLs, can use Helidon config expressions only when the OpenAPI feature config +enables `generated.resolve-config-expressions`. When enabled, Helidon resolves +those expressions at runtime when serving the generated OpenAPI document. When +disabled, which is the default, Helidon uses annotation text values literally. + +`@OpenApi.Extension` values are OpenAPI strings by default. Set `parseValue` to +`true` to parse the runtime-resolved annotation value as exactly one JSON value, +including a boolean, number, string, `null`, array, or object. Invalid JSON is +rejected when the generated OpenAPI document source runs. + +For templated server URLs, declare each substitution using +`@OpenApi.ServerVariable`. Each variable requires a name and default value and +can also declare an enumeration of allowed values and a description. + +Response metadata can declare links using `@OpenApi.Link`. A link identifies +its target using exactly one of `operationRef` or `operationId`. +`@OpenApi.LinkParameter` values and `requestBody` support literal strings and +OpenAPI runtime expressions; use the programmatic `OpenApiDocument.LinkBuilder` +for other OpenAPI value types. + +Annotation string values that identify or select generated metadata, such as +extension names, tag names, security scheme names and types, media types, +parameter names and locations, link and link-parameter names, and security +requirement scheme names and scopes, are resolved when OpenAPI metadata is +generated. For those values Helidon uses the expression default value, if one +is present. + +For generated security scheme components, prefer the type-specific annotations +`@OpenApi.ApiKeySecurityScheme`, `@OpenApi.HttpSecurityScheme`, +`@OpenApi.MutualTlsSecurityScheme`, `@OpenApi.OAuth2SecurityScheme`, and +`@OpenApi.OidcSecurityScheme`. Each exposes only the OpenAPI fields relevant to +that scheme type. Use the generic `@OpenApi.SecurityScheme` only when you need +the lower-level OpenAPI Security Scheme Object shape directly; Helidon rejects +fields that do not apply to the selected scheme type. + ### Accessing the REST Endpoint Once you have added the Helidon OpenAPI dependency to your project, if you are @@ -88,6 +203,97 @@ Alternatively, the client can pass the query parameter `format` as either `JSON` or `YAML` to receive `application/json` or `application/vnd.oai.openapi` (YAML) output, respectively. +### Static and Generated Documents + +By default, `OpenApiFeature` uses `STATIC_FIRST` generated document mode: a +static file is used when present, otherwise Helidon serves generated OpenAPI +data. Configure `generated.mode` to change this behavior. + +Generated OpenAPI document mode: + +```yaml +server: + features: + openapi: + static-file: "openapi.yaml" + generated: + mode: "MERGE" +``` + +Supported values are: + +- `STATIC_FIRST` - Use a static document when present, otherwise use generated + document sources. +- `STATIC_ONLY` - Use only a static document and ignore generated document + sources. +- `MERGE` - Strictly merge generated document sources into a static document. + Static and generated content must be non-conflicting; composition fails if + both sides define incompatible document values, the same path operation, or + duplicate `operationId` values. Helidon parses the static document and + renders the merged document through the configured `document` provider, even + for a listener where no generated source contributes. +- `GENERATED_ONLY` - Use only generated document sources, even when a static + document is present. + +When more than one generated document metadata source is visible, select the +sources to use with `generated.document-sources`. Sources generated from +`@OpenApi.Document` are named by the annotated type, using its dotted canonical +type name. Custom `OpenApiDocumentSource` services must be qualified with +`@Service.Named` to be selected by name. Unqualified sources always participate +when they support the document context and cannot be filtered with +`generated.document-sources`. + +When static and generated content or multiple generated sources contribute +component schemas, Helidon renames conflicting component names in generated +documents and rewrites references in standard JSON Schema applicator locations. +Custom JSON Schema vocabularies can define other applicator keywords whose +values are schemas, but Helidon cannot identify those locations from the +document alone. Generated sources that refer to component schemas from custom +applicator keywords must therefore use component names that are unique across +the static and generated documents being combined. + +Select generated document metadata sources: + +```yaml +server: + features: + openapi: + generated: + document-sources: + - "com.example.openapi.ApplicationOpenApi" +``` + +If a generated operation id needs to be stable or disambiguated, configure it +with `generated.operation-ids`. Each key is the fully qualified endpoint class +name, `#`, method name, and fully qualified parameter types separated by `,`. +The value is the operation id to use in the served document. + +Configure generated operation ids: + +```yaml +server: + features: + openapi: + generated: + operation-ids: + "com.example.GreetingEndpoint#greet(java.lang.String)": "greet" +``` + +Generated annotation text values are treated literally by default. Enable +`generated.resolve-config-expressions` when your OpenAPI annotations +intentionally use Helidon config expressions in user-visible document text, +such as document descriptions or server URLs. + +Resolve generated annotation config expressions: + +```yaml +server: + features: + openapi: + generated: + resolve-config-expressions: true +``` + ## API Helidon provides an API for creating and setting up the REST endpoint which @@ -107,6 +313,98 @@ Helidon OpenAPI configuration supports the settings described below in the See [Configuration options][io-helidon-opena]. +## OpenAPI Document Versions + +The base `helidon-openapi` module provides OpenAPI 3.0 support. To render +OpenAPI 3.1 or 3.2 documents, add the corresponding module. + +When the `document` provider is not configured, Helidon selects the highest +available OpenAPI document version provider discovered at runtime. With only +`helidon-openapi`, generated output uses OpenAPI 3.0.3. If +`helidon-openapi-31` is available, generated output uses OpenAPI 3.1.1. If +`helidon-openapi-32` is available, generated output uses OpenAPI 3.2.0. +Configure the `document` provider when your application must pin the generated +OpenAPI document version independently of which OpenAPI version modules are +present. + +Declarative `@OpenApi.MutualTlsSecurityScheme` metadata and +`@OpenApi.SecurityScheme` metadata with type `mutualTLS` require OpenAPI 3.1 or +3.2 output. Use `helidon-openapi-31`, `helidon-openapi-32`, or another +configured document provider which renders one of those versions. Generation +fails if the selected provider renders OpenAPI 3.0. + +Declarative `@OpenApi.OAuthFlows` metadata which configures +`deviceAuthorization` requires OpenAPI 3.2 output. Use `helidon-openapi-32` or +another configured document provider which renders OpenAPI 3.2. Generation +fails if the selected provider renders OpenAPI 3.0 or 3.1. + +Declarative `@OpenApi.Content` metadata which configures `itemSchema` requires +OpenAPI 3.2 output. When `itemSchema` is set without an explicit `schema`, it +replaces the inferred request or response entity schema. Earlier output +versions omit `itemSchema`. + +The configured `document` provider is used when Helidon renders generated or +merged OpenAPI documents. In `MERGE` mode, Helidon parses the static document +and renders the result with the configured provider, so the served document +version can differ from the static file's declared `openapi` version. Helidon +does not provide complete conversion between OpenAPI versions. A static +document which uses version-specific features must use a `document` provider +which renders a compatible OpenAPI version. When no custom `OpenApiManager` is +configured, Helidon serves a static document as-is in `STATIC_ONLY` mode and +when `STATIC_FIRST` finds one; it does not rewrite the document to the +configured `document` version. A configured custom `OpenApiManager` still loads +and formats the static content in these modes. + +To add OpenAPI 3.1 support: + +```xml [pom.xml] + + io.helidon.openapi + helidon-openapi-31 + +``` + +To add OpenAPI 3.2 support: + +```xml [pom.xml] + + io.helidon.openapi + helidon-openapi-32 + +``` + +Configure OpenAPI 3.0 output: + +```yaml +server: + features: + openapi: + document: + "3.0": + version: "3.0.3" +``` + +Configure OpenAPI 3.1 output: + +```yaml +server: + features: + openapi: + document: + "3.1": + version: "3.1.1" +``` + +Configure OpenAPI 3.2 output: + +```yaml +server: + features: + openapi: + document: + "3.2": + version: "3.2.0" +``` ## Examples @@ -163,10 +461,14 @@ If you need programmatic control over the `OpenApiFeature` instance, invoke with it, then invoke the builder’s `build` method and pass the resulting `OpenApiFeature` instance to the `WebServer.Builder` `addFeature` method. -[openapi-specific]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md +[openapi-specific]: https://spec.openapis.org/oas/latest.html [microprofile-ope]: https://download.eclipse.org/microprofile/microprofile-open-api-3.1.1/microprofile-openapi-spec-3.1.1.html [openapifeature]: https://helidon.io/docs/v27/apidocs/io.helidon.openapi/io/helidon/openapi/OpenApiFeature.html [builder]: https://helidon.io/docs/v27/apidocs/io.helidon.openapi/io/helidon/openapi/OpenApiFeatureConfig.Builder.html +[restserver-endpoint]: https://helidon.io/docs/v27/apidocs/io.helidon.webserver/io/helidon/webserver/http/RestServer.Endpoint.html +[jsonschema-schema]: https://helidon.io/docs/v27/apidocs/io.helidon.json.schema/io/helidon/json/schema/JsonSchema.Schema.html +[openapi-document-source]: https://helidon.io/docs/v27/apidocs/io.helidon.openapi/io/helidon/openapi/spi/OpenApiDocumentSource.html +[openapi-annotations]: https://helidon.io/docs/v27/apidocs/io.helidon.openapi/io/helidon/openapi/OpenApi.html [example-below]: #register-openapifeature-explicitly [complete-openapi]: https://github.com/helidon-io/helidon-examples/tree/helidon-27.x/examples/openapi [io-helidon-opena]: ../../config/io.helidon.openapi.OpenApiFeature.md#configuration-options diff --git a/etc/checkstyle-suppressions.xml b/etc/checkstyle-suppressions.xml index 1184dcc186e..de9a37ba49a 100644 --- a/etc/checkstyle-suppressions.xml +++ b/etc/checkstyle-suppressions.xml @@ -68,6 +68,10 @@ record here. + + + diff --git a/http/http/src/main/java/io/helidon/http/Http.java b/http/http/src/main/java/io/helidon/http/Http.java index 53b30ef5511..45b68f85b07 100644 --- a/http/http/src/main/java/io/helidon/http/Http.java +++ b/http/http/src/main/java/io/helidon/http/Http.java @@ -84,6 +84,10 @@ private Http() { * Inject entity into a method parameter. *

* Can also be used on {@link RequestParams} record components. + *

+ * Declarative server endpoints support {@code Optional} entities. If the request has no entity, an optional + * entity is injected as {@link Optional#empty()}; a non-optional entity is rejected as a bad request. This behavior + * applies to both method parameters and {@link RequestParams} record components. */ @Target({ElementType.PARAMETER, ElementType.RECORD_COMPONENT}) @Retention(RetentionPolicy.CLASS) diff --git a/http/media/media/src/main/java/io/helidon/http/media/ReadableEntityBase.java b/http/media/media/src/main/java/io/helidon/http/media/ReadableEntityBase.java index b9bd84f5cc3..6112b907855 100644 --- a/http/media/media/src/main/java/io/helidon/http/media/ReadableEntityBase.java +++ b/http/media/media/src/main/java/io/helidon/http/media/ReadableEntityBase.java @@ -144,6 +144,14 @@ public T as(Class type) { return as(GenericType.create(type)); } + @Override + public Optional asOptional(Class type) { + if (hasEntity()) { + return Optional.of(as(type)); + } + return Optional.empty(); + } + @Override public final T as(GenericType type) { return entityAs(type); diff --git a/http/media/media/src/test/java/io/helidon/http/media/ReadableEntityBaseTest.java b/http/media/media/src/test/java/io/helidon/http/media/ReadableEntityBaseTest.java index 36c4e7bc395..c486f0ce8eb 100644 --- a/http/media/media/src/test/java/io/helidon/http/media/ReadableEntityBaseTest.java +++ b/http/media/media/src/test/java/io/helidon/http/media/ReadableEntityBaseTest.java @@ -57,6 +57,20 @@ void testInputStream() throws IOException { } } + @Test + void testOptionalInputStream() throws IOException { + ReadableEntityBase entityBase = new ReadableEntityImpl(new Readable(), 1024); + try (InputStream is = entityBase.asOptional(InputStream.class).orElseThrow()) { + assertThat(is.readAllBytes(), is(BYTES)); + } + } + + @Test + void testOptionalByteArray() { + ReadableEntityBase entityBase = new ReadableEntityImpl(new Readable(), 1024); + assertThat(entityBase.asOptional(byte[].class).orElseThrow(), is(BYTES)); + } + @Test void testMultipleInputStream() throws IOException { ReadableEntityBase entityBase = new ReadableEntityImpl(new Readable(), 1024); diff --git a/http/media/media/src/test/java/io/helidon/http/media/ReadableEntityTest.java b/http/media/media/src/test/java/io/helidon/http/media/ReadableEntityTest.java new file mode 100644 index 00000000000..fbabc60b615 --- /dev/null +++ b/http/media/media/src/test/java/io/helidon/http/media/ReadableEntityTest.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.http.media; + +import java.io.InputStream; +import java.util.Optional; + +import io.helidon.common.GenericType; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +class ReadableEntityTest { + + @Test + void defaultOptionalClassConversionUsesGenericOptionalConversion() { + ReadableEntity entity = new CustomReadableEntity(); + + assertThat(entity.asOptional(String.class), is(Optional.of("ok"))); + } + + private static final class CustomReadableEntity implements ReadableEntity { + @Override + public InputStream inputStream() { + throw new UnsupportedOperationException("Not used by this test"); + } + + @Override + public T as(GenericType type) { + throw new IllegalStateException("Generic non-optional conversion must not be used"); + } + + @SuppressWarnings("unchecked") + @Override + public Optional asOptional(GenericType type) { + return (Optional) Optional.of("ok"); + } + + @Override + public boolean hasEntity() { + return true; + } + + @Override + public boolean consumed() { + return false; + } + + @Override + public ReadableEntity copy(Runnable entityProcessedRunnable) { + throw new UnsupportedOperationException("Not used by this test"); + } + } +} diff --git a/json/schema/schema/src/main/java/io/helidon/json/schema/SchemaSupport.java b/json/schema/schema/src/main/java/io/helidon/json/schema/SchemaSupport.java index 30a2ca43b0b..f65e0add9e9 100644 --- a/json/schema/schema/src/main/java/io/helidon/json/schema/SchemaSupport.java +++ b/json/schema/schema/src/main/java/io/helidon/json/schema/SchemaSupport.java @@ -93,6 +93,7 @@ static String generateNoKeywords(Schema schema) { * @param schema schema * @return json object */ + @Prototype.PrototypeMethod static JsonObject generateObject(Schema schema) { JsonObject.Builder builder = JsonObject.builder(); builder.set("$schema", "https://json-schema.org/draft/2020-12/schema"); @@ -108,6 +109,7 @@ static JsonObject generateObject(Schema schema) { * @param schema schema * @return json object */ + @Prototype.PrototypeMethod static JsonObject generateObjectNoKeywords(Schema schema) { JsonObject.Builder builder = JsonObject.builder(); schema.root().generate(builder); diff --git a/json/schema/schema/src/test/java/io/helidon/json/schema/SchemaTest.java b/json/schema/schema/src/test/java/io/helidon/json/schema/SchemaTest.java index f25c3e05766..d88c6b9e768 100644 --- a/json/schema/schema/src/test/java/io/helidon/json/schema/SchemaTest.java +++ b/json/schema/schema/src/test/java/io/helidon/json/schema/SchemaTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Oracle and/or its affiliates. + * Copyright (c) 2025, 2026 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,6 +16,10 @@ package io.helidon.json.schema; +import java.net.URI; + +import io.helidon.json.JsonObject; + import org.junit.jupiter.api.Test; import static org.hamcrest.CoreMatchers.is; @@ -45,4 +49,23 @@ void testSchemaMultipleRoots() { .build()); } + @Test + void testGenerateObjectWithoutKeywords() { + Schema schema = Schema.builder() + .id(URI.create("https://example.com/schemas/item")) + .rootObject(builder -> builder.addStringProperty("name", name -> name.description("Item name"))) + .build(); + + JsonObject full = schema.generateObject(); + assertThat(full.stringValue("$schema").orElseThrow(), is("https://json-schema.org/draft/2020-12/schema")); + assertThat(full.stringValue("$id").orElseThrow(), is("https://example.com/schemas/item")); + assertThat(full.stringValue("type").orElseThrow(), is("object")); + + JsonObject body = schema.generateObjectNoKeywords(); + assertThat(body.containsKey("$schema"), is(false)); + assertThat(body.containsKey("$id"), is(false)); + assertThat(body.stringValue("type").orElseThrow(), is("object")); + assertThat(body.objectValue("properties").orElseThrow().containsKey("name"), is(true)); + } + } diff --git a/openapi/openapi-31/pom.xml b/openapi/openapi-31/pom.xml new file mode 100644 index 00000000000..b5eb13e8b69 --- /dev/null +++ b/openapi/openapi-31/pom.xml @@ -0,0 +1,165 @@ + + + + 4.0.0 + + io.helidon.openapi + helidon-openapi-project + 27.0.0-SNAPSHOT + + helidon-openapi-31 + Helidon OpenAPI 3.1 + + + Helidon OpenAPI 3.1 document version support + + + + true + + + + + io.helidon.common.features + helidon-common-features-api + true + + + io.helidon.builder + helidon-builder-api + + + io.helidon.common + helidon-common + + + io.helidon.common + helidon-common-media-type + + + io.helidon.openapi + helidon-openapi + + + io.helidon.config + helidon-config + + + io.helidon.config.metadata + helidon-config-metadata + + + io.helidon.service + helidon-service-registry + + + io.helidon.json + helidon-json + + + org.yaml + snakeyaml + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-codegen + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.service + helidon-service-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + + io.helidon.common.features + helidon-common-features-codegen + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.service + helidon-service-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + + diff --git a/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31DocumentMapper.java b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31DocumentMapper.java new file mode 100644 index 00000000000..83681cb32b9 --- /dev/null +++ b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31DocumentMapper.java @@ -0,0 +1,260 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v31; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.v30.OpenApi3xMapperRules; +import io.helidon.openapi.v30.OpenApiDocumentReader; + +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.document3x; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.jsonObject; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.objectMap; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateDocumentStructure; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateOperationIds; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateSchemas; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateSecurityRequirementNames; + +final class OpenApi31DocumentMapper { + private static final Set DOCUMENT_FIELDS = Set.of("openapi", + "info", + "jsonSchemaDialect", + "servers", + "paths", + "webhooks", + "components", + "security", + "tags", + "externalDocs"); + private static final Set INFO_FIELDS = Set.of("title", + "summary", + "description", + "termsOfService", + "contact", + "license", + "version"); + private static final Set CONTACT_FIELDS = Set.of("name", + "url", + "email"); + private static final Set LICENSE_FIELDS = Set.of("name", + "identifier", + "url"); + private static final Set SERVER_FIELDS = Set.of("url", + "description", + "variables"); + private static final Set SERVER_VARIABLE_FIELDS = Set.of("enum", + "default", + "description"); + private static final Set TAG_FIELDS = Set.of("name", + "description", + "externalDocs"); + private static final Set PATH_ITEM_FIELDS = Set.of("$ref", + "summary", + "description", + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "servers", + "parameters"); + private static final Set FIXED_PATH_OPERATION_FIELDS = Set.of("get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace"); + private static final Set OPERATION_FIELDS = Set.of("tags", + "summary", + "description", + "externalDocs", + "operationId", + "parameters", + "requestBody", + "responses", + "callbacks", + "deprecated", + "security", + "servers"); + private static final Set PARAMETER_FIELDS = Set.of("$ref", + "name", + "in", + "description", + "required", + "deprecated", + "allowEmptyValue", + "style", + "explode", + "allowReserved", + "schema", + "example", + "examples", + "content"); + private static final Set HEADER_FIELDS = Set.of("$ref", + "description", + "required", + "deprecated", + "style", + "explode", + "schema", + "example", + "examples", + "content"); + private static final Set REQUEST_BODY_FIELDS = Set.of("$ref", + "description", + "content", + "required"); + private static final Set RESPONSE_FIELDS = Set.of("$ref", + "description", + "headers", + "content", + "links"); + private static final Set MEDIA_TYPE_FIELDS = Set.of("schema", + "example", + "examples", + "encoding"); + private static final Set ENCODING_FIELDS = Set.of("contentType", + "headers", + "style", + "explode", + "allowReserved"); + private static final Set COMPONENTS_FIELDS = Set.of("schemas", + "responses", + "parameters", + "examples", + "requestBodies", + "headers", + "securitySchemes", + "links", + "callbacks", + "pathItems"); + private static final Set SECURITY_SCHEME_FIELDS = Set.of("$ref", + "type", + "description", + "name", + "in", + "scheme", + "bearerFormat", + "flows", + "openIdConnectUrl"); + private static final Set SECURITY_SCHEME_TYPES = Set.of("apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect"); + private static final Set OAUTH_FLOWS_FIELDS = Set.of("implicit", + "password", + "clientCredentials", + "authorizationCode"); + private static final Set OAUTH_FLOW_FIELDS = Set.of("authorizationUrl", + "tokenUrl", + "refreshUrl", + "scopes"); + private static final Set LINK_FIELDS = Set.of("$ref", + "operationRef", + "operationId", + "parameters", + "requestBody", + "description", + "server"); + private static final Set EXAMPLE_FIELDS = Set.of("$ref", + "summary", + "description", + "value", + "externalValue"); + private static final Set EXTERNAL_DOCS_FIELDS = Set.of("description", + "url"); + private static final Set PARAMETER_LOCATIONS = Set.of("query", + "header", + "path", + "cookie"); + private static final OpenApi3xMapperRules MAPPER_RULES = OpenApi3xMapperRules.builder() + .targetVersion("3.1") + .operationResponsesRequired(true) + .responseDescriptionRequired(true) + .addDocumentFields(DOCUMENT_FIELDS) + .addInfoFields(INFO_FIELDS) + .addContactFields(CONTACT_FIELDS) + .addLicenseFields(LICENSE_FIELDS) + .addServerFields(SERVER_FIELDS) + .addServerVariableFields(SERVER_VARIABLE_FIELDS) + .addTagFields(TAG_FIELDS) + .addPathItemFields(PATH_ITEM_FIELDS) + .addFixedPathOperationFields(FIXED_PATH_OPERATION_FIELDS) + .addOperationFields(OPERATION_FIELDS) + .addParameterFields(PARAMETER_FIELDS) + .addParameterLocations(PARAMETER_LOCATIONS) + .addHeaderFields(HEADER_FIELDS) + .addRequestBodyFields(REQUEST_BODY_FIELDS) + .addResponseFields(RESPONSE_FIELDS) + .addMediaTypeFields(MEDIA_TYPE_FIELDS) + .addEncodingFields(ENCODING_FIELDS) + .addComponentsFields(COMPONENTS_FIELDS) + .addSecuritySchemeFields(SECURITY_SCHEME_FIELDS) + .addSecuritySchemeTypes(SECURITY_SCHEME_TYPES) + .addOauthFlowsFields(OAUTH_FLOWS_FIELDS) + .addOauthFlowFields(OAUTH_FLOW_FIELDS) + .addLinkFields(LINK_FIELDS) + .addExampleFields(EXAMPLE_FIELDS) + .addExternalDocsFields(EXTERNAL_DOCS_FIELDS) + .build(); + + private OpenApi31DocumentMapper() { + } + + static OpenApiDocument parse(Map document) { + validateOpenApi31(document.get("openapi")); + validateDocumentStructure(document, MAPPER_RULES); + validateSchemas(document, MAPPER_RULES); + Map mapped = document3x(document, MAPPER_RULES); + validateDocumentStructure(mapped, MAPPER_RULES); + validateSchemas(mapped, MAPPER_RULES); + validateOperationIds(mapped); + validateSecurityRequirementNames(mapped, MAPPER_RULES); + return OpenApiDocumentReader.read(jsonObject(mapped)); + } + + static Map render(OpenApiDocument document, String version) { + Map rendered = document3x(objectMap(document.toJsonObject()), MAPPER_RULES); + validateDocumentStructure(rendered, MAPPER_RULES); + validateSchemas(rendered, MAPPER_RULES); + validateOperationIds(rendered); + validateSecurityRequirementNames(rendered, MAPPER_RULES); + Map result = new LinkedHashMap<>(); + result.put("openapi", version); + rendered.forEach((key, value) -> { + if (!"openapi".equals(key)) { + result.put(key, value); + } + }); + return result; + } + + private static void validateOpenApi31(Object version) { + if (!(version instanceof String string) || !OpenApi31Version.isSupportedVersion(string)) { + throw new IllegalStateException("OpenAPI 3.1 parser requires a 3.1 document, got: " + version); + } + } +} diff --git a/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31Version.java b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31Version.java new file mode 100644 index 00000000000..efcf6173471 --- /dev/null +++ b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31Version.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v31; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.common.Api; +import io.helidon.common.media.type.MediaType; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.openapi.OpenApiFormat; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.v30.OpenApiDocumentMapperSupport; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +/** + * OpenAPI 3.1 version implementation. + */ +@Api.Preview +public final class OpenApi31Version implements OpenApiVersion, + RuntimeType.Api { + static final String TYPE = "3.1"; + private static final Pattern VERSION_PATTERN = Pattern.compile(Pattern.quote(TYPE) + "\\.[0-9]+(?:-.+)?"); + private static final DumperOptions YAML_DUMPER_OPTIONS = yamlDumperOptions(); + + private final OpenApi31VersionConfig config; + + OpenApi31Version(OpenApi31VersionConfig config) { + Objects.requireNonNull(config); + String version = config.version(); + if (!isSupportedVersion(version)) { + throw new IllegalArgumentException("OpenAPI " + TYPE + " version implementation cannot produce document version " + + version + "."); + } + this.config = config; + } + + static boolean isSupportedVersion(String version) { + return VERSION_PATTERN.matcher(version).matches(); + } + + /** + * Returns a new builder. + * + * @return new builder + */ + public static OpenApi31VersionConfig.Builder builder() { + return OpenApi31VersionConfig.builder(); + } + + /** + * Create a new OpenAPI 3.1 version implementation with default configuration. + * + * @return new version implementation + */ + public static OpenApi31Version create() { + return builder().build(); + } + + /** + * Create a new OpenAPI 3.1 version implementation with custom configuration. + * + * @param consumer configuration consumer + * @return new version implementation + */ + public static OpenApi31Version create(Consumer consumer) { + return builder() + .update(consumer) + .build(); + } + + /** + * Create a new OpenAPI 3.1 version implementation from typed configuration. + * + * @param config typed configuration + * @return new version implementation + */ + public static OpenApi31Version create(OpenApi31VersionConfig config) { + return new OpenApi31Version(config); + } + + @Override + public String version() { + return config.version(); + } + + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType) { + Objects.requireNonNull(context); + Objects.requireNonNull(content); + Objects.requireNonNull(mediaType); + if (OpenApiFormat.valueOf(mediaType) == OpenApiFormat.UNSUPPORTED) { + throw new IllegalStateException("Unsupported static OpenAPI content type: " + mediaType.text()); + } + Object loaded = OpenApiDocumentMapperSupport.parseYaml(content); + if (loaded == null) { + return OpenApiDocument.builder().build(); + } + if (loaded instanceof Map map) { + Map values = new LinkedHashMap<>(); + map.forEach((key, value) -> values.put(String.valueOf(key), value)); + return OpenApi31DocumentMapper.parse(values); + } + throw new IllegalStateException("Static OpenAPI content must be a YAML or JSON object."); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + Objects.requireNonNull(context); + Objects.requireNonNull(document); + OpenApiDocumentMapperSupport.validateDocumentRoot(document, config.version()); + Map values = OpenApi31DocumentMapper.render(document, config.version()); + return new Yaml(YAML_DUMPER_OPTIONS).dump(values); + } + + @Override + public OpenApi31VersionConfig prototype() { + return config; + } + + @Override + public String name() { + return config.name(); + } + + @Override + public String type() { + return TYPE; + } + + private static DumperOptions yamlDumperOptions() { + DumperOptions dumperOptions = new DumperOptions(); + dumperOptions.setIndent(2); + dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + return dumperOptions; + } +} diff --git a/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31VersionConfigBlueprint.java b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31VersionConfigBlueprint.java new file mode 100644 index 00000000000..9b6383e8850 --- /dev/null +++ b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31VersionConfigBlueprint.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v31; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.Api; +import io.helidon.openapi.spi.OpenApiVersionProvider; + +/** + * OpenAPI 3.1 version configuration. + */ +@Api.Preview +@Prototype.Blueprint +@Prototype.Configured(value = OpenApi31Version.TYPE, root = false) +@Prototype.Provides(OpenApiVersionProvider.class) +interface OpenApi31VersionConfigBlueprint extends Prototype.Factory { + /** + * Name of this version configuration. + * + * @return version implementation name + */ + @Option.Default(OpenApi31Version.TYPE) + String name(); + + /** + * Exact OpenAPI 3.1 document version to produce. + * + * @return OpenAPI document version + */ + @Option.Configured + @Option.Default("3.1.1") + String version(); +} diff --git a/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31VersionProvider.java b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31VersionProvider.java new file mode 100644 index 00000000000..4a9ec077582 --- /dev/null +++ b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/OpenApi31VersionProvider.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v31; + +import java.util.Objects; + +import io.helidon.common.Api; +import io.helidon.common.Weight; +import io.helidon.config.Config; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; +import io.helidon.service.registry.Service; + +/** + * OpenAPI 3.1 version provider. + */ +@Service.Singleton +@Weight(3100) +public class OpenApi31VersionProvider implements OpenApiVersionProvider { + /** + * Required public constructor. + */ + @Api.Internal + public OpenApi31VersionProvider() { + } + + @Override + public String configKey() { + return OpenApi31Version.TYPE; + } + + @Override + public OpenApiVersion create(Config config, String name) { + return OpenApi31VersionConfig.builder() + .config(Objects.requireNonNull(config)) + .name(Objects.requireNonNull(name)) + .build(); + } +} diff --git a/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/package-info.java b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/package-info.java new file mode 100644 index 00000000000..d40afc47f22 --- /dev/null +++ b/openapi/openapi-31/src/main/java/io/helidon/openapi/v31/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * OpenAPI 3.1 support. + */ +package io.helidon.openapi.v31; diff --git a/openapi/openapi-31/src/main/java/module-info.java b/openapi/openapi-31/src/main/java/module-info.java new file mode 100644 index 00000000000..64e91276d79 --- /dev/null +++ b/openapi/openapi-31/src/main/java/module-info.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import io.helidon.common.features.api.Features; +import io.helidon.common.features.api.HelidonFlavor; + +/** + * Helidon OpenAPI 3.1 document version support. + */ +@Features.Name("OpenAPI 3.1") +@Features.Description("OpenAPI 3.1 document version support") +@Features.Flavor(HelidonFlavor.SE) +@Features.Path({"OpenAPI", "3.1"}) +module io.helidon.openapi.v31 { + requires static io.helidon.common.features.api; + requires static io.helidon.config.metadata; + + requires transitive io.helidon.builder.api; + requires io.helidon.common; + requires transitive io.helidon.common.media.type; + requires transitive io.helidon.config; + requires io.helidon.json; + requires transitive io.helidon.openapi; + requires io.helidon.service.registry; + + requires org.yaml.snakeyaml; + + exports io.helidon.openapi.v31; + + provides io.helidon.openapi.spi.OpenApiVersionProvider + with io.helidon.openapi.v31.OpenApi31VersionProvider; +} diff --git a/openapi/openapi-31/src/test/java/io/helidon/openapi/v31/OpenApi31DocumentMapperTest.java b/openapi/openapi-31/src/test/java/io/helidon/openapi/v31/OpenApi31DocumentMapperTest.java new file mode 100644 index 00000000000..cc28599d591 --- /dev/null +++ b/openapi/openapi-31/src/test/java/io/helidon/openapi/v31/OpenApi31DocumentMapperTest.java @@ -0,0 +1,646 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v31; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.helidon.json.JsonString; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.v30.OpenApiDocumentMapperSupport; +import io.helidon.openapi.v30.OpenApiDocumentReader; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApi31DocumentMapperTest { + private static final long LARGE_INTEGRAL_VALUE = 9_007_199_254_740_993L; + + @Test + void validatesOpenApiVersion() { + OpenApi31DocumentMapper.parse(document("3.1.2-rc1")); + + for (String invalidVersion : List.of("3.1", "3.1.", "3.1.not-a-version", "3.1.1-", "3.1.1.0")) { + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(document(invalidVersion)), + invalidVersion); + assertThat(invalidVersion, ex.getMessage(), containsString(invalidVersion)); + } + } + + @Test + void requiresPathParametersForTemplateExpressions() { + String path = "/items/{id}"; + Map missing = documentWithPathItem(path, Map.of( + "get", Map.of("responses", Map.of("200", Map.of("description", "OK"))))); + + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(missing)); + assertThat(parsed.getMessage(), containsString(path)); + assertThat(parsed.getMessage(), containsString("template expression {id}")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(openApiDocument(missing), "3.1.1")); + assertThat(rendered.getMessage(), containsString(path)); + assertThat(rendered.getMessage(), containsString("template expression {id}")); + + Map pathLevel = documentWithPathItem(path, Map.of( + "parameters", List.of(pathParameter("id")), + "get", Map.of("responses", Map.of("200", Map.of("description", "OK"))))); + OpenApi31DocumentMapper.render(OpenApi31DocumentMapper.parse(pathLevel), "3.1.1"); + + Map operationLevel = documentWithPathItem(path, Map.of( + "get", Map.of( + "parameters", List.of(pathParameter("id")), + "responses", Map.of("200", Map.of("description", "OK"))))); + OpenApi31DocumentMapper.render(OpenApi31DocumentMapper.parse(operationLevel), "3.1.1"); + } + + @Test + void validatesParameterListUniqueness() { + String path = "/items/{id}"; + Map parameter = pathParameter("id"); + Map duplicate = documentWithPathItem(path, Map.of( + "get", Map.of( + "parameters", List.of(parameter, parameter), + "responses", Map.of("200", Map.of("description", "OK"))))); + + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(duplicate)); + assertThat(parsed.getMessage(), containsString("duplicate path parameter id")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(openApiDocument(duplicate), "3.1.1")); + assertThat(rendered.getMessage(), containsString("duplicate path parameter id")); + + Map override = documentWithPathItem(path, Map.of( + "parameters", List.of(parameter), + "get", Map.of( + "parameters", List.of(parameter), + "responses", Map.of("200", Map.of("description", "OK"))))); + OpenApi31DocumentMapper.render(OpenApi31DocumentMapper.parse(override), "3.1.1"); + } + + @Test + void validatesSchemaReferenceSiblings() { + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(Map.of( + "openapi", "3.1.1", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("schemas", Map.of( + "Referenced", Map.of( + "$ref", "#/components/schemas/Base", + "properties", Map.of( + "invalid", "not a schema"))))))); + + assertThat(thrown.getMessage(), containsString("components.schemas.Referenced.properties.invalid")); + } + + @Test + void handlesVersionSpecificResponseRequirements() { + IllegalStateException missingResponses = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(documentWithOperation("3.1.1", Map.of("summary", "Items")))); + assertThat(missingResponses.getMessage(), containsString("responses")); + + for (Map responses : List.>of( + Map.of(), + Map.of("x-note", "No response code"))) { + IllegalStateException missingResponseCode = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(documentWithOperation( + "3.1.1", + Map.of("responses", responses)))); + assertThat(missingResponseCode.getMessage(), containsString("response code")); + } + + IllegalStateException missingDescription = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(documentWithOperation( + "3.1.1", + Map.of("responses", Map.of("200", Map.of("headers", Map.of())))))); + assertThat(missingDescription.getMessage(), containsString("description")); + + IllegalStateException invalidResponseKey = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(documentWithOperation( + "3.1.1", + Map.of("responses", Map.of( + "200", Map.of("description", "OK"), + "bogus", Map.of("description", "Invalid")))))); + assertThat(invalidResponseKey.getMessage(), containsString("3.1")); + assertThat(invalidResponseKey.getMessage(), containsString("bogus")); + + OpenApiDocument operationWithoutResponses = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation("GET", operation -> operation.summary("Items"))) + .build(); + IllegalStateException renderedWithoutResponses = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(operationWithoutResponses, "3.1.1")); + assertThat(renderedWithoutResponses.getMessage(), containsString("responses")); + + OpenApiDocument responsesWithoutCode = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.responseExtension("x-note", JsonString.create("No response code")))) + .build(); + IllegalStateException renderedWithoutResponseCode = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(responsesWithoutCode, "3.1.1")); + assertThat(renderedWithoutResponseCode.getMessage(), containsString("response code")); + + OpenApiDocument responseWithoutDescription = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.response("200", response -> response.summary("Items")))) + .build(); + IllegalStateException renderedWithoutDescription = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(responseWithoutDescription, "3.1.1")); + assertThat(renderedWithoutDescription.getMessage(), containsString("description")); + + OpenApiDocument responseWithInvalidKey = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.response("200", "OK").response("bogus", "Invalid"))) + .build(); + IllegalStateException renderedWithInvalidResponseKey = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(responseWithInvalidKey, "3.1.1")); + assertThat(renderedWithInvalidResponseKey.getMessage(), containsString("3.1")); + assertThat(renderedWithInvalidResponseKey.getMessage(), containsString("bogus")); + + for (String description : List.of("", " ")) { + OpenApiDocument document = OpenApi31DocumentMapper.parse(documentWithOperation( + "3.1.1", + Map.of("responses", Map.of("200", Map.of("description", description))))); + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + Map response = map(map(map(map(rendered, "paths"), "/items"), "get"), "responses"); + + assertThat(map(response, "200").get("description"), is(description)); + } + } + + @Test + void rejectsMalformedSecurityRequirements() { + Map invalidSecurityValues = new LinkedHashMap<>(); + invalidSecurityValues.put("security value is not an array", Map.of("OAuth", List.of())); + invalidSecurityValues.put("security requirement is not an object", List.of("OAuth")); + invalidSecurityValues.put("scheme scopes are not an array", List.of(Map.of("OAuth", "read"))); + invalidSecurityValues.put("scheme scope is not a string", List.of(Map.of("OAuth", List.of("read", 42)))); + + invalidSecurityValues.forEach((description, invalidSecurity) -> { + Map topLevelDocument = new LinkedHashMap<>(document("3.1.1")); + topLevelDocument.put("security", invalidSecurity); + IllegalStateException topLevel = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(topLevelDocument), + description + " at document level"); + assertThat(description, topLevel.getMessage(), containsString("security")); + + Map operationDocument = new LinkedHashMap<>(document("3.1.1")); + operationDocument.put("paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of("200", Map.of("description", "OK")), + "security", invalidSecurity)))); + IllegalStateException operation = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(operationDocument), + description + " at operation level"); + assertThat(description, operation.getMessage(), containsString("security")); + }); + } + + @Test + void rejectsUndeclaredSecurityRequirementSchemes() { + Map> invalidDocuments = new LinkedHashMap<>(); + Map documentSecurity = new LinkedHashMap<>(document("3.1.1")); + documentSecurity.put("components", Map.of("securitySchemes", Map.of())); + documentSecurity.put("security", List.of(Map.of("missingAuth", List.of()))); + invalidDocuments.put("document", documentSecurity); + + Map operationSecurity = new LinkedHashMap<>(document("3.1.1")); + operationSecurity.put("components", Map.of("securitySchemes", Map.of())); + operationSecurity.put("paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of("200", Map.of("description", "OK")), + "security", List.of(Map.of("missingAuth", List.of())))))); + invalidDocuments.put("operation", operationSecurity); + + invalidDocuments.forEach((location, source) -> { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(source), + location + " parsing"); + assertThat(parsed.getMessage(), containsString("undeclared security scheme missingAuth")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(openApiDocument(source), "3.1.1"), + location + " rendering"); + assertThat(rendered.getMessage(), containsString("undeclared security scheme missingAuth")); + }); + } + + @Test + void preservesLargeIntegralNumbers() { + OpenApiDocument document = OpenApi31DocumentMapper.parse(document("3.1.0")); + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + + assertThat(String.valueOf(schemaProperty(rendered, "large").get("default")), is(String.valueOf(LARGE_INTEGRAL_VALUE))); + } + + @Test + void preservesNullExtensionValues() { + OpenApiDocument document = OpenApi31DocumentMapper.parse(documentWithNullExtension("3.1.0")); + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + + assertThat(rendered.containsKey("x-null"), is(true)); + assertThat(rendered.get("x-null"), is((Object) null)); + } + + @Test + void filtersUnsupportedHeaderFields() { + OpenApiDocument document = OpenApi31DocumentMapper.parse(Map.of( + "openapi", "3.1.1", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of( + "description", "OK", + "headers", Map.of( + "X-Test", Map.of( + "allowEmptyValue", true, + "allowReserved", true, + "schema", Map.of("type", "string")))))))))); + + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + Map responses = map(map(map(map(rendered, "paths"), "/items"), "get"), "responses"); + Map header = map(map(map(responses, "200"), "headers"), "X-Test"); + + assertThat(header.containsKey("allowEmptyValue"), is(false)); + assertThat(header.containsKey("allowReserved"), is(false)); + } + + @Test + void openApi31PreservesResponseAndComponentPathItemExtensions() { + OpenApiDocument document = OpenApi31DocumentMapper.parse(Map.of( + "openapi", "3.1.0", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "x-gateway-root", true, + "x-gateway-object", Map.of("stage", "prod"), + "/pets", Map.of( + "x-path-meta", "keep", + "get", Map.of( + "responses", Map.of( + "x-provider-meta", true, + "x-provider-object", Map.of("enabled", true), + "200", Map.of("description", "OK")), + "callbacks", Map.of( + "onEvent", Map.of( + "x-callback-scalar", "keep", + "x-callback-object", Map.of("enabled", true), + "{$request.body#/callbackUrl}", Map.of( + "post", Map.of( + "responses", Map.of( + "200", Map.of("description", "OK"))))), + "x-named-callback", Map.of( + "{$request.body#/fallbackUrl}", Map.of( + "post", Map.of( + "responses", Map.of( + "204", Map.of("description", "Done"))))), + "referencedCallback", Map.of( + "$ref", "#/components/callbacks/ReusableCallback", + "summary", "Reusable callback"))))), + "components", Map.of( + "callbacks", Map.of( + "ReusableCallback", Map.of( + "{$request.body#/componentUrl}", Map.of( + "post", Map.of( + "responses", Map.of( + "200", Map.of("description", "OK")))))), + "responses", Map.of( + "x-Problem", Map.of( + "description", "Problem details", + "summary", "OpenAPI 3.2 summary", + "x-response", "preserved")), + "securitySchemes", Map.of( + "OAuth", Map.of( + "type", "oauth2", + "flows", Map.of( + "x-flow-scalar", "keep", + "x-flow-object", Map.of("enabled", true), + "clientCredentials", Map.of( + "tokenUrl", "https://idp.example.com/token", + "scopes", Map.of())))), + "pathItems", Map.of( + "ReusablePath", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of("description", "OK"))), + "x-component-path-item", "keep"))))); + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + Map path = map(map(rendered, "paths"), "/pets"); + Map responses = map(map(path, "get"), "responses"); + Map callbacks = map(map(path, "get"), "callbacks"); + Map callback = map(callbacks, "onEvent"); + Map componentCallback = map(map(map(rendered, "components"), "callbacks"), "ReusableCallback"); + Map componentResponse = map(map(map(rendered, "components"), "responses"), "x-Problem"); + Map reusablePath = map(map(map(rendered, "components"), "pathItems"), "ReusablePath"); + Map flows = map(map(map(map(rendered, "components"), "securitySchemes"), "OAuth"), "flows"); + + assertThat(document.paths().containsKey("x-gateway-object"), is(false)); + assertThat(document.paths().get("/pets").operations().get("get").callbacks().get("onEvent") + .expressions().containsKey("{$request.body#/callbackUrl}"), is(true)); + assertThat(document.paths().get("/pets").operations().get("get").callbacks() + .containsKey("x-named-callback"), is(true)); + assertThat(map(rendered, "paths").get("x-gateway-root"), is(true)); + assertThat(map(map(rendered, "paths"), "x-gateway-object").get("stage"), is("prod")); + assertThat(document.paths().get("/pets").operations().get("get").responses().containsKey("x-provider-object"), + is(false)); + assertThat(path.get("x-path-meta"), is("keep")); + assertThat(responses.get("x-provider-meta"), is(true)); + assertThat(map(responses, "x-provider-object").get("enabled"), is(true)); + assertThat(callback.get("x-callback-scalar"), is("keep")); + assertThat(map(callback, "x-callback-object").get("enabled"), is(true)); + assertThat(map(callback, "{$request.body#/callbackUrl}").containsKey("post"), is(true)); + assertThat(map(callbacks, "x-named-callback").containsKey("{$request.body#/fallbackUrl}"), is(true)); + assertThat(map(callbacks, "referencedCallback").get("$ref"), + is("#/components/callbacks/ReusableCallback")); + assertThat(map(callbacks, "referencedCallback").get("summary"), is("Reusable callback")); + assertThat(componentCallback.containsKey("{$request.body#/componentUrl}"), is(true)); + assertThat(componentResponse.get("description"), is("Problem details")); + assertThat(componentResponse.containsKey("summary"), is(false)); + assertThat(componentResponse.get("x-response"), is("preserved")); + assertThat(flows.get("x-flow-scalar"), is("keep")); + assertThat(map(flows, "x-flow-object").get("enabled"), is(true)); + assertThat(reusablePath.get("x-component-path-item"), is("keep")); + } + + @Test + void openApi31AllowsMutualTlsSecurityScheme() { + OpenApiDocument document = openApiDocument(documentWithSecurityScheme(mutualTlsSecurityScheme())); + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + + assertThat(map(securitySchemes(rendered), "test").get("type"), is("mutualTLS")); + } + + @Test + void openApi31RejectsDeviceAuthorizationFlow() { + OpenApiDocument document = openApiDocument(documentWithSecurityScheme(deviceAuthorizationSecurityScheme())); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(document, "3.1.1")); + + assertThat(thrown.getMessage(), containsString("deviceAuthorization")); + } + + @Test + void openApi31PreservesMediaTypeEncodingMap() { + OpenApiDocument document = OpenApi31DocumentMapper.parse(documentWithEncoding("3.1.0")); + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + Map encoding = encoding(rendered); + + assertThat(map(encoding, "profileImage").get("contentType"), is("image/png")); + assertThat(map(map(encoding, "profileImage"), "headers").containsKey("X-Image-Name"), is(true)); + } + + @Test + void openApi31PreservesReferenceSummaryAndDescription() { + OpenApiDocument document = OpenApi31DocumentMapper.parse(documentWithReferenceObjects("3.1.0")); + Map rendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + Map components = map(rendered, "components"); + + assertReference(map(map(components, "parameters"), "testParameter")); + assertReference(map(map(components, "headers"), "testHeader")); + assertReference(map(map(components, "requestBodies"), "testRequestBody")); + assertReference(map(map(components, "responses"), "testResponse")); + assertReference(map(map(components, "examples"), "testExample")); + assertReference(map(map(components, "links"), "testLink")); + assertReference(map(map(components, "securitySchemes"), "testSecurity")); + assertThat(map(map(components, "schemas"), "testSchema").get("x-reference"), is("Reference extension")); + } + + @Test + void validatesReferenceUris() { + String malformed = "http://[bad"; + assertInvalidReferenceUri(documentWithExampleReference(malformed), "must be a URI"); + assertInvalidReferenceUri(documentWithSchemaReference(malformed), "must be a URI"); + + String ipvFuture = "http://[v1.fe]/description.yaml#/components/examples/Example"; + assertInvalidReferenceUri(documentWithExampleReference(ipvFuture), "IPvFuture host literal"); + assertInvalidReferenceUri(documentWithSchemaReference(ipvFuture), "IPvFuture host literal"); + + for (String valid : List.of("https://example.test/openapi.yaml#/components/examples/Example", + "../openapi.yaml#/components/examples/Example", + "#/components/examples/Example", + "other.yaml#anchor")) { + OpenApiDocument document = OpenApi31DocumentMapper.parse(documentWithExampleReference(valid)); + Map validRendered = OpenApi31DocumentMapper.render(document, "3.1.1"); + assertThat(map(map(map(validRendered, "components"), "examples"), "test").get("$ref"), is(valid)); + } + } + + private static Map document(String version) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "components", Map.of("schemas", Map.of("StaticItem", Map.of( + "type", "object", + "properties", Map.of("large", Map.of( + "type", "integer", + "format", "int64", + "default", LARGE_INTEGRAL_VALUE)))))); + } + + private static Map documentWithOperation(String version, Map operation) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of("/items", Map.of("get", operation))); + } + + private static Map documentWithPathItem(String path, Map pathItem) { + return Map.of("openapi", "3.1.1", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(path, pathItem)); + } + + private static Map pathParameter(String name) { + return Map.of("name", name, + "in", "path", + "required", true, + "schema", Map.of("type", "string")); + } + + private static Map documentWithNullExtension(String version) { + Map result = new LinkedHashMap<>(); + result.put("openapi", version); + result.put("info", Map.of("title", "Static API", + "version", "1.0.0")); + result.put("x-null", null); + return result; + } + + private static Map documentWithEncoding(String version) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of("/upload", Map.of("post", Map.of( + "requestBody", Map.of("content", Map.of("multipart/form-data", Map.of( + "schema", Map.of("type", "object"), + "encoding", Map.of("profileImage", Map.of( + "contentType", "image/png", + "headers", Map.of("X-Image-Name", Map.of( + "description", "Image name", + "schema", Map.of("type", "string")))))))), + "responses", Map.of("204", Map.of("description", "Done.")))))); + } + + private static Map documentWithReferenceObjects(String version) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "components", Map.of( + "schemas", Map.of("testSchema", reference("#/components/schemas/real")), + "parameters", Map.of("testParameter", reference("#/components/parameters/real")), + "headers", Map.of("testHeader", reference("#/components/headers/real")), + "requestBodies", Map.of("testRequestBody", reference("#/components/requestBodies/real")), + "responses", Map.of("testResponse", reference("#/components/responses/real")), + "examples", Map.of("testExample", reference("#/components/examples/real")), + "links", Map.of("testLink", reference("#/components/links/real")), + "securitySchemes", Map.of("testSecurity", reference("#/components/securitySchemes/real")))); + } + + private static Map documentWithExampleReference(String reference) { + return Map.of("openapi", "3.1.1", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("examples", Map.of("test", Map.of("$ref", reference)))); + } + + private static Map documentWithSchemaReference(String reference) { + return Map.of("openapi", "3.1.1", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("schemas", Map.of("test", Map.of("$ref", reference)))); + } + + private static void assertInvalidReferenceUri(Map source, String expectedMessage) { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi31DocumentMapper.parse(source)); + assertThat(parsed.getMessage(), containsString(expectedMessage)); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi31DocumentMapper.render(openApiDocument(source), "3.1.1")); + assertThat(rendered.getMessage(), containsString(expectedMessage)); + } + + private static OpenApiDocument openApiDocument(Map document) { + return OpenApiDocumentReader.read(OpenApiDocumentMapperSupport.jsonObject(document)); + } + + private static Map documentWithSecurityScheme(Map securityScheme) { + Map result = new LinkedHashMap<>(); + result.put("openapi", "3.2.0"); + result.put("info", Map.of("title", "Static API", + "version", "1.0.0")); + result.put("components", Map.of("securitySchemes", Map.of("test", securityScheme))); + return result; + } + + private static Map mutualTlsSecurityScheme() { + Map result = new LinkedHashMap<>(); + result.put("type", "mutualTLS"); + return result; + } + + private static Map deviceAuthorizationSecurityScheme() { + Map flow = new LinkedHashMap<>(); + flow.put("deviceAuthorizationUrl", "https://idp.example.com/device"); + flow.put("tokenUrl", "https://idp.example.com/token"); + flow.put("scopes", Map.of()); + + Map flows = new LinkedHashMap<>(); + flows.put("deviceAuthorization", flow); + + Map result = new LinkedHashMap<>(); + result.put("type", "oauth2"); + result.put("flows", flows); + return result; + } + + private static Map securitySchemes(Map document) { + return map(map(document, "components"), "securitySchemes"); + } + + private static Map reference(String ref) { + Map result = new LinkedHashMap<>(); + result.put("$ref", ref); + result.put("summary", "Reference summary"); + result.put("description", "Reference description"); + result.put("x-reference", "Reference extension"); + result.put("additional", "Additional property"); + return result; + } + + private static void assertReference(Map reference) { + assertThat(reference.keySet(), is(Set.of("$ref", "summary", "description"))); + assertThat(reference.get("summary"), is("Reference summary")); + assertThat(reference.get("description"), is("Reference description")); + } + + @SuppressWarnings("unchecked") + private static Map schemaProperty(Map document, String propertyName) { + return (Map) map(map(map(map(document, "components"), "schemas"), "StaticItem"), "properties") + .get(propertyName); + } + + private static Map encoding(Map document) { + return map(map(map(map(map(map(map(document, "paths"), "/upload"), "post"), + "requestBody"), "content"), "multipart/form-data"), "encoding"); + } + + @SuppressWarnings("unchecked") + private static Map map(Map map, String name) { + return (Map) map.get(name); + } +} diff --git a/openapi/openapi-31/src/test/java/io/helidon/openapi/v31/OpenApi31VersionTest.java b/openapi/openapi-31/src/test/java/io/helidon/openapi/v31/OpenApi31VersionTest.java new file mode 100644 index 00000000000..fc75a4e813f --- /dev/null +++ b/openapi/openapi-31/src/test/java/io/helidon/openapi/v31/OpenApi31VersionTest.java @@ -0,0 +1,313 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v31; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.json.JsonBoolean; +import io.helidon.json.JsonNull; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.openapi.OpenApiGeneratedMode; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApi31VersionTest { + @Test + void preservesEmptyRequiredNames() { + OpenApi31Version version = OpenApi31Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, + """ + openapi: 3.1.1 + info: + title: API + version: "1" + license: + name: "" + tags: + - name: " " + paths: + /items: + get: + parameters: + - name: "" + in: query + schema: {type: string} + responses: + "200": {description: OK} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = parse(version.render(context, document)); + assertThat(map(map(rendered, "info"), "license").get("name"), is("")); + assertThat(((Map) ((List) rendered.get("tags")).getFirst()).get("name"), is(" ")); + Map operation = map(map(map(rendered, "paths"), "/items"), "get"); + assertThat(((Map) ((List) operation.get("parameters")).getFirst()).get("name"), is("")); + } + + @Test + void preservesEmptyInfoStrings() { + OpenApi31Version version = OpenApi31Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, + """ + openapi: 3.1.1 + info: + title: "" + version: " " + components: {} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + assertThat(document.info().orElseThrow().title(), is("")); + assertThat(document.info().orElseThrow().version(), is(" ")); + + Map renderedInfo = map(parse(version.render(context, document)), "info"); + assertThat(renderedInfo.get("title"), is("")); + assertThat(renderedInfo.get("version"), is(" ")); + } + + @Test + void requiresInfoWhenRendering() { + OpenApi31Version version = OpenApi31Version.create(); + OpenApiDocument withoutInfo = OpenApiDocument.builder() + .paths(Map.of()) + .build(); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), withoutInfo)); + assertThat(thrown.getMessage(), containsString("requires Info metadata")); + } + + @Test + void requiresPathsComponentsOrWebhooksWhenRendering() { + OpenApi31Version version = OpenApi31Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument infoOnly = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .build(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> version.render(context, infoOnly)); + assertThat(thrown.getMessage(), containsString("requires at least one of paths, components, or webhooks")); + + OpenApiDocument emptyComponents = version.parse(context, + """ + openapi: 3.1.1 + info: + title: Generated API + version: 1.0.0 + components: {} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + assertThat(parse(version.render(context, emptyComponents)).containsKey("components"), is(true)); + } + + @Test + void rendersJsonSchemaVocabularyWithoutOpenApi30Translation() { + OpenApiDocument document = OpenApiDocument.builder() + .openapi("3.1.0") + .jsonSchemaDialect("https://json-schema.org/draft/2020-12/schema") + .info(info -> info.title("Generated API") + .version("1.0.0") + .summary("Generated summary.")) + .components(components -> components.schema("Item", + JsonObject.builder() + .set("type", "object") + .set("properties", properties -> properties + .set("status", JsonObject.builder() + .setValues("type", List.of( + JsonString.create("string"), + JsonString.create("null"))) + .setValues("enum", List.of( + JsonString.create("new"), + JsonString.create("done"), + JsonNull.instance())) + .build()) + .set("payload", JsonBoolean.TRUE) + .set("mode", JsonObject.builder() + .set("const", "modern") + .build())) + .build())) + .build(); + + OpenApi31Version version = OpenApi31Version.create(); + Map rendered = parse(version.render(context(version), document)); + + assertThat(rendered.get("openapi"), is("3.1.1")); + assertThat(rendered.get("jsonSchemaDialect"), is("https://json-schema.org/draft/2020-12/schema")); + assertThat(map(rendered, "info").get("summary"), is("Generated summary.")); + + Map status = schemaProperty(rendered, "Item", "status"); + assertThat(status.get("type"), is(List.of("string", "null"))); + assertThat(((List) status.get("enum")).contains(null), is(true)); + + Object payload = schemaPropertyValue(rendered, "Item", "payload"); + assertThat(payload, is(true)); + + Map mode = schemaProperty(rendered, "Item", "mode"); + assertThat(mode.get("const"), is("modern")); + } + + @Test + void parsesJsonSchemaVocabularyIntoCanonicalDocument() { + OpenApi31Version version = OpenApi31Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, static31(), MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = parse(version.render(context, document)); + + assertThat(rendered.get("openapi"), is("3.1.1")); + assertThat(rendered.get("jsonSchemaDialect"), is("https://spec.openapis.org/oas/3.1/dialect/base")); + assertThat(map(rendered, "info").get("summary"), is("Static document using OpenAPI 3.1 features.")); + assertThat(map(rendered, "webhooks").containsKey("itemChanged"), is(true)); + assertThat(schemaProperty(rendered, "StaticItem", "status").get("type"), is(List.of("string", "null"))); + assertThat(((List) schemaProperty(rendered, "StaticItem", "status").get("enum")).contains(null), is(true)); + assertThat(schemaPropertyValue(rendered, "StaticItem", "payload"), is(true)); + } + + @Test + void parsesOnlyOpenApi31Documents() { + OpenApi31Version version = OpenApi31Version.create(); + + assertThrows(IllegalStateException.class, + () -> version.parse(context(version), + """ + openapi: 3.0.3 + info: + title: Static API + version: 1.0.0 + """, + MediaTypes.APPLICATION_OPENAPI_YAML)); + } + + @Test + void rejectsNullArguments() { + OpenApi31Version version = OpenApi31Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = OpenApiDocument.builder().build(); + + assertThrows(NullPointerException.class, () -> OpenApi31Version.create((OpenApi31VersionConfig) null)); + assertThrows(NullPointerException.class, () -> version.parse(null, "", MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThrows(NullPointerException.class, () -> version.parse(context, null, MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThrows(NullPointerException.class, () -> version.parse(context, "", null)); + assertThrows(NullPointerException.class, () -> version.render(null, document)); + assertThrows(NullPointerException.class, () -> version.render(context, null)); + } + + @Test + void validatesConfiguredVersion() { + assertThat(OpenApi31Version.builder().version("3.1.99").build().version(), is("3.1.99")); + assertThat(OpenApi31Version.builder().version("3.1.2-rc1").build().version(), is("3.1.2-rc1")); + + for (String invalidVersion : List.of("3.1", "3.1.", "3.1.not-a-version", "3.1.1-", "3.1.1.0", "3.2.0")) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> OpenApi31Version.builder() + .version(invalidVersion) + .build(), + invalidVersion); + assertThat(invalidVersion, ex.getMessage(), containsString("3.1")); + assertThat(invalidVersion, ex.getMessage(), containsString(invalidVersion)); + } + } + + @Test + void serviceLoaderDiscoversProvider() { + boolean found = ServiceLoader.load(OpenApiVersionProvider.class) + .stream() + .map(ServiceLoader.Provider::get) + .anyMatch(provider -> "3.1".equals(provider.configKey())); + + assertThat(found, is(true)); + } + + @SuppressWarnings("unchecked") + private static Map parse(String content) { + return new Yaml().load(content); + } + + @SuppressWarnings("unchecked") + private static Map schemaProperty(Map document, String schemaName, String propertyName) { + return (Map) schemaPropertyValue(document, schemaName, propertyName); + } + + private static Object schemaPropertyValue(Map document, String schemaName, String propertyName) { + return map(map(map(map(document, "components"), "schemas"), schemaName), "properties") + .get(propertyName); + } + + @SuppressWarnings("unchecked") + private static Map map(Map map, String name) { + return (Map) map.get(name); + } + + private static OpenApiDocumentContext context(OpenApiVersion version) { + return new TestOpenApiDocumentContext(version); + } + + private static String static31() { + try (InputStream is = OpenApi31VersionTest.class.getResourceAsStream("/static-3.1.yaml")) { + if (is == null) { + throw new IllegalArgumentException("Resource not found: static-3.1.yaml"); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private record TestOpenApiDocumentContext(OpenApiVersion openApiVersion) implements OpenApiDocumentContext { + @Override + public String featureName() { + return "openapi"; + } + + @Override + public String webContext() { + return "/openapi"; + } + + @Override + public String listener() { + return "default"; + } + + @Override + public OpenApiGeneratedMode generatedMode() { + return OpenApiGeneratedMode.STATIC_ONLY; + } + } +} diff --git a/openapi/openapi-31/src/test/resources/static-3.1.yaml b/openapi/openapi-31/src/test/resources/static-3.1.yaml new file mode 100644 index 00000000000..870a79869a7 --- /dev/null +++ b/openapi/openapi-31/src/test/resources/static-3.1.yaml @@ -0,0 +1,92 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +openapi: 3.1.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +info: + title: Static 3.1 API + summary: Static document using OpenAPI 3.1 features. + version: 1.0.0 +tags: + - name: static + description: Static document operations. +paths: + /static/{id}: + get: + tags: + - static + operationId: staticGet + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Static response. + headers: + X-Request-Id: + description: Request correlation id. + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/StaticItem" + examples: + active: + value: + id: "42" + status: active +webhooks: + itemChanged: + post: + operationId: staticItemChanged + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/StaticItem" + responses: + "204": + description: Webhook accepted. +components: + schemas: + StaticItem: + type: object + required: + - id + properties: + id: + type: string + status: + type: + - string + - "null" + enum: + - active + - inactive + - null + payload: true + mode: + const: modern + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +security: + - bearerAuth: [] diff --git a/openapi/openapi-32/pom.xml b/openapi/openapi-32/pom.xml new file mode 100644 index 00000000000..99bce4e2bcb --- /dev/null +++ b/openapi/openapi-32/pom.xml @@ -0,0 +1,170 @@ + + + + 4.0.0 + + io.helidon.openapi + helidon-openapi-project + 27.0.0-SNAPSHOT + + helidon-openapi-32 + Helidon OpenAPI 3.2 + + + Helidon OpenAPI 3.2 document version support + + + + true + + + + + io.helidon.common.features + helidon-common-features-api + true + + + io.helidon.builder + helidon-builder-api + + + io.helidon.common + helidon-common + + + io.helidon.common + helidon-common-media-type + + + io.helidon.openapi + helidon-openapi + + + io.helidon.config + helidon-config + + + io.helidon.config.metadata + helidon-config-metadata + + + io.helidon.service + helidon-service-registry + + + io.helidon.json + helidon-json + + + org.yaml + snakeyaml + + + io.helidon.openapi + helidon-openapi-31 + test + + + org.junit.jupiter + junit-jupiter-api + test + + + org.hamcrest + hamcrest-all + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + io.helidon.common.features + helidon-common-features-codegen + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.service + helidon-service-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + + io.helidon.common.features + helidon-common-features-codegen + ${helidon.version} + + + io.helidon.config.metadata + helidon-config-metadata-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-apt + ${helidon.version} + + + io.helidon.builder + helidon-builder-codegen + ${helidon.version} + + + io.helidon.service + helidon-service-codegen + ${helidon.version} + + + io.helidon.codegen + helidon-codegen-helidon-copyright + ${helidon.version} + + + + + + diff --git a/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32DocumentMapper.java b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32DocumentMapper.java new file mode 100644 index 00000000000..320df021888 --- /dev/null +++ b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32DocumentMapper.java @@ -0,0 +1,288 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v32; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.v30.OpenApi3xMapperRules; +import io.helidon.openapi.v30.OpenApiDocumentReader; + +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.document3x; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.jsonObject; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.objectMap; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateDocumentStructure; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateMediaTypes; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateOperationIds; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateQueryStringParameters; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateSchemas; + +final class OpenApi32DocumentMapper { + private static final Set DOCUMENT_FIELDS = Set.of("openapi", + "$self", + "info", + "jsonSchemaDialect", + "servers", + "paths", + "webhooks", + "components", + "security", + "tags", + "externalDocs"); + private static final Set INFO_FIELDS = Set.of("title", + "summary", + "description", + "termsOfService", + "contact", + "license", + "version"); + private static final Set CONTACT_FIELDS = Set.of("name", + "url", + "email"); + private static final Set LICENSE_FIELDS = Set.of("name", + "identifier", + "url"); + private static final Set SERVER_FIELDS = Set.of("url", + "description", + "name", + "variables"); + private static final Set SERVER_VARIABLE_FIELDS = Set.of("enum", + "default", + "description"); + private static final Set TAG_FIELDS = Set.of("name", + "summary", + "description", + "externalDocs", + "parent", + "kind"); + private static final Set PATH_ITEM_FIELDS = Set.of("$ref", + "summary", + "description", + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query", + "additionalOperations", + "servers", + "parameters"); + private static final Set FIXED_PATH_OPERATION_FIELDS = Set.of("get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query"); + private static final Set OPERATION_FIELDS = Set.of("tags", + "summary", + "description", + "externalDocs", + "operationId", + "parameters", + "requestBody", + "responses", + "callbacks", + "deprecated", + "security", + "servers"); + private static final Set PARAMETER_FIELDS = Set.of("$ref", + "name", + "in", + "description", + "required", + "deprecated", + "allowEmptyValue", + "style", + "explode", + "allowReserved", + "schema", + "example", + "examples", + "content"); + private static final Set HEADER_FIELDS = Set.of("$ref", + "description", + "required", + "deprecated", + "style", + "explode", + "schema", + "example", + "examples", + "content"); + private static final Set REQUEST_BODY_FIELDS = Set.of("$ref", + "description", + "content", + "required"); + private static final Set RESPONSE_FIELDS = Set.of("$ref", + "summary", + "description", + "headers", + "content", + "links"); + private static final Set MEDIA_TYPE_FIELDS = Set.of("$ref", + "schema", + "itemSchema", + "example", + "examples", + "encoding", + "prefixEncoding", + "itemEncoding"); + private static final Set ENCODING_FIELDS = Set.of("contentType", + "headers", + "encoding", + "prefixEncoding", + "itemEncoding", + "style", + "explode", + "allowReserved"); + private static final Set COMPONENTS_FIELDS = Set.of("schemas", + "responses", + "parameters", + "examples", + "requestBodies", + "headers", + "securitySchemes", + "links", + "callbacks", + "pathItems", + "mediaTypes"); + private static final Set SECURITY_SCHEME_FIELDS = Set.of("$ref", + "type", + "description", + "name", + "in", + "scheme", + "bearerFormat", + "flows", + "openIdConnectUrl", + "oauth2MetadataUrl", + "deprecated"); + private static final Set SECURITY_SCHEME_TYPES = Set.of("apiKey", + "http", + "mutualTLS", + "oauth2", + "openIdConnect"); + private static final Set OAUTH_FLOWS_FIELDS = Set.of("implicit", + "password", + "clientCredentials", + "authorizationCode", + "deviceAuthorization"); + private static final Set OAUTH_FLOW_FIELDS = Set.of("authorizationUrl", + "tokenUrl", + "refreshUrl", + "scopes", + "deviceAuthorizationUrl"); + private static final Set LINK_FIELDS = Set.of("$ref", + "operationRef", + "operationId", + "parameters", + "requestBody", + "description", + "server"); + private static final Set EXAMPLE_FIELDS = Set.of("$ref", + "summary", + "description", + "value", + "externalValue", + "dataValue", + "serializedValue"); + private static final Set EXTERNAL_DOCS_FIELDS = Set.of("description", + "url"); + private static final Set PARAMETER_LOCATIONS = Set.of("query", + "header", + "path", + "cookie", + "querystring"); + private static final OpenApi3xMapperRules MAPPER_RULES = OpenApi3xMapperRules.builder() + .targetVersion("3.2") + .operationResponsesRequired(false) + .responseDescriptionRequired(false) + .addDocumentFields(DOCUMENT_FIELDS) + .addInfoFields(INFO_FIELDS) + .addContactFields(CONTACT_FIELDS) + .addLicenseFields(LICENSE_FIELDS) + .addServerFields(SERVER_FIELDS) + .addServerVariableFields(SERVER_VARIABLE_FIELDS) + .addTagFields(TAG_FIELDS) + .addPathItemFields(PATH_ITEM_FIELDS) + .addFixedPathOperationFields(FIXED_PATH_OPERATION_FIELDS) + .addOperationFields(OPERATION_FIELDS) + .addParameterFields(PARAMETER_FIELDS) + .addParameterLocations(PARAMETER_LOCATIONS) + .addHeaderFields(HEADER_FIELDS) + .addRequestBodyFields(REQUEST_BODY_FIELDS) + .addResponseFields(RESPONSE_FIELDS) + .addMediaTypeFields(MEDIA_TYPE_FIELDS) + .addEncodingFields(ENCODING_FIELDS) + .addComponentsFields(COMPONENTS_FIELDS) + .addSecuritySchemeFields(SECURITY_SCHEME_FIELDS) + .addSecuritySchemeTypes(SECURITY_SCHEME_TYPES) + .addOauthFlowsFields(OAUTH_FLOWS_FIELDS) + .addOauthFlowFields(OAUTH_FLOW_FIELDS) + .addLinkFields(LINK_FIELDS) + .addExampleFields(EXAMPLE_FIELDS) + .addExternalDocsFields(EXTERNAL_DOCS_FIELDS) + .build(); + + private OpenApi32DocumentMapper() { + } + + static OpenApiDocument parse(Map document) { + validateOpenApi32(document.get("openapi")); + validateDocumentStructure(document, MAPPER_RULES); + validateSchemas(document, MAPPER_RULES); + Map mapped = document3x(document, MAPPER_RULES); + validateDocumentStructure(mapped, MAPPER_RULES); + validateSchemas(mapped, MAPPER_RULES); + validateQueryStringParameters(mapped, MAPPER_RULES); + validateMediaTypes(mapped, MAPPER_RULES); + validateOperationIds(mapped); + return OpenApiDocumentReader.read(jsonObject(mapped)); + } + + static Map render(OpenApiDocument document, String version) { + Map rendered = document3x(objectMap(document.toJsonObject()), MAPPER_RULES); + validateDocumentStructure(rendered, MAPPER_RULES); + validateSchemas(rendered, MAPPER_RULES); + validateQueryStringParameters(rendered, MAPPER_RULES); + validateMediaTypes(rendered, MAPPER_RULES); + validateOperationIds(rendered); + Map result = new LinkedHashMap<>(); + result.put("openapi", version); + rendered.forEach((key, value) -> { + if (!"openapi".equals(key)) { + result.put(key, value); + } + }); + return result; + } + + private static void validateOpenApi32(Object version) { + if (!(version instanceof String string) || !OpenApi32Version.isSupportedVersion(string)) { + throw new IllegalStateException("OpenAPI 3.2 parser requires a 3.2 document, got: " + version); + } + } + +} diff --git a/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32Version.java b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32Version.java new file mode 100644 index 00000000000..8c642aa1fdb --- /dev/null +++ b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32Version.java @@ -0,0 +1,158 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v32; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.common.Api; +import io.helidon.common.media.type.MediaType; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.openapi.OpenApiFormat; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.v30.OpenApiDocumentMapperSupport; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +/** + * OpenAPI 3.2 version implementation. + */ +@Api.Preview +public final class OpenApi32Version implements OpenApiVersion, + RuntimeType.Api { + static final String TYPE = "3.2"; + private static final Pattern VERSION_PATTERN = Pattern.compile(Pattern.quote(TYPE) + "\\.[0-9]+(?:-.+)?"); + private static final DumperOptions YAML_DUMPER_OPTIONS = yamlDumperOptions(); + + private final OpenApi32VersionConfig config; + + OpenApi32Version(OpenApi32VersionConfig config) { + Objects.requireNonNull(config); + String version = config.version(); + if (!isSupportedVersion(version)) { + throw new IllegalArgumentException("OpenAPI " + TYPE + " version implementation cannot produce document version " + + version + "."); + } + this.config = config; + } + + static boolean isSupportedVersion(String version) { + return VERSION_PATTERN.matcher(version).matches(); + } + + /** + * Returns a new builder. + * + * @return new builder + */ + public static OpenApi32VersionConfig.Builder builder() { + return OpenApi32VersionConfig.builder(); + } + + /** + * Create a new OpenAPI 3.2 version implementation with default configuration. + * + * @return new version implementation + */ + public static OpenApi32Version create() { + return builder().build(); + } + + /** + * Create a new OpenAPI 3.2 version implementation with custom configuration. + * + * @param consumer configuration consumer + * @return new version implementation + */ + public static OpenApi32Version create(Consumer consumer) { + return builder() + .update(consumer) + .build(); + } + + /** + * Create a new OpenAPI 3.2 version implementation from typed configuration. + * + * @param config typed configuration + * @return new version implementation + */ + public static OpenApi32Version create(OpenApi32VersionConfig config) { + return new OpenApi32Version(config); + } + + @Override + public String version() { + return config.version(); + } + + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType) { + Objects.requireNonNull(context); + Objects.requireNonNull(content); + Objects.requireNonNull(mediaType); + if (OpenApiFormat.valueOf(mediaType) == OpenApiFormat.UNSUPPORTED) { + throw new IllegalStateException("Unsupported static OpenAPI content type: " + mediaType.text()); + } + Object loaded = OpenApiDocumentMapperSupport.parseYaml(content); + if (loaded == null) { + return OpenApiDocument.builder().build(); + } + if (loaded instanceof Map map) { + Map values = new LinkedHashMap<>(); + map.forEach((key, value) -> values.put(String.valueOf(key), value)); + return OpenApi32DocumentMapper.parse(values); + } + throw new IllegalStateException("Static OpenAPI content must be a YAML or JSON object."); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + Objects.requireNonNull(context); + Objects.requireNonNull(document); + OpenApiDocumentMapperSupport.validateDocumentRoot(document, config.version()); + Map values = OpenApi32DocumentMapper.render(document, config.version()); + return new Yaml(YAML_DUMPER_OPTIONS).dump(values); + } + + @Override + public OpenApi32VersionConfig prototype() { + return config; + } + + @Override + public String name() { + return config.name(); + } + + @Override + public String type() { + return TYPE; + } + + private static DumperOptions yamlDumperOptions() { + DumperOptions dumperOptions = new DumperOptions(); + dumperOptions.setIndent(2); + dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + return dumperOptions; + } +} diff --git a/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32VersionConfigBlueprint.java b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32VersionConfigBlueprint.java new file mode 100644 index 00000000000..bae8cedfe43 --- /dev/null +++ b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32VersionConfigBlueprint.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v32; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.Api; +import io.helidon.openapi.spi.OpenApiVersionProvider; + +/** + * OpenAPI 3.2 version configuration. + */ +@Api.Preview +@Prototype.Blueprint +@Prototype.Configured(value = OpenApi32Version.TYPE, root = false) +@Prototype.Provides(OpenApiVersionProvider.class) +interface OpenApi32VersionConfigBlueprint extends Prototype.Factory { + /** + * Name of this version configuration. + * + * @return version implementation name + */ + @Option.Default(OpenApi32Version.TYPE) + String name(); + + /** + * Exact OpenAPI 3.2 document version to produce. + * + * @return OpenAPI document version + */ + @Option.Configured + @Option.Default("3.2.0") + String version(); +} diff --git a/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32VersionProvider.java b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32VersionProvider.java new file mode 100644 index 00000000000..4dee1bb9ddc --- /dev/null +++ b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/OpenApi32VersionProvider.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v32; + +import java.util.Objects; + +import io.helidon.common.Api; +import io.helidon.common.Weight; +import io.helidon.config.Config; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; +import io.helidon.service.registry.Service; + +/** + * OpenAPI 3.2 version provider. + */ +@Service.Singleton +@Weight(3200) +public class OpenApi32VersionProvider implements OpenApiVersionProvider { + /** + * Required public constructor. + */ + @Api.Internal + public OpenApi32VersionProvider() { + } + + @Override + public String configKey() { + return OpenApi32Version.TYPE; + } + + @Override + public OpenApiVersion create(Config config, String name) { + return OpenApi32VersionConfig.builder() + .config(Objects.requireNonNull(config)) + .name(Objects.requireNonNull(name)) + .build(); + } +} diff --git a/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/package-info.java b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/package-info.java new file mode 100644 index 00000000000..7ed8b2e21ab --- /dev/null +++ b/openapi/openapi-32/src/main/java/io/helidon/openapi/v32/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * OpenAPI 3.2 support. + */ +package io.helidon.openapi.v32; diff --git a/openapi/openapi-32/src/main/java/module-info.java b/openapi/openapi-32/src/main/java/module-info.java new file mode 100644 index 00000000000..9cfe5711f97 --- /dev/null +++ b/openapi/openapi-32/src/main/java/module-info.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import io.helidon.common.features.api.Features; +import io.helidon.common.features.api.HelidonFlavor; + +/** + * Helidon OpenAPI 3.2 document version support. + */ +@Features.Name("OpenAPI 3.2") +@Features.Description("OpenAPI 3.2 document version support") +@Features.Flavor(HelidonFlavor.SE) +@Features.Path({"OpenAPI", "3.2"}) +module io.helidon.openapi.v32 { + requires static io.helidon.common.features.api; + requires static io.helidon.config.metadata; + + requires transitive io.helidon.builder.api; + requires io.helidon.common; + requires transitive io.helidon.common.media.type; + requires transitive io.helidon.config; + requires io.helidon.json; + requires transitive io.helidon.openapi; + requires io.helidon.service.registry; + + requires org.yaml.snakeyaml; + + exports io.helidon.openapi.v32; + + provides io.helidon.openapi.spi.OpenApiVersionProvider + with io.helidon.openapi.v32.OpenApi32VersionProvider; +} diff --git a/openapi/openapi-32/src/test/java/io/helidon/openapi/v32/OpenApi32DocumentMapperTest.java b/openapi/openapi-32/src/test/java/io/helidon/openapi/v32/OpenApi32DocumentMapperTest.java new file mode 100644 index 00000000000..8ed04e8e173 --- /dev/null +++ b/openapi/openapi-32/src/test/java/io/helidon/openapi/v32/OpenApi32DocumentMapperTest.java @@ -0,0 +1,1205 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v32; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +import io.helidon.json.JsonArray; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.v30.OpenApiDocumentMapperSupport; +import io.helidon.openapi.v30.OpenApiDocumentReader; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApi32DocumentMapperTest { + private static final long LARGE_INTEGRAL_VALUE = 9_007_199_254_740_993L; + + @Test + void validatesOpenApiVersion() { + OpenApi32DocumentMapper.parse(document("3.2.0-beta")); + + for (String invalidVersion : List.of("3.2", "3.2.", "3.2.not-a-version", "3.2.1-", "3.2.1.0")) { + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(document(invalidVersion)), + invalidVersion); + assertThat(invalidVersion, ex.getMessage(), containsString(invalidVersion)); + } + } + + @Test + void rejectsRepeatedPathTemplateExpressions() { + String path = "/items/{itemId}/{itemId}"; + IllegalStateException parsed = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithSection("paths", Map.of(path, Map.of())))); + assertThat(parsed.getMessage(), containsString(path)); + assertThat(parsed.getMessage(), containsString("must not repeat path template expression {itemId}")); + + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path(path, _ -> { }) + .build(); + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(document, "3.2.0")); + assertThat(rendered.getMessage(), containsString(path)); + assertThat(rendered.getMessage(), containsString("must not repeat path template expression {itemId}")); + } + + @Test + void rejectsEmptyPathTemplateExpressions() { + String path = "/items/{}"; + IllegalStateException parsed = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithSection("paths", Map.of(path, Map.of())))); + assertThat(parsed.getMessage(), containsString(path)); + assertThat(parsed.getMessage(), containsString("must not contain an empty path template expression")); + + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path(path, _ -> { }) + .build(); + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(document, "3.2.0")); + assertThat(rendered.getMessage(), containsString(path)); + assertThat(rendered.getMessage(), containsString("must not contain an empty path template expression")); + } + + @Test + void validatesPathTemplateGrammar() { + Map invalidPaths = Map.of( + "/items/[id]", "contains invalid path literal character at index", + "/items/item id", "contains invalid path literal character at index", + "/items/\u017E", "contains invalid path literal character at index", + "/items/%2", "contains an invalid percent-encoded path literal", + "/items/%GG", "contains an invalid percent-encoded path literal", + "/items/%\uFF26\uFF26", "contains an invalid percent-encoded path literal", + "/items/%\uFF11\uFF12", "contains an invalid percent-encoded path literal", + "/items//details", "must not contain an empty path segment"); + invalidPaths.forEach((path, expectedMessage) -> { + IllegalStateException parsed = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithSection("paths", Map.of(path, Map.of())))); + assertThat(parsed.getMessage(), containsString(path)); + assertThat(parsed.getMessage(), containsString(expectedMessage)); + + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path(path, _ -> { }) + .build(); + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(document, "3.2.0")); + assertThat(rendered.getMessage(), containsString(path)); + assertThat(rendered.getMessage(), containsString(expectedMessage)); + }); + + for (String validPath : List.of("/", + "/items/", + "/items/%5Bid%5D", + "/items/segment-._~!$&'()*+,;=:@", + "/items/{item/name?mode#fragment}", + "/items/{item-\u017E}")) { + OpenApi32DocumentMapper.parse(documentWithSection("paths", Map.of(validPath, Map.of()))); + OpenApi32DocumentMapper.render(OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path(validPath, _ -> { }) + .build(), + "3.2.0"); + } + } + + @Test + void validatesPathTemplateParameters() { + String path = "/items/{id}"; + assertMissingPathParameter(documentWithPathItem(path, Map.of("get", Map.of())), "get"); + + assertValidPathTemplateDocument(documentWithPathItem(path, Map.of( + "parameters", List.of(pathParameter("id")), + "get", Map.of()))); + assertValidPathTemplateDocument(documentWithPathItem(path, Map.of( + "get", Map.of("parameters", List.of(pathParameter("id")))))); + + assertMissingPathParameter(documentWithPathItem(path, Map.of( + "get", Map.of("parameters", List.of(pathParameter("id"))), + "post", Map.of())), "post"); + + Map localReference = documentWithPathItem(path, Map.of( + "get", Map.of("parameters", List.of(Map.of( + "$ref", "#/components/parameters/Id"))))); + localReference.put("components", Map.of("parameters", Map.of("Id", pathParameter("id")))); + assertValidPathTemplateDocument(localReference); + + Map sameDocumentReferences = documentWithSection("paths", Map.of( + "/shared/{id}", Map.of("get", Map.of("parameters", List.of(pathParameter("id")))), + "/alias/{id}", Map.of("get", Map.of("parameters", List.of(Map.of( + "$ref", "#/paths/~1shared~1%7Bid%7D/get/parameters/0")))), + path, Map.of("get", Map.of("parameters", List.of(Map.of( + "$ref", "#/paths/~1alias~1%7Bid%7D/get/parameters/0")))))); + assertValidPathTemplateDocument(sameDocumentReferences); + + assertValidPathTemplateDocument(documentWithPathItem(path, Map.of( + "get", Map.of("parameters", List.of(Map.of( + "$ref", "parameters.yaml#/components/parameters/Id")))))); + + assertValidPathTemplateDocument(documentWithPathItem(path, Map.of( + "get", Map.of("parameters", List.of(Map.of( + "$ref", "#/components/parameters/Missing")))))); + + Map cyclicReference = documentWithPathItem(path, Map.of( + "get", Map.of("parameters", List.of(Map.of( + "$ref", "#/components/parameters/First"))))); + cyclicReference.put("components", Map.of("parameters", Map.of( + "First", Map.of("$ref", "#/components/parameters/Second"), + "Second", Map.of("$ref", "#/components/parameters/First")))); + assertValidPathTemplateDocument(cyclicReference); + + assertValidPathTemplateDocument(documentWithPathItem(path, Map.of( + "$ref", "paths.yaml#/components/pathItems/Items", + "get", Map.of()))); + + assertValidPathTemplateDocument(documentWithPathItem(path, Map.of( + "query", Map.of("parameters", List.of(pathParameter("id")))))); + assertMissingPathParameter(documentWithPathItem(path, Map.of( + "additionalOperations", Map.of("COPY", Map.of()))), "COPY"); + assertValidPathTemplateDocument(documentWithPathItem(path, Map.of( + "parameters", List.of(pathParameter("id")), + "additionalOperations", Map.of("COPY", Map.of())))); + } + + @Test + void resolvesLongPathItemReferenceChains() { + int referenceCount = 10_000; + int pathCount = 100; + CountingMap pathItems = new CountingMap(); + for (int i = 0; i < referenceCount - 1; i++) { + pathItems.put("Item" + i, Map.of("$ref", "#/components/pathItems/Item" + (i + 1))); + } + pathItems.put("Item" + (referenceCount - 1), Map.of( + "get", Map.of("parameters", List.of(pathParameter("id"))))); + + Map paths = new LinkedHashMap<>(); + for (int i = 0; i < pathCount; i++) { + paths.put("/items" + i + "/{id}", Map.of("$ref", "#/components/pathItems/Item0")); + } + Map document = documentWithSection("paths", paths); + document.put("components", Map.of("pathItems", pathItems)); + OpenApiDocument parsed = OpenApi32DocumentMapper.parse(document); + assertThat(pathItems.lookups() <= referenceCount + pathCount, is(true)); + OpenApi32DocumentMapper.render(parsed, "3.2.0"); + } + + @Test + void validatesParameterListUniqueness() { + Map pathParameter = pathParameter("id"); + assertDuplicateParameters(documentWithPathItem("/items/{id}", Map.of( + "get", Map.of("parameters", List.of(pathParameter, pathParameter))))); + + Map query = queryParameter("id"); + assertDuplicateParameters(documentWithParameters( + List.of(query, query), + List.of())); + + Map localReference = documentWithParameters( + List.of(), + List.of(query, Map.of("$ref", "#/components/parameters/Alias"))); + localReference.put("components", Map.of("parameters", Map.of("Alias", query))); + assertDuplicateParameters(localReference); + + Map referenceChain = documentWithParameters( + List.of(), + List.of(Map.of("$ref", "#/components/parameters/First"), + Map.of("$ref", "#/components/parameters/Second"))); + referenceChain.put("components", Map.of("parameters", Map.of( + "First", Map.of("$ref", "#/components/parameters/Target"), + "Second", Map.of("$ref", "#/components/parameters/Target"), + "Target", query))); + assertDuplicateParameters(referenceChain); + + Map sameDocumentReference = documentWithSection("paths", Map.of( + "/shared", Map.of("get", Map.of("parameters", List.of(query))), + "/alias", Map.of("get", Map.of("parameters", List.of(Map.of( + "$ref", "#/paths/~1shared/get/parameters/0")))), + "/items", Map.of("get", Map.of("parameters", List.of( + query, + Map.of("$ref", "#/paths/~1alias/get/parameters/0")))))); + assertDuplicateParameters(sameDocumentReference); + + assertDuplicateParameters(documentWithParameters( + List.of(), + List.of(headerParameter("X-Request-Id"), headerParameter("x-request-id")))); + + assertValidParameterDocument(documentWithParameters( + List.of(query), + List.of(query))); + assertValidParameterDocument(documentWithParameters( + List.of(), + List.of(queryParameter("id"), queryParameter("ID"), headerParameter("id")))); + assertValidParameterDocument(documentWithParameters( + List.of(), + List.of(Map.of("$ref", "parameters.yaml#/components/parameters/Id"), + Map.of("$ref", "parameters.yaml#/components/parameters/Id")))); + } + + @Test + void validatesSchemaReferenceSiblings() { + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(Map.of( + "openapi", "3.2.0", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("schemas", Map.of( + "Referenced", Map.of( + "$ref", "#/components/schemas/Base", + "properties", Map.of( + "invalid", "not a schema"))))))); + + assertThat(thrown.getMessage(), containsString("components.schemas.Referenced.properties.invalid")); + } + + @Test + void validatesReferenceUris() { + String malformed = "http://[bad"; + assertInvalidReferenceUri(documentWithExampleReference(malformed), "must be a URI"); + assertInvalidReferenceUri(documentWithSchemaReference(malformed), "must be a URI"); + assertInvalidReferenceUri(documentWithPathItem("/items", Map.of("$ref", malformed)), "must be a URI"); + + String ipvFuture = "http://[v1.fe]/description.yaml#/components/examples/Example"; + assertInvalidReferenceUri(documentWithExampleReference(ipvFuture), "IPvFuture host literal"); + assertInvalidReferenceUri(documentWithSchemaReference(ipvFuture), "IPvFuture host literal"); + assertInvalidReferenceUri(documentWithPathItem("/items", Map.of("$ref", ipvFuture)), + "IPvFuture host literal"); + + for (String valid : List.of("https://example.test/openapi.yaml#/components/examples/Example", + "../openapi.yaml#/components/examples/Example", + "#/components/examples/Example", + "other.yaml#anchor")) { + OpenApiDocument document = OpenApi32DocumentMapper.parse(documentWithExampleReference(valid)); + Map validRendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + assertThat(map(map(map(validRendered, "components"), "examples"), "test").get("$ref"), is(valid)); + } + } + + @Test + void handlesVersionSpecificResponseRequirements() { + for (Map responses : List.>of( + Map.of(), + Map.of("x-note", "No response code"))) { + IllegalStateException missingResponseCode = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(Map.of( + "openapi", "3.2.0", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "/items", Map.of( + "get", Map.of("responses", responses)))))); + assertThat(missingResponseCode.getMessage(), containsString("response code")); + } + + IllegalStateException invalidResponseKey = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(Map.of( + "openapi", "3.2.0", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of("description", "OK"), + "bogus", Map.of("description", "Invalid")))))))); + assertThat(invalidResponseKey.getMessage(), containsString("3.2")); + assertThat(invalidResponseKey.getMessage(), containsString("bogus")); + + OpenApiDocument document = OpenApi32DocumentMapper.parse(Map.of( + "openapi", "3.2.0", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "/without-responses", Map.of( + "get", Map.of("summary", "Items")), + "/without-description", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of("summary", "Items")))), + "/empty-description", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of("description", ""))))))); + + OpenApiDocument.Response omittedDescription = document.paths() + .get("/without-description") + .operations() + .get("get") + .responses() + .get("200"); + OpenApiDocument.Response emptyDescription = document.paths() + .get("/empty-description") + .operations() + .get("get") + .responses() + .get("200"); + + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + Map paths = map(rendered, "paths"); + Map withoutResponses = map(map(paths, "/without-responses"), "get"); + Map response = map(map(map(paths, "/without-description"), "get"), "responses"); + + assertThat(withoutResponses.containsKey("responses"), is(false)); + assertThat(map(response, "200").get("summary"), is("Items")); + assertThat(map(response, "200").containsKey("description"), is(false)); + assertThat(omittedDescription.description(), is(Optional.empty())); + assertThat(emptyDescription.description(), is(Optional.of(""))); + + OpenApiDocument responsesWithoutCode = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.responseExtension("x-note", JsonString.create("No response code")))) + .build(); + IllegalStateException renderedWithoutResponseCode = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(responsesWithoutCode, "3.2.0")); + assertThat(renderedWithoutResponseCode.getMessage(), containsString("response code")); + + OpenApiDocument responseWithInvalidKey = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.response("200", "OK").response("bogus", "Invalid"))) + .build(); + IllegalStateException renderedWithInvalidResponseKey = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(responseWithInvalidKey, "3.2.0")); + assertThat(renderedWithInvalidResponseKey.getMessage(), containsString("3.2")); + assertThat(renderedWithInvalidResponseKey.getMessage(), containsString("bogus")); + } + + @Test + void rejectsMalformedSecurityRequirements() { + Map invalidSecurityValues = new LinkedHashMap<>(); + invalidSecurityValues.put("security value is not an array", Map.of("OAuth", List.of())); + invalidSecurityValues.put("security requirement is not an object", List.of("OAuth")); + invalidSecurityValues.put("scheme scopes are not an array", List.of(Map.of("OAuth", "read"))); + invalidSecurityValues.put("scheme scope is not a string", List.of(Map.of("OAuth", List.of("read", 42)))); + + invalidSecurityValues.forEach((description, invalidSecurity) -> { + Map topLevelDocument = new LinkedHashMap<>(document("3.2.0")); + topLevelDocument.put("security", invalidSecurity); + IllegalStateException topLevel = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(topLevelDocument), + description + " at document level"); + assertThat(description, topLevel.getMessage(), containsString("security")); + + Map operationDocument = new LinkedHashMap<>(document("3.2.0")); + operationDocument.put("paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of("200", Map.of("description", "OK")), + "security", invalidSecurity)))); + IllegalStateException operation = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(operationDocument), + description + " at operation level"); + assertThat(description, operation.getMessage(), containsString("security")); + }); + } + + @Test + void preservesLargeIntegralNumbers() { + OpenApiDocument document = OpenApi32DocumentMapper.parse(document("3.2.0")); + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + + assertThat(String.valueOf(schemaProperty(rendered, "large").get("default")), is(String.valueOf(LARGE_INTEGRAL_VALUE))); + } + + @Test + void filtersReferenceObjectFields() { + Map reference = Map.of( + "$ref", "#/components/responses/real", + "summary", "Reference summary", + "description", "Reference description", + "x-reference", "Reference extension", + "additional", "Additional property"); + OpenApiDocument document = OpenApi32DocumentMapper.parse(Map.of( + "openapi", "3.2.0", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "components", Map.of( + "schemas", Map.of("testSchema", reference), + "responses", Map.of("testResponse", reference)))); + + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + Map components = map(rendered, "components"); + Map responseReference = map(map(components, "responses"), "testResponse"); + + assertThat(responseReference.keySet(), is(Set.of("$ref", "summary", "description"))); + assertThat(map(map(components, "schemas"), "testSchema").get("x-reference"), is("Reference extension")); + } + + @Test + void preservesNullExtensionValues() { + OpenApiDocument document = OpenApi32DocumentMapper.parse(documentWithNullExtension("3.2.0")); + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + + assertThat(rendered.containsKey("x-null"), is(true)); + assertThat(rendered.get("x-null"), is((Object) null)); + } + + @Test + void filtersUnsupportedHeaderFields() { + OpenApiDocument document = OpenApi32DocumentMapper.parse(Map.of( + "openapi", "3.2.0", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of( + "description", "OK", + "headers", Map.of( + "X-Test", Map.of( + "allowEmptyValue", true, + "allowReserved", true, + "schema", Map.of("type", "string")))))))))); + + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + Map responses = map(map(map(map(rendered, "paths"), "/items"), "get"), "responses"); + Map header = map(map(map(responses, "200"), "headers"), "X-Test"); + + assertThat(header.containsKey("allowEmptyValue"), is(false)); + assertThat(header.containsKey("allowReserved"), is(false)); + } + + @Test + void validatesQueryStringParameterShape() { + Map validExample = new LinkedHashMap<>(queryStringParameter("example")); + validExample.put("example", "q=one"); + Map validExamples = new LinkedHashMap<>(queryStringParameter("examples")); + validExamples.put("examples", Map.of("one", Map.of("value", "q=one"))); + OpenApi32DocumentMapper.parse(documentWithParameters(List.of(validExample), List.of())); + OpenApi32DocumentMapper.parse(documentWithParameters(List.of(validExamples), List.of())); + + OpenApiDocument valid = OpenApi32DocumentMapper.parse(documentWithParameters( + List.of(), List.of(queryStringParameter("query")))); + Map rendered = OpenApi32DocumentMapper.render(valid, "3.2.0"); + Map operation = map(map(map(rendered, "paths"), "/items"), "get"); + Map parameter = map((Map) ((List) operation.get("parameters")).getFirst(), "content"); + assertThat(parameter.keySet(), is(Set.of("application/x-www-form-urlencoded"))); + + List> invalidParameters = new ArrayList<>(); + invalidParameters.add(Map.of("name", "missing", "in", "querystring")); + invalidParameters.add(Map.of("name", "empty", "in", "querystring", "content", Map.of())); + Map multipleContent = new LinkedHashMap<>(queryStringParameter("multiple")); + multipleContent.put("content", Map.of( + "application/x-www-form-urlencoded", Map.of(), + "application/json", Map.of())); + invalidParameters.add(multipleContent); + Map schemaFields = Map.of( + "allowEmptyValue", true, + "style", "form", + "explode", true, + "allowReserved", true, + "schema", Map.of("type", "object")); + schemaFields.forEach((field, value) -> { + Map invalid = new LinkedHashMap<>(queryStringParameter(field)); + invalid.put(field, value); + invalidParameters.add(invalid); + }); + + for (Map invalid : invalidParameters) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithParameters(List.of(invalid), List.of()))); + assertThat(thrown.getMessage(), containsString("querystring parameter")); + } + + OpenApiDocument invalidGenerated = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.parameter(parameterBuilder -> parameterBuilder + .name("query") + .in("querystring") + .schema(JsonObject.builder().set("type", "string").build())) + .operation("GET", _ -> { })) + .build(); + IllegalStateException renderedInvalid = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(invalidGenerated, "3.2.0")); + assertThat(renderedInvalid.getMessage(), containsString("querystring parameter")); + } + + @Test + void validatesEffectiveQueryStringParameterLocations() { + IllegalStateException repeated = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithParameters( + List.of(queryStringParameter("first"), queryStringParameter("second")), List.of()))); + assertThat(repeated.getMessage(), containsString("more than one querystring")); + + IllegalStateException mixed = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithParameters( + List.of(queryParameter("named")), List.of(queryStringParameter("query"))))); + assertThat(mixed.getMessage(), containsString("cannot combine query and querystring")); + + IllegalStateException reverseMixed = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithParameters( + List.of(queryStringParameter("query")), List.of(queryParameter("named"))))); + assertThat(reverseMixed.getMessage(), containsString("cannot combine query and querystring")); + + IllegalStateException inheritedRepeated = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithParameters( + List.of(queryStringParameter("first")), List.of(queryStringParameter("second"))))); + assertThat(inheritedRepeated.getMessage(), containsString("more than one querystring")); + + OpenApi32DocumentMapper.parse(documentWithParameters( + List.of(queryStringParameter("query")), List.of(queryStringParameter("query")))); + } + + @Test + void ignoresQueryStringFieldsOnComponentReferenceObjects() { + Map document = documentWithSection("components", Map.of( + "parameters", Map.of( + "Actual", queryStringParameter("actual"), + "Alias", Map.of( + "$ref", "#/components/parameters/Actual", + "in", "querystring", + "style", "form")))); + + OpenApiDocument parsed = OpenApi32DocumentMapper.parse(document); + Map rendered = OpenApi32DocumentMapper.render(parsed, "3.2.0"); + Map alias = map(map(map(rendered, "components"), "parameters"), "Alias"); + + assertThat(alias.get("$ref"), is("#/components/parameters/Actual")); + assertThat(alias.containsKey("in"), is(false)); + assertThat(alias.containsKey("style"), is(false)); + } + + @Test + void validatesEffectiveQueryStringLocationsThroughParameterReferenceChains() { + Map document = documentWithParameters( + List.of(queryParameter("named")), + List.of(Map.of("$ref", "#/components/parameters/Alias"))); + document.put("components", Map.of("parameters", Map.of( + "Alias", Map.of( + "$ref", "#/components/parameters/Query%2EString", + "in", "querystring", + "style", "form"), + "Query.String", queryStringParameter("query")))); + + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(document)); + assertThat(parsed.getMessage(), containsString("cannot combine query and querystring")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(openApiDocument(document), "3.2.0")); + assertThat(rendered.getMessage(), containsString("cannot combine query and querystring")); + + Map cyclic = documentWithParameters( + List.of(), + List.of(Map.of("$ref", "#/components/parameters/First"))); + cyclic.put("components", Map.of("parameters", Map.of( + "First", Map.of("$ref", "#/components/parameters/Second"), + "Second", Map.of("$ref", "#/components/parameters/First")))); + OpenApi32DocumentMapper.parse(cyclic); + OpenApi32DocumentMapper.render(openApiDocument(cyclic), "3.2.0"); + } + + @Test + void validatesQueryStringLocationsThroughSelfReferences() { + String self = "https://example.test/api"; + String ref = self + "#/components/parameters/QueryString"; + Map valid = documentWithParameters(List.of(), List.of(Map.of("$ref", ref))); + valid.put("$self", self); + valid.put("components", Map.of("parameters", Map.of( + "QueryString", queryStringParameter("query")))); + + OpenApiDocument parsed = OpenApi32DocumentMapper.parse(valid); + Map rendered = OpenApi32DocumentMapper.render(parsed, "3.2.0"); + Map operation = map(map(map(rendered, "paths"), "/items"), "get"); + Map renderedReference = (Map) ((List) operation.get("parameters")).getFirst(); + + assertThat(rendered.get("$self"), is(self)); + assertThat(renderedReference.get("$ref"), is(ref)); + + Map invalid = documentWithParameters( + List.of(queryParameter("named")), + List.of(Map.of("$ref", ref))); + invalid.put("$self", self); + invalid.put("components", valid.get("components")); + + IllegalStateException parsedInvalid = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(invalid)); + assertThat(parsedInvalid.getMessage(), containsString("cannot combine query and querystring")); + + IllegalStateException renderedInvalid = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(openApiDocument(invalid), "3.2.0")); + assertThat(renderedInvalid.getMessage(), containsString("cannot combine query and querystring")); + } + + @Test + void validatesQueryStringParametersInReusableAndCallbackPaths() { + Map invalidPathItem = Map.of( + "parameters", List.of(queryParameter("named"), queryStringParameter("query")), + "get", Map.of()); + Map invalidComponentParameter = new LinkedHashMap<>(queryStringParameter("component")); + invalidComponentParameter.remove("content"); + invalidComponentParameter.put("schema", Map.of("type", "object")); + Map callbackPathItem = Map.of( + "get", Map.of("callbacks", Map.of( + "Nested", Map.of("{$request.body#/callbackUrl}", invalidPathItem)))); + Map additionalOperationPathItem = Map.of( + "additionalOperations", Map.of("SUBSCRIBE", Map.of( + "parameters", List.of(queryParameter("named"), queryStringParameter("query"))))); + + List> invalidDocuments = List.of( + documentWithSection("webhooks", Map.of("event", invalidPathItem)), + documentWithSection("components", Map.of( + "pathItems", Map.of("Reusable", invalidPathItem))), + documentWithSection("components", Map.of( + "callbacks", Map.of("Reusable", Map.of("{$request.body#/callbackUrl}", invalidPathItem)))), + documentWithSection("paths", Map.of("/callback", callbackPathItem)), + documentWithSection("paths", Map.of("/additional", additionalOperationPathItem)), + documentWithSection("components", Map.of( + "parameters", Map.of("Query", invalidComponentParameter)))); + + for (Map invalidDocument : invalidDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(invalidDocument)); + assertThat(thrown.getMessage(), containsString("querystring")); + } + } + + @Test + void ignoresQueryStringLikeExtensionData() { + Map extensionValue = Map.of( + "parameters", List.of(queryParameter("named"), queryStringParameter("query")), + "get", Map.of()); + + OpenApi32DocumentMapper.parse(documentWithSection("paths", Map.of( + "x-validation-data", extensionValue, + "/items", Map.of()))); + OpenApi32DocumentMapper.parse(documentWithSection("components", Map.of( + "callbacks", Map.of("Reusable", Map.of("x-validation-data", extensionValue))))); + } + + @Test + void openApi32AllowsDeviceAuthorizationFlow() { + OpenApiDocument document = openApiDocument(documentWithSecurityScheme(deviceAuthorizationSecurityScheme())); + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + Map flow = map(map(securityScheme(rendered), "flows"), "deviceAuthorization"); + + assertThat(flow.get("deviceAuthorizationUrl"), is("https://idp.example.com/device")); + assertThat(flow.get("tokenUrl"), is("https://idp.example.com/token")); + } + + @Test + void openApi32PreservesMediaTypeEncodingMap() { + OpenApiDocument document = OpenApi32DocumentMapper.parse(documentWithEncoding("3.2.0")); + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + Map encoding = encoding(rendered); + + assertThat(map(encoding, "profileImage").get("contentType"), is("image/png")); + assertThat(map(map(encoding, "profileImage"), "headers").containsKey("X-Image-Name"), is(true)); + } + + @Test + void validatesMediaTypeEncodingCombinations() { + OpenApi32DocumentMapper.parse(documentWithMediaType( + "Multipart/Mixed; boundary=test", + Map.of( + "itemSchema", Map.of("type", "string"), + "prefixEncoding", List.of(Map.of()), + "itemEncoding", Map.of()))); + OpenApi32DocumentMapper.parse(documentWithMediaType( + "multipart/form-data", + Map.of( + "schema", Map.of("type", List.of("array", "null")), + "prefixEncoding", List.of(Map.of())))); + OpenApiDocument ignoredPositional = OpenApi32DocumentMapper.parse(documentWithMediaType( + "application/json", + Map.of( + "prefixEncoding", List.of(Map.of()), + "itemEncoding", Map.of()))); + Map ignoredRendered = OpenApi32DocumentMapper.render(ignoredPositional, "3.2.0"); + Map ignoredMediaType = map(map(map(map(map(map(ignoredRendered, "paths"), "/upload"), "post"), + "requestBody"), "content"), "application/json"); + assertThat(ignoredMediaType.containsKey("prefixEncoding"), is(true)); + assertThat(ignoredMediaType.containsKey("itemEncoding"), is(true)); + + List> invalidDocuments = List.of( + documentWithMediaType("multipart/form-data", Map.of( + "schema", Map.of("type", "array"), + "encoding", Map.of("item", Map.of()), + "prefixEncoding", List.of(Map.of()))), + documentWithMediaType("multipart/form-data", Map.of( + "itemSchema", Map.of("type", "string"), + "encoding", Map.of("item", Map.of()), + "itemEncoding", Map.of())), + documentWithMediaType("multipart/mixed", Map.of( + "prefixEncoding", List.of(Map.of()))), + documentWithMediaType("multipart/mixed", Map.of( + "schema", Map.of("type", "object"), + "itemEncoding", Map.of()))); + + for (Map invalidDocument : invalidDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(invalidDocument)); + assertThat(thrown.getMessage(), containsString("OpenAPI 3.2 media type")); + } + + OpenApiDocument invalidGenerated = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/upload", path -> path.operation("POST", operation -> operation + .requestBody(body -> body.content("multipart/form-data", mediaType -> mediaType + .schema(JsonObject.builder().set("type", "array").build()) + .encoding("item", OpenApiDocument.Encoding.builder().build()) + .prefixEncoding(JsonArray.empty()))))) + .build(); + IllegalStateException renderedInvalid = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(invalidGenerated, "3.2.0")); + assertThat(renderedInvalid.getMessage(), containsString("cannot combine encoding")); + } + + @Test + void validatesNestedEncodingCombinations() { + List> invalidEncodings = List.of( + Map.of( + "encoding", Map.of("nested", Map.of()), + "prefixEncoding", List.of(Map.of())), + Map.of( + "encoding", Map.of("nested", Map.of()), + "itemEncoding", Map.of())); + + for (Map invalidEncoding : invalidEncodings) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(documentWithMediaType( + "multipart/form-data", + Map.of("encoding", Map.of("item", invalidEncoding))))); + assertThat(thrown.getMessage(), containsString("OpenAPI 3.2 encoding at")); + assertThat(thrown.getMessage(), containsString(".encoding.item")); + assertThat(thrown.getMessage(), containsString("cannot combine encoding with prefixEncoding or itemEncoding")); + } + + OpenApiDocument.Encoding invalidEncoding = OpenApiDocument.Encoding.builder() + .encoding("nested", OpenApiDocument.Encoding.builder().build()) + .prefixEncoding(JsonArray.empty()) + .build(); + OpenApiDocument invalidGenerated = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/upload", path -> path.operation("POST", operation -> operation + .requestBody(body -> body.content("multipart/form-data", mediaType -> mediaType + .encoding("item", invalidEncoding))))) + .build(); + + IllegalStateException renderedInvalid = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(invalidGenerated, "3.2.0")); + assertThat(renderedInvalid.getMessage(), containsString("OpenAPI 3.2 encoding at")); + assertThat(renderedInvalid.getMessage(), containsString(".encoding.item")); + assertThat(renderedInvalid.getMessage(), + containsString("cannot combine encoding with prefixEncoding or itemEncoding")); + } + + @Test + void validatesReusableMediaTypesInContentContext() { + Map positional = Map.of( + "itemSchema", Map.of("type", "string"), + "prefixEncoding", List.of(Map.of())); + OpenApi32DocumentMapper.parse(documentWithSection("components", Map.of( + "mediaTypes", Map.of("Positional", positional)))); + OpenApi32DocumentMapper.parse(documentWithMediaTypeReference( + "multipart/mixed", "#/components/mediaTypes/Positional", positional)); + + OpenApi32DocumentMapper.parse(documentWithMediaTypeReference( + "application/json", "#/components/mediaTypes/Positional", positional)); + + OpenApi32DocumentMapper.parse(documentWithMediaTypeReference( + "application/json", "https://example.test/media-types/Positional", positional)); + + Map localArrayRef = documentWithMediaType( + "multipart/mixed", + Map.of( + "schema", Map.of("$ref", "#/components/schemas/Items"), + "prefixEncoding", List.of(Map.of()))); + localArrayRef.put("components", Map.of("schemas", Map.of("Items", Map.of("type", "array")))); + OpenApi32DocumentMapper.parse(localArrayRef); + + Map localObjectRef = documentWithMediaType( + "multipart/mixed", + Map.of( + "schema", Map.of("$ref", "#/components/schemas/Item"), + "prefixEncoding", List.of(Map.of()))); + localObjectRef.put("components", Map.of("schemas", Map.of("Item", Map.of("type", "object")))); + IllegalStateException nonArrayRef = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(localObjectRef)); + assertThat(nonArrayRef.getMessage(), containsString("requires itemSchema or an array schema")); + + OpenApi32DocumentMapper.parse(documentWithMediaType( + "multipart/mixed", + Map.of( + "schema", Map.of("$ref", "https://example.test/schemas/Items"), + "prefixEncoding", List.of(Map.of())))); + } + + @Test + void validatesMediaTypesAcrossDocumentLocations() { + Map invalidMediaType = Map.of( + "schema", Map.of("type", "array"), + "encoding", Map.of("item", Map.of()), + "prefixEncoding", List.of(Map.of())); + Map invalidContent = Map.of("multipart/form-data", invalidMediaType); + Map invalidParameter = Map.of( + "name", "item", "in", "query", "content", invalidContent); + Map invalidHeader = Map.of("content", invalidContent); + Map invalidRequestBody = Map.of("content", invalidContent); + Map invalidResponse = Map.of("content", invalidContent); + Map invalidPathItem = Map.of("get", Map.of( + "responses", Map.of("200", Map.of( + "headers", Map.of("X-Item", invalidHeader))))); + Map invalidNestedEncoding = Map.of( + "schema", Map.of("type", "object"), + "encoding", Map.of("item", Map.of( + "headers", Map.of("X-Item", invalidHeader)))); + + List> invalidDocuments = List.of( + documentWithSection("paths", Map.of("/items", invalidPathItem)), + documentWithSection("webhooks", Map.of("event", invalidPathItem)), + documentWithSection("components", Map.of( + "pathItems", Map.of("Reusable", invalidPathItem))), + documentWithSection("components", Map.of( + "callbacks", Map.of("Reusable", Map.of("{$request.body#/callbackUrl}", invalidPathItem)))), + documentWithSection("components", Map.of( + "parameters", Map.of("Item", invalidParameter))), + documentWithSection("components", Map.of( + "headers", Map.of("X-Item", invalidHeader))), + documentWithSection("components", Map.of( + "requestBodies", Map.of("Item", invalidRequestBody))), + documentWithSection("components", Map.of( + "responses", Map.of("Item", invalidResponse))), + documentWithSection("components", Map.of( + "mediaTypes", Map.of("Item", invalidMediaType))), + documentWithMediaType("multipart/form-data", invalidNestedEncoding)); + + for (Map invalidDocument : invalidDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(invalidDocument)); + assertThat(thrown.getMessage(), containsString("cannot combine encoding")); + } + + OpenApi32DocumentMapper.parse(documentWithMediaType("application/json", Map.of( + "schema", Map.of( + "type", "object", + "properties", Map.of("content", Map.of("multipart/form-data", invalidMediaType)))))); + + Map ignoredPositional = Map.of( + "prefixEncoding", List.of(Map.of( + "headers", Map.of("X-Item", invalidHeader)))); + OpenApiDocument ignored = OpenApi32DocumentMapper.parse(documentWithMediaType( + "application/json", ignoredPositional)); + OpenApi32DocumentMapper.render(ignored, "3.2.0"); + } + + @Test + void openApi32PreservesMediaTypeReferences() { + OpenApiDocument document = OpenApi32DocumentMapper.parse(documentWithMediaTypeReference()); + Map rendered = OpenApi32DocumentMapper.render(document, "3.2.0"); + Map content = map(map(map(map(map(map(rendered, "paths"), "/items"), "get"), + "responses"), "200"), "content"); + Map mediaTypes = map(map(rendered, "components"), "mediaTypes"); + + assertMediaTypeReference(map(content, "application/json"), "#/components/mediaTypes/Json"); + assertMediaTypeReference(map(mediaTypes, "JsonReference"), "#/components/mediaTypes/Json"); + } + + private static Map document(String version) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "components", Map.of("schemas", Map.of("StaticItem", Map.of( + "type", "object", + "properties", Map.of("large", Map.of( + "type", "integer", + "format", "int64", + "default", LARGE_INTEGRAL_VALUE)))))); + } + + private static Map documentWithParameters(List> pathParameters, + List> operationParameters) { + Map operation = new LinkedHashMap<>(); + if (!operationParameters.isEmpty()) { + operation.put("parameters", operationParameters); + } + Map pathItem = new LinkedHashMap<>(); + if (!pathParameters.isEmpty()) { + pathItem.put("parameters", pathParameters); + } + pathItem.put("get", operation); + return documentWithSection("paths", Map.of("/items", pathItem)); + } + + private static Map documentWithPathItem(String path, Map pathItem) { + return documentWithSection("paths", Map.of(path, pathItem)); + } + + private static Map documentWithSection(String section, Object value) { + Map result = new LinkedHashMap<>(); + result.put("openapi", "3.2.0"); + result.put("info", Map.of("title", "Static API", "version", "1.0.0")); + result.put(section, value); + return result; + } + + private static Map documentWithExampleReference(String reference) { + return Map.of("openapi", "3.2.0", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("examples", Map.of("test", Map.of("$ref", reference)))); + } + + private static Map documentWithSchemaReference(String reference) { + return Map.of("openapi", "3.2.0", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("schemas", Map.of("test", Map.of("$ref", reference)))); + } + + private static Map pathParameter(String name) { + return Map.of("name", name, + "in", "path", + "required", true, + "schema", Map.of("type", "string")); + } + + private static Map queryStringParameter(String name) { + return Map.of( + "name", name, + "in", "querystring", + "content", Map.of("application/x-www-form-urlencoded", Map.of("schema", Map.of("type", "object")))); + } + + private static Map queryParameter(String name) { + return Map.of("name", name, "in", "query", "schema", Map.of("type", "string")); + } + + private static Map headerParameter(String name) { + return Map.of("name", name, "in", "header", "schema", Map.of("type", "string")); + } + + private static void assertDuplicateParameters(Map source) { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(source)); + assertThat(parsed.getMessage(), containsString("parameters contain duplicate")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(openApiDocument(source), "3.2.0")); + assertThat(rendered.getMessage(), containsString("parameters contain duplicate")); + } + + private static void assertValidParameterDocument(Map source) { + OpenApi32DocumentMapper.render(OpenApi32DocumentMapper.parse(source), "3.2.0"); + } + + private static void assertMissingPathParameter(Map source, String operationName) { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(source)); + assertThat(parsed.getMessage(), containsString("operation " + operationName)); + assertThat(parsed.getMessage(), containsString("template expression {id}")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(openApiDocument(source), "3.2.0")); + assertThat(rendered.getMessage(), containsString("operation " + operationName)); + assertThat(rendered.getMessage(), containsString("template expression {id}")); + } + + private static void assertValidPathTemplateDocument(Map source) { + OpenApi32DocumentMapper.render(OpenApi32DocumentMapper.parse(source), "3.2.0"); + } + + private static void assertInvalidReferenceUri(Map source, String expectedMessage) { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi32DocumentMapper.parse(source)); + assertThat(parsed.getMessage(), containsString(expectedMessage)); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi32DocumentMapper.render(openApiDocument(source), "3.2.0")); + assertThat(rendered.getMessage(), containsString(expectedMessage)); + } + + private static Map documentWithNullExtension(String version) { + Map result = new LinkedHashMap<>(); + result.put("openapi", version); + result.put("info", Map.of("title", "Static API", + "version", "1.0.0")); + result.put("x-null", null); + return result; + } + + private static Map documentWithEncoding(String version) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of("/upload", Map.of("post", Map.of( + "requestBody", Map.of("content", Map.of("multipart/form-data", Map.of( + "schema", Map.of("type", "object"), + "encoding", Map.of("profileImage", Map.of( + "contentType", "image/png", + "headers", Map.of("X-Image-Name", Map.of( + "description", "Image name", + "schema", Map.of("type", "string")))))))), + "responses", Map.of("204", Map.of("description", "Done.")))))); + } + + private static Map documentWithMediaType(String mediaType, + Map mediaTypeObject) { + return documentWithSection("paths", Map.of("/upload", Map.of("post", Map.of( + "requestBody", Map.of("content", Map.of(mediaType, mediaTypeObject)))))); + } + + private static Map documentWithMediaTypeReference(String mediaType, + String ref, + Map component) { + Map document = documentWithMediaType(mediaType, Map.of("$ref", ref)); + document.put("components", Map.of("mediaTypes", Map.of("Positional", component))); + return document; + } + + private static Map documentWithMediaTypeReference() { + return Map.of("openapi", "3.2.0", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of("/items", Map.of("get", Map.of( + "responses", Map.of("200", Map.of( + "description", "Items.", + "content", Map.of("application/json", + mediaTypeReference("#/components/mediaTypes/Json"))))))), + "components", Map.of("mediaTypes", Map.of( + "Json", Map.of("schema", Map.of("type", "object")), + "JsonReference", mediaTypeReference("#/components/mediaTypes/Json")))); + } + + private static OpenApiDocument openApiDocument(Map document) { + return OpenApiDocumentReader.read(OpenApiDocumentMapperSupport.jsonObject(document)); + } + + private static Map documentWithSecurityScheme(Map securityScheme) { + Map result = new LinkedHashMap<>(); + result.put("openapi", "3.2.0"); + result.put("info", Map.of("title", "Static API", + "version", "1.0.0")); + result.put("components", Map.of("securitySchemes", Map.of("test", securityScheme))); + return result; + } + + private static Map deviceAuthorizationSecurityScheme() { + Map flow = new LinkedHashMap<>(); + flow.put("deviceAuthorizationUrl", "https://idp.example.com/device"); + flow.put("tokenUrl", "https://idp.example.com/token"); + flow.put("scopes", Map.of()); + + Map flows = new LinkedHashMap<>(); + flows.put("deviceAuthorization", flow); + + Map result = new LinkedHashMap<>(); + result.put("type", "oauth2"); + result.put("flows", flows); + return result; + } + + private static Map securityScheme(Map document) { + return map(map(map(document, "components"), "securitySchemes"), "test"); + } + + private static Map mediaTypeReference(String ref) { + Map result = new LinkedHashMap<>(); + result.put("$ref", ref); + result.put("summary", "Media type summary"); + result.put("description", "Media type description"); + return result; + } + + private static void assertMediaTypeReference(Map reference, String ref) { + assertThat(reference.get("$ref"), is(ref)); + assertThat(reference.get("summary"), is("Media type summary")); + assertThat(reference.get("description"), is("Media type description")); + } + + @SuppressWarnings("unchecked") + private static Map schemaProperty(Map document, String propertyName) { + return (Map) map(map(map(map(document, "components"), "schemas"), "StaticItem"), "properties") + .get(propertyName); + } + + private static Map encoding(Map document) { + return map(map(map(map(map(map(map(document, "paths"), "/upload"), "post"), + "requestBody"), "content"), "multipart/form-data"), "encoding"); + } + + @SuppressWarnings("unchecked") + private static Map map(Map map, String name) { + return (Map) map.get(name); + } + + private static final class CountingMap extends LinkedHashMap { + private int lookups; + + @Override + public Object get(Object key) { + lookups++; + return super.get(key); + } + + private int lookups() { + return lookups; + } + } +} diff --git a/openapi/openapi-32/src/test/java/io/helidon/openapi/v32/OpenApi32VersionTest.java b/openapi/openapi-32/src/test/java/io/helidon/openapi/v32/OpenApi32VersionTest.java new file mode 100644 index 00000000000..26ddad10b26 --- /dev/null +++ b/openapi/openapi-32/src/test/java/io/helidon/openapi/v32/OpenApi32VersionTest.java @@ -0,0 +1,1780 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v32; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.ServiceLoader; +import java.util.Set; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.json.JsonObject; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.openapi.OpenApiGeneratedMode; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; +import io.helidon.openapi.v30.OpenApi30Version; +import io.helidon.openapi.v30.OpenApiDocumentMapperSupport; +import io.helidon.openapi.v30.OpenApiDocumentReader; +import io.helidon.openapi.v31.OpenApi31Version; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApi32VersionTest { + @Test + void rejectsDuplicateOperationIdsAcrossVersions() { + String staticDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: + /first: + get: + operationId: duplicate + responses: + "200": {description: OK} + /second: + post: + operationId: duplicate + responses: + "200": {description: OK} + """; + OpenApiDocument generatedDocument = OpenApiDocument.builder() + .info("API", "1") + .path("/first", path -> path.operation( + "GET", + operation -> operation.operationId("duplicate").response("200", "OK"))) + .path("/second", path -> path.operation( + "POST", + operation -> operation.operationId("duplicate").response("200", "OK"))) + .build(); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + IllegalStateException parsed = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + staticDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(version.version(), + parsed.getMessage(), + containsString("Duplicate OpenAPI operationId duplicate")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), generatedDocument)); + assertThat(version.version(), + rendered.getMessage(), + containsString("Duplicate OpenAPI operationId duplicate")); + } + } + + @Test + void validatesEmptyOperationsAcrossVersions() { + String staticDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: {} + """; + OpenApiDocument generatedDocument = OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", _ -> { })) + .build(); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), OpenApi31Version.create())) { + IllegalStateException parsed = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + staticDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(parsed.getMessage(), containsString("requires responses")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), generatedDocument)); + assertThat(rendered.getMessage(), containsString("requires responses")); + } + + OpenApiVersion version32 = OpenApi32Version.create(); + OpenApiDocument parsed = version32.parse(context(version32), + staticDocument.formatted(version32.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + version32.render(context(version32), parsed); + version32.render(context(version32), generatedDocument); + } + + @Test + void preservesEmptyRequiredUriReferencesAcrossVersions() { + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + OpenApiDocument document = version.parse( + context(version), + """ + openapi: %s + info: {title: API, version: "1"} + servers: + - url: "" + paths: {} + externalDocs: + url: "" + components: + securitySchemes: + oauth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: "" + tokenUrl: "" + scopes: {} + openId: + type: openIdConnect + openIdConnectUrl: "" + """.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + Map rendered = parse(version.render(context(version), document)); + + assertThat(((Map) ((List) rendered.get("servers")).getFirst()).get("url"), is("")); + assertThat(map(rendered, "externalDocs").get("url"), is("")); + Map securitySchemes = map(map(rendered, "components"), "securitySchemes"); + Map authorizationCode = map(map(map(securitySchemes, "oauth"), "flows"), + "authorizationCode"); + assertThat(authorizationCode.get("authorizationUrl"), is("")); + assertThat(authorizationCode.get("tokenUrl"), is("")); + assertThat(map(securitySchemes, "openId").get("openIdConnectUrl"), is("")); + } + + OpenApiVersion version32 = OpenApi32Version.create(); + OpenApiDocument deviceDocument = version32.parse( + context(version32), + """ + openapi: 3.2.0 + info: {title: API, version: "1"} + paths: {} + components: + securitySchemes: + oauth: + type: oauth2 + flows: + deviceAuthorization: + deviceAuthorizationUrl: "" + tokenUrl: "" + scopes: {} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + Map renderedDevice = parse(version32.render(context(version32), deviceDocument)); + Map deviceAuthorization = map( + map(map(map(map(renderedDevice, "components"), "securitySchemes"), "oauth"), "flows"), + "deviceAuthorization"); + assertThat(deviceAuthorization.get("deviceAuthorizationUrl"), is("")); + assertThat(deviceAuthorization.get("tokenUrl"), is("")); + } + + @Test + void validatesReferenceObjectFieldsAcrossVersions() { + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + List invalidDocuments = List.of( + """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + examples: + Invalid: {$ref: 42} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + callbacks: + invalid: {$ref: 42} + responses: + "200": {description: OK} + """); + for (String invalidDocument : invalidDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("field $ref must be a string")); + } + } + + String invalidSummary = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + examples: + Invalid: {$ref: '#/components/examples/Other', summary: 42} + """; + OpenApiVersion version30 = OpenApi30Version.create(); + OpenApiDocument filtered = version30.parse(context(version30), + invalidSummary.formatted(version30.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + version30.render(context(version30), filtered); + + for (OpenApiVersion version : List.of(OpenApi31Version.create(), OpenApi32Version.create())) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidSummary.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("field summary must be a string")); + } + } + + @Test + void ignoresReferenceObjectSchemaSiblingsAcrossVersions() { + String parameterAndHeaderDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + parameters: + Actual: {name: query, in: query, schema: {type: string}} + Alias: + $ref: '#/components/parameters/Actual' + schema: not-an-object + headers: + Actual: {schema: {type: string}} + Alias: + $ref: '#/components/headers/Actual' + schema: not-an-object + """; + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + OpenApiDocument parsed = version.parse( + context(version), + parameterAndHeaderDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + Map rendered = parse(version.render(context(version), parsed)); + Map components = map(rendered, "components"); + Map parameterAlias = map(map(components, "parameters"), "Alias"); + Map headerAlias = map(map(components, "headers"), "Alias"); + + assertThat(parameterAlias.get("$ref"), is("#/components/parameters/Actual")); + assertThat(parameterAlias.containsKey("schema"), is(false)); + assertThat(headerAlias.get("$ref"), is("#/components/headers/Actual")); + assertThat(headerAlias.containsKey("schema"), is(false)); + } + + OpenApiVersion version32 = OpenApi32Version.create(); + OpenApiDocument parsed = version32.parse( + context(version32), + """ + openapi: 3.2.0 + info: {title: API, version: "1"} + paths: {} + components: + mediaTypes: + Actual: {schema: {type: string}} + Alias: + $ref: '#/components/mediaTypes/Actual' + schema: not-an-object + itemSchema: not-an-object + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + Map rendered = parse(version32.render(context(version32), parsed)); + Map alias = map(map(map(rendered, "components"), "mediaTypes"), "Alias"); + + assertThat(alias.get("$ref"), is("#/components/mediaTypes/Actual")); + assertThat(alias.containsKey("schema"), is(false)); + assertThat(alias.containsKey("itemSchema"), is(false)); + } + + @Test + void validatesSecuritySchemeRequirementsAcrossVersions() { + List invalidSecuritySchemes = List.of( + "{type: apiKey, in: header}", + "{type: http}", + "{type: oauth2}", + "{type: openIdConnect}", + """ + type: oauth2 + flows: + implicit: + scopes: {} + """, + """ + type: oauth2 + flows: + clientCredentials: + tokenUrl: https://example.com/token + """); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + for (String securityScheme : invalidSecuritySchemes) { + String invalidDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + securitySchemes: + test: + %s + """.formatted(version.version(), securityScheme.indent(6)); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), invalidDocument, MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("requires")); + } + + OpenApiDocument invalidGenerated = OpenApiDocument.builder() + .info("API", "1") + .paths(Map.of()) + .components(components -> components.securityScheme("test", scheme -> scheme.type("http"))) + .build(); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), invalidGenerated)); + assertThat(thrown.getMessage(), containsString("requires scheme")); + } + + OpenApiVersion version32 = OpenApi32Version.create(); + for (String deviceFlow : List.of( + "{tokenUrl: https://example.com/token, scopes: {}}", + "{deviceAuthorizationUrl: https://example.com/device, scopes: {}}", + "{deviceAuthorizationUrl: https://example.com/device, tokenUrl: https://example.com/token}")) { + String invalidDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + securitySchemes: + test: + type: oauth2 + flows: + deviceAuthorization: %s + """.formatted(version32.version(), deviceFlow); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version32.parse(context(version32), invalidDocument, MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("requires")); + } + } + + @Test + void requiresTrueForPathParametersAcrossVersions() { + List invalidStaticDocuments = List.of( + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items/{id}: + parameters: + - name: id + in: path + schema: {type: string} + get: + responses: + "200": {description: OK} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items/{id}: + parameters: + - name: id + in: path + required: false + schema: {type: string} + get: + responses: + "200": {description: OK} + """); + String validStaticDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items/{id}: + parameters: + - name: id + in: path + required: true + schema: {type: string} + get: + responses: + "200": {description: OK} + """; + + JsonObject stringSchema = JsonObject.builder().set("type", "string").build(); + List invalidGeneratedDocuments = List.of( + OpenApiDocument.builder() + .info("API", "1") + .path("/items/{id}", path -> path + .parameter(parameter -> parameter.name("id") + .in("path") + .schema(stringSchema)) + .operation("GET", operation -> operation.response("200", "OK"))) + .build(), + OpenApiDocument.builder() + .info("API", "1") + .path("/items/{id}", path -> path + .parameter(parameter -> parameter.name("id") + .in("path") + .required(false) + .schema(stringSchema)) + .operation("GET", operation -> operation.response("200", "OK"))) + .build()); + OpenApiDocument validGeneratedDocument = OpenApiDocument.builder() + .info("API", "1") + .path("/items/{id}", path -> path + .parameter(parameter -> parameter.name("id") + .in("path") + .required(true) + .schema(stringSchema)) + .operation("GET", operation -> operation.response("200", "OK"))) + .build(); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + for (String invalidStaticDocument : invalidStaticDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidStaticDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("path parameter requires required: true")); + } + for (OpenApiDocument invalidGeneratedDocument : invalidGeneratedDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), invalidGeneratedDocument)); + assertThat(thrown.getMessage(), containsString("path parameter requires required: true")); + } + version.parse(context(version), + validStaticDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + version.render(context(version), validGeneratedDocument); + } + } + + @Test + void validatesParameterAndHeaderSchemaContentChoiceAcrossVersions() { + List invalidDocuments = List.of( + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: [{name: query, in: query}] + responses: + "200": {description: OK} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: + - name: query + in: query + schema: {type: string} + content: {application/json: {}} + responses: + "200": {description: OK} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + headers: {X-Test: {}} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + headers: + X-Test: + schema: {type: string} + content: {application/json: {}} + """); + + JsonObject stringSchema = JsonObject.builder().set("type", "string").build(); + List invalidGenerated = List.of( + OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .parameter(parameter -> parameter.name("query").in("query")) + .response("200", "OK"))) + .build(), + OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .parameter(parameter -> parameter.name("query") + .in("query") + .schema(stringSchema) + .content("application/json", _ -> { })) + .response("200", "OK"))) + .build(), + OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .response("200", response -> response.description("OK") + .header("X-Test", _ -> { })))) + .build(), + OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .response("200", response -> response.description("OK") + .header("X-Test", header -> header.schema(stringSchema) + .content("application/json", _ -> { }))))) + .build()); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + for (String invalidDocument : invalidDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("exactly one of schema or content")); + } + for (OpenApiDocument invalidDocument : invalidGenerated) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), invalidDocument)); + assertThat(thrown.getMessage(), containsString("exactly one of schema or content")); + } + } + } + + @Test + void rejectsExampleAndExamplesAcrossVersions() { + List invalidStaticDocuments = List.of( + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: + - name: query + in: query + schema: {type: string} + example: short + examples: {named: {value: named}} + responses: + "200": {description: OK} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + headers: + X-Test: + schema: {type: string} + example: short + examples: {named: {value: named}} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + content: + text/plain: + schema: {type: string} + example: short + examples: {named: {value: named}} + """); + + JsonObject stringSchema = JsonObject.builder().set("type", "string").build(); + JsonObject exampleValue = JsonObject.builder().set("value", "short").build(); + OpenApiDocument.Example namedExample = OpenApiDocument.Example.builder() + .value(JsonObject.builder().set("value", "named").build()) + .build(); + List invalidGeneratedDocuments = List.of( + OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .parameter(parameter -> parameter.name("query") + .in("query") + .schema(stringSchema) + .example(exampleValue) + .example("named", namedExample)) + .response("200", "OK"))) + .build(), + OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .response("200", response -> response.description("OK") + .header("X-Test", header -> header.schema(stringSchema) + .example(exampleValue) + .example("named", namedExample))))) + .build(), + OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .response("200", response -> response.description("OK") + .content("text/plain", mediaType -> mediaType.schema(stringSchema) + .example(exampleValue) + .example("named", namedExample))))) + .build()); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + for (String invalidDocument : invalidStaticDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("cannot combine example with examples")); + } + for (OpenApiDocument invalidDocument : invalidGeneratedDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), invalidDocument)); + assertThat(thrown.getMessage(), containsString("cannot combine example with examples")); + } + } + } + + @Test + void validatesParameterAndHeaderContentCardinalityAcrossVersions() { + List invalidStaticDocuments = List.of( + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: + - name: query + in: query + content: {} + responses: + "200": {description: OK} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: + - name: query + in: query + content: + application/json: {} + text/plain: {} + responses: + "200": {description: OK} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + headers: + X-Test: {content: {}} + """, + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + headers: + X-Test: + content: + application/json: {} + text/plain: {} + """); + String validStaticDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: + - name: query + in: query + content: {application/json: {}} + responses: + "200": + description: OK + headers: + X-Test: {content: {text/plain: {}}} + """; + + Map multipleContent = Map.of("application/json", Map.of(), + "text/plain", Map.of()); + List invalidGeneratedDocuments = List.of( + contentDocument(false, multipleContent), + contentDocument(true, multipleContent)); + OpenApiDocument validGeneratedDocument = OpenApiDocument.builder() + .info("API", "1") + .path("/items", path -> path.operation("GET", operation -> operation + .parameter(parameter -> parameter.name("query") + .in("query") + .content("application/json", _ -> { })) + .response("200", response -> response.description("OK") + .header("X-Test", header -> header.content("text/plain", _ -> { }))))) + .build(); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + for (String invalidStaticDocument : invalidStaticDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidStaticDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("content must contain exactly one entry")); + } + for (OpenApiDocument invalidGeneratedDocument : invalidGeneratedDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), invalidGeneratedDocument)); + assertThat(thrown.getMessage(), containsString("content must contain exactly one entry")); + } + version.parse(context(version), + validStaticDocument.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + version.render(context(version), validGeneratedDocument); + } + } + + @Test + void validatesLinkOperationChoiceAcrossVersions() { + List invalidLinks = List.of("{}", + "{operationRef: '#/paths/~1items/get', operationId: getItems}"); + List invalidGenerated = List.of( + OpenApiDocument.builder() + .info("API", "1") + .paths(Map.of()) + .components(components -> components.link("Invalid", _ -> { })) + .build(), + OpenApiDocument.builder() + .info("API", "1") + .paths(Map.of()) + .components(components -> components.link("Invalid", link -> link + .operationRef("#/paths/~1items/get") + .operationId("getItems"))) + .build()); + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + for (String invalidLink : invalidLinks) { + String invalidDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + links: + Invalid: %s + """.formatted(version.version(), invalidLink); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidDocument, + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("requires exactly one of operationRef or operationId")); + } + for (OpenApiDocument invalidDocument : invalidGenerated) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), invalidDocument)); + assertThat(thrown.getMessage(), containsString("requires exactly one of operationRef or operationId")); + } + } + } + + @Test + void validatesExampleValueChoicesAcrossVersions() { + JsonObject value = JsonObject.builder().set("item", "value").build(); + OpenApiDocument valueAndExternal = exampleDocument(OpenApiDocument.Example.builder() + .value(value) + .externalValue("https://example.com/value.json") + .build()); + String parsedValueAndExternal = "{value: local, externalValue: https://example.com/value.json}"; + + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + assertInvalidExample(version, parsedValueAndExternal); + assertInvalidExample(version, valueAndExternal); + } + + OpenApiVersion version32 = OpenApi32Version.create(); + for (String invalidExample : List.of("{value: local, dataValue: structured}", + "{value: local, serializedValue: serialized}", + "{serializedValue: serialized, externalValue: https://example.com/value}")) { + assertInvalidExample(version32, invalidExample); + } + for (OpenApiDocument invalidExample : List.of( + exampleDocument(OpenApiDocument.Example.builder().value(value).dataValue(value).build()), + exampleDocument(OpenApiDocument.Example.builder().value(value).serializedValue("serialized").build()), + exampleDocument(OpenApiDocument.Example.builder() + .serializedValue("serialized") + .externalValue("https://example.com/value") + .build()))) { + assertInvalidExample(version32, invalidExample); + } + + for (String validExample : List.of("{dataValue: structured, serializedValue: serialized}", + "{dataValue: structured, externalValue: https://example.com/value}")) { + OpenApiDocument document = parseExample(version32, validExample); + version32.render(context(version32), document); + } + for (OpenApiDocument validExample : List.of( + exampleDocument(OpenApiDocument.Example.builder().dataValue(value).serializedValue("serialized").build()), + exampleDocument(OpenApiDocument.Example.builder() + .dataValue(value) + .externalValue("https://example.com/value") + .build()))) { + version32.render(context(version32), validExample); + } + + OpenApiDocument newerGenerated = exampleDocument(OpenApiDocument.Example.builder() + .value(value) + .dataValue(value) + .serializedValue("serialized") + .build()); + for (OpenApiVersion olderVersion : List.of(OpenApi30Version.create(), OpenApi31Version.create())) { + OpenApiDocument newerParsed = parseExample( + olderVersion, + "{value: local, dataValue: structured, serializedValue: serialized}"); + olderVersion.render(context(olderVersion), newerParsed); + olderVersion.render(context(olderVersion), newerGenerated); + } + } + + @Test + void validatesBooleanSchemasAcrossVersions() { + String directBooleanSchema = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + schemas: + Item: true + """; + String nestedBooleanSchema = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + schemas: + Item: + type: object + properties: + value: false + """; + + OpenApiVersion version30 = OpenApi30Version.create(); + for (String invalid : List.of(directBooleanSchema, nestedBooleanSchema)) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version30.parse(context(version30), + invalid.formatted(version30.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("must be an object")); + } + + for (OpenApiVersion version : List.of(OpenApi31Version.create(), OpenApi32Version.create())) { + for (String valid : List.of(directBooleanSchema, nestedBooleanSchema)) { + OpenApiDocument document = version.parse(context(version), + valid.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + version.render(context(version), document); + } + } + + OpenApiDocument additionalProperties = version30.parse( + context(version30), + """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + schemas: + Item: + type: object + additionalProperties: false + """.formatted(version30.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + version30.render(context(version30), additionalProperties); + } + + @Test + void treatsAdditionalItemsAccordingToSchemaDialect() { + String document = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + schemas: + Item: + additionalItems: annotation + """; + + OpenApiVersion version30 = OpenApi30Version.create(); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version30.parse(context(version30), + document.formatted(version30.version()), + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("components.schemas.Item.additionalItems")); + assertThat(thrown.getMessage(), containsString("must be an object or boolean")); + + for (OpenApiVersion version : List.of(OpenApi31Version.create(), OpenApi32Version.create())) { + OpenApiDocument parsed = version.parse(context(version), + document.formatted(version.version()), + MediaTypes.APPLICATION_OPENAPI_YAML); + Map rendered = parse(version.render(context(version), parsed)); + Map schema = map(map(rendered, "components"), "schemas"); + assertThat(map(schema, "Item").get("additionalItems"), is("annotation")); + } + } + + @Test + void rejectsWrongRecognizedFieldTypesAcrossVersions() { + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + List invalidDocuments = List.of( + """ + openapi: %s + info: + title: API + version: "1" + description: 42 + paths: {} + """.formatted(version.version()), + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: + - name: query + in: query + required: "true" + schema: {type: string} + responses: + "200": {description: OK} + """.formatted(version.version()), + """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + $ref: https://example.com/path-item + servers: not-an-array + """.formatted(version.version())); + + for (String invalidDocument : invalidDocuments) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), invalidDocument, MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(version.version(), thrown.getMessage(), containsString("must be")); + } + } + } + + @Test + void rejectsNonObjectComponentEntriesAcrossVersions() { + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + List componentFields = switch (version.version().substring(0, 3)) { + case "3.0" -> List.of("responses", + "parameters", + "examples", + "requestBodies", + "headers", + "securitySchemes", + "links", + "callbacks"); + case "3.1" -> List.of("responses", + "parameters", + "examples", + "requestBodies", + "headers", + "securitySchemes", + "links", + "callbacks", + "pathItems"); + default -> List.of("responses", + "parameters", + "examples", + "requestBodies", + "headers", + "securitySchemes", + "links", + "callbacks", + "pathItems", + "mediaTypes"); + }; + for (String componentField : componentFields) { + for (String invalidValue : List.of("not-an-object", "null")) { + String invalidDocument = """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + %s: + Invalid: %s + """.formatted(version.version(), componentField, invalidValue); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), + invalidDocument, + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(componentField, + thrown.getMessage(), + containsString("components." + componentField + ".Invalid")); + assertThat(componentField, thrown.getMessage(), containsString("must be an object")); + } + } + } + + OpenApiVersion version32 = OpenApi32Version.create(); + version32.parse(context(version32), + """ + openapi: 3.2.0 + info: {title: API, version: "1"} + paths: {} + components: + responses: {Empty: {}} + examples: {Empty: {}} + links: + Empty: + operationRef: https://example.com/openapi.yaml#/paths/~1target/get + callbacks: {Empty: {}} + pathItems: {Empty: {}} + mediaTypes: {Empty: {}} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + } + + @Test + void rejectsNonObjectWalkedNodesAcrossVersions() { + for (OpenApiVersion version : List.of(OpenApi30Version.create(), + OpenApi31Version.create(), + OpenApi32Version.create())) { + Map invalidDocuments = Map.ofEntries( + Map.entry("paths./items", """ + openapi: %s + info: {title: API, version: "1"} + paths: {/items: not-an-object} + """), + Map.entry("servers[0]", """ + openapi: %s + info: {title: API, version: "1"} + servers: [not-an-object] + paths: {} + """), + Map.entry("paths./items.get.servers[0]", """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + servers: [not-an-object] + responses: {"200": {description: OK}} + """), + Map.entry("paths./items.get.parameters[0].examples.Invalid", """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + parameters: + - name: query + in: query + schema: {type: string} + examples: {Invalid: not-an-object} + responses: {"200": {description: OK}} + """), + Map.entry("paths./items.get.responses.200.content.application/json", """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + content: {application/json: not-an-object} + """), + Map.entry("paths./items.get.responses.200.content.multipart/form-data.encoding.value", """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + content: + multipart/form-data: + schema: {type: object} + encoding: {value: not-an-object} + """), + Map.entry("paths./items.get.responses.200.links.Invalid", """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + description: OK + links: {Invalid: not-an-object} + """), + Map.entry("paths./items.get.callbacks.Invalid", """ + openapi: %s + info: {title: API, version: "1"} + paths: + /items: + get: + callbacks: + Invalid: + '{$request.body#/callback}': not-an-object + responses: {"200": {description: OK}} + """)); + invalidDocuments.forEach((location, document) -> assertNonObjectRejected( + version, + location, + document.formatted(version.version()))); + } + + OpenApiVersion version32 = OpenApi32Version.create(); + assertNonObjectRejected(version32, + "paths./items.additionalOperations.COPY", + """ + openapi: 3.2.0 + info: {title: API, version: "1"} + paths: + /items: + additionalOperations: {COPY: not-an-object} + """); + assertNonObjectRejected(version32, + "prefixEncoding[0]", + """ + openapi: 3.2.0 + info: {title: API, version: "1"} + paths: + /items: + get: + responses: + "200": + content: + multipart/form-data: + itemSchema: {type: string} + prefixEncoding: [not-an-object] + """); + } + + @Test + void filtersUnsupportedObjectShapesBeforeWalking() { + OpenApiVersion version30 = OpenApi30Version.create(); + OpenApiDocument document30 = version30.parse(context(version30), + """ + openapi: 3.0.3 + info: {title: API, version: "1"} + paths: + /items: + additionalOperations: {COPY: not-an-object} + webhooks: {Invalid: not-an-object} + components: + pathItems: {Invalid: not-an-object} + mediaTypes: {Invalid: not-an-object} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + version30.render(context(version30), document30); + + OpenApiVersion version31 = OpenApi31Version.create(); + OpenApiDocument document31 = version31.parse(context(version31), + """ + openapi: 3.1.1 + info: {title: API, version: "1"} + paths: + /items: + additionalOperations: {COPY: not-an-object} + components: + mediaTypes: {Invalid: not-an-object} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + version31.render(context(version31), document31); + } + + @Test + void preservesEmptyRequiredNames() { + OpenApi32Version version = OpenApi32Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, + """ + openapi: 3.2.0 + info: + title: API + version: "1" + license: + name: "" + tags: + - name: " " + paths: + /items: + get: + parameters: + - name: "" + in: query + schema: {type: string} + responses: + "200": {description: OK} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = parse(version.render(context, document)); + assertThat(map(map(rendered, "info"), "license").get("name"), is("")); + assertThat(((Map) ((List) rendered.get("tags")).getFirst()).get("name"), is(" ")); + Map operation = map(map(map(rendered, "paths"), "/items"), "get"); + assertThat(((Map) ((List) operation.get("parameters")).getFirst()).get("name"), is("")); + } + + @Test + void preservesEmptyInfoStrings() { + OpenApi32Version version = OpenApi32Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, + """ + openapi: 3.2.0 + info: + title: "" + version: " " + webhooks: {} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + assertThat(document.info().orElseThrow().title(), is("")); + assertThat(document.info().orElseThrow().version(), is(" ")); + + Map renderedInfo = map(parse(version.render(context, document)), "info"); + assertThat(renderedInfo.get("title"), is("")); + assertThat(renderedInfo.get("version"), is(" ")); + } + + @Test + void requiresInfoWhenRendering() { + OpenApi32Version version = OpenApi32Version.create(); + OpenApiDocument withoutInfo = OpenApiDocument.builder() + .paths(Map.of()) + .build(); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), withoutInfo)); + assertThat(thrown.getMessage(), containsString("requires Info metadata")); + } + + @Test + void requiresPathsComponentsOrWebhooksWhenRendering() { + OpenApi32Version version = OpenApi32Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument infoOnly = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .build(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> version.render(context, infoOnly)); + assertThat(thrown.getMessage(), containsString("requires at least one of paths, components, or webhooks")); + + OpenApiDocument emptyWebhooks = version.parse(context, + """ + openapi: 3.2.0 + info: + title: Generated API + version: 1.0.0 + webhooks: {} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + assertThat(parse(version.render(context, emptyWebhooks)).containsKey("webhooks"), is(true)); + } + + @Test + void parsesAndRendersOpenApi32Fields() { + OpenApi32Version version = OpenApi32Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, static32(), MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = parse(version.render(context, document)); + + assertThat(rendered.get("openapi"), is("3.2.0")); + assertThat(rendered.get("$self"), is("https://example.com/openapi/static-3.2.yaml")); + + Map server = (Map) ((List) rendered.get("servers")).getFirst(); + assertThat(server.get("name"), is("local")); + + Map secondTag = (Map) ((List) rendered.get("tags")).get(1); + assertThat(secondTag.get("summary"), is("Internal")); + assertThat(secondTag.get("parent"), is("static")); + assertThat(secondTag.get("kind"), is("badge")); + + Map staticPath = map(map(rendered, "paths"), "/static/{id}"); + assertThat(staticPath.containsKey("query"), is(true)); + assertThat(staticPath.containsKey("additionalOperations"), is(true)); + Map staticResponse = map(map(map(staticPath, "get"), "responses"), "200"); + assertThat(staticResponse.get("summary"), is("Static item")); + assertThat(map(map(staticResponse, "headers"), "X-Request-Id").containsKey("allowReserved"), is(false)); + + Map queryResponse = map(map(staticPath, "query"), "responses"); + assertThat(map(queryResponse, "200").get("summary"), is("Static query stream")); + Map queryContent = map(map(queryResponse, "200"), "content"); + assertThat(map(queryContent, "application/jsonl").containsKey("itemSchema"), is(true)); + + Map search = map(map(map(rendered, "paths"), "/search"), "get"); + Map queryString = (Map) ((List) search.get("parameters")).getFirst(); + assertThat(queryString.get("name"), is("query")); + assertThat(queryString.get("in"), is("querystring")); + Map formExample = map(map(map(map(queryString, "content"), "application/x-www-form-urlencoded"), "examples"), + "form"); + assertThat(formExample.containsKey("dataValue"), is(true)); + assertThat(formExample.get("serializedValue"), is("q=static+item&active=true")); + + Map securityScheme = map(map(map(rendered, "components"), "securitySchemes"), "bearerAuth"); + assertThat(securityScheme.get("deprecated"), is(true)); + Map oauthFlows = map(map(map(map(rendered, "components"), "securitySchemes"), "oauthDevice"), "flows"); + assertThat(oauthFlows.containsKey("deviceAuthorization"), is(true)); + } + + @Test + void rendersOpenApi32StaticDocumentAsOpenApi31() { + OpenApi32Version parseVersion = OpenApi32Version.create(); + OpenApiDocument document = parseVersion.parse(context(parseVersion), + static32WithoutDeviceAuthorization(), + MediaTypes.APPLICATION_OPENAPI_YAML); + + OpenApi31Version renderVersion = OpenApi31Version.create(); + Map rendered = parse(renderVersion.render(context(renderVersion), document)); + + assertThat(rendered.get("openapi"), is("3.1.1")); + assertThat(rendered.containsKey("$self"), is(false)); + assertThat(rendered.get("jsonSchemaDialect"), is("https://spec.openapis.org/oas/3.1/dialect/base")); + + Map server = (Map) ((List) rendered.get("servers")).getFirst(); + assertThat(server.containsKey("name"), is(false)); + + Map secondTag = (Map) ((List) rendered.get("tags")).get(1); + assertThat(secondTag.containsKey("summary"), is(false)); + assertThat(secondTag.containsKey("parent"), is(false)); + assertThat(secondTag.containsKey("kind"), is(false)); + + Map staticPath = map(map(rendered, "paths"), "/static/{id}"); + assertThat(staticPath.containsKey("query"), is(false)); + assertThat(staticPath.containsKey("additionalOperations"), is(false)); + Map staticResponse = map(map(map(staticPath, "get"), "responses"), "200"); + assertThat(staticResponse.containsKey("summary"), is(false)); + assertThat(map(map(staticResponse, "headers"), "X-Request-Id").containsKey("allowReserved"), is(false)); + + Map search = map(map(map(rendered, "paths"), "/search"), "get"); + assertThat(search.get("parameters"), is(List.of())); + + Map securityScheme = map(map(map(rendered, "components"), "securitySchemes"), "bearerAuth"); + assertThat(securityScheme.containsKey("deprecated"), is(false)); + Map oauthFlows = map(map(map(map(rendered, "components"), "securitySchemes"), "oauthDevice"), "flows"); + assertThat(oauthFlows.containsKey("deviceAuthorization"), is(false)); + assertThat(oauthFlows.containsKey("authorizationCode"), is(true)); + } + + @Test + void rendersOpenApi32StaticDocumentAsOpenApi30() { + OpenApi32Version parseVersion = OpenApi32Version.create(); + OpenApiDocument document = parseVersion.parse(context(parseVersion), + static32WithoutDeviceAuthorization(), + MediaTypes.APPLICATION_OPENAPI_YAML); + + OpenApi30Version renderVersion = OpenApi30Version.create(); + Map rendered = parse(renderVersion.render(context(renderVersion), document)); + + assertThat(rendered.get("openapi"), is("3.0.3")); + assertThat(rendered.containsKey("$self"), is(false)); + assertThat(rendered.containsKey("jsonSchemaDialect"), is(false)); + + Map staticPath = map(map(rendered, "paths"), "/static/{id}"); + assertThat(staticPath.containsKey("query"), is(false)); + assertThat(staticPath.containsKey("additionalOperations"), is(false)); + Map staticResponse = map(map(map(staticPath, "get"), "responses"), "200"); + assertThat(staticResponse.containsKey("summary"), is(false)); + assertThat(map(map(staticResponse, "headers"), "X-Request-Id").containsKey("allowReserved"), is(false)); + + Map search = map(map(map(rendered, "paths"), "/search"), "get"); + assertThat(search.get("parameters"), is(List.of())); + + Map status = schemaProperty(rendered, "StaticItem", "status"); + assertThat(status.get("type"), is("string")); + assertThat(status.get("nullable"), is(true)); + assertThat(((List) status.get("enum")).contains(null), is(true)); + + Map mode = schemaProperty(rendered, "StaticItem", "mode"); + assertThat(mode.containsKey("const"), is(false)); + assertThat(mode.get("enum"), is(List.of("modern"))); + + assertThat(schemaPropertyValue(rendered, "StaticItem", "payload"), is(Map.of())); + + Map securitySchemes = map(map(rendered, "components"), "securitySchemes"); + Map oauthFlows = map(map(securitySchemes, "oauthDevice"), "flows"); + assertThat(oauthFlows.containsKey("deviceAuthorization"), is(false)); + assertThat(oauthFlows.containsKey("authorizationCode"), is(true)); + } + + @Test + void rejectsOpenApi32DeviceAuthorizationWhenRenderingOpenApi31() { + OpenApi32Version parseVersion = OpenApi32Version.create(); + OpenApiDocument document = parseVersion.parse(context(parseVersion), static32(), MediaTypes.APPLICATION_OPENAPI_YAML); + + OpenApi31Version renderVersion = OpenApi31Version.create(); + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> renderVersion.render(context(renderVersion), document)); + + assertThat(thrown.getMessage(), containsString("deviceAuthorization")); + } + + @Test + void rejectsOpenApi32DeviceAuthorizationWhenRenderingOpenApi30() { + OpenApi32Version parseVersion = OpenApi32Version.create(); + OpenApiDocument document = parseVersion.parse(context(parseVersion), static32(), MediaTypes.APPLICATION_OPENAPI_YAML); + + OpenApi30Version renderVersion = OpenApi30Version.create(); + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> renderVersion.render(context(renderVersion), document)); + + assertThat(thrown.getMessage(), containsString("deviceAuthorization")); + } + + @Test + void arbitraryHttpMethodUsesAdditionalOperations() { + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/static/{id}", + path -> path.parameter(parameter -> parameter + .name("id") + .in("path") + .required(true) + .schema(JsonObject.builder().set("type", "string").build())) + .operation("COPY", + operation -> operation.operationId("copyStatic") + .response("200", "Copied."))) + .build(); + + OpenApi32Version version32 = OpenApi32Version.create(); + Map rendered32 = parse(version32.render(context(version32), document)); + Map path32 = map(map(rendered32, "paths"), "/static/{id}"); + assertThat(path32.containsKey("copy"), is(false)); + assertThat(map(path32, "additionalOperations").containsKey("COPY"), is(true)); + + OpenApi30Version version30 = OpenApi30Version.create(); + Map rendered30 = parse(version30.render(context(version30), document)); + Map path30 = map(map(rendered30, "paths"), "/static/{id}"); + assertThat(path30.containsKey("copy"), is(false)); + assertThat(path30.containsKey("additionalOperations"), is(false)); + } + + @Test + void rejectsFixedMethodInParsedAdditionalOperations() { + OpenApi32Version version = OpenApi32Version.create(); + + IllegalArgumentException thrown = assertThrows( + IllegalArgumentException.class, + () -> version.parse(context(version), + """ + openapi: 3.2.0 + info: + title: Static API + version: 1.0.0 + paths: + /static: + additionalOperations: + POST: + responses: + "200": + description: Static response. + """, + MediaTypes.APPLICATION_OPENAPI_YAML)); + + assertThat(thrown.getMessage(), containsString("fixed-field HTTP method: POST")); + } + + @Test + void preservesCaseSensitiveCustomMethodsInParsedAdditionalOperations() { + OpenApi32Version version = OpenApi32Version.create(); + OpenApiDocument document = version.parse(context(version), + """ + openapi: 3.2.0 + info: + title: Static API + version: 1.0.0 + paths: + /static: + post: + responses: + default: + description: Fixed POST response + additionalOperations: + post: + responses: + default: + description: Lowercase post response + PoSt: + responses: + default: + description: Mixed-case PoSt response + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = parse(version.render(context(version), document)); + Map path = map(map(rendered, "paths"), "/static"); + assertThat(path.containsKey("post"), is(true)); + Map additionalOperations = map(path, "additionalOperations"); + assertThat(additionalOperations.keySet(), is(Set.of("post", "PoSt"))); + } + + @Test + void parsesOnlyOpenApi32Documents() { + OpenApi32Version version = OpenApi32Version.create(); + + assertThrows(IllegalStateException.class, + () -> version.parse(context(version), + """ + openapi: 3.1.0 + info: + title: Static API + version: 1.0.0 + """, + MediaTypes.APPLICATION_OPENAPI_YAML)); + } + + @Test + void rejectsNullArguments() { + OpenApi32Version version = OpenApi32Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = OpenApiDocument.builder().build(); + + assertThrows(NullPointerException.class, () -> OpenApi32Version.create((OpenApi32VersionConfig) null)); + assertThrows(NullPointerException.class, () -> version.parse(null, "", MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThrows(NullPointerException.class, () -> version.parse(context, null, MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThrows(NullPointerException.class, () -> version.parse(context, "", null)); + assertThrows(NullPointerException.class, () -> version.render(null, document)); + assertThrows(NullPointerException.class, () -> version.render(context, null)); + } + + @Test + void validatesConfiguredVersion() { + assertThat(OpenApi32Version.builder().version("3.2.99").build().version(), is("3.2.99")); + assertThat(OpenApi32Version.builder().version("3.2.0-beta").build().version(), is("3.2.0-beta")); + + for (String invalidVersion : List.of("3.2", "3.2.", "3.2.not-a-version", "3.2.1-", "3.2.1.0", "3.1.0")) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> OpenApi32Version.builder() + .version(invalidVersion) + .build(), + invalidVersion); + assertThat(invalidVersion, ex.getMessage(), containsString("3.2")); + assertThat(invalidVersion, ex.getMessage(), containsString(invalidVersion)); + } + } + + @Test + void serviceLoaderDiscoversProvider() { + boolean found = ServiceLoader.load(OpenApiVersionProvider.class) + .stream() + .map(ServiceLoader.Provider::get) + .anyMatch(provider -> "3.2".equals(provider.configKey())); + + assertThat(found, is(true)); + } + + private static void assertNonObjectRejected(OpenApiVersion version, String location, String document) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.parse(context(version), document, MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(location, thrown.getMessage(), containsString(location)); + assertThat(location, thrown.getMessage(), containsString("must be an object")); + } + + private static OpenApiDocument exampleDocument(OpenApiDocument.Example example) { + return OpenApiDocument.builder() + .info("API", "1") + .paths(Map.of()) + .components(components -> components.example("Example", example)) + .build(); + } + + private static OpenApiDocument parseExample(OpenApiVersion version, String example) { + return version.parse(context(version), + """ + openapi: %s + info: {title: API, version: "1"} + paths: {} + components: + examples: + Example: %s + """.formatted(version.version(), example), + MediaTypes.APPLICATION_OPENAPI_YAML); + } + + private static void assertInvalidExample(OpenApiVersion version, String example) { + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> parseExample(version, example)); + assertThat(thrown.getMessage(), containsString("cannot combine")); + } + + private static void assertInvalidExample(OpenApiVersion version, OpenApiDocument document) { + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), document)); + assertThat(thrown.getMessage(), containsString("cannot combine")); + } + + private static OpenApiDocument contentDocument(boolean header, Map content) { + Map response = header + ? Map.of("description", "OK", "headers", Map.of("X-Test", Map.of("content", content))) + : Map.of("description", "OK"); + Map operation = header + ? Map.of("responses", Map.of("200", response)) + : Map.of("parameters", List.of(Map.of("name", "query", "in", "query", "content", content)), + "responses", Map.of("200", response)); + return OpenApiDocumentReader.read(OpenApiDocumentMapperSupport.jsonObject(Map.of( + "info", Map.of("title", "API", "version", "1"), + "paths", Map.of("/items", Map.of("get", operation))))); + } + + @SuppressWarnings("unchecked") + private static Map parse(String content) { + return new Yaml().load(content); + } + + @SuppressWarnings("unchecked") + private static Map schemaProperty(Map document, String schemaName, String propertyName) { + return (Map) schemaPropertyValue(document, schemaName, propertyName); + } + + private static Object schemaPropertyValue(Map document, String schemaName, String propertyName) { + return map(map(map(map(document, "components"), "schemas"), schemaName), "properties") + .get(propertyName); + } + + @SuppressWarnings("unchecked") + private static Map map(Map map, String name) { + return (Map) map.get(name); + } + + private static OpenApiDocumentContext context(OpenApiVersion version) { + return new TestOpenApiDocumentContext(version); + } + + private static String static32() { + try (InputStream is = OpenApi32VersionTest.class.getResourceAsStream("/static-3.2.yaml")) { + if (is == null) { + throw new IllegalArgumentException("Resource not found: static-3.2.yaml"); + } + return new String(is.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private static String static32WithoutDeviceAuthorization() { + Map document = parse(static32()); + Map securityScheme = map(map(map(document, "components"), "securitySchemes"), "oauthDevice"); + map(securityScheme, "flows").remove("deviceAuthorization"); + return new Yaml().dump(document); + } + + private record TestOpenApiDocumentContext(OpenApiVersion openApiVersion) implements OpenApiDocumentContext { + @Override + public String featureName() { + return "openapi"; + } + + @Override + public String webContext() { + return "/openapi"; + } + + @Override + public String listener() { + return "default"; + } + + @Override + public OpenApiGeneratedMode generatedMode() { + return OpenApiGeneratedMode.STATIC_ONLY; + } + } +} diff --git a/openapi/openapi-32/src/test/resources/static-3.2.yaml b/openapi/openapi-32/src/test/resources/static-3.2.yaml new file mode 100644 index 00000000000..b86c30d67d4 --- /dev/null +++ b/openapi/openapi-32/src/test/resources/static-3.2.yaml @@ -0,0 +1,161 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +openapi: 3.2.0 +jsonSchemaDialect: https://spec.openapis.org/oas/3.1/dialect/base +$self: https://example.com/openapi/static-3.2.yaml +info: + title: Static 3.2 API + summary: Static document using OpenAPI 3.2 features. + version: 1.0.0 +servers: + - name: local + url: https://api.example.com +tags: + - name: static + summary: Static + description: Static document operations. + kind: nav + - name: internal + summary: Internal + description: Internal static document operations. + parent: static + kind: badge +paths: + /static/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + tags: + - static + operationId: staticGet + responses: + "200": + summary: Static item + description: Static response. + headers: + X-Request-Id: + description: Request correlation id. + allowReserved: true + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/StaticItem" + examples: + active: + value: + id: "42" + status: active + query: + tags: + - static + operationId: queryStatic + requestBody: + content: + application/json: + schema: + type: object + properties: + filter: + type: string + responses: + "200": + summary: Static query stream + description: Static query response stream. + content: + application/jsonl: + itemSchema: + $ref: "#/components/schemas/StaticItem" + additionalOperations: + COPY: + tags: + - internal + operationId: copyStatic + responses: + "202": + description: Static copy accepted. + /search: + get: + tags: + - static + operationId: searchStatic + parameters: + - name: query + in: querystring + content: + application/x-www-form-urlencoded: + schema: + type: object + properties: + q: + type: string + active: + type: boolean + examples: + form: + dataValue: + q: static item + active: true + serializedValue: q=static+item&active=true + responses: + "204": + description: Search accepted. +components: + schemas: + StaticItem: + type: object + required: + - id + properties: + id: + type: string + status: + type: + - string + - "null" + enum: + - active + - inactive + - null + payload: true + mode: + const: modern + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + deprecated: true + oauthDevice: + type: oauth2 + flows: + deviceAuthorization: + deviceAuthorizationUrl: https://auth.example.com/device + tokenUrl: https://auth.example.com/token + scopes: + read: Read access. + authorizationCode: + authorizationUrl: https://auth.example.com/authorize + tokenUrl: https://auth.example.com/token + scopes: + read: Read access. +security: + - bearerAuth: [] diff --git a/openapi/openapi/pom.xml b/openapi/openapi/pom.xml index 13834accd7a..eb566889d09 100644 --- a/openapi/openapi/pom.xml +++ b/openapi/openapi/pom.xml @@ -57,6 +57,14 @@ io.helidon.common helidon-common-media-type + + io.helidon.json.schema + helidon-json-schema + + + io.helidon.service + helidon-service-registry + io.helidon.webserver helidon-webserver @@ -124,6 +132,11 @@ helidon-builder-codegen ${helidon.version} + + io.helidon.service + helidon-service-codegen + ${helidon.version} + io.helidon.codegen helidon-codegen-helidon-copyright @@ -152,6 +165,11 @@ helidon-builder-codegen ${helidon.version} + + io.helidon.service + helidon-service-codegen + ${helidon.version} + io.helidon.codegen helidon-codegen-helidon-copyright diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApi.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApi.java new file mode 100644 index 00000000000..3907ef21af6 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApi.java @@ -0,0 +1,1813 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Inherited; +import java.lang.annotation.Repeatable; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import io.helidon.common.Api; + +/** + * OpenAPI document annotations. + *

+ * Declarative OpenAPI annotations do not model operation callbacks or OpenAPI 3.1 and 3.2 top-level webhooks. To + * describe a callback, use a static OpenAPI document or an {@link io.helidon.openapi.spi.OpenApiDocumentSource} that + * defines the complete containing operation, and configure Helidon not to generate the same path and method from + * annotations. A static document or document source can also define top-level webhooks. Combining static and generated + * content uses {@link OpenApiGeneratedMode#MERGE MERGE} mode and requires non-conflicting content. + */ +@Api.Preview +@Api.Since("27.0.0") +public final class OpenApi { + private OpenApi() { + } + + /** + * Requiredness override. + */ + public enum Required { + /** + * Infer requiredness from the HTTP binding and Java type. + */ + UNSPECIFIED, + + /** + * Document the item as required. + */ + TRUE, + + /** + * Document the item as optional. + */ + FALSE + } + + /** + * Parameter explode override. + */ + public enum Explode { + /** + * Infer explode from the HTTP binding and parameter type. + */ + UNSPECIFIED, + + /** + * Document the parameter with {@code explode=true}. + */ + TRUE, + + /** + * Document the parameter with {@code explode=false}. + */ + FALSE + } + + /** + * Parameter serialization style. + *

+ * Declarative OpenAPI generation supports {@link #SIMPLE} style for path and header parameters, {@link #COOKIE} + * style for cookie parameters in OpenAPI 3.2, {@link #FORM} style for cookie parameters in earlier OpenAPI versions, + * and {@link #FORM}, {@link #SPACE_DELIMITED}, or {@link #PIPE_DELIMITED} style for query parameters. Delimited query + * styles require list-valued parameters and cannot use {@link Explode#TRUE}. Header parameters cannot use + * {@link Explode#TRUE}. {@link #DEEP_OBJECT}, {@link #MATRIX}, and {@link #LABEL} are not supported by declarative HTTP + * parameter binding. + */ + public enum Style { + /** + * Infer style from the parameter location and type. + */ + UNSPECIFIED, + + /** + * Matrix style parameters. + */ + MATRIX, + + /** + * Label style parameters. + */ + LABEL, + + /** + * Form style parameters. + */ + FORM, + + /** + * Cookie style parameters in OpenAPI 3.2. + */ + COOKIE, + + /** + * Simple style parameters. + */ + SIMPLE, + + /** + * Space-delimited array parameters. + */ + SPACE_DELIMITED, + + /** + * Pipe-delimited array parameters. + */ + PIPE_DELIMITED, + + /** + * Deep object style parameters. + */ + DEEP_OBJECT + } + + /** + * Marker for a type that contributes document-level OpenAPI metadata. + *

+ * A type annotated with {@code @OpenApi.Document} must also be annotated with {@link Info @OpenApi.Info}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Document { + /** + * Document identity URI. + *

+ * Rendered only for OpenAPI 3.2 output. + * A relative identity is resolved against the configured OpenAPI web context. When the identity is relative, + * Helidon cannot determine whether an absolute reference identifies this document because composition does not + * have the request scheme or authority. That combination is not supported; use an absolute document identity or + * relative references instead. + * + * @return document identity URI + */ + String self() default ""; + + /** + * JSON Schema dialect URI. + *

+ * Rendered only for OpenAPI 3.1 and later output. + * + * @return JSON Schema dialect URI + */ + String jsonSchemaDialect() default ""; + } + + /** + * Explicitly enables generated OpenAPI data for a declarative REST endpoint. + *

+ * This annotation is useful when the endpoint does not use any other endpoint-applicable OpenAPI annotation. The + * generated OpenAPI data is derived from the endpoint's declarative HTTP annotations and Java signatures. + * When declared on a declarative REST endpoint contract, this annotation also applies to its implementations. + *

+ * Without this marker, Helidon processes a declarative REST endpoint for OpenAPI when it uses any of the following: + *

    + *
  • On the endpoint type: {@link Document}, {@link Hidden}, {@link SecuritySchemeRequirement}, + * {@link SecurityRequirement}, or {@link SecurityRequirements}.
  • + *
  • On an endpoint method: {@link Operation}, {@link Hidden}, {@link Server}, {@link Servers}, + * {@link ExternalDocs}, {@link Extension}, {@link Extensions}, {@link SecuritySchemeRequirement}, + * {@link SecurityRequirement}, {@link SecurityRequirements}, {@link Parameter}, {@link Parameters}, + * {@link RequestBody}, {@link Response}, or {@link Responses}.
  • + *
  • On a method parameter: {@link Parameter} or {@link Parameters}.
  • + *
+ * Document-only companion annotations such as {@link Info}, {@link Contact}, {@link License}, {@link Tag}, and + * security scheme declarations do not enable endpoint generation by themselves. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + @Inherited + public @interface Endpoint { + } + + /** + * OpenAPI Info Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Info { + /** + * API title. + * + * @return title + */ + String title(); + + /** + * API version. + * + * @return version + */ + String version(); + + /** + * API description. + * + * @return description + */ + String description() default ""; + + /** + * API summary. + *

+ * Rendered only for OpenAPI 3.1 and later output. + * + * @return summary + */ + String summary() default ""; + + /** + * API terms of service URL. + * + * @return terms of service URL + */ + String termsOfService() default ""; + } + + /** + * OpenAPI Contact Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Contact { + /** + * Contact name. + * + * @return name + */ + String value() default ""; + + /** + * Contact URL. + * + * @return URL + */ + String url() default ""; + + /** + * Contact email. + * + * @return email + */ + String email() default ""; + } + + /** + * OpenAPI License Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface License { + /** + * License name. + * + * @return name + */ + String value(); + + /** + * License URL. + *

+ * For OpenAPI 3.1 and later output, this value is ignored when {@link #identifier()} is set. + * + * @return URL + */ + String url() default ""; + + /** + * SPDX license identifier. + *

+ * Rendered only for OpenAPI 3.1 and later output. When set together with {@link #url()}, this value takes + * precedence for those versions; the URL remains available for OpenAPI 3.0 output. + * + * @return license identifier + */ + String identifier() default ""; + } + + /** + * OpenAPI Server Object metadata. + *

+ * Type-level usage applies only to {@link Document @OpenApi.Document} metadata types. Method-level usage applies to + * generated operations. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Repeatable(Servers.class) + @Documented + public @interface Server { + /** + * Server URL. + * + * @return URL + */ + String value(); + + /** + * Server description. + * + * @return description + */ + String description() default ""; + + /** + * Server variables. + * + * @return server variables + */ + ServerVariable[] variables() default {}; + + /** + * Server name. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return name + */ + String name() default ""; + } + + /** + * Container for repeated {@link Server}. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Servers { + /** + * Servers. + * + * @return servers + */ + Server[] value(); + } + + /** + * OpenAPI Server Variable Object metadata. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface ServerVariable { + /** + * Variable name. + * + * @return variable name + */ + String name(); + + /** + * Default value. + *

+ * The default value can be empty. When {@link #enumeration()} is set, it must contain the default value. + * + * @return default value + */ + String defaultValue(); + + /** + * Allowed values. + * + * @return allowed values + */ + String[] enumeration() default {}; + + /** + * Variable description. + * + * @return description + */ + String description() default ""; + } + + /** + * OpenAPI Tag Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types to declare top-level tags. Use + * {@link Operation#tags()} to tag generated operations. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Repeatable(Tags.class) + @Documented + public @interface Tag { + /** + * Tag name. + * + * @return name + */ + String value(); + + /** + * Tag description. + * + * @return description + */ + String description() default ""; + + /** + * Tag summary. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return summary + */ + String summary() default ""; + + /** + * Parent tag name. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return parent tag name + */ + String parent() default ""; + + /** + * Tag kind. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return tag kind + */ + String kind() default ""; + } + + /** + * Container for repeated {@link Tag}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Tags { + /** + * Tags. + * + * @return tags + */ + Tag[] value(); + } + + /** + * OpenAPI External Documentation Object metadata. + *

+ * Type-level usage applies only to {@link Document @OpenApi.Document} metadata types. Method-level usage applies to + * generated operations. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface ExternalDocs { + /** + * Documentation URL. + * + * @return URL + */ + String value(); + + /** + * Documentation description. + * + * @return description + */ + String description() default ""; + } + + /** + * OpenAPI Operation Object metadata. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Operation { + /** + * Short operation summary. + * + * @return summary + */ + String value() default ""; + + /** + * Operation id. + * + * @return operation id + */ + String operationId() default ""; + + /** + * Complete OpenAPI path template for this operation. This is useful when the Helidon route template cannot be + * represented directly as an OpenAPI path. The value is not relative to the declarative HTTP path annotation. + *

+ * This element configures only the path template. Use a static OpenAPI document or an + * {@link io.helidon.openapi.spi.OpenApiDocumentSource} to define Path Item Object summary, description, or + * path-level servers. If a Path Item {@code $ref} accompanies locally generated fields, the referenced Path Item + * must not define any of the same fields; otherwise the static document or document source must own the complete + * Path Item and Helidon must not also generate the same path and method from annotations. + *

+ * Path overrides must start with {@code /} and must declare the same path parameters as the generated route. + * Use simple OpenAPI path parameters such as {@code {id}}. Regex constraints, optional path segments, wildcards, + * escaped path characters, greedy parameters, and path parameter names containing path or template metacharacters + * are not supported. + * + * @return OpenAPI path template + */ + String path() default ""; + + /** + * Operation description. + * + * @return description + */ + String description() default ""; + + /** + * Operation tags. + * + * @return tag names + */ + String[] tags() default {}; + + /** + * Whether the operation is deprecated. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * Excludes an endpoint type or operation method from generated OpenAPI output. + *

+ * When declared on a declarative REST endpoint contract, this annotation also applies to its implementations. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Documented + @Inherited + public @interface Hidden { + } + + /** + * OpenAPI Parameter Object metadata. + *

+ * On a method parameter, this annotation decorates the generated parameter from the declarative HTTP binding; it + * cannot change the bound parameter {@link #name()} or {@link #in()} location. On a method, this annotation must + * declare non-blank {@link #name()} and {@link #in()} values which match an existing generated path, query, header, + * or cookie parameter. + *

+ * Path parameters are always required and cannot be made optional. Query, header, and cookie parameters which are + * required by the Java signature or HTTP binding cannot be made optional. {@link #allowReserved()} can be used only + * for query parameters. If {@link #content()} is configured, {@link #style()} and {@link #explode()} must not be + * configured. + *

+ * Generated OpenAPI omits declarative header parameters named {@code Accept}, {@code Content-Type}, or + * {@code Authorization}. Use media type metadata, request body metadata, or security metadata to describe those + * concerns. + */ + @Target({ElementType.METHOD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.CLASS) + @Repeatable(Parameters.class) + @Documented + public @interface Parameter { + /** + * Parameter description. + * + * @return description + */ + String value() default ""; + + /** + * Parameter name. Defaults to the HTTP binding name on parameter-target usage. + *

+ * Method-target usage requires a non-blank value matching a generated parameter. Parameter-target usage cannot + * override the generated parameter name. + * + * @return name + */ + String name() default ""; + + /** + * Parameter location. Defaults to the HTTP binding location on parameter-target usage. + *

+ * Method-target usage requires a non-blank value matching a generated parameter location. Supported generated + * locations are {@code path}, {@code query}, {@code header}, and {@code cookie}. Parameter-target usage cannot + * override the generated parameter location. + *

+ * Declarative OpenAPI annotations do not support OpenAPI 3.2 {@code querystring} parameters. To use one, define + * the complete containing operation in a static OpenAPI document or an + * {@link io.helidon.openapi.spi.OpenApiDocumentSource}, and configure Helidon not to generate the same path and + * method from annotations. + * + * @return location + */ + String in() default ""; + + /** + * Requiredness override. + *

+ * Path parameters are always required. Required query, header, and cookie parameters cannot be made optional. + * + * @return requiredness + */ + Required required() default Required.UNSPECIFIED; + + /** + * Parameter example. + *

+ * Mutually exclusive with {@link #examples()}. Can be used with generated schema parameters or explicit + * {@link #content()}. + * + * @return example + */ + String example() default ""; + + /** + * Parameter examples. + *

+ * Mutually exclusive with {@link #example()}. Can be used with generated schema parameters or explicit + * {@link #content()}. + * + * @return examples + */ + Example[] examples() default {}; + + /** + * Parameter content entries. + *

+ * At most one entry is supported. When configured, generated schema, {@link #style()}, and {@link #explode()} + * are omitted. Parameter {@link #example()} or {@link #examples()} can still be configured. + * + * @return content + */ + Content[] content() default {}; + + /** + * Parameter style. + *

+ * Must remain unspecified when {@link #content()} is configured. See {@link Style} for supported styles by + * parameter location. + * + * @return style + */ + Style style() default Style.UNSPECIFIED; + + /** + * Parameter explode override. + *

+ * Must remain unspecified when {@link #content()} is configured. Header parameters cannot use + * {@link Explode#TRUE}. Query parameters cannot use {@link Explode#TRUE} with {@link Style#SPACE_DELIMITED} or + * {@link Style#PIPE_DELIMITED}. + * + * @return explode + */ + Explode explode() default Explode.UNSPECIFIED; + + /** + * Whether reserved characters are allowed unencoded. + *

+ * Supported only for query parameters. + * + * @return allow reserved flag + */ + boolean allowReserved() default false; + + /** + * Whether the parameter is deprecated. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * Container for repeated {@link Parameter}. + */ + @Target({ElementType.METHOD, ElementType.PARAMETER}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Parameters { + /** + * Parameters. + * + * @return parameters + */ + Parameter[] value(); + } + + /** + * OpenAPI Request Body Object metadata. + *

+ * Use only on a method with an effective declarative HTTP request body input. The input can be a direct + * {@link io.helidon.http.Http.Entity @Http.Entity} parameter, an + * {@link io.helidon.http.Http.RequestParams @Http.RequestParams} record with an {@code @Http.Entity} component, or + * one or more {@link io.helidon.http.Http.FormParam @Http.FormParam} parameters or request-param record components. + *

+ * For entity inputs, content is inferred from the entity type and the method's consumed media types unless + * {@link #content()} overrides it. For form inputs, generated content is always + * {@code application/x-www-form-urlencoded}; the schema is inferred from the form field names and types and cannot be + * overridden with {@link Content#schema()}. + *

+ * Requiredness is inferred from the effective input: {@code Optional} entity parameters, entity components, and form + * fields are optional, defaulted form fields are optional, and other entity or form inputs are required. Use + * {@link #required()} to make an optional input required. A required input cannot be documented as optional because + * the runtime binding still requires it. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface RequestBody { + /** + * Request body description. + * + * @return description + */ + String value() default ""; + + /** + * Requiredness override. + * + * @return requiredness + */ + Required required() default Required.UNSPECIFIED; + + /** + * Request body content entries. + * + * @return content + */ + Content[] content() default {}; + } + + /** + * OpenAPI Response Object metadata. + *

+ * When a method declares one or more explicit responses, generated OpenAPI response content is taken only from + * {@link #content()}; return-type response content is not inferred for those explicit responses. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @Repeatable(Responses.class) + @Documented + public @interface Response { + /** + * HTTP status code in the range {@code 100..599}. A method can declare at most one response for each status. + *

+ * This element supports exact status codes only. To declare {@code default} or a wildcard response range + * ({@code 1XX} through {@code 5XX}), define the complete containing operation in a static OpenAPI document or an + * {@link io.helidon.openapi.spi.OpenApiDocumentSource}, and configure Helidon not to generate the same path and + * method from annotations. + * + * @return status code + */ + int status(); + + /** + * Response description. + * + * @return description + */ + String description(); + + /** + * Response summary. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return summary + */ + String summary() default ""; + + /** + * Response content entries. + * + * @return content + */ + Content[] content() default {}; + + /** + * Response headers. + * + * @return headers + */ + Header[] headers() default {}; + + /** + * Response links. + * + * @return links + */ + Link[] links() default {}; + } + + /** + * OpenAPI Link Object metadata. + *

+ * A link must define exactly one of {@link #operationRef()} or {@link #operationId()}. + *

+ * Declarative OpenAPI generation supports string-valued link parameters and request bodies. Use + * {@link OpenApiDocument.LinkBuilder} for other OpenAPI value types. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Link { + /** + * Link name. + * + * @return link name + */ + String name(); + + /** + * Operation reference. + *

+ * Mutually exclusive with {@link #operationId()}. + * + * @return operation reference + */ + String operationRef() default ""; + + /** + * Operation ID. + *

+ * Mutually exclusive with {@link #operationRef()}. + * + * @return operation ID + */ + String operationId() default ""; + + /** + * Parameters passed to the linked operation. + * + * @return link parameters + */ + LinkParameter[] parameters() default {}; + + /** + * Literal string or runtime expression used as the linked operation request body. + * + * @return request body + */ + String requestBody() default ""; + + /** + * Link description. + * + * @return description + */ + String description() default ""; + } + + /** + * OpenAPI Link Object parameter metadata. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface LinkParameter { + /** + * Parameter name. + * + * @return parameter name + */ + String name(); + + /** + * Literal string or runtime expression passed to the linked operation. + * + * @return parameter value + */ + String value(); + } + + /** + * OpenAPI Header Object metadata. + *

+ * Declarative OpenAPI annotations do not model Header Object {@code example}, {@code examples}, or {@code explode} + * metadata. To use such metadata, define the complete containing operation in a static OpenAPI document or an + * {@link io.helidon.openapi.spi.OpenApiDocumentSource}, and configure Helidon not to generate the same path and + * method from annotations. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Header { + /** + * Header name. + *

+ * Response header names cannot be {@code Content-Type} and cannot repeat case-insensitively within one response. + * Use {@link Content} to describe response media types. + * + * @return header name + */ + String name(); + + /** + * Header description. + * + * @return description + */ + String value() default ""; + + /** + * Requiredness override. + * + * @return requiredness + */ + Required required() default Required.UNSPECIFIED; + + /** + * Whether the header is deprecated. + * + * @return deprecated flag + */ + boolean deprecated() default false; + + /** + * Schema class. Defaults to {@link String}. + * + * @return schema class + */ + Class schema() default Void.class; + + /** + * Header content entries. + *

+ * At most one entry is supported. + * + * @return content + */ + Content[] content() default {}; + } + + /** + * Container for repeated {@link Response}. + */ + @Target(ElementType.METHOD) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Responses { + /** + * Responses. + * + * @return responses + */ + Response[] value(); + } + + /** + * OpenAPI Media Type Object metadata. + *

+ * Declarative OpenAPI annotations do not model {@code encoding} metadata, including per-part content types and + * headers, or OpenAPI 3.2 {@code prefixEncoding} and {@code itemEncoding}. To use such metadata, define the complete + * containing operation in a static OpenAPI document or an + * {@link io.helidon.openapi.spi.OpenApiDocumentSource}, and configure Helidon not to generate the same path and + * method from annotations. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Content { + /** + * Media type. + * + * @return media type + */ + String value() default ""; + + /** + * Schema class. Defaults to the effective request or response entity type unless {@link #itemSchema()} is set. + * If {@code itemSchema} is set without an explicit {@code schema}, the inferred entity schema is omitted. + * + * @return schema class + */ + Class schema() default Void.class; + + /** + * Item schema class for OpenAPI 3.2 sequential media types. This is an alternative to {@link #schema()}. + *

+ * If set without an explicit {@code schema}, this suppresses the inferred entity schema. Applications that use + * this attribute must select OpenAPI 3.2 output; earlier output versions omit it. + * + * @return item schema class + */ + Class itemSchema() default Void.class; + + /** + * Examples. + * + * @return examples + */ + Example[] examples() default {}; + } + + /** + * OpenAPI Example Object metadata. + *

+ * {@link #value()} is mutually exclusive with {@link #dataValue()}, {@link #serializedValue()}, and + * {@link #externalValue()}. {@link #serializedValue()} and {@link #externalValue()} are mutually exclusive. + * {@link #dataValue()} can be used by itself, or with either {@link #serializedValue()} or + * {@link #externalValue()} for OpenAPI 3.2 output. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Example { + /** + * Example name. + * + * @return name + */ + String name() default ""; + + /** + * Example summary. + * + * @return summary + */ + String summary() default ""; + + /** + * Example description. + * + * @return description + */ + String description() default ""; + + /** + * Example value. + *

+ * Declarative OpenAPI generation parses valid JSON as a structured OpenAPI value and emits non-JSON text as a + * string. + *

+ * Mutually exclusive with {@link #dataValue()}, {@link #serializedValue()}, and {@link #externalValue()}. + * + * @return value + */ + String value() default ""; + + /** + * OpenAPI 3.2 data value. + *

+ * Declarative OpenAPI generation parses valid JSON as a structured OpenAPI value and emits non-JSON text as a + * string. + *

+ * Rendered only for OpenAPI 3.2 output. + *

+ * Can be used with either {@link #serializedValue()} or {@link #externalValue()}. Mutually exclusive with + * {@link #value()}. + * + * @return data value + */ + String dataValue() default ""; + + /** + * OpenAPI 3.2 serialized value. + *

+ * Declarative OpenAPI generation emits this value as a string and does not parse it as JSON. + *

+ * Rendered only for OpenAPI 3.2 output. + *

+ * Can be used with {@link #dataValue()}. Mutually exclusive with {@link #value()} and + * {@link #externalValue()}. + * + * @return serialized value + */ + String serializedValue() default ""; + + /** + * External example value URI. + *

+ * Can be used with {@link #dataValue()}. Mutually exclusive with {@link #value()} and + * {@link #serializedValue()}. + * + * @return external value URI + */ + String externalValue() default ""; + } + + /** + * OpenAPI Specification Extension metadata. + *

+ * Type-level usage applies only to {@link Document @OpenApi.Document} metadata types. Method-level usage applies to + * generated operations. + *

+ * Declarative OpenAPI annotations do not apply extensions to other OpenAPI objects, including servers, responses, + * headers, and links. Use a static OpenAPI document or an + * {@link io.helidon.openapi.spi.OpenApiDocumentSource} to define such extensions. If the extended object belongs to + * an operation, the static document or document source must own the complete operation and Helidon must not also + * generate the same path and method from annotations. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Repeatable(Extensions.class) + @Documented + public @interface Extension { + /** + * Extension name. Must start with {@code x-}. + * + * @return name + */ + String name(); + + /** + * Extension value. + *

+ * By default, the value is rendered as an OpenAPI string. Set {@link #parseValue()} to {@code true} to parse + * the resolved value as JSON. + * + * @return value + */ + String value(); + + /** + * Whether to parse the resolved {@link #value()} as JSON. + *

+ * When enabled, the resolved value must contain exactly one valid JSON value. + * + * @return whether to parse the value + */ + boolean parseValue() default false; + } + + /** + * Container for repeated {@link Extension}. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface Extensions { + /** + * Extensions. + * + * @return extensions + */ + Extension[] value(); + } + + /** + * OpenAPI Security Scheme Object metadata. + *

+ * Supported {@link #type()} values are {@code apiKey}, {@code http}, {@code mutualTLS}, {@code oauth2}, and + * {@code openIdConnect}. The {@code apiKey} type requires {@link #apiKeyName()} and {@link #in()} with + * {@code query}, {@code header}, or {@code cookie}. The {@code http} type requires {@link #scheme()}. The + * {@code mutualTLS} type has no additional required fields but requires OpenAPI 3.1 or 3.2 output; generation fails + * when the selected document provider produces OpenAPI 3.0. The {@code oauth2} type requires {@link #flows()} with + * at least one configured flow. + * The {@code openIdConnect} type requires {@link #openIdConnectUrl()}. + *

+ * Declarative OpenAPI generation rejects fields that do not apply to the selected {@link #type()}. + * Prefer the type-specific annotations such as {@link ApiKeySecurityScheme}, {@link HttpSecurityScheme}, + * {@link MutualTlsSecurityScheme}, {@link OAuth2SecurityScheme}, and {@link OidcSecurityScheme} when they match + * the security scheme you need. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Repeatable(SecuritySchemes.class) + @Documented + public @interface SecurityScheme { + /** + * Component name. + * + * @return name + */ + String name(); + + /** + * Scheme type. Supported values are {@code apiKey}, {@code http}, {@code mutualTLS}, {@code oauth2}, and + * {@code openIdConnect}. + * + * @return type + */ + String type(); + + /** + * Security scheme description. + * + * @return description + */ + String description() default ""; + + /** + * API key parameter name. Valid only when {@link #type()} is {@code apiKey}, where it is required. + * + * @return API key parameter name + */ + String apiKeyName() default ""; + + /** + * HTTP authorization scheme. Valid only when {@link #type()} is {@code http}, where it is required. + * + * @return scheme + */ + String scheme() default ""; + + /** + * Bearer format. Valid only when {@link #type()} is {@code http} and {@link #scheme()} is {@code bearer}. + * + * @return bearer format + */ + String bearerFormat() default ""; + + /** + * API key location. Valid only when {@link #type()} is {@code apiKey}, where it is required; supported values + * are {@code query}, {@code header}, and {@code cookie}. + * + * @return location + */ + String in() default ""; + + /** + * OAuth flows. Valid only when {@link #type()} is {@code oauth2}, where at least one flow must be configured. + * + * @return OAuth flows + */ + OAuthFlows flows() default @OAuthFlows; + + /** + * OpenID Connect discovery URL. Valid only when {@link #type()} is {@code openIdConnect}, where it is + * required. + * + * @return OpenID Connect discovery URL + */ + String openIdConnectUrl() default ""; + + /** + * OpenAPI 3.2 OAuth 2 metadata URL. Valid only when {@link #type()} is {@code oauth2}. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return OAuth 2 metadata URL + */ + String oauth2MetadataUrl() default ""; + + /** + * Whether the security scheme is deprecated. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * OpenAPI API Key Security Scheme Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Repeatable(ApiKeySecuritySchemes.class) + @Documented + public @interface ApiKeySecurityScheme { + /** + * Component name. + * + * @return name + */ + String name(); + + /** + * Security scheme description. + * + * @return description + */ + String description() default ""; + + /** + * API key parameter name. + * + * @return API key parameter name + */ + String apiKeyName(); + + /** + * API key location; supported values are {@code query}, {@code header}, and {@code cookie}. + * + * @return location + */ + String in(); + + /** + * Whether the security scheme is deprecated. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * OpenAPI HTTP Security Scheme Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Repeatable(HttpSecuritySchemes.class) + @Documented + public @interface HttpSecurityScheme { + /** + * Component name. + * + * @return name + */ + String name(); + + /** + * Security scheme description. + * + * @return description + */ + String description() default ""; + + /** + * HTTP authorization scheme. + * + * @return scheme + */ + String scheme(); + + /** + * Bearer format. Valid only when {@link #scheme()} is {@code bearer}. + * + * @return bearer format + */ + String bearerFormat() default ""; + + /** + * Whether the security scheme is deprecated. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * OpenAPI Mutual TLS Security Scheme Object metadata. + *

+ * The selected document provider must produce OpenAPI 3.1 or 3.2 output; generation fails for OpenAPI 3.0 output. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Repeatable(MutualTlsSecuritySchemes.class) + @Documented + public @interface MutualTlsSecurityScheme { + /** + * Component name. + * + * @return name + */ + String name(); + + /** + * Security scheme description. + * + * @return description + */ + String description() default ""; + + /** + * Whether the security scheme is deprecated. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * OpenAPI OAuth 2 Security Scheme Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Repeatable(OAuth2SecuritySchemes.class) + @Documented + public @interface OAuth2SecurityScheme { + /** + * Component name. + * + * @return name + */ + String name(); + + /** + * Security scheme description. + * + * @return description + */ + String description() default ""; + + /** + * OAuth flows. At least one flow must be configured. + * + * @return OAuth flows + */ + OAuthFlows flows(); + + /** + * OpenAPI 3.2 OAuth 2 metadata URL. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return OAuth 2 metadata URL + */ + String oauth2MetadataUrl() default ""; + + /** + * Whether the security scheme is deprecated. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * OpenAPI OIDC Security Scheme Object metadata. + *

+ * Use only on {@link Document @OpenApi.Document} metadata types. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Repeatable(OidcSecuritySchemes.class) + @Documented + public @interface OidcSecurityScheme { + /** + * Component name. + * + * @return name + */ + String name(); + + /** + * Security scheme description. + * + * @return description + */ + String description() default ""; + + /** + * OpenID Connect discovery URL. + * + * @return OpenID Connect discovery URL + */ + String openIdConnectUrl(); + + /** + * Whether the security scheme is deprecated. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return deprecated flag + */ + boolean deprecated() default false; + } + + /** + * OpenAPI OAuth Flows Object metadata. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface OAuthFlows { + /** + * OAuth implicit flow. + * + * @return implicit flow + */ + OAuthFlow implicit() default @OAuthFlow; + + /** + * OAuth resource owner password flow. + * + * @return password flow + */ + OAuthFlow password() default @OAuthFlow; + + /** + * OAuth client credentials flow. + * + * @return client credentials flow + */ + OAuthFlow clientCredentials() default @OAuthFlow; + + /** + * OAuth authorization code flow. + * + * @return authorization code flow + */ + OAuthFlow authorizationCode() default @OAuthFlow; + + /** + * OpenAPI 3.2 OAuth device authorization flow. + *

+ * Configuring this flow requires OpenAPI 3.2 output. Document generation fails if the configured provider + * renders OpenAPI 3.0 or 3.1. + * + * @return device authorization flow + */ + OAuthFlow deviceAuthorization() default @OAuthFlow; + } + + /** + * OpenAPI OAuth Flow Object metadata. + *

+ * The {@code implicit} flow requires {@link #authorizationUrl()}. The {@code password} and + * {@code clientCredentials} flows require {@link #tokenUrl()}. The {@code authorizationCode} flow requires + * {@link #authorizationUrl()} and {@link #tokenUrl()}. The {@code deviceAuthorization} flow requires + * {@link #deviceAuthorizationUrl()} and {@link #tokenUrl()}. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface OAuthFlow { + /** + * Authorization URL. + * + * @return authorization URL + */ + String authorizationUrl() default ""; + + /** + * OpenAPI 3.2 device authorization URL. + *

+ * Rendered only for OpenAPI 3.2 output. + * + * @return device authorization URL + */ + String deviceAuthorizationUrl() default ""; + + /** + * Token URL. + * + * @return token URL + */ + String tokenUrl() default ""; + + /** + * Refresh URL. + * + * @return refresh URL + */ + String refreshUrl() default ""; + + /** + * OAuth scopes. + * + * @return scopes + */ + OAuthScope[] scopes() default {}; + } + + /** + * OpenAPI OAuth scope metadata. + */ + @Target({}) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface OAuthScope { + /** + * Scope name. + * + * @return scope name + */ + String value(); + + /** + * Scope description. + * + * @return scope description + */ + String description() default ""; + } + + /** + * Container for repeated {@link SecurityScheme}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface SecuritySchemes { + /** + * Security schemes. + * + * @return security schemes + */ + SecurityScheme[] value(); + } + + /** + * Container for repeated {@link ApiKeySecurityScheme}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface ApiKeySecuritySchemes { + /** + * Security schemes. + * + * @return security schemes + */ + ApiKeySecurityScheme[] value(); + } + + /** + * Container for repeated {@link HttpSecurityScheme}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface HttpSecuritySchemes { + /** + * Security schemes. + * + * @return security schemes + */ + HttpSecurityScheme[] value(); + } + + /** + * Container for repeated {@link MutualTlsSecurityScheme}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface MutualTlsSecuritySchemes { + /** + * Security schemes. + * + * @return security schemes + */ + MutualTlsSecurityScheme[] value(); + } + + /** + * Container for repeated {@link OAuth2SecurityScheme}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface OAuth2SecuritySchemes { + /** + * Security schemes. + * + * @return security schemes + */ + OAuth2SecurityScheme[] value(); + } + + /** + * Container for repeated {@link OidcSecurityScheme}. + */ + @Target(ElementType.TYPE) + @Retention(RetentionPolicy.CLASS) + @Documented + public @interface OidcSecuritySchemes { + /** + * Security schemes. + * + * @return security schemes + */ + OidcSecurityScheme[] value(); + } + + /** + * OpenAPI Security Requirement Object scheme entry metadata. + *

+ * Use directly on {@link Document @OpenApi.Document} metadata types, endpoint types, or methods to declare a single + * security requirement object with one scheme. Use inside {@link SecurityRequirement @OpenApi.SecurityRequirement} + * to declare multiple schemes required together by one OpenAPI security requirement object. + *

+ * On {@link Document @OpenApi.Document} metadata types, direct type-level usage emits a top-level document security + * requirement. On endpoint types, direct type-level usage applies to generated operations for the endpoint. + * Method-level usage replaces inherited endpoint requirements for that operation. Direct usage cannot be combined + * with {@link SecurityRequirement @OpenApi.SecurityRequirement} or + * {@link SecurityRequirements @OpenApi.SecurityRequirements} on the same type or method. + * Type-level usage on a declarative REST endpoint contract also applies to its implementations. + * A security requirement declared directly on an endpoint implementation replaces requirements inherited from its + * contract. + *

+ * If matching methods inherited from multiple endpoint contracts declare scheme requirements, each inherited method + * must declare the same requirements, including repeated occurrences, although the annotation order can differ. + * Helidon emits the inherited requirements once. Different inherited declarations cause code generation to fail. + * A scheme requirement declared directly on the endpoint implementation method replaces all inherited method-level + * requirements. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Documented + @Inherited + public @interface SecuritySchemeRequirement { + /** + * Required scheme name. + * + * @return scheme name + */ + String value(); + + /** + * OAuth/OpenID Connect scopes for this scheme. + * + * @return scopes + */ + String[] scopes() default {}; + } + + /** + * OpenAPI Security Requirement Object metadata. + *

+ * On {@link Document @OpenApi.Document} metadata types, type-level requirements emit top-level document security + * requirements. On endpoint types, type-level requirements apply to generated operations for the endpoint. + * Method-level requirements replace inherited endpoint requirements for that operation. Each annotation emits one + * OpenAPI security requirement object. Multiple + * {@link SecuritySchemeRequirement @OpenApi.SecuritySchemeRequirement} entries inside one annotation require all + * listed schemes together. Repeated {@code @OpenApi.SecurityRequirement} annotations declare alternative + * requirement objects. + *

+ * An empty individual requirement emits an empty OpenAPI security requirement object. To clear inherited endpoint + * security for an operation, use an empty {@link SecurityRequirements} container instead. + * Type-level usage on a declarative REST endpoint contract also applies to its implementations. + * A security requirement declared directly on an endpoint implementation replaces requirements inherited from its + * contract. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Repeatable(SecurityRequirements.class) + @Documented + @Inherited + public @interface SecurityRequirement { + /** + * Required schemes. All schemes are required together. + *

+ * Using an empty value emits an empty OpenAPI security requirement object. Use an empty + * {@link SecurityRequirements} container to declare an operation with no security requirements. + * + * @return scheme requirements + */ + SecuritySchemeRequirement[] value(); + } + + /** + * Container for repeated {@link SecurityRequirement}. + *

+ * An empty container on a method clears inherited endpoint security and emits {@code security: []} for the + * generated operation. An empty container on an endpoint clears endpoint-level security for operations which do not + * declare method-level security requirements. + * Type-level usage on a declarative REST endpoint contract also applies to its implementations. + * An inherited empty container conflicts with a non-empty security requirement from an unrelated endpoint contract + * and causes declarative OpenAPI code generation to fail. + * A security requirements container declared directly on an endpoint implementation replaces requirements inherited + * from its contract. + */ + @Target({ElementType.TYPE, ElementType.METHOD}) + @Retention(RetentionPolicy.CLASS) + @Documented + @Inherited + public @interface SecurityRequirements { + /** + * Security requirements. + * + * @return security requirements + */ + SecurityRequirement[] value(); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocument.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocument.java new file mode 100644 index 00000000000..8862f83c8bf --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocument.java @@ -0,0 +1,4851 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Consumer; + +import io.helidon.common.Api; +import io.helidon.json.JsonArray; +import io.helidon.json.JsonBoolean; +import io.helidon.json.JsonNull; +import io.helidon.json.JsonNumber; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; +import io.helidon.json.JsonValue; + +/** + * Version-neutral OpenAPI document model. + *

+ * The model is based on the latest supported OpenAPI shape. Version implementations render this model to their target + * OpenAPI version, omitting or translating some fields that do not exist in that target version. They do not provide + * complete conversion between OpenAPI versions. Callers must select a target version compatible with the features used + * in the document. + */ +@Api.Preview +public final class OpenApiDocument { + /* + * This model and its nested object builders intentionally use hand-written builders to keep the OpenAPI object + * vocabulary nested under one public type and to preserve arbitrary JSON extensions. The Blueprint generator does not + * support this API shape. This is an architect-approved exception to Helidon development guideline Rule 8.1. + */ + private static final Set FIXED_PATH_OPERATION_FIELDS = Set.of("get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query"); + + private final Map node; + private final String openapi; + private final String self; + private final String jsonSchemaDialect; + private final Info info; + private final List servers; + private final Map paths; + private final Map webhooks; + private final Components components; + private final List securityRequirements; + private final List tags; + private final ExternalDocs externalDocs; + + private OpenApiDocument(Map node) { + this.node = immutableMap(node); + this.openapi = stringValue(this.node.get("openapi")).orElse(null); + this.self = stringValue(this.node.get("$self")).orElse(null); + this.jsonSchemaDialect = stringValue(this.node.get("jsonSchemaDialect")).orElse(null); + this.info = objectValue(this.node.get("info")) + .map(Info::new) + .orElse(null); + this.servers = servers(this.node.get("servers")); + this.paths = pathItems(this.node.get("paths"), true); + this.webhooks = pathItems(this.node.get("webhooks"), false); + this.components = objectValue(this.node.get("components")) + .map(Components::new) + .orElse(null); + this.securityRequirements = securityRequirements(this.node.get("security")); + this.tags = tags(this.node.get("tags")); + this.externalDocs = objectValue(this.node.get("externalDocs")) + .map(ExternalDocs::new) + .orElse(null); + } + + /** + * Create a new builder. + * + * @return builder + */ + public static Builder builder() { + return new Builder(); + } + + /** + * OpenAPI specification version declared by this document. + * + * @return OpenAPI version + */ + public Optional openapi() { + return Optional.ofNullable(openapi); + } + + /** + * Document identity URI. + *

+ * A relative identity is resolved against the configured OpenAPI web context. When the identity is relative, + * Helidon cannot determine whether an absolute reference identifies this document because composition does not have + * the request scheme or authority. That combination is not supported; use an absolute document identity or relative + * references instead. + * + * @return document identity URI + */ + public Optional self() { + return Optional.ofNullable(self); + } + + /** + * JSON Schema dialect URI. + * + * @return JSON Schema dialect URI + */ + public Optional jsonSchemaDialect() { + return Optional.ofNullable(jsonSchemaDialect); + } + + /** + * Info object. + * + * @return info object + */ + public Optional info() { + return Optional.ofNullable(info); + } + + /** + * Servers. + * + * @return servers + */ + public List servers() { + return servers; + } + + /** + * Path items keyed by OpenAPI path template. + * + * @return path items + */ + public Map paths() { + return paths; + } + + /** + * Webhooks keyed by name. + * + * @return webhooks + */ + public Map webhooks() { + return webhooks; + } + + /** + * Components object. + * + * @return components object + */ + public Optional components() { + return Optional.ofNullable(components); + } + + /** + * Security requirements. + * + * @return security requirements + */ + public List securityRequirements() { + return securityRequirements; + } + + /** + * Document tags. + * + * @return tags + */ + public List tags() { + return tags; + } + + /** + * External documentation. + * + * @return external documentation + */ + public Optional externalDocs() { + return Optional.ofNullable(externalDocs); + } + + /** + * Whether this document has no model content. + * + * @return whether this document is empty + */ + public boolean isEmpty() { + return node.isEmpty(); + } + + /** + * Convert this document model to a structured JSON object. + * + * @return JSON object representation of this document + */ + public JsonObject toJsonObject() { + return jsonObject(node); + } + + Map mutableNode() { + return mutableMap(node); + } + + private static List servers(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + list.forEach(item -> objectValue(item).ifPresent(node -> result.add(new Server(node)))); + return Collections.unmodifiableList(result); + } + + private static Map pathItems(Object value, boolean filterExtensions) { + return objectValue(value) + .map(paths -> { + Map result = new LinkedHashMap<>(); + paths.forEach((path, pathItem) -> { + if (!filterExtensions || !path.startsWith("x-")) { + objectValue(pathItem).ifPresent(node -> result.put(path, new PathItem(path, node))); + } + }); + return Collections.unmodifiableMap(result); + }) + .orElseGet(Map::of); + } + + private static List tags(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + list.forEach(item -> objectValue(item).ifPresent(node -> result.add(new Tag(node)))); + return Collections.unmodifiableList(result); + } + + private static List securityRequirements(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + list.forEach(item -> objectValue(item).ifPresent(node -> result.add(new SecurityRequirement(node)))); + return Collections.unmodifiableList(result); + } + + private static Optional> objectValue(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(String.valueOf(key), item)); + return Optional.of(result); + } + return Optional.empty(); + } + + private static Optional stringValue(Object value) { + return value instanceof String string ? Optional.of(string) : Optional.empty(); + } + + private static List stringList(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + return list.stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .toList(); + } + + private static Map immutableMap(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> result.put(key, immutableValue(value))); + return Collections.unmodifiableMap(result); + } + + private static Object immutableValue(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(String.valueOf(key), immutableValue(item))); + return Collections.unmodifiableMap(result); + } + if (value instanceof List list) { + List result = new ArrayList<>(); + list.forEach(item -> result.add(immutableValue(item))); + return Collections.unmodifiableList(result); + } + return value; + } + + @SuppressWarnings("unchecked") + static void merge(Map target, Map source, String path) { + merge(target, source, path, null, null); + } + + @SuppressWarnings("unchecked") + private static void merge(Map target, + Map source, + String path, + Map pathTemplates, + Map tagsByName) { + source.forEach((key, value) -> { + String childPath = path.isEmpty() ? key : path + "." + key; + boolean existingKey = target.containsKey(key); + Object existing = target.get(key); + if (!existingKey) { + if ("tags".equals(childPath) && tagsByName != null && value instanceof List tags) { + List targetTags = new ArrayList<>(); + target.put(key, targetTags); + mergeArray(targetTags, tags, childPath, tagsByName); + } else { + target.put(key, value); + } + indexPathItems(templateIndex(childPath, pathTemplates), target.get(key)); + } else if (("paths".equals(childPath) || "webhooks".equals(childPath)) + && existing instanceof Map existingMap + && value instanceof Map valueMap) { + mergePathItems((Map) existingMap, + (Map) valueMap, + childPath, + templateIndex(childPath, pathTemplates)); + } else if (("tags".equals(childPath) || "security".equals(childPath)) + && existing instanceof List existingList + && value instanceof List valueList) { + mergeArray((List) existingList, valueList, childPath, tagsByName); + } else if (existing instanceof Map existingMap && value instanceof Map valueMap) { + merge((Map) existingMap, + (Map) valueMap, + childPath, + pathTemplates, + tagsByName); + } else if (!Objects.equals(existing, value)) { + throw new IllegalStateException("Conflicting OpenAPI document value at " + childPath); + } + }); + } + + private static void mergeArray(List target, + List source, + String path, + Map tagsByName) { + for (Object item : source) { + if ("tags".equals(path)) { + mergeTag(target, item, tagsByName); + } else if (!target.contains(item)) { + target.add(item); + } + } + } + + private static void mergeTag(List target, Object source, Map tagsByName) { + Optional sourceName = tagName(source); + if (sourceName.isEmpty()) { + if (!target.contains(source)) { + target.add(source); + } + return; + } + + if (tagsByName != null) { + Object existing = tagsByName.get(sourceName.get()); + if (existing != null) { + if (!Objects.equals(existing, source)) { + throw new IllegalStateException("Conflicting OpenAPI tag at tags." + sourceName.get()); + } + return; + } + target.add(source); + tagsByName.putIfAbsent(sourceName.get(), source); + return; + } + + for (Object existing : target) { + if (sourceName.equals(tagName(existing))) { + if (!Objects.equals(existing, source)) { + throw new IllegalStateException("Conflicting OpenAPI tag at tags." + sourceName.get()); + } + return; + } + } + target.add(source); + } + + private static Optional tagName(Object tag) { + if (tag instanceof Map map) { + return Optional.ofNullable(map.get("name")).map(String::valueOf); + } + return Optional.empty(); + } + + @SuppressWarnings("unchecked") + private static void mergePathItems(Map target, + Map source, + String fieldName, + Map pathTemplates) { + source.forEach((path, value) -> { + if ("paths".equals(fieldName) && path.startsWith("x-")) { + Map extension = new LinkedHashMap<>(); + extension.put(path, value); + merge(target, extension, fieldName); + return; + } + String existingPathTemplate = equivalentPathTemplate(target, path, pathTemplates); + if (existingPathTemplate == null) { + target.put(path, value); + indexPathTemplate(pathTemplates, path); + return; + } + Object existing = target.get(existingPathTemplate); + if (!existingPathTemplate.equals(path)) { + throw new IllegalStateException("Conflicting OpenAPI path template at " + fieldName + "." + + existingPathTemplate + " and " + fieldName + "." + path); + } + if (existing == null || value == null) { + if (!Objects.equals(existing, value)) { + throw new IllegalStateException("Conflicting OpenAPI document value at " + fieldName + "." + path); + } + return; + } + if (!(existing instanceof Map existingPath) || !(value instanceof Map sourcePath)) { + if (!Objects.equals(existing, value)) { + throw new IllegalStateException("Conflicting OpenAPI document value at " + fieldName + "." + path); + } + return; + } + for (Map.Entry entry : ((Map) sourcePath).entrySet()) { + String methodOrField = entry.getKey(); + if (isFixedPathOperationField(methodOrField) + && existingPath.containsKey(methodOrField)) { + throw new IllegalStateException("Conflicting OpenAPI operation at " + fieldName + "." + path + "." + + methodOrField); + } + if ("additionalOperations".equals(methodOrField)) { + mergeAdditionalOperations((Map) existingPath, entry.getValue(), fieldName, path); + continue; + } + Map operation = new LinkedHashMap<>(); + operation.put(methodOrField, entry.getValue()); + merge((Map) existingPath, operation, fieldName + "." + path); + } + }); + } + + private static String equivalentPathTemplate(Map target, + String path, + Map pathTemplates) { + if (target.containsKey(path)) { + return path; + } + if (pathTemplates == null) { + return null; + } + String normalizedPath = normalizedPathTemplate(path); + return pathTemplates.get(normalizedPath); + } + + private static Map templateIndex(String fieldName, + Map pathTemplates) { + return switch (fieldName) { + case "paths" -> pathTemplates; + default -> null; + }; + } + + private static void indexPathItems(Map pathTemplates, Object value) { + if (pathTemplates == null || !(value instanceof Map pathItems)) { + return; + } + pathItems.keySet() + .stream() + .filter(String.class::isInstance) + .map(String.class::cast) + .forEach(path -> indexPathTemplate(pathTemplates, path)); + } + + private static void indexPathTemplate(Map pathTemplates, String path) { + if (pathTemplates != null) { + pathTemplates.putIfAbsent(normalizedPathTemplate(path), path); + } + } + + private static String normalizedPathTemplate(String path) { + StringBuilder result = new StringBuilder(path.length()); + boolean inTemplate = false; + for (int i = 0; i < path.length(); i++) { + char current = path.charAt(i); + if (current == '{') { + inTemplate = true; + result.append("{}"); + } else if (current == '}') { + inTemplate = false; + } else if (!inTemplate) { + result.append(current); + } + } + return result.toString(); + } + + @SuppressWarnings("unchecked") + private static void mergeAdditionalOperations(Map targetPath, + Object sourceValue, + String fieldName, + String path) { + if (!(sourceValue instanceof Map sourceOperations)) { + Map additionalOperations = new LinkedHashMap<>(); + additionalOperations.put("additionalOperations", sourceValue); + merge(targetPath, additionalOperations, fieldName + "." + path); + return; + } + + boolean existingKey = targetPath.containsKey("additionalOperations"); + Object existing = targetPath.get("additionalOperations"); + if (!existingKey) { + targetPath.put("additionalOperations", sourceValue); + return; + } + if (existing == null) { + throw new IllegalStateException("Conflicting OpenAPI document value at " + fieldName + "." + path + + ".additionalOperations"); + } + if (!(existing instanceof Map existingOperations)) { + throw new IllegalStateException("Conflicting OpenAPI document value at " + fieldName + "." + path + + ".additionalOperations"); + } + + sourceOperations.forEach((method, operation) -> { + String methodName = String.valueOf(method); + if (existingOperations.containsKey(methodName)) { + throw new IllegalStateException("Conflicting OpenAPI operation at " + fieldName + "." + path + + ".additionalOperations." + methodName); + } + ((Map) existingOperations).put(methodName, operation); + }); + } + + private static Map mutableMap(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> result.put(key, mutableValue(value))); + return result; + } + + private static Object mutableValue(Object value) { + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(String.valueOf(key), mutableValue(item))); + return result; + } + if (value instanceof List list) { + List result = new ArrayList<>(); + list.forEach(item -> result.add(mutableValue(item))); + return result; + } + return value; + } + + private static boolean isFixedPathOperationField(String value) { + return FIXED_PATH_OPERATION_FIELDS.contains(value); + } + + private static String fixedPathOperationField(String method) { + if (!method.equals(method.toUpperCase(Locale.ROOT))) { + return null; + } + String field = method.toLowerCase(Locale.ROOT); + return isFixedPathOperationField(field) ? field : null; + } + + private static String validateHttpMethod(String method) { + String result = Objects.requireNonNull(method); + if (result.isEmpty()) { + throw new IllegalArgumentException("HTTP method must be a non-empty ASCII RFC tchar token"); + } + for (int i = 0; i < result.length(); i++) { + char ch = result.charAt(i); + boolean valid = ch >= '0' && ch <= '9' + || ch >= 'A' && ch <= 'Z' + || ch >= 'a' && ch <= 'z' + || switch (ch) { + case '!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~' -> true; + default -> false; + }; + if (!valid) { + throw new IllegalArgumentException("HTTP method must be a non-empty ASCII RFC tchar token"); + } + } + return result; + } + + private static void extension(Map node, String name, JsonValue value) { + Objects.requireNonNull(name); + Objects.requireNonNull(value); + if (!name.startsWith("x-")) { + throw new IllegalArgumentException("OpenAPI extension names must start with x-: " + name); + } + node.put(name, jsonValue(value)); + } + + private static boolean isReference(Map node) { + return node.containsKey("$ref"); + } + + private static List parameterList(Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + list.forEach(item -> objectValue(item).ifPresent(node -> result.add(new Parameter(node)))); + return Collections.unmodifiableList(result); + } + + @SuppressWarnings("unchecked") + private static Map object(Map parent, String name) { + return (Map) parent.computeIfAbsent(name, _ -> new LinkedHashMap()); + } + + @SuppressWarnings("unchecked") + private static List array(Map parent, String name) { + return (List) parent.computeIfAbsent(name, _ -> new ArrayList<>()); + } + + private static JsonObject jsonObject(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> result.put(key, jsonValue(value))); + return JsonObject.create(result); + } + + private static JsonValue jsonValue(Object value) { + switch (value) { + case null -> { + return JsonNull.instance(); + } + case JsonValue jsonValue -> { + return jsonValue; + } + case String string -> { + return JsonString.create(string); + } + case Boolean bool -> { + return JsonBoolean.create(bool); + } + case BigDecimal number -> { + return JsonNumber.create(number); + } + case Number number -> { + return jsonNumber(number); + } + case Map map -> { + Map object = new LinkedHashMap<>(); + map.forEach((key, item) -> object.put(String.valueOf(key), item)); + return jsonObject(object); + } + case List list -> { + return JsonArray.create(list.stream() + .map(OpenApiDocument::jsonValue) + .toList()); + } + default -> { + } + } + throw new IllegalArgumentException("Unsupported OpenAPI document value type: " + value.getClass().getName()); + } + + private static JsonNumber jsonNumber(Number number) { + if (number instanceof Byte + || number instanceof Short + || number instanceof Integer + || number instanceof Long) { + return JsonNumber.create(number.longValue()); + } + if (number instanceof BigInteger bigInteger) { + return JsonNumber.create(new BigDecimal(bigInteger)); + } + return JsonNumber.create(new BigDecimal(number.toString())); + } + + private static Map jsonObject(JsonObject object) { + Map result = new LinkedHashMap<>(); + object.keysAsStrings() + .forEach(key -> object.value(key) + .ifPresent(value -> result.put(key, jsonValue(value)))); + return result; + } + + private static Object jsonValue(JsonValue value) { + return switch (value.type()) { + case OBJECT -> jsonObject(value.asObject()); + case ARRAY -> value.asArray() + .values() + .stream() + .map(OpenApiDocument::jsonValue) + .toList(); + case STRING -> value.asString().value(); + case NUMBER -> value.asNumber().bigDecimalValue(); + case BOOLEAN -> value.asBoolean().value(); + case NULL -> null; + case UNKNOWN -> value.toString(); + }; + } + + private Map toNode() { + return node; + } + + /** + * OpenAPI Info Object. + */ + @Api.Preview + public static final class Info { + private final Map node; + private final String title; + private final String version; + private final Contact contact; + private final License license; + + private Info(Map node) { + this.node = immutableMap(node); + this.title = stringValue(this.node.get("title")).orElse(""); + this.version = stringValue(this.node.get("version")).orElse(""); + this.contact = objectValue(this.node.get("contact")).map(Contact::new).orElse(null); + this.license = objectValue(this.node.get("license")).map(License::new).orElse(null); + } + + /** + * Create a new info builder. + * + * @return builder + */ + public static InfoBuilder builder() { + return new InfoBuilder(); + } + + /** + * API title. + * + * @return title + */ + public String title() { + return title; + } + + /** + * API version. + * + * @return version + */ + public String version() { + return version; + } + + /** + * API summary. + * + * @return summary + */ + public Optional summary() { + return stringValue(node.get("summary")); + } + + /** + * API description. + * + * @return description + */ + public Optional description() { + return stringValue(node.get("description")); + } + + /** + * API terms of service. + * + * @return terms of service + */ + public Optional termsOfService() { + return stringValue(node.get("termsOfService")); + } + + /** + * Contact. + * + * @return contact + */ + public Optional contact() { + return Optional.ofNullable(contact); + } + + /** + * License. + * + * @return license + */ + public Optional license() { + return Optional.ofNullable(license); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI info builder. + */ + @Api.Preview + public static final class InfoBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private InfoBuilder() { + } + + /** + * Set title. + * + * @param title title + * @return updated builder + */ + public InfoBuilder title(String title) { + node.put("title", Objects.requireNonNull(title)); + return this; + } + + /** + * Set version. + * + * @param version version + * @return updated builder + */ + public InfoBuilder version(String version) { + node.put("version", Objects.requireNonNull(version)); + return this; + } + + /** + * Set summary. + * + * @param summary summary + * @return updated builder + */ + public InfoBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public InfoBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set terms of service. + * + * @param termsOfService terms of service + * @return updated builder + */ + public InfoBuilder termsOfService(String termsOfService) { + node.put("termsOfService", Objects.requireNonNull(termsOfService)); + return this; + } + + /** + * Set contact. + * + * @param contact contact + * @return updated builder + */ + public InfoBuilder contact(Contact contact) { + node.put("contact", Objects.requireNonNull(contact).toNode()); + return this; + } + + /** + * Set contact. + * + * @param contact consumer to update contact builder + * @return updated builder + */ + public InfoBuilder contact(Consumer contact) { + ContactBuilder builder = Contact.builder(); + contact.accept(builder); + return contact(builder.build()); + } + + /** + * Set license. + * + * @param license license + * @return updated builder + */ + public InfoBuilder license(License license) { + node.put("license", Objects.requireNonNull(license).toNode()); + return this; + } + + /** + * Set license. + * + * @param license consumer to update license builder + * @return updated builder + */ + public InfoBuilder license(Consumer license) { + LicenseBuilder builder = License.builder(); + license.accept(builder); + return license(builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public InfoBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Info build() { + return new Info(node); + } + } + + /** + * OpenAPI Contact Object. + */ + @Api.Preview + public static final class Contact { + private final Map node; + + private Contact(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new contact builder. + * + * @return builder + */ + public static ContactBuilder builder() { + return new ContactBuilder(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI contact builder. + */ + @Api.Preview + public static final class ContactBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ContactBuilder() { + } + + /** + * Set name. + * + * @param name name + * @return updated builder + */ + public ContactBuilder name(String name) { + node.put("name", Objects.requireNonNull(name)); + return this; + } + + /** + * Set URL. + * + * @param url URL + * @return updated builder + */ + public ContactBuilder url(String url) { + node.put("url", Objects.requireNonNull(url)); + return this; + } + + /** + * Set email. + * + * @param email email + * @return updated builder + */ + public ContactBuilder email(String email) { + node.put("email", Objects.requireNonNull(email)); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ContactBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Contact build() { + return new Contact(node); + } + } + + /** + * OpenAPI License Object. + */ + @Api.Preview + public static final class License { + private final Map node; + + private License(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new license builder. + * + * @return builder + */ + public static LicenseBuilder builder() { + return new LicenseBuilder(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI license builder. + */ + @Api.Preview + public static final class LicenseBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private LicenseBuilder() { + } + + /** + * Set license name. + * + * @param name license name + * @return updated builder + */ + public LicenseBuilder name(String name) { + node.put("name", Objects.requireNonNull(name)); + return this; + } + + /** + * Set license identifier. + *

+ * Rendered only for OpenAPI 3.1 and later output. When set together with {@link #url(String)}, this value takes + * precedence for those versions; the URL remains available for OpenAPI 3.0 output. + * + * @param identifier SPDX license identifier + * @return updated builder + */ + public LicenseBuilder identifier(String identifier) { + node.put("identifier", Objects.requireNonNull(identifier)); + return this; + } + + /** + * Set license URL. + *

+ * For OpenAPI 3.1 and later output, this value is ignored when {@link #identifier(String)} is set. + * + * @param url license URL + * @return updated builder + */ + public LicenseBuilder url(String url) { + node.put("url", Objects.requireNonNull(url)); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public LicenseBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public License build() { + return new License(node); + } + } + + /** + * OpenAPI External Documentation Object. + */ + @Api.Preview + public static final class ExternalDocs { + private final Map node; + + private ExternalDocs(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new external documentation builder. + * + * @return builder + */ + public static ExternalDocsBuilder builder() { + return new ExternalDocsBuilder(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI external documentation builder. + */ + @Api.Preview + public static final class ExternalDocsBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ExternalDocsBuilder() { + } + + /** + * Set URL. + * + * @param url URL + * @return updated builder + */ + public ExternalDocsBuilder url(String url) { + node.put("url", Objects.requireNonNull(url)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public ExternalDocsBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ExternalDocsBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public ExternalDocs build() { + return new ExternalDocs(node); + } + } + + /** + * OpenAPI Server Object. + */ + @Api.Preview + public static final class Server { + private final Map node; + private final Map variables; + + private Server(Map node) { + this.node = immutableMap(node); + this.variables = objectValue(this.node.get("variables")) + .map(values -> { + Map result = new LinkedHashMap<>(); + values.forEach((name, value) -> objectValue(value) + .ifPresent(item -> result.put(name, new ServerVariable(item)))); + return Collections.unmodifiableMap(result); + }) + .orElseGet(Map::of); + } + + /** + * Create a new server builder. + * + * @return builder + */ + public static ServerBuilder builder() { + return new ServerBuilder(); + } + + /** + * Server variables. + * + * @return server variables + */ + public Map variables() { + return variables; + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI server builder. + */ + @Api.Preview + public static final class ServerBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ServerBuilder() { + } + + /** + * Set server URL. + * + * @param url URL + * @return updated builder + */ + public ServerBuilder url(String url) { + node.put("url", Objects.requireNonNull(url)); + return this; + } + + /** + * Set server description. + * + * @param description description + * @return updated builder + */ + public ServerBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set server name. + * + * @param name server name + * @return updated builder + */ + public ServerBuilder name(String name) { + node.put("name", Objects.requireNonNull(name)); + return this; + } + + /** + * Add a server variable. + * + * @param name variable name + * @param variable variable + * @return updated builder + */ + public ServerBuilder variable(String name, ServerVariable variable) { + object(node, "variables").put(Objects.requireNonNull(name), Objects.requireNonNull(variable).toNode()); + return this; + } + + /** + * Add a server variable. + * + * @param name variable name + * @param variable consumer to update server variable builder + * @return updated builder + */ + public ServerBuilder variable(String name, Consumer variable) { + ServerVariableBuilder builder = ServerVariable.builder(); + variable.accept(builder); + return variable(name, builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ServerBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Server build() { + return new Server(node); + } + } + + /** + * OpenAPI Server Variable Object. + */ + @Api.Preview + public static final class ServerVariable { + private final Map node; + + private ServerVariable(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new server variable builder. + * + * @return builder + */ + public static ServerVariableBuilder builder() { + return new ServerVariableBuilder(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI server variable builder. + */ + @Api.Preview + public static final class ServerVariableBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ServerVariableBuilder() { + } + + /** + * Set default value. + * + * @param value default value + * @return updated builder + */ + public ServerVariableBuilder value(String value) { + node.put("default", Objects.requireNonNull(value)); + return this; + } + + /** + * Set allowed values. + * + * @param values allowed values + * @return updated builder + */ + public ServerVariableBuilder allowedValues(List values) { + node.put("enum", List.copyOf(values)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public ServerVariableBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ServerVariableBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public ServerVariable build() { + return new ServerVariable(node); + } + } + + /** + * OpenAPI Tag Object. + */ + @Api.Preview + public static final class Tag { + private final Map node; + private final String name; + + private Tag(Map node) { + this.node = immutableMap(node); + this.name = stringValue(this.node.get("name")).orElse(""); + } + + /** + * Create a new tag builder. + * + * @return builder + */ + public static TagBuilder builder() { + return new TagBuilder(); + } + + /** + * Tag name. + * + * @return name + */ + public String name() { + return name; + } + + /** + * Tag description. + * + * @return description + */ + public Optional description() { + return stringValue(node.get("description")); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI tag builder. + */ + @Api.Preview + public static final class TagBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private TagBuilder() { + } + + /** + * Set tag name. + * + * @param name tag name + * @return updated builder + */ + public TagBuilder name(String name) { + node.put("name", Objects.requireNonNull(name)); + return this; + } + + /** + * Set tag description. + * + * @param description description + * @return updated builder + */ + public TagBuilder description(String description) { + if (!description.isBlank()) { + node.put("description", description); + } + return this; + } + + /** + * Set tag summary. + * + * @param summary summary + * @return updated builder + */ + public TagBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set external documentation. + * + * @param externalDocs external documentation + * @return updated builder + */ + public TagBuilder externalDocs(ExternalDocs externalDocs) { + node.put("externalDocs", Objects.requireNonNull(externalDocs).toNode()); + return this; + } + + /** + * Set external documentation. + * + * @param externalDocs consumer to update external documentation builder + * @return updated builder + */ + public TagBuilder externalDocs(Consumer externalDocs) { + ExternalDocsBuilder builder = ExternalDocs.builder(); + externalDocs.accept(builder); + return externalDocs(builder.build()); + } + + /** + * Set tag kind. + * + * @param kind kind + * @return updated builder + */ + public TagBuilder kind(String kind) { + node.put("kind", Objects.requireNonNull(kind)); + return this; + } + + /** + * Set tag parent. + * + * @param parent parent tag + * @return updated builder + */ + public TagBuilder parent(String parent) { + node.put("parent", Objects.requireNonNull(parent)); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public TagBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Tag build() { + return new Tag(node); + } + } + + /** + * OpenAPI Path Item Object. + */ + @Api.Preview + public static final class PathItem { + private final String name; + private final Map node; + private final Map operations; + private final Map additionalOperations; + private final List parameters; + + private PathItem(String name, Map node) { + this.name = name; + this.node = immutableMap(node); + this.operations = operations(this.node); + this.additionalOperations = additionalOperations(this.node.get("additionalOperations")); + this.parameters = parameterList(this.node.get("parameters")); + } + + /** + * Create a new path item builder. + * + * @return builder + */ + public static PathItemBuilder builder() { + return new PathItemBuilder(); + } + + /** + * OpenAPI path template, webhook name, or callback expression. + * + * @return path template or webhook name + */ + public String name() { + return name; + } + + /** + * Operations keyed by lowercase HTTP method or {@code query}. + * + * @return operations + */ + public Map operations() { + return operations; + } + + /** + * Additional operations keyed by method name. + * + * @return additional operations + */ + public Map additionalOperations() { + return additionalOperations; + } + + /** + * Common parameters. + * + * @return common parameters + */ + public List parameters() { + return parameters; + } + + private Map toNode() { + return node; + } + + private static Map operations(Map node) { + Map result = new LinkedHashMap<>(); + node.forEach((key, value) -> { + if (isFixedPathOperationField(key)) { + objectValue(value).ifPresent(operation -> result.put(key, new Operation(operation))); + } + }); + return Collections.unmodifiableMap(result); + } + + private static Map additionalOperations(Object value) { + return objectValue(value) + .map(operations -> { + Map result = new LinkedHashMap<>(); + operations.forEach((key, operation) -> objectValue(operation) + .ifPresent(node -> result.put(key, new Operation(node)))); + return Collections.unmodifiableMap(result); + }) + .orElseGet(Map::of); + } + } + + /** + * OpenAPI path item builder. + */ + @Api.Preview + public static final class PathItemBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private PathItemBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public PathItemBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set summary. + * + * @param summary summary + * @return updated builder + */ + public PathItemBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public PathItemBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Add an HTTP operation. + *

+ * Method names are case-sensitive. Standard uppercase HTTP method tokens use the corresponding lowercase fixed + * field; other spellings are preserved in {@code additionalOperations}. + * + * @param method HTTP method + * @param operation operation + * @return updated builder + * @throws IllegalArgumentException if the method is not a non-empty ASCII RFC tchar token + */ + public PathItemBuilder operation(String method, Operation operation) { + String methodName = validateHttpMethod(method); + String fixedField = fixedPathOperationField(methodName); + if (fixedField == null) { + return additionalOperation(methodName, operation); + } + failIfOperationExists(fixedField); + node.put(fixedField, Objects.requireNonNull(operation).toNode()); + return this; + } + + /** + * Add an HTTP operation. + *

+ * Method names are case-sensitive. Standard uppercase HTTP method tokens use the corresponding lowercase fixed + * field; other spellings are preserved in {@code additionalOperations}. + * + * @param method HTTP method + * @param operation consumer to update operation builder + * @return updated builder + * @throws IllegalArgumentException if the method is not a non-empty ASCII RFC tchar token + */ + public PathItemBuilder operation(String method, Consumer operation) { + validateHttpMethod(method); + OperationBuilder builder = Operation.builder(); + operation.accept(builder); + return operation(method, builder.build()); + } + + /** + * Set the OpenAPI 3.2 query operation. + * + * @param operation query operation + * @return updated builder + */ + public PathItemBuilder query(Operation operation) { + failIfOperationExists("query"); + node.put("query", Objects.requireNonNull(operation).toNode()); + return this; + } + + /** + * Set the OpenAPI 3.2 query operation. + * + * @param operation consumer to update operation builder + * @return updated builder + */ + public PathItemBuilder query(Consumer operation) { + OperationBuilder builder = Operation.builder(); + operation.accept(builder); + return query(builder.build()); + } + + /** + * Add an OpenAPI 3.2 additional operation. + *

+ * Method names are case-sensitive and preserved as supplied. + * + * @param method method name + * @param operation operation + * @return updated builder + * @throws IllegalArgumentException if the method is not a non-empty ASCII RFC tchar token, or if it is an + * uppercase HTTP method represented by a fixed field + * @throws IllegalStateException if the method is already defined + */ + public PathItemBuilder additionalOperation(String method, Operation operation) { + String methodName = validateHttpMethod(method); + if (fixedPathOperationField(methodName) != null) { + throw new IllegalArgumentException("OpenAPI Path Item additionalOperations must not contain fixed-field " + + "HTTP method: " + methodName); + } + Map operations = object(node, "additionalOperations"); + if (operations.containsKey(methodName)) { + throw new IllegalStateException("Conflicting OpenAPI operation: additionalOperations." + methodName); + } + operations.put(methodName, Objects.requireNonNull(operation).toNode()); + return this; + } + + /** + * Add an OpenAPI 3.2 additional operation. + *

+ * Method names are case-sensitive and preserved as supplied. + * + * @param method method name + * @param operation consumer to update operation builder + * @return updated builder + * @throws IllegalArgumentException if the method is not a non-empty ASCII RFC tchar token, or if it is an + * uppercase HTTP method represented by a fixed field + * @throws IllegalStateException if the method is already defined + */ + public PathItemBuilder additionalOperation(String method, Consumer operation) { + validateHttpMethod(method); + OperationBuilder builder = Operation.builder(); + operation.accept(builder); + return additionalOperation(method, builder.build()); + } + + /** + * Add server. + * + * @param server server + * @return updated builder + */ + public PathItemBuilder server(Server server) { + array(node, "servers").add(Objects.requireNonNull(server).toNode()); + return this; + } + + /** + * Add server. + * + * @param server consumer to update server builder + * @return updated builder + */ + public PathItemBuilder server(Consumer server) { + ServerBuilder builder = Server.builder(); + server.accept(builder); + return server(builder.build()); + } + + /** + * Add common parameter. + * + * @param parameter parameter + * @return updated builder + */ + public PathItemBuilder parameter(Parameter parameter) { + array(node, "parameters").add(Objects.requireNonNull(parameter).toNode()); + return this; + } + + /** + * Add common parameter. + * + * @param parameter consumer to update parameter builder + * @return updated builder + */ + public PathItemBuilder parameter(Consumer parameter) { + ParameterBuilder builder = Parameter.builder(); + parameter.accept(builder); + return parameter(builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public PathItemBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public PathItem build() { + return new PathItem("", node); + } + + private void failIfOperationExists(String field) { + if (node.containsKey(field)) { + throw new IllegalStateException("Conflicting OpenAPI operation: " + field); + } + } + } + + /** + * OpenAPI Callback Object. + */ + @Api.Preview + public static final class Callback { + private final Map node; + private final Map expressions; + + private Callback(Map node) { + this.node = immutableMap(node); + this.expressions = pathItems(this.node, true); + } + + /** + * Create a new callback builder. + * + * @return builder + */ + public static CallbackBuilder builder() { + return new CallbackBuilder(); + } + + /** + * Create a reference callback. + * + * @param ref reference + * @return callback + */ + public static Callback reference(String ref) { + return builder().ref(ref).build(); + } + + /** + * Callback path items keyed by runtime expression. + * + * @return callback expressions + */ + public Map expressions() { + return expressions; + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI callback builder. + */ + @Api.Preview + public static final class CallbackBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private CallbackBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public CallbackBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set reference summary. + * + * @param summary summary + * @return updated builder + */ + public CallbackBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set reference description. + * + * @param description description + * @return updated builder + */ + public CallbackBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Add a callback path item. + * + * @param expression callback runtime expression + * @param pathItem path item + * @return updated builder + */ + public CallbackBuilder expression(String expression, PathItem pathItem) { + node.put(Objects.requireNonNull(expression), Objects.requireNonNull(pathItem).toNode()); + return this; + } + + /** + * Add a callback path item. + * + * @param expression callback runtime expression + * @param pathItem consumer to update path item builder + * @return updated builder + */ + public CallbackBuilder expression(String expression, Consumer pathItem) { + PathItemBuilder builder = PathItem.builder(); + pathItem.accept(builder); + return expression(expression, builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public CallbackBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Callback build() { + return new Callback(node); + } + } + + /** + * OpenAPI Operation Object. + */ + @Api.Preview + public static final class Operation { + private final Map node; + private final String operationId; + private final List parameters; + private final RequestBody requestBody; + private final Map responses; + private final Map callbacks; + + private Operation(Map node) { + this.node = immutableMap(node); + this.operationId = stringValue(this.node.get("operationId")).orElse(""); + this.parameters = parameterList(this.node.get("parameters")); + this.requestBody = objectValue(this.node.get("requestBody")).map(RequestBody::new).orElse(null); + this.responses = responses(this.node.get("responses")); + this.callbacks = callbacks(this.node.get("callbacks")); + } + + /** + * Create a new operation builder. + * + * @return builder + */ + public static OperationBuilder builder() { + return new OperationBuilder(); + } + + /** + * Operation id. + * + * @return operation id + */ + public Optional operationId() { + return operationId.isBlank() ? Optional.empty() : Optional.of(operationId); + } + + /** + * Operation parameters. + * + * @return operation parameters + */ + public List parameters() { + return parameters; + } + + /** + * Request body. + * + * @return request body + */ + public Optional requestBody() { + return Optional.ofNullable(requestBody); + } + + /** + * Operation responses. + *

+ * OpenAPI 3.0 and 3.1 require at least one response. OpenAPI 3.2 permits responses to be omitted. If the + * Responses Object is present, it must contain at least one status-code, status-code-range, or {@code default} + * response; extensions alone do not satisfy this requirement. + * + * @return responses keyed by status code, status-code range, or {@code default} + */ + public Map responses() { + return responses; + } + + /** + * Operation callbacks. + * + * @return callbacks keyed by callback name + */ + public Map callbacks() { + return callbacks; + } + + private Map toNode() { + return node; + } + + private static Map responses(Object value) { + return objectValue(value) + .map(responses -> { + Map result = new LinkedHashMap<>(); + responses.forEach((status, response) -> { + if (!status.startsWith("x-")) { + objectValue(response).ifPresent(node -> result.put(status, new Response(node))); + } + }); + return Collections.unmodifiableMap(result); + }) + .orElseGet(Map::of); + } + + private static Map callbacks(Object value) { + return objectValue(value) + .map(callbacks -> { + Map result = new LinkedHashMap<>(); + callbacks.forEach((name, callback) -> + objectValue(callback) + .ifPresent(node -> result.put(name, new Callback(node)))); + return Collections.unmodifiableMap(result); + }) + .orElseGet(Map::of); + } + } + + /** + * OpenAPI operation builder. + */ + @Api.Preview + public static final class OperationBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private OperationBuilder() { + } + + /** + * Add an operation tag. + * + * @param tag tag name + * @return updated builder + */ + public OperationBuilder tag(String tag) { + array(node, "tags").add(Objects.requireNonNull(tag)); + return this; + } + + /** + * Set summary. + * + * @param summary summary + * @return updated builder + */ + public OperationBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public OperationBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set external documentation. + * + * @param externalDocs external documentation + * @return updated builder + */ + public OperationBuilder externalDocs(ExternalDocs externalDocs) { + node.put("externalDocs", Objects.requireNonNull(externalDocs).toNode()); + return this; + } + + /** + * Set external documentation. + * + * @param externalDocs consumer to update external documentation builder + * @return updated builder + */ + public OperationBuilder externalDocs(Consumer externalDocs) { + ExternalDocsBuilder builder = ExternalDocs.builder(); + externalDocs.accept(builder); + return externalDocs(builder.build()); + } + + /** + * Set operation id. + * + * @param operationId operation id + * @return updated builder + */ + public OperationBuilder operationId(String operationId) { + node.put("operationId", Objects.requireNonNull(operationId)); + return this; + } + + /** + * Add parameter. + * + * @param parameter parameter + * @return updated builder + */ + public OperationBuilder parameter(Parameter parameter) { + array(node, "parameters").add(Objects.requireNonNull(parameter).toNode()); + return this; + } + + /** + * Add parameter. + * + * @param parameter consumer to update parameter builder + * @return updated builder + */ + public OperationBuilder parameter(Consumer parameter) { + ParameterBuilder builder = Parameter.builder(); + parameter.accept(builder); + return parameter(builder.build()); + } + + /** + * Set request body. + * + * @param requestBody request body + * @return updated builder + */ + public OperationBuilder requestBody(RequestBody requestBody) { + node.put("requestBody", Objects.requireNonNull(requestBody).toNode()); + return this; + } + + /** + * Set request body. + * + * @param requestBody consumer to update request body builder + * @return updated builder + */ + public OperationBuilder requestBody(Consumer requestBody) { + RequestBodyBuilder builder = RequestBody.builder(); + requestBody.accept(builder); + return requestBody(builder.build()); + } + + /** + * Add a response. + *

+ * OpenAPI 3.0 and 3.1 require each operation to have at least one response. OpenAPI 3.2 permits an operation + * to omit responses. + * + * @param status status code, status-code range, or {@code default} + * @param description response description + * @return updated builder + */ + public OperationBuilder response(String status, String description) { + return response(status, Response.builder() + .description(description) + .build()); + } + + /** + * Add a response. + *

+ * OpenAPI 3.0 and 3.1 require each operation to have at least one response. OpenAPI 3.2 permits an operation + * to omit responses. + * + * @param status status code, status-code range, or {@code default} + * @param response response + * @return updated builder + */ + public OperationBuilder response(String status, Response response) { + object(node, "responses").put(Objects.requireNonNull(status), Objects.requireNonNull(response).toNode()); + return this; + } + + /** + * Add a response. + *

+ * OpenAPI 3.0 and 3.1 require each operation to have at least one response. OpenAPI 3.2 permits an operation + * to omit responses. + * + * @param status status code, status-code range, or {@code default} + * @param response consumer to update response builder + * @return updated builder + */ + public OperationBuilder response(String status, Consumer response) { + ResponseBuilder builder = Response.builder(); + response.accept(builder); + return response(status, builder.build()); + } + + /** + * Add an extension to the responses object. + *

+ * Extension names must start with {@code x-}. + * An extension alone does not satisfy the requirement that a present Responses Object contain at least one + * response. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public OperationBuilder responseExtension(String name, JsonValue value) { + OpenApiDocument.extension(object(node, "responses"), name, value); + return this; + } + + /** + * Add callback. + * + * @param name callback name + * @param callback callback + * @return updated builder + */ + public OperationBuilder callback(String name, Callback callback) { + object(node, "callbacks").put(Objects.requireNonNull(name), Objects.requireNonNull(callback).toNode()); + return this; + } + + /** + * Add callback. + * + * @param name callback name + * @param callback consumer to update callback builder + * @return updated builder + */ + public OperationBuilder callback(String name, Consumer callback) { + CallbackBuilder builder = Callback.builder(); + callback.accept(builder); + return callback(name, builder.build()); + } + + /** + * Set deprecated flag. + * + * @param deprecated deprecated flag + * @return updated builder + */ + public OperationBuilder deprecated(boolean deprecated) { + node.put("deprecated", deprecated); + return this; + } + + /** + * Add security requirement. + * + * @param requirement security requirement + * @return updated builder + */ + public OperationBuilder securityRequirement(SecurityRequirement requirement) { + array(node, "security").add(Objects.requireNonNull(requirement).toNode()); + return this; + } + + /** + * Add security requirement. + * + * @param requirement consumer to update security requirement builder + * @return updated builder + */ + public OperationBuilder securityRequirement(Consumer requirement) { + SecurityRequirementBuilder builder = SecurityRequirement.builder(); + requirement.accept(builder); + return securityRequirement(builder.build()); + } + + /** + * Set security requirements. + *

+ * An empty list declares that this operation overrides document-level security requirements with no security. + * + * @param requirements security requirements + * @return updated builder + */ + public OperationBuilder security(List requirements) { + List result = new ArrayList<>(); + Objects.requireNonNull(requirements).forEach(requirement -> result.add(requirement.toNode())); + node.put("security", result); + return this; + } + + /** + * Add server. + * + * @param server server + * @return updated builder + */ + public OperationBuilder server(Server server) { + array(node, "servers").add(Objects.requireNonNull(server).toNode()); + return this; + } + + /** + * Add server. + * + * @param server consumer to update server builder + * @return updated builder + */ + public OperationBuilder server(Consumer server) { + ServerBuilder builder = Server.builder(); + server.accept(builder); + return server(builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public OperationBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Operation build() { + return new Operation(node); + } + } + + /** + * OpenAPI Parameter Object. + */ + @Api.Preview + public static final class Parameter { + private final Map node; + + private Parameter(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new parameter builder. + * + * @return builder + */ + public static ParameterBuilder builder() { + return new ParameterBuilder(); + } + + /** + * Create a reference parameter. + * + * @param ref reference + * @return parameter + */ + public static Parameter reference(String ref) { + return new ParameterBuilder().ref(ref).build(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI parameter builder. + */ + @Api.Preview + public static final class ParameterBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ParameterBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public ParameterBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set reference summary. + * + * @param summary summary + * @return updated builder + */ + public ParameterBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set name. + * + * @param name name + * @return updated builder + */ + public ParameterBuilder name(String name) { + node.put("name", Objects.requireNonNull(name)); + return this; + } + + /** + * Set location. + * + * @param in location + * @return updated builder + */ + public ParameterBuilder in(String in) { + node.put("in", Objects.requireNonNull(in)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public ParameterBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set required flag. + * + * @param required required flag + * @return updated builder + */ + public ParameterBuilder required(boolean required) { + node.put("required", required); + return this; + } + + /** + * Set deprecated flag. + * + * @param deprecated deprecated flag + * @return updated builder + */ + public ParameterBuilder deprecated(boolean deprecated) { + node.put("deprecated", deprecated); + return this; + } + + /** + * Set allow empty value flag. + * + * @param allowEmptyValue allow empty value flag + * @return updated builder + */ + public ParameterBuilder allowEmptyValue(boolean allowEmptyValue) { + node.put("allowEmptyValue", allowEmptyValue); + return this; + } + + /** + * Set style. + * + * @param style style + * @return updated builder + */ + public ParameterBuilder style(String style) { + node.put("style", Objects.requireNonNull(style)); + return this; + } + + /** + * Set explode flag. + * + * @param explode explode flag + * @return updated builder + */ + public ParameterBuilder explode(boolean explode) { + node.put("explode", explode); + return this; + } + + /** + * Set allow reserved flag. + * + * @param allowReserved allow reserved flag + * @return updated builder + */ + public ParameterBuilder allowReserved(boolean allowReserved) { + node.put("allowReserved", allowReserved); + return this; + } + + /** + * Set schema. + * + * @param schema schema + * @return updated builder + */ + public ParameterBuilder schema(JsonValue schema) { + node.put("schema", jsonValue(Objects.requireNonNull(schema))); + return this; + } + + /** + * Set example. + * + * @param example example value + * @return updated builder + */ + public ParameterBuilder example(JsonValue example) { + node.put("example", jsonValue(Objects.requireNonNull(example))); + return this; + } + + /** + * Add example. + * + * @param name example name + * @param example example + * @return updated builder + */ + public ParameterBuilder example(String name, Example example) { + object(node, "examples").put(Objects.requireNonNull(name), Objects.requireNonNull(example).toNode()); + return this; + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content media type object + * @return updated builder + */ + public ParameterBuilder content(String mediaType, MediaTypeObject content) { + object(node, "content").put(Objects.requireNonNull(mediaType), Objects.requireNonNull(content).toNode()); + return this; + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content consumer to update media type object builder + * @return updated builder + */ + public ParameterBuilder content(String mediaType, Consumer content) { + MediaTypeObjectBuilder builder = MediaTypeObject.builder(); + content.accept(builder); + return content(mediaType, builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ParameterBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + private Map toNode() { + return node; + } + + @Override + public Parameter build() { + return new Parameter(node); + } + } + + /** + * OpenAPI Header Object. + */ + @Api.Preview + public static final class Header { + private final Map node; + + private Header(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new header builder. + * + * @return builder + */ + public static HeaderBuilder builder() { + return new HeaderBuilder(); + } + + /** + * Create a reference header. + * + * @param ref reference + * @return header + */ + public static Header reference(String ref) { + return new HeaderBuilder().ref(ref).build(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI header builder. + */ + @Api.Preview + public static final class HeaderBuilder implements io.helidon.common.Builder { + private final ParameterBuilder delegate = Parameter.builder(); + + private HeaderBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public HeaderBuilder ref(String ref) { + delegate.ref(ref); + return this; + } + + /** + * Set reference summary. + * + * @param summary summary + * @return updated builder + */ + public HeaderBuilder summary(String summary) { + delegate.summary(summary); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public HeaderBuilder description(String description) { + delegate.description(description); + return this; + } + + /** + * Set required flag. + * + * @param required required flag + * @return updated builder + */ + public HeaderBuilder required(boolean required) { + delegate.required(required); + return this; + } + + /** + * Set deprecated flag. + * + * @param deprecated deprecated flag + * @return updated builder + */ + public HeaderBuilder deprecated(boolean deprecated) { + delegate.deprecated(deprecated); + return this; + } + + /** + * Set style. + * + * @param style style + * @return updated builder + */ + public HeaderBuilder style(String style) { + delegate.style(style); + return this; + } + + /** + * Set explode flag. + * + * @param explode explode flag + * @return updated builder + */ + public HeaderBuilder explode(boolean explode) { + delegate.explode(explode); + return this; + } + + /** + * Set allow reserved flag. + * + * @param allowReserved allow reserved flag + * @return updated builder + */ + public HeaderBuilder allowReserved(boolean allowReserved) { + delegate.allowReserved(allowReserved); + return this; + } + + /** + * Set schema. + * + * @param schema schema + * @return updated builder + */ + public HeaderBuilder schema(JsonValue schema) { + delegate.schema(schema); + return this; + } + + /** + * Set example. + * + * @param example example value + * @return updated builder + */ + public HeaderBuilder example(JsonValue example) { + delegate.example(example); + return this; + } + + /** + * Add example. + * + * @param name example name + * @param example example + * @return updated builder + */ + public HeaderBuilder example(String name, Example example) { + delegate.example(name, example); + return this; + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content media type object + * @return updated builder + */ + public HeaderBuilder content(String mediaType, MediaTypeObject content) { + delegate.content(mediaType, content); + return this; + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content consumer to update media type object builder + * @return updated builder + */ + public HeaderBuilder content(String mediaType, Consumer content) { + delegate.content(mediaType, content); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public HeaderBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(delegate.toNode(), name, value); + return this; + } + + @Override + public Header build() { + Map node = new LinkedHashMap<>(delegate.toNode()); + node.remove("name"); + node.remove("in"); + return new Header(node); + } + } + + /** + * OpenAPI Request Body Object. + */ + @Api.Preview + public static final class RequestBody { + private final Map node; + + private RequestBody(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new request body builder. + * + * @return builder + */ + public static RequestBodyBuilder builder() { + return new RequestBodyBuilder(); + } + + /** + * Create a reference request body. + * + * @param ref reference + * @return request body + */ + public static RequestBody reference(String ref) { + return new RequestBodyBuilder().ref(ref).build(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI request body builder. + */ + @Api.Preview + public static final class RequestBodyBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private RequestBodyBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public RequestBodyBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set reference summary. + * + * @param summary summary + * @return updated builder + */ + public RequestBodyBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public RequestBodyBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content media type object + * @return updated builder + */ + public RequestBodyBuilder content(String mediaType, MediaTypeObject content) { + object(node, "content").put(Objects.requireNonNull(mediaType), Objects.requireNonNull(content).toNode()); + return this; + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content consumer to update media type object builder + * @return updated builder + */ + public RequestBodyBuilder content(String mediaType, Consumer content) { + MediaTypeObjectBuilder builder = MediaTypeObject.builder(); + content.accept(builder); + return content(mediaType, builder.build()); + } + + /** + * Set required flag. + * + * @param required required flag + * @return updated builder + */ + public RequestBodyBuilder required(boolean required) { + node.put("required", required); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public RequestBodyBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public RequestBody build() { + return new RequestBody(node); + } + } + + /** + * OpenAPI Response Object. + */ + @Api.Preview + public static final class Response { + private final Map node; + + private Response(Map node) { + this.node = immutableMap(node); + } + + /** + * Create an empty response builder. + * + * @return builder + */ + public static ResponseBuilder builder() { + return new ResponseBuilder(); + } + + /** + * Create a reference response. + * + * @param ref reference + * @return response + */ + public static Response reference(String ref) { + return builder().ref(ref).build(); + } + + /** + * Response summary. + * + * @return summary + */ + public Optional summary() { + return stringValue(node.get("summary")); + } + + /** + * Response description. + *

+ * OpenAPI 3.0 and 3.1 require a description for a Response Object, although the description can be empty. + * OpenAPI 3.2 makes the description optional. + * + * @return description, if present + */ + public Optional description() { + return stringValue(node.get("description")); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI response builder. + */ + @Api.Preview + public static final class ResponseBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ResponseBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public ResponseBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set summary. + * + * @param summary summary + * @return updated builder + */ + public ResponseBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set description. + *

+ * OpenAPI 3.0 and 3.1 require a description for a Response Object, although the description can be empty. + * OpenAPI 3.2 makes the description optional. + * + * @param description description + * @return updated builder + */ + public ResponseBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Add header. + * + * @param name header name + * @param header header + * @return updated builder + */ + public ResponseBuilder header(String name, Header header) { + object(node, "headers").put(Objects.requireNonNull(name), Objects.requireNonNull(header).toNode()); + return this; + } + + /** + * Add header. + * + * @param name header name + * @param header consumer to update header builder + * @return updated builder + */ + public ResponseBuilder header(String name, Consumer header) { + HeaderBuilder builder = Header.builder(); + header.accept(builder); + return header(name, builder.build()); + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content media type object + * @return updated builder + */ + public ResponseBuilder content(String mediaType, MediaTypeObject content) { + object(node, "content").put(Objects.requireNonNull(mediaType), Objects.requireNonNull(content).toNode()); + return this; + } + + /** + * Add content entry. + * + * @param mediaType media type + * @param content consumer to update media type object builder + * @return updated builder + */ + public ResponseBuilder content(String mediaType, Consumer content) { + MediaTypeObjectBuilder builder = MediaTypeObject.builder(); + content.accept(builder); + return content(mediaType, builder.build()); + } + + /** + * Add link. + * + * @param name link name + * @param link link + * @return updated builder + */ + public ResponseBuilder link(String name, Link link) { + object(node, "links").put(Objects.requireNonNull(name), Objects.requireNonNull(link).toNode()); + return this; + } + + /** + * Add link. + * + * @param name link name + * @param link consumer to update link builder + * @return updated builder + */ + public ResponseBuilder link(String name, Consumer link) { + LinkBuilder builder = Link.builder(); + link.accept(builder); + return link(name, builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ResponseBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Response build() { + return new Response(node); + } + } + + /** + * OpenAPI Media Type Object. + */ + @Api.Preview + public static final class MediaTypeObject { + private final Map node; + + private MediaTypeObject(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new media type builder. + * + * @return builder + */ + public static MediaTypeObjectBuilder builder() { + return new MediaTypeObjectBuilder(); + } + + /** + * Create a reference media type. + * + * @param ref reference + * @return media type object + */ + public static MediaTypeObject reference(String ref) { + return builder().ref(ref).build(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI media type builder. + */ + @Api.Preview + public static final class MediaTypeObjectBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private MediaTypeObjectBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public MediaTypeObjectBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set reference summary. + * + * @param summary summary + * @return updated builder + */ + public MediaTypeObjectBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set reference description. + * + * @param description description + * @return updated builder + */ + public MediaTypeObjectBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set schema. + * + * @param schema schema + * @return updated builder + */ + public MediaTypeObjectBuilder schema(JsonValue schema) { + node.put("schema", jsonValue(Objects.requireNonNull(schema))); + return this; + } + + /** + * Set OpenAPI 3.2 item schema. + * + * @param schema item schema + * @return updated builder + */ + public MediaTypeObjectBuilder itemSchema(JsonValue schema) { + node.put("itemSchema", jsonValue(Objects.requireNonNull(schema))); + return this; + } + + /** + * Set example. + * + * @param example example value + * @return updated builder + */ + public MediaTypeObjectBuilder example(JsonValue example) { + node.put("example", jsonValue(Objects.requireNonNull(example))); + return this; + } + + /** + * Add example. + * + * @param name example name + * @param example example + * @return updated builder + */ + public MediaTypeObjectBuilder example(String name, Example example) { + object(node, "examples").put(Objects.requireNonNull(name), Objects.requireNonNull(example).toNode()); + return this; + } + + /** + * Add encoding. + * + * @param name encoding name + * @param encoding encoding + * @return updated builder + */ + public MediaTypeObjectBuilder encoding(String name, Encoding encoding) { + object(node, "encoding").put(Objects.requireNonNull(name), Objects.requireNonNull(encoding).toNode()); + return this; + } + + /** + * Set OpenAPI 3.2 prefix encodings. + * + * @param prefixEncoding prefix encodings + * @return updated builder + */ + public MediaTypeObjectBuilder prefixEncoding(JsonArray prefixEncoding) { + node.put("prefixEncoding", jsonValue(Objects.requireNonNull(prefixEncoding))); + return this; + } + + /** + * Set OpenAPI 3.2 item encoding. + * + * @param itemEncoding item encoding + * @return updated builder + */ + public MediaTypeObjectBuilder itemEncoding(Encoding itemEncoding) { + node.put("itemEncoding", Objects.requireNonNull(itemEncoding).toNode()); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public MediaTypeObjectBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public MediaTypeObject build() { + return new MediaTypeObject(node); + } + } + + /** + * OpenAPI Encoding Object. + */ + @Api.Preview + public static final class Encoding { + private final Map node; + + private Encoding(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new encoding builder. + * + * @return builder + */ + public static EncodingBuilder builder() { + return new EncodingBuilder(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI encoding builder. + */ + @Api.Preview + public static final class EncodingBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private EncodingBuilder() { + } + + /** + * Set content type. + * + * @param contentType content type + * @return updated builder + */ + public EncodingBuilder contentType(String contentType) { + node.put("contentType", Objects.requireNonNull(contentType)); + return this; + } + + /** + * Add header. + * + * @param name header name + * @param header header + * @return updated builder + */ + public EncodingBuilder header(String name, Header header) { + object(node, "headers").put(Objects.requireNonNull(name), Objects.requireNonNull(header).toNode()); + return this; + } + + /** + * Add nested OpenAPI 3.2 encoding. + * + * @param name encoding name + * @param encoding encoding + * @return updated builder + */ + public EncodingBuilder encoding(String name, Encoding encoding) { + object(node, "encoding").put(Objects.requireNonNull(name), Objects.requireNonNull(encoding).toNode()); + return this; + } + + /** + * Set OpenAPI 3.2 prefix encodings. + * + * @param prefixEncoding prefix encodings + * @return updated builder + */ + public EncodingBuilder prefixEncoding(JsonArray prefixEncoding) { + node.put("prefixEncoding", jsonValue(Objects.requireNonNull(prefixEncoding))); + return this; + } + + /** + * Set OpenAPI 3.2 item encoding. + * + * @param itemEncoding item encoding + * @return updated builder + */ + public EncodingBuilder itemEncoding(Encoding itemEncoding) { + node.put("itemEncoding", Objects.requireNonNull(itemEncoding).toNode()); + return this; + } + + /** + * Set style. + * + * @param style style + * @return updated builder + */ + public EncodingBuilder style(String style) { + node.put("style", Objects.requireNonNull(style)); + return this; + } + + /** + * Set explode flag. + * + * @param explode explode flag + * @return updated builder + */ + public EncodingBuilder explode(boolean explode) { + node.put("explode", explode); + return this; + } + + /** + * Set allow reserved flag. + * + * @param allowReserved allow reserved flag + * @return updated builder + */ + public EncodingBuilder allowReserved(boolean allowReserved) { + node.put("allowReserved", allowReserved); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public EncodingBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Encoding build() { + return new Encoding(node); + } + } + + /** + * OpenAPI Example Object. + */ + @Api.Preview + public static final class Example { + private final Map node; + + private Example(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new example builder. + * + * @return builder + */ + public static ExampleBuilder builder() { + return new ExampleBuilder(); + } + + /** + * Create a reference example. + * + * @param ref reference + * @return example + */ + public static Example reference(String ref) { + return builder().ref(ref).build(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI example builder. + */ + @Api.Preview + public static final class ExampleBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ExampleBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public ExampleBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set summary. + * + * @param summary summary + * @return updated builder + */ + public ExampleBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public ExampleBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set value. + * + * @param value value + * @return updated builder + */ + public ExampleBuilder value(JsonValue value) { + node.put("value", jsonValue(Objects.requireNonNull(value))); + return this; + } + + /** + * Set OpenAPI 3.2 data value. + * + * @param value data value + * @return updated builder + */ + public ExampleBuilder dataValue(JsonValue value) { + node.put("dataValue", jsonValue(Objects.requireNonNull(value))); + return this; + } + + /** + * Set OpenAPI 3.2 serialized value. + * + * @param value serialized value + * @return updated builder + */ + public ExampleBuilder serializedValue(String value) { + node.put("serializedValue", Objects.requireNonNull(value)); + return this; + } + + /** + * Set external value. + * + * @param externalValue external value + * @return updated builder + */ + public ExampleBuilder externalValue(String externalValue) { + node.put("externalValue", Objects.requireNonNull(externalValue)); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ExampleBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Example build() { + return new Example(node); + } + } + + /** + * OpenAPI Link Object. + */ + @Api.Preview + public static final class Link { + private final Map node; + + private Link(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new link builder. + * + * @return builder + */ + public static LinkBuilder builder() { + return new LinkBuilder(); + } + + /** + * Create a reference link. + * + * @param ref reference + * @return link + */ + public static Link reference(String ref) { + return builder().ref(ref).build(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI link builder. + */ + @Api.Preview + public static final class LinkBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private LinkBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public LinkBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set reference summary. + * + * @param summary summary + * @return updated builder + */ + public LinkBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set operation reference. + * + * @param operationRef operation reference + * @return updated builder + */ + public LinkBuilder operationRef(String operationRef) { + node.put("operationRef", Objects.requireNonNull(operationRef)); + return this; + } + + /** + * Set operation id. + * + * @param operationId operation id + * @return updated builder + */ + public LinkBuilder operationId(String operationId) { + node.put("operationId", Objects.requireNonNull(operationId)); + return this; + } + + /** + * Set parameters. + * + * @param parameters parameters + * @return updated builder + */ + public LinkBuilder parameters(JsonObject parameters) { + node.put("parameters", jsonValue(Objects.requireNonNull(parameters))); + return this; + } + + /** + * Set request body. + * + * @param requestBody request body + * @return updated builder + */ + public LinkBuilder requestBody(JsonValue requestBody) { + node.put("requestBody", jsonValue(Objects.requireNonNull(requestBody))); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public LinkBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set server. + * + * @param server server + * @return updated builder + */ + public LinkBuilder server(Server server) { + node.put("server", Objects.requireNonNull(server).toNode()); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public LinkBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Link build() { + return new Link(node); + } + } + + /** + * OpenAPI Components Object. + */ + @Api.Preview + public static final class Components { + private final Map node; + + private Components(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new components builder. + * + * @return builder + */ + public static ComponentsBuilder builder() { + return new ComponentsBuilder(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI components builder. + */ + @Api.Preview + public static final class ComponentsBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private ComponentsBuilder() { + } + + /** + * Set schema. + * + * @param name schema name + * @param schema schema + * @return updated builder + */ + public ComponentsBuilder schema(String name, JsonValue schema) { + object(node, "schemas").put(Objects.requireNonNull(name), jsonValue(Objects.requireNonNull(schema))); + return this; + } + + /** + * Set response. + * + * @param name response name + * @param response response + * @return updated builder + */ + public ComponentsBuilder response(String name, Response response) { + object(node, "responses").put(Objects.requireNonNull(name), Objects.requireNonNull(response).toNode()); + return this; + } + + /** + * Set response. + * + * @param name response name + * @param response consumer to update response builder + * @return updated builder + */ + public ComponentsBuilder response(String name, Consumer response) { + ResponseBuilder builder = Response.builder(); + response.accept(builder); + return response(name, builder.build()); + } + + /** + * Set parameter. + * + * @param name parameter name + * @param parameter parameter + * @return updated builder + */ + public ComponentsBuilder parameter(String name, Parameter parameter) { + object(node, "parameters").put(Objects.requireNonNull(name), Objects.requireNonNull(parameter).toNode()); + return this; + } + + /** + * Set parameter. + * + * @param name parameter name + * @param parameter consumer to update parameter builder + * @return updated builder + */ + public ComponentsBuilder parameter(String name, Consumer parameter) { + ParameterBuilder builder = Parameter.builder(); + parameter.accept(builder); + return parameter(name, builder.build()); + } + + /** + * Set example. + * + * @param name example name + * @param example example + * @return updated builder + */ + public ComponentsBuilder example(String name, Example example) { + object(node, "examples").put(Objects.requireNonNull(name), Objects.requireNonNull(example).toNode()); + return this; + } + + /** + * Set example. + * + * @param name example name + * @param example consumer to update example builder + * @return updated builder + */ + public ComponentsBuilder example(String name, Consumer example) { + ExampleBuilder builder = Example.builder(); + example.accept(builder); + return example(name, builder.build()); + } + + /** + * Set request body. + * + * @param name request body name + * @param requestBody request body + * @return updated builder + */ + public ComponentsBuilder requestBody(String name, RequestBody requestBody) { + object(node, "requestBodies").put(Objects.requireNonNull(name), Objects.requireNonNull(requestBody).toNode()); + return this; + } + + /** + * Set request body. + * + * @param name request body name + * @param requestBody consumer to update request body builder + * @return updated builder + */ + public ComponentsBuilder requestBody(String name, Consumer requestBody) { + RequestBodyBuilder builder = RequestBody.builder(); + requestBody.accept(builder); + return requestBody(name, builder.build()); + } + + /** + * Set header. + * + * @param name header name + * @param header header + * @return updated builder + */ + public ComponentsBuilder header(String name, Header header) { + object(node, "headers").put(Objects.requireNonNull(name), Objects.requireNonNull(header).toNode()); + return this; + } + + /** + * Set header. + * + * @param name header name + * @param header consumer to update header builder + * @return updated builder + */ + public ComponentsBuilder header(String name, Consumer header) { + HeaderBuilder builder = Header.builder(); + header.accept(builder); + return header(name, builder.build()); + } + + /** + * Set security scheme. + * + * @param name security scheme name + * @param securityScheme security scheme + * @return updated builder + */ + public ComponentsBuilder securityScheme(String name, SecurityScheme securityScheme) { + object(node, "securitySchemes").put(Objects.requireNonNull(name), Objects.requireNonNull(securityScheme).toNode()); + return this; + } + + /** + * Set security scheme. + * + * @param name security scheme name + * @param securityScheme consumer to update security scheme builder + * @return updated builder + */ + public ComponentsBuilder securityScheme(String name, Consumer securityScheme) { + SecuritySchemeBuilder builder = SecurityScheme.builder(); + securityScheme.accept(builder); + return securityScheme(name, builder.build()); + } + + /** + * Set link. + * + * @param name link name + * @param link link + * @return updated builder + */ + public ComponentsBuilder link(String name, Link link) { + object(node, "links").put(Objects.requireNonNull(name), Objects.requireNonNull(link).toNode()); + return this; + } + + /** + * Set link. + * + * @param name link name + * @param link consumer to update link builder + * @return updated builder + */ + public ComponentsBuilder link(String name, Consumer link) { + LinkBuilder builder = Link.builder(); + link.accept(builder); + return link(name, builder.build()); + } + + /** + * Set callback. + * + * @param name callback name + * @param callback callback + * @return updated builder + */ + public ComponentsBuilder callback(String name, Callback callback) { + object(node, "callbacks").put(Objects.requireNonNull(name), Objects.requireNonNull(callback).toNode()); + return this; + } + + /** + * Set callback. + * + * @param name callback name + * @param callback consumer to update callback builder + * @return updated builder + */ + public ComponentsBuilder callback(String name, Consumer callback) { + CallbackBuilder builder = Callback.builder(); + callback.accept(builder); + return callback(name, builder.build()); + } + + /** + * Set path item. + * + * @param name path item name + * @param pathItem path item + * @return updated builder + */ + public ComponentsBuilder pathItem(String name, PathItem pathItem) { + object(node, "pathItems").put(Objects.requireNonNull(name), Objects.requireNonNull(pathItem).toNode()); + return this; + } + + /** + * Set path item. + * + * @param name path item name + * @param pathItem consumer to update path item builder + * @return updated builder + */ + public ComponentsBuilder pathItem(String name, Consumer pathItem) { + PathItemBuilder builder = PathItem.builder(); + pathItem.accept(builder); + return pathItem(name, builder.build()); + } + + /** + * Set OpenAPI 3.2 media type. + * + * @param name media type name + * @param mediaType media type object + * @return updated builder + */ + public ComponentsBuilder mediaType(String name, MediaTypeObject mediaType) { + object(node, "mediaTypes").put(Objects.requireNonNull(name), Objects.requireNonNull(mediaType).toNode()); + return this; + } + + /** + * Set OpenAPI 3.2 media type. + * + * @param name media type name + * @param mediaType consumer to update media type object builder + * @return updated builder + */ + public ComponentsBuilder mediaType(String name, Consumer mediaType) { + MediaTypeObjectBuilder builder = MediaTypeObject.builder(); + mediaType.accept(builder); + return mediaType(name, builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public ComponentsBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public Components build() { + return new Components(node); + } + } + + /** + * OpenAPI Security Scheme Object. + */ + @Api.Preview + public static final class SecurityScheme { + private final Map node; + + private SecurityScheme(Map node) { + this.node = immutableMap(node); + } + + /** + * Create a new security scheme builder. + * + * @return builder + */ + public static SecuritySchemeBuilder builder() { + return new SecuritySchemeBuilder(); + } + + /** + * Create a reference security scheme. + * + * @param ref reference + * @return security scheme + */ + public static SecurityScheme reference(String ref) { + return new SecuritySchemeBuilder().ref(ref).build(); + } + + private Map toNode() { + return node; + } + } + + /** + * OpenAPI security scheme builder. + */ + @Api.Preview + public static final class SecuritySchemeBuilder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private SecuritySchemeBuilder() { + } + + /** + * Set reference. + * + * @param ref reference + * @return updated builder + */ + public SecuritySchemeBuilder ref(String ref) { + node.put("$ref", Objects.requireNonNull(ref)); + return this; + } + + /** + * Set reference summary. + * + * @param summary summary + * @return updated builder + */ + public SecuritySchemeBuilder summary(String summary) { + node.put("summary", Objects.requireNonNull(summary)); + return this; + } + + /** + * Set security scheme type. + * + * @param type security scheme type + * @return updated builder + */ + public SecuritySchemeBuilder type(String type) { + node.put("type", Objects.requireNonNull(type)); + return this; + } + + /** + * Set description. + * + * @param description description + * @return updated builder + */ + public SecuritySchemeBuilder description(String description) { + node.put("description", Objects.requireNonNull(description)); + return this; + } + + /** + * Set parameter name. + * + * @param name parameter name + * @return updated builder + */ + public SecuritySchemeBuilder name(String name) { + node.put("name", Objects.requireNonNull(name)); + return this; + } + + /** + * Set parameter location. + * + * @param in parameter location + * @return updated builder + */ + public SecuritySchemeBuilder in(String in) { + node.put("in", Objects.requireNonNull(in)); + return this; + } + + /** + * Set HTTP scheme. + * + * @param scheme HTTP scheme + * @return updated builder + */ + public SecuritySchemeBuilder scheme(String scheme) { + node.put("scheme", Objects.requireNonNull(scheme)); + return this; + } + + /** + * Set bearer format. + * + * @param bearerFormat bearer format + * @return updated builder + */ + public SecuritySchemeBuilder bearerFormat(String bearerFormat) { + node.put("bearerFormat", Objects.requireNonNull(bearerFormat)); + return this; + } + + /** + * Set OAuth flows object. + * + * @param flows OAuth flows object + * @return updated builder + */ + public SecuritySchemeBuilder flows(JsonObject flows) { + node.put("flows", jsonValue(Objects.requireNonNull(flows))); + return this; + } + + /** + * Set OpenID Connect URL. + * + * @param openIdConnectUrl OpenID Connect URL + * @return updated builder + */ + public SecuritySchemeBuilder openIdConnectUrl(String openIdConnectUrl) { + node.put("openIdConnectUrl", Objects.requireNonNull(openIdConnectUrl)); + return this; + } + + /** + * Set OpenAPI 3.2 OAuth 2 metadata URL. + * + * @param oauth2MetadataUrl OAuth 2 metadata URL + * @return updated builder + */ + public SecuritySchemeBuilder oauth2MetadataUrl(String oauth2MetadataUrl) { + node.put("oauth2MetadataUrl", Objects.requireNonNull(oauth2MetadataUrl)); + return this; + } + + /** + * Set whether the security scheme is deprecated. + * + * @param deprecated whether the security scheme is deprecated + * @return updated builder + */ + public SecuritySchemeBuilder deprecated(boolean deprecated) { + node.put("deprecated", deprecated); + return this; + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public SecuritySchemeBuilder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + @Override + public SecurityScheme build() { + return new SecurityScheme(node); + } + } + + /** + * OpenAPI Security Requirement Object. + */ + @Api.Preview + public static final class SecurityRequirement { + private final Map node; + private final List schemes; + + private SecurityRequirement(Map node) { + this.node = immutableMap(node); + this.schemes = schemes(this.node); + } + + /** + * Create a new security requirement builder. + * + * @return builder + */ + public static SecurityRequirementBuilder builder() { + return new SecurityRequirementBuilder(); + } + + /** + * Required schemes. All returned schemes are required together. + * + * @return required schemes + */ + public List schemes() { + return schemes; + } + + private Map toNode() { + return node; + } + + private static List schemes(Map node) { + List result = new ArrayList<>(); + node.forEach((name, scopes) -> result.add(new SchemeRequirement(name, stringList(scopes)))); + return Collections.unmodifiableList(result); + } + } + + /** + * OpenAPI security requirement builder. + */ + @Api.Preview + public static final class SecurityRequirementBuilder + implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + + private SecurityRequirementBuilder() { + } + + /** + * Add a required security scheme to this requirement. + * + * @param name security scheme name + * @param scopes required scopes + * @return updated builder + */ + public SecurityRequirementBuilder scheme(String name, List scopes) { + node.put(Objects.requireNonNull(name), List.copyOf(scopes)); + return this; + } + + @Override + public SecurityRequirement build() { + return new SecurityRequirement(node); + } + } + + /** + * Scheme requirement within an OpenAPI Security Requirement Object. + */ + @Api.Preview + public static final class SchemeRequirement { + private final String name; + private final List scopes; + + private SchemeRequirement(String name, List scopes) { + this.name = name; + this.scopes = scopes; + } + + /** + * Security scheme name. + * + * @return security scheme name + */ + public String name() { + return name; + } + + /** + * Required scopes. + * + * @return required scopes + */ + public List scopes() { + return scopes; + } + } + + /** + * OpenAPI document builder. + */ + @Api.Preview + public static final class Builder implements io.helidon.common.Builder { + private final Map node = new LinkedHashMap<>(); + private final Map pathTemplates = new LinkedHashMap<>(); + private final Map tagsByName = new LinkedHashMap<>(); + + private Builder() { + } + + /** + * Set the OpenAPI version string. + * + * @param openapi OpenAPI version + * @return updated builder + */ + public Builder openapi(String openapi) { + node.put("openapi", Objects.requireNonNull(openapi)); + return this; + } + + /** + * Set the OpenAPI 3.2 document identity URI. + *

+ * A relative identity is resolved against the configured OpenAPI web context. When the identity is relative, + * Helidon cannot determine whether an absolute reference identifies this document because composition does not + * have the request scheme or authority. That combination is not supported; use an absolute document identity or + * relative references instead. + * + * @param self document identity URI + * @return updated builder + */ + public Builder self(String self) { + node.put("$self", Objects.requireNonNull(self)); + return this; + } + + /** + * Set the JSON Schema dialect URI. + * + * @param jsonSchemaDialect JSON Schema dialect URI + * @return updated builder + */ + public Builder jsonSchemaDialect(String jsonSchemaDialect) { + node.put("jsonSchemaDialect", Objects.requireNonNull(jsonSchemaDialect)); + return this; + } + + /** + * Set the required Info object values. + * + * @param title API title + * @param version API version + * @return updated builder + */ + public Builder info(String title, String version) { + return info(Info.builder() + .title(title) + .version(version) + .build()); + } + + /** + * Set the Info object. + * + * @param info info + * @return updated builder + */ + public Builder info(Info info) { + node.put("info", mutableMap(Objects.requireNonNull(info).toNode())); + return this; + } + + /** + * Set the Info object. + * + * @param info consumer to update info builder + * @return updated builder + */ + public Builder info(Consumer info) { + InfoBuilder builder = Info.builder(); + info.accept(builder); + return info(builder.build()); + } + + /** + * Add a server. + * + * @param server server model + * @return updated builder + */ + public Builder server(Server server) { + array(node, "servers").add(mutableMap(Objects.requireNonNull(server).toNode())); + return this; + } + + /** + * Add a server. + * + * @param server consumer to update server builder + * @return updated builder + */ + public Builder server(Consumer server) { + ServerBuilder builder = Server.builder(); + server.accept(builder); + return server(builder.build()); + } + + /** + * Add or merge path items. + *

+ * An empty map adds an empty Paths Object. + * + * @param paths path items keyed by OpenAPI path + * @return updated builder + */ + public Builder paths(Map paths) { + Objects.requireNonNull(paths); + object(node, "paths"); + paths.forEach(this::path); + return this; + } + + /** + * Add or merge a path item. + * + * @param path OpenAPI path + * @param pathItem path item + * @return updated builder + */ + public Builder path(String path, PathItem pathItem) { + Map source = new LinkedHashMap<>(); + source.put(Objects.requireNonNull(path), mutableMap(Objects.requireNonNull(pathItem).toNode())); + mergePathItems(object(node, "paths"), source, "paths", pathTemplates); + return this; + } + + /** + * Add or merge a path item. + * + * @param path OpenAPI path + * @param pathItem consumer to update path item builder + * @return updated builder + */ + public Builder path(String path, Consumer pathItem) { + PathItemBuilder builder = PathItem.builder(); + pathItem.accept(builder); + return path(path, builder.build()); + } + + /** + * Add an extension to the paths object. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public Builder pathExtension(String name, JsonValue value) { + OpenApiDocument.extension(object(node, "paths"), name, value); + return this; + } + + /** + * Add or merge webhook path items. + *

+ * An empty map adds an empty Webhooks Object. + * + * @param webhooks webhook path items keyed by name + * @return updated builder + */ + public Builder webhooks(Map webhooks) { + Objects.requireNonNull(webhooks); + object(node, "webhooks"); + webhooks.forEach(this::webhook); + return this; + } + + /** + * Add or merge a webhook path item. + * + * @param name webhook name + * @param pathItem path item + * @return updated builder + */ + public Builder webhook(String name, PathItem pathItem) { + Map source = new LinkedHashMap<>(); + source.put(Objects.requireNonNull(name), mutableMap(Objects.requireNonNull(pathItem).toNode())); + mergePathItems(object(node, "webhooks"), source, "webhooks", null); + return this; + } + + /** + * Add or merge a webhook path item. + * + * @param name webhook name + * @param pathItem consumer to update path item builder + * @return updated builder + */ + public Builder webhook(String name, Consumer pathItem) { + PathItemBuilder builder = PathItem.builder(); + pathItem.accept(builder); + return webhook(name, builder.build()); + } + + /** + * Add or merge components. + * + * @param components components + * @return updated builder + */ + public Builder components(Components components) { + OpenApiDocument.merge(object(node, "components"), + mutableMap(Objects.requireNonNull(components).toNode()), + "components"); + return this; + } + + /** + * Add or merge components. + * + * @param components consumer to update components builder + * @return updated builder + */ + public Builder components(Consumer components) { + ComponentsBuilder builder = Components.builder(); + components.accept(builder); + return components(builder.build()); + } + + /** + * Add a security requirement. + * + * @param name security scheme name + * @param scopes security scopes + * @return updated builder + */ + public Builder securityRequirement(String name, List scopes) { + return securityRequirement(SecurityRequirement.builder() + .scheme(name, scopes) + .build()); + } + + /** + * Add a security requirement. + * + * @param requirement security requirement + * @return updated builder + */ + public Builder securityRequirement(SecurityRequirement requirement) { + array(node, "security").add(mutableMap(Objects.requireNonNull(requirement).toNode())); + return this; + } + + /** + * Add a security requirement. + * + * @param requirement consumer to update security requirement builder + * @return updated builder + */ + public Builder securityRequirement(Consumer requirement) { + SecurityRequirementBuilder builder = SecurityRequirement.builder(); + requirement.accept(builder); + return securityRequirement(builder.build()); + } + + /** + * Add a document tag. + * + * @param name tag name + * @param description tag description + * @return updated builder + */ + public Builder tag(String name, String description) { + return tag(Tag.builder() + .name(name) + .description(description) + .build()); + } + + /** + * Add a document tag. + * + * @param tag tag model + * @return updated builder + */ + public Builder tag(Tag tag) { + Map tagNode = mutableMap(Objects.requireNonNull(tag).toNode()); + mergeTag(array(node, "tags"), tagNode, tagsByName); + return this; + } + + /** + * Add a document tag. + * + * @param tag consumer to update tag builder + * @return updated builder + */ + public Builder tag(Consumer tag) { + TagBuilder builder = Tag.builder(); + tag.accept(builder); + return tag(builder.build()); + } + + /** + * Set external documentation. + * + * @param externalDocs external documentation + * @return updated builder + */ + public Builder externalDocs(ExternalDocs externalDocs) { + node.put("externalDocs", mutableMap(Objects.requireNonNull(externalDocs).toNode())); + return this; + } + + /** + * Set external documentation. + * + * @param externalDocs consumer to update external documentation builder + * @return updated builder + */ + public Builder externalDocs(Consumer externalDocs) { + ExternalDocsBuilder builder = ExternalDocs.builder(); + externalDocs.accept(builder); + return externalDocs(builder.build()); + } + + /** + * Add an extension. + *

+ * Extension names must start with {@code x-}. + * + * @param name extension name + * @param value extension value + * @return updated builder + */ + public Builder extension(String name, JsonValue value) { + OpenApiDocument.extension(node, name, value); + return this; + } + + /** + * Merge another OpenAPI document into this builder. + * + * @param document document to merge + * @return updated builder + * @throws IllegalStateException if both documents define conflicting values or the same path operation + */ + public Builder merge(OpenApiDocument document) { + OpenApiDocument.merge(node, mutableMap(Objects.requireNonNull(document).toNode()), "", + pathTemplates, tagsByName); + return this; + } + + Builder mergeNode(Map source) { + OpenApiDocument.merge(node, Objects.requireNonNull(source), "", pathTemplates, tagsByName); + return this; + } + + Map node() { + return node; + } + + @Override + public OpenApiDocument build() { + return new OpenApiDocument(node); + } + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentComposer.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentComposer.java new file mode 100644 index 00000000000..6bb6b487d99 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentComposer.java @@ -0,0 +1,758 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; + +import io.helidon.openapi.spi.OpenApiDocumentSource; + +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateOperationIds; + +final class OpenApiDocumentComposer { + private static final Set SCHEMA_VALUE_FIELDS = Set.of("additionalProperties", + "allOf", + "anyOf", + "contains", + "contentSchema", + "else", + "if", + "items", + "not", + "oneOf", + "prefixItems", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties"); + private static final Set SCHEMA_MAP_FIELDS = Set.of("$defs", + "definitions", + "dependencies", + "dependentSchemas", + "patternProperties", + "properties"); + private OpenApiDocumentComposer() { + } + + static String compose(OpenApiDocumentContext context, + Optional> staticDocument, + String staticContent, + List sources) { + boolean hasStaticContent = !staticContent.isBlank(); + OpenApiGeneratedMode mode = context.generatedMode(); + if (mode == OpenApiGeneratedMode.STATIC_ONLY + || (hasStaticContent && mode == OpenApiGeneratedMode.STATIC_FIRST)) { + return staticContent; + } + if (sources.isEmpty()) { + return mode == OpenApiGeneratedMode.GENERATED_ONLY ? "" : staticContent; + } + + Optional mergeDocument = mode == OpenApiGeneratedMode.MERGE && hasStaticContent + ? Optional.of(staticDocument.orElseThrow().get()) + : Optional.empty(); + OpenApiDocument generated = generatedDocument(context, sources); + if (generated.isEmpty()) { + if (mode == OpenApiGeneratedMode.GENERATED_ONLY) { + return ""; + } + if (mode == OpenApiGeneratedMode.MERGE && hasStaticContent) { + OpenApiDocument composed = mergeDocument.orElseThrow(); + validateComposedDocument(context, composed); + return context.openApiVersion().render(context, composed); + } + return staticContent; + } + + if (mode == OpenApiGeneratedMode.GENERATED_ONLY || !hasStaticContent) { + validateComposedDocument(context, generated); + return context.openApiVersion().render(context, generated); + } + + if (mode == OpenApiGeneratedMode.MERGE) { + OpenApiDocument.Builder builder = OpenApiDocument.builder() + .merge(mergeDocument.orElseThrow()); + mergeGeneratedDocument(context, + builder, + generated, + false, + Map.of()); + OpenApiDocument merged = builder.build(); + validateComposedDocument(context, merged); + return context.openApiVersion().render(context, merged); + } + + return staticContent; + } + + private static OpenApiDocument generatedDocument(OpenApiDocumentContext context, + List sources) { + List sourceDocuments = new ArrayList<>(); + for (OpenApiDocumentSource source : sources) { + if (source.supports(context)) { + OpenApiDocument.Builder sourceBuilder = OpenApiDocument.builder(); + source.describe(context, sourceBuilder); + sourceDocuments.add(sourceBuilder.build()); + } + } + OpenApiDocument.Builder builder = OpenApiDocument.builder(); + Map schemaNamesByValue = new HashMap<>(); + for (OpenApiDocument sourceDocument : sourceDocuments) { + mergeGeneratedDocument(context, + builder, + sourceDocument, + true, + schemaNamesByValue); + } + return builder.build(); + } + + private static void mergeGeneratedDocument(OpenApiDocumentContext context, + OpenApiDocument.Builder targetBuilder, + OpenApiDocument source, + boolean reuseEquivalentSchemas, + Map schemaNamesByValue) { + Map targetNode = targetBuilder.node(); + Map sourceNode = source.mutableNode(); + Object sourceSelf = sourceNode.containsKey("$self") ? sourceNode.get("$self") : targetNode.get("$self"); + URI sourceDocumentUri = documentUri(sourceSelf, context.webContext()); + Map originalNamesByRenamedName = new HashMap<>(); + Map schemaNames = rewriteSchemaNames(targetNode, + sourceNode, + reuseEquivalentSchemas, + schemaNamesByValue, + originalNamesByRenamedName); + boolean supportsDynamicRef = "3.1".equals(context.openApiVersion().type()) + || "3.2".equals(context.openApiVersion().type()); + boolean additionalItemsHasSchemaValue = "3.0".equals(context.openApiVersion().type()); + if (reuseEquivalentSchemas && !schemaNamesByValue.isEmpty()) { + Map sourceSchemas = schemas(sourceNode); + Map> dependentSchemasByName = new HashMap<>(); + sourceSchemas.forEach((name, schema) -> { + Set referencedSchemaNames = new HashSet<>(); + rewriteSchemaValueRefs(schema, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + new SchemaResource(sourceDocumentUri, true), + referencedSchemaNames); + referencedSchemaNames.forEach(referencedName -> dependentSchemasByName + .computeIfAbsent(referencedName, _ -> new LinkedHashSet<>()) + .add(name)); + }); + + Map rewrittenSchemaNames = new LinkedHashMap<>(schemaNames); + List resolvedSchemaNames = new ArrayList<>(); + for (Map.Entry entry : List.copyOf(sourceSchemas.entrySet())) { + String matchingName = schemaNamesByValue.get(entry.getValue()); + if (matchingName != null) { + sourceSchemas.remove(entry.getKey()); + rewrittenSchemaNames.put(entry.getKey(), matchingName); + String originalName = originalNamesByRenamedName.get(entry.getKey()); + if (originalName != null) { + rewrittenSchemaNames.put(originalName, matchingName); + } + resolvedSchemaNames.add(entry.getKey()); + } + } + + while (!resolvedSchemaNames.isEmpty()) { + Map> rewritesByDependentSchema = new LinkedHashMap<>(); + for (String resolvedSchemaName : resolvedSchemaNames) { + String matchingName = rewrittenSchemaNames.get(resolvedSchemaName); + for (String dependentSchemaName : dependentSchemasByName.getOrDefault(resolvedSchemaName, + Set.of())) { + if (sourceSchemas.containsKey(dependentSchemaName)) { + rewritesByDependentSchema + .computeIfAbsent(dependentSchemaName, _ -> new LinkedHashMap<>()) + .put(resolvedSchemaName, matchingName); + } + } + } + resolvedSchemaNames.clear(); + rewritesByDependentSchema.forEach((dependentSchemaName, dependentRewrites) -> { + Object dependentSchema = sourceSchemas.get(dependentSchemaName); + rewriteSchemaValueRefs(dependentSchema, + dependentRewrites, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + new SchemaResource(sourceDocumentUri, true)); + String matchingName = schemaNamesByValue.get(dependentSchema); + if (matchingName != null) { + sourceSchemas.remove(dependentSchemaName); + rewrittenSchemaNames.put(dependentSchemaName, matchingName); + String originalName = originalNamesByRenamedName.get(dependentSchemaName); + if (originalName != null) { + rewrittenSchemaNames.put(originalName, matchingName); + } + resolvedSchemaNames.add(dependentSchemaName); + } + }); + } + rewriteOpenApiSchemaRefs(sourceNode, + rewrittenSchemaNames, + sourceDocumentUri, + InlineSchemaContext.OPEN_API_OBJECT, + supportsDynamicRef, + additionalItemsHasSchemaValue); + } else { + rewriteSchemaRefs(sourceNode, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue); + } + targetBuilder.mergeNode(sourceNode); + if (reuseEquivalentSchemas) { + // Schema values are structural hash keys, so index them only after reference rewriting is complete. + schemas(sourceNode).forEach((name, schema) -> schemaNamesByValue.putIfAbsent(schema, name)); + } + } + + private static Map rewriteSchemaNames(Map targetNode, + Map sourceNode, + boolean reuseEquivalentSchemas, + Map schemaNamesByValue, + Map originalNamesByRenamedName) { + Map targetSchemas = schemas(targetNode); + Map sourceSchemas = schemas(sourceNode); + if (targetSchemas.isEmpty() || sourceSchemas.isEmpty()) { + return Map.of(); + } + + Set usedNames = null; + Map result = new LinkedHashMap<>(); + Map renamedSchemas = new LinkedHashMap<>(); + for (Map.Entry entry : List.copyOf(sourceSchemas.entrySet())) { + String sourceName = entry.getKey(); + Object sourceSchema = entry.getValue(); + String matchingName = reuseEquivalentSchemas + ? schemaNamesByValue.get(sourceSchema) + : null; + if (targetSchemas.containsKey(sourceName)) { + if (Objects.equals(targetSchemas.get(sourceName), sourceSchema)) { + continue; + } + if (matchingName == null && usedNames == null) { + usedNames = new LinkedHashSet<>(targetSchemas.keySet()); + usedNames.addAll(sourceSchemas.keySet()); + } + String targetName = matchingName == null ? uniqueSchemaName(sourceName, usedNames) : matchingName; + result.put(sourceName, targetName); + if (!targetSchemas.containsKey(targetName)) { + renamedSchemas.put(targetName, sourceSchema); + originalNamesByRenamedName.put(targetName, sourceName); + } + if (usedNames != null) { + usedNames.add(targetName); + } + } else if (matchingName != null) { + result.put(sourceName, matchingName); + } + } + + result.keySet().forEach(sourceSchemas::remove); + sourceSchemas.putAll(renamedSchemas); + return result; + } + + private static String uniqueSchemaName(String name, Set usedNames) { + int index = 2; + String candidate = name + index; + while (usedNames.contains(candidate)) { + index++; + candidate = name + index; + } + return candidate; + } + + @SuppressWarnings("unchecked") + private static Map schemas(Map node) { + Object components = node.get("components"); + if (!(components instanceof Map componentsMap)) { + return Map.of(); + } + Object schemas = componentsMap.get("schemas"); + if (!(schemas instanceof Map schemaMap)) { + return Map.of(); + } + return (Map) schemaMap; + } + + @SuppressWarnings("unchecked") + private static void rewriteSchemaRefs(Object value, + Map schemaNames, + URI sourceDocumentUri, + boolean supportsDynamicRef, + boolean additionalItemsHasSchemaValue) { + if (schemaNames.isEmpty()) { + return; + } + if (value instanceof Map map) { + schemas((Map) map).values() + .forEach(schema -> rewriteSchemaValueRefs(schema, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + new SchemaResource(sourceDocumentUri, true))); + } + rewriteOpenApiSchemaRefs(value, + schemaNames, + sourceDocumentUri, + InlineSchemaContext.OPEN_API_OBJECT, + supportsDynamicRef, + additionalItemsHasSchemaValue); + } + + private static void rewriteOpenApiSchemaRefs(Object value, + Map schemaNames, + URI sourceDocumentUri, + InlineSchemaContext context, + boolean supportsDynamicRef, + boolean additionalItemsHasSchemaValue) { + if (value instanceof Map map) { + switch (context) { + case NAMED_OPEN_API_OBJECTS: + map.values().forEach(item -> rewriteOpenApiSchemaRefs( + item, + schemaNames, + sourceDocumentUri, + InlineSchemaContext.OPEN_API_OBJECT, + supportsDynamicRef, + additionalItemsHasSchemaValue)); + return; + case EXTENSIBLE_NAMED_OPEN_API_OBJECTS: + map.forEach((key, item) -> { + if (key instanceof String field && !field.startsWith("x-")) { + rewriteOpenApiSchemaRefs(item, + schemaNames, + sourceDocumentUri, + InlineSchemaContext.OPEN_API_OBJECT, + supportsDynamicRef, + additionalItemsHasSchemaValue); + } + }); + return; + case CALLBACKS: + map.values().forEach(item -> rewriteOpenApiSchemaRefs( + item, + schemaNames, + sourceDocumentUri, + InlineSchemaContext.EXTENSIBLE_NAMED_OPEN_API_OBJECTS, + supportsDynamicRef, + additionalItemsHasSchemaValue)); + return; + case LINKS: + map.values().forEach(item -> rewriteOpenApiSchemaRefs( + item, + schemaNames, + sourceDocumentUri, + InlineSchemaContext.LINK_OBJECT, + supportsDynamicRef, + additionalItemsHasSchemaValue)); + return; + case COMPONENTS: + map.forEach((key, item) -> { + if (!(key instanceof String field) || "schemas".equals(field) || field.startsWith("x-")) { + return; + } + InlineSchemaContext childContext = switch (field) { + case "callbacks" -> InlineSchemaContext.CALLBACKS; + case "links" -> InlineSchemaContext.LINKS; + default -> InlineSchemaContext.NAMED_OPEN_API_OBJECTS; + }; + rewriteOpenApiSchemaRefs(item, + schemaNames, + sourceDocumentUri, + childContext, + supportsDynamicRef, + additionalItemsHasSchemaValue); + }); + return; + case OPEN_API_OBJECT, LINK_OBJECT: + break; + default: + throw new IllegalStateException("Unsupported inline schema context " + context); + } + map.forEach((key, item) -> { + if (!(key instanceof String field) + || "example".equals(field) + || "dataValue".equals(field) + || "value".equals(field) + || (context == InlineSchemaContext.LINK_OBJECT + && ("parameters".equals(field) || "requestBody".equals(field))) + || field.startsWith("x-")) { + return; + } + if ("schema".equals(field) || "itemSchema".equals(field)) { + rewriteSchemaValueRefs(item, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + new SchemaResource(sourceDocumentUri, true)); + } else { + InlineSchemaContext childContext = switch (field) { + case "components" -> InlineSchemaContext.COMPONENTS; + case "paths", "responses" -> InlineSchemaContext.EXTENSIBLE_NAMED_OPEN_API_OBJECTS; + case "callbacks" -> InlineSchemaContext.CALLBACKS; + case "links" -> InlineSchemaContext.LINKS; + case "additionalOperations", "content", "encoding", "examples", "headers", "webhooks" -> + InlineSchemaContext.NAMED_OPEN_API_OBJECTS; + default -> InlineSchemaContext.OPEN_API_OBJECT; + }; + rewriteOpenApiSchemaRefs(item, + schemaNames, + sourceDocumentUri, + childContext, + supportsDynamicRef, + additionalItemsHasSchemaValue); + } + }); + } else if (value instanceof List list) { + list.forEach(it -> rewriteOpenApiSchemaRefs( + it, + schemaNames, + sourceDocumentUri, + InlineSchemaContext.OPEN_API_OBJECT, + supportsDynamicRef, + additionalItemsHasSchemaValue)); + } + } + + @SuppressWarnings("unchecked") + private static void rewriteSchemaValueRefs(Object value, + Map schemaNames, + URI sourceDocumentUri, + boolean supportsDynamicRef, + boolean additionalItemsHasSchemaValue, + SchemaResource resource) { + rewriteSchemaValueRefs(value, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + resource, + null); + } + + @SuppressWarnings("unchecked") + private static void rewriteSchemaValueRefs(Object value, + Map schemaNames, + URI sourceDocumentUri, + boolean supportsDynamicRef, + boolean additionalItemsHasSchemaValue, + SchemaResource resource, + Set referencedSchemaNames) { + if (value instanceof Map map) { + SchemaResource currentResource = map.containsKey("$id") + ? new SchemaResource(resolveDocumentUri(map.get("$id"), resource.baseUri()), false) + : resource; + Object ref = map.get("$ref"); + if (ref instanceof String refValue + && schemaReference(refValue, currentResource, sourceDocumentUri).isPresent()) { + String rewrittenRef = rewriteSchemaRef(refValue, schemaNames, currentResource, sourceDocumentUri); + ((Map) map).put("$ref", rewrittenRef); + if (referencedSchemaNames != null) { + referencedSchemaNames.add(schemaRefName(rewrittenRef, currentResource, sourceDocumentUri)); + } + } + Object dynamicRef = map.get("$dynamicRef"); + if (supportsDynamicRef + && dynamicRef instanceof String refValue + && schemaReference(refValue, currentResource, sourceDocumentUri).isPresent()) { + String rewrittenRef = rewriteSchemaRef(refValue, schemaNames, currentResource, sourceDocumentUri); + ((Map) map).put("$dynamicRef", rewrittenRef); + if (referencedSchemaNames != null) { + referencedSchemaNames.add(schemaRefName(rewrittenRef, currentResource, sourceDocumentUri)); + } + } + Object discriminator = map.get("discriminator"); + if (discriminator instanceof Map discriminatorMap) { + Object mapping = discriminatorMap.get("mapping"); + if (mapping instanceof Map mappingMap) { + ((Map) mappingMap).replaceAll((_, mappingValue) -> { + if (mappingValue instanceof String mappingRef + && (schemaNames.containsKey(mappingRef) + || schemaReference(mappingRef, currentResource, sourceDocumentUri).isPresent())) { + String rewrittenRef = rewriteSchemaRef(mappingRef, + schemaNames, + currentResource, + sourceDocumentUri); + if (referencedSchemaNames != null) { + referencedSchemaNames.add(schemaRefName(rewrittenRef, + currentResource, + sourceDocumentUri)); + } + return rewrittenRef; + } + return mappingValue; + }); + } + Object defaultMapping = discriminatorMap.get("defaultMapping"); + if (defaultMapping instanceof String mappingRef + && (schemaNames.containsKey(mappingRef) + || schemaReference(mappingRef, currentResource, sourceDocumentUri).isPresent())) { + String rewrittenRef = rewriteSchemaRef(mappingRef, + schemaNames, + currentResource, + sourceDocumentUri); + ((Map) discriminatorMap).put("defaultMapping", rewrittenRef); + if (referencedSchemaNames != null) { + referencedSchemaNames.add(schemaRefName(rewrittenRef, currentResource, sourceDocumentUri)); + } + } + } + map.forEach((key, item) -> { + if (!(key instanceof String field)) { + return; + } + if (SCHEMA_VALUE_FIELDS.contains(field) + || (additionalItemsHasSchemaValue && "additionalItems".equals(field))) { + rewriteSchemaValueRefs(item, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + currentResource, + referencedSchemaNames); + } else if (SCHEMA_MAP_FIELDS.contains(field) && item instanceof Map schemaMap) { + schemaMap.values().forEach(schema -> rewriteSchemaValueRefs(schema, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + currentResource, + referencedSchemaNames)); + } + }); + } else if (value instanceof List list) { + list.forEach(it -> rewriteSchemaValueRefs(it, + schemaNames, + sourceDocumentUri, + supportsDynamicRef, + additionalItemsHasSchemaValue, + resource, + referencedSchemaNames)); + } + } + + private static String schemaRefName(String refValue, + SchemaResource resource, + URI sourceDocumentUri) { + return schemaReference(refValue, resource, sourceDocumentUri) + .map(SchemaReference::sourceName) + .orElse(refValue); + } + + private static String rewriteSchemaRef(String refValue, + Map schemaNames, + SchemaResource resource, + URI sourceDocumentUri) { + Optional schemaReference = schemaReference(refValue, resource, sourceDocumentUri); + String sourceName = schemaReference + .map(SchemaReference::sourceName) + .orElse(refValue); + String targetName = schemaNames.get(sourceName); + if (targetName == null) { + return refValue; + } + if (schemaReference.isEmpty()) { + return targetName; + } + try { + String fragment = OpenApiSourceBase.SCHEMA_REF_PREFIX.substring(1) + + targetName + + schemaReference.get().suffix(); + return schemaReference.get().prefix() + new URI(null, null, fragment).toASCIIString(); + } catch (URISyntaxException _) { + return refValue; + } + } + + private static Optional schemaReference(String refValue, + SchemaResource resource, + URI sourceDocumentUri) { + URI reference; + try { + reference = URI.create(refValue); + } catch (IllegalArgumentException _) { + return Optional.empty(); + } + if (refValue.startsWith("#")) { + if (!resource.openApiDocumentResource() + && !Objects.equals(resource.baseUri(), sourceDocumentUri)) { + return Optional.empty(); + } + } else { + if (sourceDocumentUri == null) { + return Optional.empty(); + } + if (!reference.isAbsolute()) { + if (resource.baseUri() == null) { + return Optional.empty(); + } + reference = resource.baseUri().resolve(reference); + } + if (!sourceDocumentUri.equals(documentUri(reference))) { + return Optional.empty(); + } + } + String fragment = reference.getFragment(); + String prefix = OpenApiSourceBase.SCHEMA_REF_PREFIX.substring(1); + if (fragment == null || !fragment.startsWith(prefix)) { + return Optional.empty(); + } + int fragmentStart = refValue.indexOf('#'); + String referencePrefix = fragmentStart < 0 ? "" : refValue.substring(0, fragmentStart); + int suffixStart = fragment.indexOf('/', prefix.length()); + return suffixStart < 0 + ? Optional.of(new SchemaReference(referencePrefix, fragment.substring(prefix.length()), "")) + : Optional.of(new SchemaReference(referencePrefix, + fragment.substring(prefix.length(), suffixStart), + fragment.substring(suffixStart))); + } + + private static URI documentUri(Object value) { + if (!(value instanceof String uri)) { + return null; + } + try { + return documentUri(URI.create(uri)); + } catch (IllegalArgumentException _) { + return null; + } + } + + private static URI documentUri(Object value, String webContext) { + URI uri = documentUri(value); + if (uri == null || uri.isAbsolute()) { + return uri; + } + String basePath = webContext.startsWith("/") ? webContext : "/" + webContext; + try { + URI baseUri = URI.create(basePath); + if (uri.getRawAuthority() == null && uri.getRawPath().isEmpty()) { + return documentUri(uri.getRawQuery() == null + ? baseUri + : URI.create(basePath + "?" + uri.getRawQuery())); + } + return documentUri(baseUri.resolve(uri)); + } catch (IllegalArgumentException _) { + return uri; + } + } + + private static URI resolveDocumentUri(Object value, URI baseUri) { + URI uri = documentUri(value); + if (uri == null || uri.isAbsolute() || baseUri == null) { + return uri; + } + return documentUri(baseUri.resolve(uri)); + } + + private static URI documentUri(URI uri) { + String value = uri.toString(); + int fragment = value.indexOf('#'); + return URI.create(fragment < 0 ? value : value.substring(0, fragment)).normalize(); + } + + private static void validateComposedDocument(OpenApiDocumentContext context, OpenApiDocument document) { + validateOperationIds(document); + if ("3.2".equals(context.openApiVersion().type())) { + Map tagParents = new LinkedHashMap<>(); + Set tagNames = new LinkedHashSet<>(); + Object tags = document.mutableNode().get("tags"); + if (tags instanceof List tagList) { + for (Object tagNode : tagList) { + if (tagNode instanceof Map tag && tag.get("name") instanceof String tagName) { + tagNames.add(tagName); + if (tag.get("parent") instanceof String parentName) { + tagParents.put(tagName, parentName); + } + } + } + } + tagParents.forEach((tagName, parentName) -> { + if (!tagNames.contains(parentName)) { + throw new IllegalStateException("OpenAPI tag " + tagName + + " references missing parent tag " + parentName); + } + if (tagName.equals(parentName)) { + throw new IllegalStateException("OpenAPI tag " + tagName + " cannot be its own parent"); + } + }); + Set completedTagNames = new HashSet<>(); + for (String tagName : tagParents.keySet()) { + if (completedTagNames.contains(tagName)) { + continue; + } + Set path = new LinkedHashSet<>(); + String currentName = tagName; + while (currentName != null + && !completedTagNames.contains(currentName) + && path.add(currentName)) { + currentName = tagParents.get(currentName); + } + if (currentName != null && !completedTagNames.contains(currentName)) { + List pathNames = List.copyOf(path); + int cycleStart = pathNames.indexOf(currentName); + String cycle = String.join(" -> ", pathNames.subList(cycleStart, pathNames.size())) + + " -> " + currentName; + throw new IllegalStateException("OpenAPI tag parent cycle: " + cycle); + } + completedTagNames.addAll(path); + } + } + if (document.info().isEmpty()) { + throw new IllegalStateException("Composed OpenAPI document requires Info metadata. " + + "Add an @OpenApi.Document type with @OpenApi.Info, provide Info " + + "from an OpenApiDocumentSource, or merge static content with Info."); + } + } + + private enum InlineSchemaContext { + OPEN_API_OBJECT, + COMPONENTS, + NAMED_OPEN_API_OBJECTS, + EXTENSIBLE_NAMED_OPEN_API_OBJECTS, + CALLBACKS, + LINKS, + LINK_OBJECT + } + + private record SchemaResource(URI baseUri, boolean openApiDocumentResource) { + } + + private record SchemaReference(String prefix, String sourceName, String suffix) { + } + +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContext.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContext.java new file mode 100644 index 00000000000..0a45cd9b0b6 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContext.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import io.helidon.common.Api; +import io.helidon.openapi.spi.OpenApiVersion; + +/** + * Context for generated OpenAPI document composition. + */ +@Api.Preview +public interface OpenApiDocumentContext { + /** + * OpenAPI feature instance name. + * + * @return feature name + */ + String featureName(); + + /** + * OpenAPI endpoint web context. + * + * @return web context + */ + String webContext(); + + /** + * Listener this document is served from. + * + * @return listener name + */ + String listener(); + + /** + * Generated document mode. + * + * @return generated mode + */ + OpenApiGeneratedMode generatedMode(); + + /** + * Selected OpenAPI version implementation. + * + * @return OpenAPI version implementation + */ + OpenApiVersion openApiVersion(); +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContextImpl.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContextImpl.java new file mode 100644 index 00000000000..b36020d2f6c --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContextImpl.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.util.Map; +import java.util.Objects; + +import io.helidon.config.Config; +import io.helidon.config.ConfigBuilderSupport; +import io.helidon.openapi.spi.OpenApiVersion; + +final class OpenApiDocumentContextImpl implements OpenApiDocumentContext { + private final String featureName; + private final String webContext; + private final String listener; + private final OpenApiGeneratedMode generatedMode; + private final OpenApiVersion openApiVersion; + private final Config config; + private final Map operationIds; + private final boolean resolveConfigExpressions; + + OpenApiDocumentContextImpl(String featureName, + String webContext, + String listener, + OpenApiGeneratedMode generatedMode, + OpenApiVersion openApiVersion) { + this(featureName, webContext, listener, generatedMode, openApiVersion, Map.of()); + } + + OpenApiDocumentContextImpl(String featureName, + String webContext, + String listener, + OpenApiGeneratedMode generatedMode, + OpenApiVersion openApiVersion, + Config config) { + this(featureName, webContext, listener, generatedMode, openApiVersion, config, Map.of()); + } + + OpenApiDocumentContextImpl(String featureName, + String webContext, + String listener, + OpenApiGeneratedMode generatedMode, + OpenApiVersion openApiVersion, + Map operationIds) { + this(featureName, webContext, listener, generatedMode, openApiVersion, Config.empty(), operationIds, false); + } + + OpenApiDocumentContextImpl(String featureName, + String webContext, + String listener, + OpenApiGeneratedMode generatedMode, + OpenApiVersion openApiVersion, + Config config, + Map operationIds) { + this(featureName, webContext, listener, generatedMode, openApiVersion, config, operationIds, false); + } + + OpenApiDocumentContextImpl(String featureName, + String webContext, + String listener, + OpenApiGeneratedMode generatedMode, + OpenApiVersion openApiVersion, + Config config, + Map operationIds, + boolean resolveConfigExpressions) { + this.featureName = Objects.requireNonNull(featureName); + this.webContext = Objects.requireNonNull(webContext); + this.listener = Objects.requireNonNull(listener); + this.generatedMode = Objects.requireNonNull(generatedMode); + this.openApiVersion = Objects.requireNonNull(openApiVersion); + this.config = Objects.requireNonNull(config); + this.operationIds = Map.copyOf(operationIds); + this.resolveConfigExpressions = resolveConfigExpressions; + } + + @Override + public String featureName() { + return featureName; + } + + @Override + public String webContext() { + return webContext; + } + + @Override + public String listener() { + return listener; + } + + @Override + public OpenApiGeneratedMode generatedMode() { + return generatedMode; + } + + @Override + public OpenApiVersion openApiVersion() { + return openApiVersion; + } + + String operationId(String signature, String defaultOperationId) { + String configured = operationIds.get(Objects.requireNonNull(signature)); + if (configured == null || configured.isBlank()) { + return Objects.requireNonNull(defaultOperationId); + } + return configured; + } + + String resolveExpression(String expression) { + Objects.requireNonNull(expression); + if (!resolveConfigExpressions) { + return expression; + } + return ConfigBuilderSupport.resolveExpression(config, expression); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContextSupport.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContextSupport.java new file mode 100644 index 00000000000..83d5423076a --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiDocumentContextSupport.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.util.Objects; + +import io.helidon.common.Api; + +/** + * Support methods for generated OpenAPI document sources. + */ +@Api.Internal +public final class OpenApiDocumentContextSupport { + private OpenApiDocumentContextSupport() { + } + + /** + * Resolve an OpenAPI operation id for a generated Java method signature. + * + * @param context document context + * @param signature Java method signature + * @param defaultOperationId default operation id + * @return configured operation id if present, otherwise the default + */ + public static String operationId(OpenApiDocumentContext context, String signature, String defaultOperationId) { + Objects.requireNonNull(context); + if (context instanceof OpenApiDocumentContextImpl contextImpl) { + return contextImpl.operationId(signature, defaultOperationId); + } + Objects.requireNonNull(signature); + return Objects.requireNonNull(defaultOperationId); + } + + /** + * Resolve a generated OpenAPI annotation expression using runtime configuration when enabled for the OpenAPI feature. + * Otherwise returns the expression unchanged. + * + * @param context document context + * @param expression expression to resolve + * @return resolved expression value, or the original expression when resolution is disabled + */ + public static String resolveExpression(OpenApiDocumentContext context, String expression) { + Objects.requireNonNull(context); + if (context instanceof OpenApiDocumentContextImpl contextImpl) { + return contextImpl.resolveExpression(expression); + } + return Objects.requireNonNull(expression); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java index 238e0129cde..1423dc04f0e 100644 --- a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeature.java @@ -22,24 +22,46 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.ArrayList; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicBoolean; import java.util.function.Consumer; +import java.util.function.Supplier; import io.helidon.builder.api.RuntimeType; import io.helidon.common.LazyValue; +import io.helidon.common.Weight; import io.helidon.common.Weighted; import io.helidon.common.media.type.MediaType; import io.helidon.common.media.type.MediaTypes; +import io.helidon.common.types.TypeName; import io.helidon.config.Config; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonParser; +import io.helidon.openapi.spi.OpenApiDocumentSource; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; +import io.helidon.openapi.v30.OpenApiDocumentMapperSupport; +import io.helidon.service.registry.GlobalServiceRegistry; +import io.helidon.service.registry.Qualifier; +import io.helidon.service.registry.Service; +import io.helidon.service.registry.ServiceInfo; +import io.helidon.service.registry.ServiceRegistry; import io.helidon.webserver.WebServer; import io.helidon.webserver.spi.ServerFeature; /** * Helidon Support for OpenAPI. */ +@Weight(OpenApiFeature.WEIGHT) +@Service.Singleton public final class OpenApiFeature implements Weighted, ServerFeature, RuntimeType.Api { static final String OPENAPI_ID = "openapi"; @@ -49,41 +71,125 @@ public final class OpenApiFeature implements Weighted, ServerFeature, RuntimeTyp "yaml", MediaTypes.APPLICATION_OPENAPI_YAML, "yml", MediaTypes.APPLICATION_OPENAPI_YAML); private static final System.Logger LOGGER = System.getLogger(OpenApiFeature.class.getName()); + private static final String STANDBY_NAME = OPENAPI_ID + "-service-registry"; private static final String DEFAULT_STATIC_FILE_PATH_PREFIX = "META-INF/openapi."; + private static final String GENERATED_DOCUMENT_SOURCES_CONFIG_KEY = "generated.document-sources"; + private static final TypeName OPENAPI_DOCUMENT_SOURCE = TypeName.create(OpenApiDocumentSource.class); private static final List DEFAULT_FILE_PATHS = SUPPORTED_FORMATS.keySet() .stream() .map(fileType -> DEFAULT_STATIC_FILE_PATH_PREFIX + fileType) .toList(); private final String content; + private final MediaType contentMediaType; + private final OpenApiFormat contentFormat; + private final ConcurrentMap>> staticOpenApiDocuments; + private final LazyValue staticDocumentVersion; private final OpenApiFeatureConfig config; + private final Config sourceConfig; private final OpenApiManager manager; - private final LazyValue model; + private final OpenApiLockCoordinator.CoordinationLock managerLock; + private final LazyValue sharedStaticModel; + private final ConcurrentMap> modelsByListener; + private final LazyValue> documentSources; + private final LazyValue> openApiVersions; + private final AtomicBoolean initialized; + private volatile List> listenerModels = List.of(); OpenApiFeature(OpenApiFeatureConfig config) { + this(GlobalServiceRegistry::registry, Config.empty(), config); + } + + @Service.Inject + OpenApiFeature(ServiceRegistry registry, + Config config, + Supplier> openApiVersionProviders) { + this(registry, config.root(), serviceConfig(registry, config), openApiVersionProviders); + } + + OpenApiFeature(ServiceRegistry registry, Config config) { + this(registry, config.root(), serviceConfig(registry, config)); + } + + OpenApiFeature(ServiceRegistry registry, OpenApiFeatureConfig config) { + this(registry, Config.empty(), config); + } + + OpenApiFeature(Supplier registrySupplier, Config sourceConfig, OpenApiFeatureConfig config) { + this(sourceConfig, + config, + () -> documentSources(registrySupplier.get(), config.generatedDocumentSources()), + () -> registrySupplier.get().all(OpenApiVersionProvider.class)); + } + + private OpenApiFeature(ServiceRegistry registry, + Config sourceConfig, + OpenApiFeatureConfig config, + Supplier> openApiVersionProviders) { + this(sourceConfig, config, documentSources(registry, config), openApiVersionProviders); + } + + OpenApiFeature(ServiceRegistry registry, Config sourceConfig, OpenApiFeatureConfig config) { + this(registry, sourceConfig, config, () -> registry.all(OpenApiVersionProvider.class)); + } + + private OpenApiFeature(Config sourceConfig, + OpenApiFeatureConfig config, + Supplier> documentSources, + Supplier> openApiVersionProviders) { this.config = config; - String staticFile = config.staticFile().orElse(null); - String defaultContent = null; - if (staticFile != null) { - defaultContent = readContent(staticFile); - if (defaultContent == null) { - defaultContent = ""; - LOGGER.log(Level.WARNING, "Static OpenAPI file not found: {0}", staticFile); - } - } else { - for (String path : DEFAULT_FILE_PATHS) { - defaultContent = readContent(path); - if (defaultContent != null) { - break; + this.sourceConfig = sourceConfig; + this.documentSources = LazyValue.create(documentSources); + this.openApiVersions = LazyValue.create(() -> openApiVersionProviders.get() + .stream() + .map(provider -> provider.create(Config.empty(), provider.configKey())) + .toList()); + String defaultContent = ""; + MediaType defaultContentMediaType = MediaTypes.APPLICATION_OCTET_STREAM; + OpenApiFormat defaultContentFormat = OpenApiFormat.UNSUPPORTED; + if (config.isEnabled()) { + String staticFile = config.staticFile().orElse(null); + if (staticFile != null) { + defaultContent = readContent(staticFile); + if (defaultContent == null) { + defaultContent = ""; + LOGGER.log(Level.WARNING, "Static OpenAPI file not found: {0}", staticFile); + } else { + defaultContentMediaType = contentTypeOf(staticFile); + defaultContentFormat = OpenApiFormat.valueOf(defaultContentMediaType); + } + } else { + for (String path : DEFAULT_FILE_PATHS) { + defaultContent = readContent(path); + if (defaultContent != null) { + defaultContentMediaType = contentTypeOf(path); + defaultContentFormat = OpenApiFormat.valueOf(defaultContentMediaType); + break; + } + } + if (defaultContent == null) { + defaultContent = ""; + LOGGER.log(Level.DEBUG, "Static OpenAPI file not found, checked: {0}", DEFAULT_FILE_PATHS); } - } - if (defaultContent == null) { - defaultContent = ""; - LOGGER.log(Level.DEBUG, "Static OpenAPI file not found, checked: {0}", DEFAULT_FILE_PATHS); } } content = defaultContent; + contentMediaType = defaultContentMediaType; + contentFormat = defaultContentFormat; + staticOpenApiDocuments = new ConcurrentHashMap<>(); + staticDocumentVersion = LazyValue.create(() -> { + String declaredVersion = openApiVersion(content, contentFormat).orElseThrow(() -> + new IllegalStateException("Static OpenAPI document does not declare an openapi version.")); + return new StaticDocumentVersion(declaredVersion, staticOpenApiVersion(declaredVersion)); + }); manager = config.manager().orElseGet(SimpleOpenApiManager::new); - model = LazyValue.create(() -> manager.load(content)); + managerLock = OpenApiLockCoordinator.coordinationLock(manager); + sharedStaticModel = LazyValue.create(() -> { + try (var _ = OpenApiLockCoordinator.lock(List.of(managerLock))) { + return manager.load(content); + } + }); + modelsByListener = new ConcurrentHashMap<>(); + initialized = new AtomicBoolean(); } /** @@ -111,7 +217,10 @@ public static OpenApiFeature create() { * @return new instance */ public static OpenApiFeature create(Config config) { - return new OpenApiFeature(OpenApiFeatureConfig.create(config)); + Config rootConfig = config.root(); + OpenApiFeatureConfig featureConfig = configureFeatureBuilder(OpenApiFeature.builder(), config) + .buildPrototype(); + return new OpenApiFeature(GlobalServiceRegistry::registry, rootConfig, featureConfig); } /** @@ -121,7 +230,9 @@ public static OpenApiFeature create(Config config) { * @return new instance */ public static OpenApiFeature create(Consumer builderConsumer) { - return OpenApiFeatureConfig.builder().update(builderConsumer).build(); + OpenApiFeatureConfig.Builder builder = builder(); + builderConsumer.accept(builder); + return builder.build(); } /** @@ -131,7 +242,21 @@ public static OpenApiFeature create(Consumer build * @return new instance */ static OpenApiFeature create(OpenApiFeatureConfig config) { - return new OpenApiFeature(config); + return OpenApiFeatureConfigSupport.create(config); + } + + static OpenApiFeatureConfig.Builder configureFeatureBuilder(OpenApiFeatureConfig.Builder builder, Config config) { + builder.config(config); + if (!config.get("enabled").asBoolean().orElse(true)) { + disableProviderDiscovery(builder); + } + return builder; + } + + static void disableProviderDiscovery(OpenApiFeatureConfig.BuilderBase builder) { + builder.openApiVersionDiscoverServices(false) + .servicesDiscoverServices(false) + .managerDiscoverServices(false); } @Override @@ -151,10 +276,22 @@ public void setup(ServerFeatureContext featureContext) { sockets.add(WebServer.DEFAULT_SOCKET_NAME); } + List> socketModels = new ArrayList<>(sockets.size()); for (String socket : sockets) { + LazyValue socketModel = listenerModel(socket); + socketModels.add(socketModel); featureContext.socket(socket) .httpRouting() - .addFeature(new OpenApiHttpFeature(config, manager, model)); + .addFeature(new OpenApiHttpFeature(config, + manager, + socketModel, + managerLock.lock(), + exactStaticContent(), + contentFormat)); + } + listenerModels = List.copyOf(socketModels); + if (initialized.get()) { + listenerModels.forEach(LazyValue::get); } } @@ -177,7 +314,69 @@ public double weight() { * Initialize the model. */ public void initialize() { - model.get(); + if (!config.isEnabled()) { + return; + } + initialized.set(true); + List> currentListenerModels = listenerModels; + if (currentListenerModels.isEmpty()) { + Set configuredSockets = config.sockets(); + if (configuredSockets.isEmpty()) { + listenerModel(WebServer.DEFAULT_SOCKET_NAME).get(); + } else { + configuredSockets.forEach(socket -> listenerModel(socket).get()); + } + } else { + currentListenerModels.forEach(LazyValue::get); + } + } + + private static OpenApiFeatureConfig serviceConfig(ServiceRegistry registry, Config config) { + return serviceConfig(() -> OpenApiFeatureConfig.builder().serviceRegistry(registry), config); + } + + private static OpenApiFeatureConfig serviceConfig(Supplier builderSupplier, Config config) { + Config featuresConfig = config.get("server.features"); + Config openApiConfig = featuresConfig.get(OPENAPI_ID); + if (openApiConfig.exists()) { + return configureFeatureBuilder(builderSupplier.get(), openApiConfig) + .buildPrototype(); + } + if (hasConfiguredOpenApiFeature(featuresConfig)) { + OpenApiFeatureConfig.Builder builder = builderSupplier.get() + .isEnabled(false) + .name(STANDBY_NAME); + disableProviderDiscovery(builder); + return builder.buildPrototype(); + } + return configureFeatureBuilder(builderSupplier.get(), config.root().get(OPENAPI_ID)) + .buildPrototype(); + } + + private static boolean hasConfiguredOpenApiFeature(Config featuresConfig) { + boolean featuresList = featuresConfig.isList(); + return featuresConfig.asNodeList() + .orElseGet(List::of) + .stream() + .anyMatch(it -> isOpenApiFeatureConfig(it, featuresList)); + } + + private static boolean isOpenApiFeatureConfig(Config featureConfig, boolean listItem) { + if (isOpenApiFeatureNode(featureConfig)) { + return true; + } + if (!listItem) { + return false; + } + return featureConfig.asNodeList() + .orElseGet(List::of) + .stream() + .anyMatch(OpenApiFeature::isOpenApiFeatureNode); + } + + private static boolean isOpenApiFeatureNode(Config featureConfig) { + return OPENAPI_ID.equals(featureConfig.name()) + || OPENAPI_ID.equals(featureConfig.get("type").asString().orElse(null)); } private static String readContent(String path) { @@ -199,4 +398,299 @@ private static ClassLoader contextClassLoader() { ClassLoader classLoader = Thread.currentThread().getContextClassLoader(); return classLoader == null ? OpenApiFeature.class.getClassLoader() : classLoader; } + + private static MediaType contentTypeOf(String path) { + return MediaTypes.detectType(path) + .orElse(MediaTypes.APPLICATION_OCTET_STREAM); + } + + private static Supplier> documentSources(ServiceRegistry registry, + OpenApiFeatureConfig config) { + return () -> documentSources(registry, config.generatedDocumentSources()); + } + + private static List documentSources(ServiceRegistry registry, List configuredNames) { + List services = registry.allServices(OPENAPI_DOCUMENT_SOURCE); + List unqualified = new ArrayList<>(); + Map> named = new LinkedHashMap<>(); + + for (ServiceInfo serviceInfo : services) { + Optional name = documentSourceName(serviceInfo); + if (name.isPresent()) { + named.computeIfAbsent(name.get(), _ -> new ArrayList<>()).add(serviceInfo); + } else { + unqualified.add(serviceInfo); + } + } + + List selected = new ArrayList<>(); + if (configuredNames.isEmpty()) { + selectSingleNamedSource(named).ifPresent(selected::add); + } else { + for (String configuredName : configuredNames) { + selected.add(selectNamedSource(named, configuredName)); + } + } + selected.addAll(unqualified); + + return selected.stream() + .map(serviceInfo -> registry.get(serviceInfo) + .orElseThrow(() -> new IllegalStateException("OpenAPI document source " + + serviceInfo.serviceType().fqName() + + " is not available."))) + .toList(); + } + + private static Optional selectSingleNamedSource(Map> named) { + int namedCount = named.values() + .stream() + .mapToInt(List::size) + .sum(); + if (namedCount == 0) { + return Optional.empty(); + } + if (namedCount == 1) { + return Optional.of(named.values().iterator().next().getFirst()); + } + + throw new IllegalStateException("Multiple named OpenAPI document sources are available: " + + namedDocumentSources(named) + + ". Configure " + GENERATED_DOCUMENT_SOURCES_CONFIG_KEY + + " for this OpenAPI feature with the source name to use."); + } + + private static ServiceInfo selectNamedSource(Map> named, String configuredName) { + List sources = named.get(configuredName); + if (sources == null || sources.isEmpty()) { + throw new IllegalStateException("Configured OpenAPI document source " + configuredName + + " was not found. Available named sources: " + named.keySet()); + } + if (sources.size() > 1) { + throw new IllegalStateException("Configured OpenAPI document source " + configuredName + + " matches multiple sources: " + serviceTypes(sources) + + ". Use unique source names or remove duplicate document metadata" + + " sources."); + } + return sources.getFirst(); + } + + private static Optional documentSourceName(ServiceInfo serviceInfo) { + return serviceInfo.qualifiers() + .stream() + .filter(qualifier -> Service.Named.TYPE.equals(qualifier.typeName())) + .map(Qualifier::value) + .flatMap(Optional::stream) + .filter(name -> !name.isBlank()) + .filter(name -> !Service.Named.WILDCARD_NAME.equals(name)) + .findFirst(); + } + + private static String namedDocumentSources(Map> named) { + List values = new ArrayList<>(); + named.forEach((name, sources) -> values.add(name + "=" + serviceTypes(sources))); + return values.toString(); + } + + private static List serviceTypes(List sources) { + return sources.stream() + .map(serviceInfo -> serviceInfo.serviceType().fqName()) + .sorted() + .toList(); + } + + private static Optional openApiVersion(String content, OpenApiFormat format) { + return switch (format) { + case JSON -> jsonOpenApiVersion(content); + case YAML -> yamlOpenApiVersion(content); + case UNSUPPORTED -> Optional.empty(); + }; + } + + private static Optional jsonOpenApiVersion(String content) { + JsonObject object = JsonParser.create(content) + .readJsonValue() + .asObject(); + return object.stringValue("openapi"); + } + + private static Optional yamlOpenApiVersion(String content) { + Object loaded = OpenApiDocumentMapperSupport.parseYaml(content); + if (!(loaded instanceof Map map)) { + return Optional.empty(); + } + Object version = map.get("openapi"); + if (version == null) { + return Optional.empty(); + } + return Optional.of(version.toString()); + } + + private static boolean compatibleOpenApiVersion(OpenApiVersion version, String openApiVersion) { + String versionFamily = version.type(); + return openApiVersion.equals(version.version()) + || openApiVersion.equals(versionFamily) + || openApiVersion.startsWith(versionFamily + "."); + } + + private LazyValue model(String listener) { + return LazyValue.create(() -> { + List sources = documentSources(); + OpenApiGeneratedMode mode = config.generatedMode(); + boolean hasStaticContent = !content.isBlank(); + boolean usesGeneratedDocument = !sources.isEmpty() + && mode != OpenApiGeneratedMode.STATIC_ONLY + && (mode != OpenApiGeneratedMode.STATIC_FIRST || !hasStaticContent); + List locks = new ArrayList<>(sources.size() + 3); + locks.add(managerLock); + sources.forEach(source -> locks.add(OpenApiLockCoordinator.coordinationLock(source))); + if (usesGeneratedDocument || (mode == OpenApiGeneratedMode.MERGE && hasStaticContent)) { + config.openApiVersion() + .map(OpenApiLockCoordinator::coordinationLock) + .ifPresent(locks::add); + } + if (mode == OpenApiGeneratedMode.MERGE && hasStaticContent) { + // Resolve the parser before locking so its identity participates in the global lock order. + locks.add(OpenApiLockCoordinator.coordinationLock(staticDocumentVersion.get().openApiVersion())); + } + try (var _ = OpenApiLockCoordinator.lock(locks)) { + return manager.load(documentContent(listener, sources)); + } + }); + } + + private LazyValue listenerModel(String listener) { + OpenApiGeneratedMode mode = config.generatedMode(); + if (mode == OpenApiGeneratedMode.STATIC_ONLY + || (mode == OpenApiGeneratedMode.STATIC_FIRST && !content.isBlank())) { + return sharedStaticModel; + } + return modelsByListener.computeIfAbsent(listener, this::model); + } + + private String documentContent(String listener, List sources) { + boolean hasStaticContent = !content.isBlank(); + OpenApiGeneratedMode mode = config.generatedMode(); + if (mode == OpenApiGeneratedMode.STATIC_ONLY + || (hasStaticContent && mode == OpenApiGeneratedMode.STATIC_FIRST)) { + return finalStaticContent(listener); + } + if (sources.isEmpty()) { + if (mode == OpenApiGeneratedMode.GENERATED_ONLY) { + return ""; + } + return hasStaticContent ? finalStaticContent(listener) : ""; + } + + OpenApiVersion openApiVersion = config.openApiVersion() + .orElseThrow(() -> new IllegalStateException("No OpenAPI version provider is available.")); + OpenApiDocumentContext context = new OpenApiDocumentContextImpl(config.name(), + config.webContext(), + listener, + mode, + openApiVersion, + sourceConfig, + config.generatedOperationIds(), + config.generatedResolveConfigExpressions()); + Optional> staticDocument = Optional.empty(); + if (mode == OpenApiGeneratedMode.MERGE && hasStaticContent) { + staticDocument = Optional.of(() -> staticOpenApiDocument(listener).document()); + } + return OpenApiDocumentComposer.compose(context, staticDocument, content, sources); + } + + private String finalStaticContent(String listener) { + if (content.isBlank()) { + return content; + } + if (config.generatedMode() == OpenApiGeneratedMode.STATIC_ONLY + || config.generatedMode() == OpenApiGeneratedMode.STATIC_FIRST) { + return content; + } + Optional openApiVersion = config.openApiVersion(); + StaticOpenApiDocument staticDocument = staticOpenApiDocument(listener); + if (openApiVersion.isEmpty() + || compatibleOpenApiVersion(openApiVersion.get(), staticDocument.openApiVersion())) { + return content; + } + OpenApiDocumentContext context = new OpenApiDocumentContextImpl(config.name(), + config.webContext(), + listener, + config.generatedMode(), + openApiVersion.get(), + sourceConfig, + config.generatedOperationIds(), + config.generatedResolveConfigExpressions()); + return openApiVersion.get().render(context, staticDocument.document()); + } + + private List documentSources() { + if (!shouldLookupDocumentSources()) { + return List.of(); + } + return documentSources.get(); + } + + private boolean shouldLookupDocumentSources() { + return switch (config.generatedMode()) { + case STATIC_FIRST -> content.isBlank(); + case STATIC_ONLY -> false; + case MERGE, GENERATED_ONLY -> true; + }; + } + + private Optional exactStaticContent() { + if (config.manager().isPresent() + || content.isBlank() + || contentFormat == OpenApiFormat.UNSUPPORTED) { + return Optional.empty(); + } + return switch (config.generatedMode()) { + case STATIC_ONLY, STATIC_FIRST -> Optional.of(content); + case MERGE, GENERATED_ONLY -> Optional.empty(); + }; + } + + private Optional parseStaticOpenApiDocument(String listener) { + if (content.isBlank()) { + return Optional.empty(); + } + StaticDocumentVersion documentVersion = staticDocumentVersion.get(); + OpenApiVersion staticOpenApiVersion = documentVersion.openApiVersion(); + OpenApiDocumentContext staticContext = new OpenApiDocumentContextImpl(config.name(), + config.webContext(), + listener, + config.generatedMode(), + staticOpenApiVersion); + OpenApiDocument document = staticOpenApiVersion.parse(staticContext, content, contentMediaType); + return Optional.of(new StaticOpenApiDocument(documentVersion.declaredVersion(), document)); + } + + private StaticOpenApiDocument staticOpenApiDocument(String listener) { + return staticOpenApiDocuments + .computeIfAbsent(listener, key -> LazyValue.create(() -> parseStaticOpenApiDocument(key))) + .get() + .orElseThrow(() -> + new IllegalStateException("Static OpenAPI document does not declare an openapi version.")); + } + + private OpenApiVersion staticOpenApiVersion(String staticDocumentVersion) { + Optional configuredVersion = config.openApiVersion() + .filter(version -> compatibleOpenApiVersion(version, staticDocumentVersion)); + if (configuredVersion.isPresent()) { + return configuredVersion.get(); + } + return openApiVersions.get() + .stream() + .filter(version -> compatibleOpenApiVersion(version, staticDocumentVersion)) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "No OpenAPI version provider is available to parse static OpenAPI document version " + + staticDocumentVersion + ".")); + } + + private record StaticOpenApiDocument(String openApiVersion, OpenApiDocument document) { + } + + private record StaticDocumentVersion(String declaredVersion, OpenApiVersion openApiVersion) { + } } diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigBlueprint.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigBlueprint.java index d58ae9f288c..1bfaa79f638 100644 --- a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigBlueprint.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigBlueprint.java @@ -17,21 +17,28 @@ package io.helidon.openapi; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import io.helidon.builder.api.Option; import io.helidon.builder.api.Prototype; +import io.helidon.common.Api; import io.helidon.openapi.spi.OpenApiManagerProvider; import io.helidon.openapi.spi.OpenApiServiceProvider; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; +import io.helidon.service.registry.ServiceRegistry; import io.helidon.webserver.spi.ServerFeatureProvider; /** - * {@link OpenApiFeature} prototype. + * OpenAPI feature configuration. */ -@Prototype.Blueprint +@Prototype.Blueprint(decorator = OpenApiFeatureConfigSupport.BuilderDecorator.class) @Prototype.Configured("openapi") +@Prototype.CustomMethods(OpenApiFeatureConfigSupport.class) @Prototype.Provides(ServerFeatureProvider.class) +@Prototype.RegistrySupport interface OpenApiFeatureConfigBlueprint extends Prototype.Factory { /** * Weight of the OpenAPI feature. This is quite low, to be registered after routing. @@ -48,6 +55,7 @@ interface OpenApiFeatureConfigBlueprint extends Prototype.Factory staticFile(); + /** + * Generated document source handling mode. + * + * @return generated document source handling mode + */ + @Api.Preview + @Option.Configured("generated.mode") + @Option.Default("STATIC_FIRST") + OpenApiGeneratedMode generatedMode(); + + /** + * Whether generated document sources resolve annotation string values as Helidon config expressions at runtime. + *

+ * When disabled, generated document sources use annotation string values literally. This is disabled by default + * because annotation text can be user-visible OpenAPI content. + * + * @return whether to resolve generated OpenAPI annotation string values as config expressions + */ + @Api.Preview + @Option.Configured("generated.resolve-config-expressions") + @Option.DefaultBoolean(false) + boolean generatedResolveConfigExpressions(); + + /** + * Named generated document metadata sources to use, in the order configured. + *

+ * Sources generated from {@link OpenApi.Document @OpenApi.Document} use the annotated type's dotted canonical type + * name. Custom {@link io.helidon.openapi.spi.OpenApiDocumentSource} services must be qualified with + * {@link io.helidon.service.registry.Service.Named @Service.Named} to be selected by name. Unqualified sources always + * participate when they support the document context and cannot be filtered with this option. + * + * @return generated document metadata source names + */ + @Api.Preview + @Option.Configured("generated.document-sources") + @Option.Singular + List generatedDocumentSources(); + + /** + * Operation ids to use for generated Java methods. + *

+ * Each key is a Java method signature consisting of the fully qualified class name, {@code #}, + * the method name, and fully qualified parameter types separated by {@code ,}. The value is the + * operation id to use for that method. + * + * @return generated operation ids keyed by fully qualified Java method signature + */ + @Api.Preview + @Option.Configured("generated.operation-ids") + Map generatedOperationIds(); + + /** + * OpenAPI version implementation for rendered generated or merged documents. + *

+ * If not configured, service discovery selects the highest-weighted available OpenAPI version provider. This means + * that adding OpenAPI 3.1 or 3.2 support modules can change generated output to the highest available OpenAPI + * document version. Configure this option to pin the OpenAPI document version for generated output. + *

+ * Static documents served directly by {@link OpenApiGeneratedMode#STATIC_ONLY} or by a static hit in + * {@link OpenApiGeneratedMode#STATIC_FIRST} are not re-rendered through this provider. + * + * @return version implementation for generated or merged OpenAPI documents + */ + @Api.Preview + @Option.Configured("document") + @Option.Provider(OpenApiVersionProvider.class) + Optional openApiVersion(); + /** * OpenAPI services. * @@ -123,4 +199,20 @@ interface OpenApiFeatureConfigBlueprint extends Prototype.Factory sockets(); + + /** + * Runtime service registry supplied to the builder. + * + * @return runtime service registry + */ + @Option.Access("") + Optional runtimeServiceRegistry(); + + /** + * Runtime source configuration supplied to the builder. + * + * @return runtime source configuration + */ + @Option.Access("") + Optional sourceRoot(); } diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigSupport.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigSupport.java new file mode 100644 index 00000000000..277092e7d29 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureConfigSupport.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.util.function.Supplier; + +import io.helidon.builder.api.Prototype; +import io.helidon.config.Config; +import io.helidon.service.registry.GlobalServiceRegistry; +import io.helidon.service.registry.ServiceRegistry; + +final class OpenApiFeatureConfigSupport { + private OpenApiFeatureConfigSupport() { + } + + @Prototype.RuntimeTypeFactoryMethod + static OpenApiFeature create(OpenApiFeatureConfig config) { + Supplier registrySupplier = () -> config.runtimeServiceRegistry() + .orElseGet(GlobalServiceRegistry::registry); + Config sourceConfig = config.sourceRoot() + .map(Config.class::cast) + .orElseGet(Config::empty); + return new OpenApiFeature(registrySupplier, sourceConfig, config); + } + + static final class BuilderDecorator implements Prototype.BuilderDecorator> { + @Override + public void decorate(OpenApiFeatureConfig.BuilderBase builder) { + builder.serviceRegistry().ifPresent(builder::runtimeServiceRegistry); + builder.config() + .map(Config::root) + .ifPresent(builder::sourceRoot); + } + } + + static final class EnabledDecorator implements Prototype.OptionDecorator, Boolean> { + @Override + public void decorate(OpenApiFeatureConfig.BuilderBase builder, Boolean enabled) { + if (enabled) { + builder.openApiVersionDiscoverServices(true) + .servicesDiscoverServices(true); + } else { + OpenApiFeature.disableProviderDiscovery(builder); + } + } + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureProvider.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureProvider.java index 74cd0fff344..8fa283bb9d5 100644 --- a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureProvider.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiFeatureProvider.java @@ -16,9 +16,12 @@ package io.helidon.openapi; +import java.util.Objects; + import io.helidon.common.Api; import io.helidon.common.Weight; import io.helidon.config.Config; +import io.helidon.service.registry.GlobalServiceRegistry; import io.helidon.webserver.spi.ServerFeatureProvider; /** @@ -40,9 +43,10 @@ public String configKey() { @Override public OpenApiFeature create(Config config, String name) { - return OpenApiFeature.builder() - .config(config) - .name(name) - .build(); + Config resolvedConfig = Objects.requireNonNull(config); + OpenApiFeatureConfig.Builder builder = OpenApiFeature.configureFeatureBuilder(OpenApiFeature.builder(), resolvedConfig); + builder.name(Objects.requireNonNull(name)); + OpenApiFeatureConfig featureConfig = builder.buildPrototype(); + return new OpenApiFeature(GlobalServiceRegistry::registry, resolvedConfig.root(), featureConfig); } } diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiGeneratedMode.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiGeneratedMode.java new file mode 100644 index 00000000000..81dc2e7e532 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiGeneratedMode.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import io.helidon.common.Api; + +/** + * Generated OpenAPI document source handling mode. + */ +@Api.Preview +public enum OpenApiGeneratedMode { + /** + * Use a static document when present, otherwise use generated document sources. + */ + STATIC_FIRST, + + /** + * Use only a static document and ignore generated document sources. + */ + STATIC_ONLY, + + /** + * Strictly merge generated document sources into a static document. + *

+ * Static and generated content must be non-conflicting; composition fails if both sides define incompatible document + * values, the same path operation, or duplicate {@code operationId} values. + */ + MERGE, + + /** + * Use only generated document sources, even when a static document is present. + */ + GENERATED_ONLY +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiHttpFeature.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiHttpFeature.java index 776c7904208..18bb64c7ed8 100644 --- a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiHttpFeature.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiHttpFeature.java @@ -19,6 +19,7 @@ import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.locks.ReentrantLock; import io.helidon.common.LazyValue; import io.helidon.common.media.type.MediaType; @@ -62,13 +63,22 @@ class OpenApiHttpFeature implements HttpFeature { private final OpenApiFeatureConfig config; private final OpenApiManager manager; private final LazyValue model; + private final ReentrantLock managerLock; + private final Optional exactStaticContent; + private final OpenApiFormat exactStaticFormat; OpenApiHttpFeature(OpenApiFeatureConfig config, OpenApiManager manager, - LazyValue model) { + LazyValue model, + ReentrantLock managerLock, + Optional exactStaticContent, + OpenApiFormat exactStaticFormat) { this.config = config; this.manager = manager; this.model = model; + this.managerLock = managerLock; + this.exactStaticContent = exactStaticContent; + this.exactStaticFormat = exactStaticFormat; } @Override @@ -134,12 +144,27 @@ private void handle(ServerRequest req, ServerResponse res) { private String content(MediaType mediaType) { OpenApiFormat format = OpenApiFormat.valueOf(mediaType); + if (format == exactStaticFormat) { + return exactStaticContent.orElseGet(() -> formattedContent(format)); + } + return formattedContent(format); + } + + private String formattedContent(OpenApiFormat format) { if (format == OpenApiFormat.UNSUPPORTED) { if (LOGGER.isLoggable(System.Logger.Level.TRACE)) { - LOGGER.log(System.Logger.Level.TRACE, "Requested format {0} not supported", mediaType); + LOGGER.log(System.Logger.Level.TRACE, "Requested format {0} not supported", format); } } - return cachedDocuments.computeIfAbsent(format, fmt -> format(manager, fmt, model.get())); + return cachedDocuments.computeIfAbsent(format, fmt -> { + Object loadedModel = model.get(); + managerLock.lock(); + try { + return format(manager, fmt, loadedModel); + } finally { + managerLock.unlock(); + } + }); } private static final class SecuredRules implements HttpRules { diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiLockCoordinator.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiLockCoordinator.java new file mode 100644 index 00000000000..acefcde2be4 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiLockCoordinator.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.openapi; + +import java.lang.ref.ReferenceQueue; +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.concurrent.locks.ReentrantLock; + +final class OpenApiLockCoordinator { + private static final AtomicLong LOCK_IDS = new AtomicLong(); + private static final ReferenceQueue STALE_OWNERS = new ReferenceQueue<>(); + private static final ConcurrentMap LOCKS = new ConcurrentHashMap<>(); + + private OpenApiLockCoordinator() { + } + + static CoordinationLock coordinationLock(Object owner) { + IdentityWeakReference staleOwner; + while ((staleOwner = (IdentityWeakReference) STALE_OWNERS.poll()) != null) { + LOCKS.remove(staleOwner); + } + return LOCKS.computeIfAbsent(new IdentityWeakReference(owner, STALE_OWNERS), + _ -> new CoordinationLock(LOCK_IDS.getAndIncrement())); + } + + static LockHandle lock(Iterable requestedLocks) { + Set uniqueLocks = Collections.newSetFromMap(new IdentityHashMap<>()); + requestedLocks.forEach(uniqueLocks::add); + List locks = new ArrayList<>(uniqueLocks); + locks.sort(Comparator.comparingLong(lock -> lock.id)); + locks.forEach(lock -> lock.lock().lock()); + return new LockHandle(locks); + } + + static final class CoordinationLock { + private final long id; + private final ReentrantLock lock = new ReentrantLock(); + + private CoordinationLock(long id) { + this.id = id; + } + + ReentrantLock lock() { + return lock; + } + } + + static final class LockHandle implements AutoCloseable { + private final List locks; + + private LockHandle(List locks) { + this.locks = locks; + } + + @Override + public void close() { + for (int i = locks.size() - 1; i >= 0; i--) { + locks.get(i).lock().unlock(); + } + } + } + + private static final class IdentityWeakReference extends WeakReference { + private final int hashCode; + + private IdentityWeakReference(Object referent, ReferenceQueue referenceQueue) { + super(referent, referenceQueue); + this.hashCode = System.identityHashCode(referent); + } + + @Override + public int hashCode() { + return hashCode; + } + + @Override + public boolean equals(Object obj) { + if (this == obj) { + return true; + } + if (!(obj instanceof IdentityWeakReference other)) { + return false; + } + Object owner = get(); + return owner != null && owner == other.get(); + } + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiSourceBase.java b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiSourceBase.java new file mode 100644 index 00000000000..f75d4fb18ae --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/OpenApiSourceBase.java @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import io.helidon.common.Api; +import io.helidon.json.JsonException; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonParser; +import io.helidon.json.JsonString; +import io.helidon.json.JsonValue; +import io.helidon.json.schema.spi.JsonSchemaProvider; +import io.helidon.openapi.spi.OpenApiDocumentSource; + +/** + * Base class for generated OpenAPI document sources. + */ +@Api.Internal +public abstract class OpenApiSourceBase implements OpenApiDocumentSource { + static final String SCHEMA_REF_PREFIX = "#/components/schemas/"; + + /** + * Constructor with no side effects. + */ + @Api.Internal + protected OpenApiSourceBase() { + } + + /** + * Create a schema reference object. + * + * @param name schema name + * @return schema reference JSON object + */ + @Api.Internal + protected static JsonObject schemaRef(String name) { + return JsonObject.builder() + .set("$ref", SCHEMA_REF_PREFIX + name) + .build(); + } + + /** + * Create a simple schema object for a JSON type. + * + * @param type JSON schema type + * @return schema JSON object + */ + @Api.Internal + protected static JsonObject schema(String type) { + return JsonObject.builder() + .set("type", type) + .build(); + } + + /** + * Create an array schema object. + * + * @param items array item schema + * @return array schema JSON object + */ + @Api.Internal + protected static JsonObject arraySchema(JsonObject items) { + return JsonObject.builder() + .set("type", "array") + .set("items", items) + .build(); + } + + /** + * Create an OpenAPI example value from annotation text. + * + * @param value annotation value + * @return parsed JSON value, or a JSON string if the value is not JSON + */ + @Api.Internal + protected static JsonValue exampleValue(String value) { + String stripped = value.strip(); + if (!stripped.isEmpty()) { + try { + JsonParser parser = JsonParser.create(stripped); + JsonValue result = parser.readJsonValue(); + if (!parser.hasNext()) { + return result; + } + } catch (JsonException _) { + // Annotation values are strings by default; JSON parsing is an opt-in convenience. + } + } + return JsonString.create(value); + } + + /** + * Create an OpenAPI extension value from resolved annotation text. + * + * @param name extension name + * @param value resolved annotation value + * @param parseValue whether to parse the value as JSON + * @return extension JSON value + * @throws IllegalArgumentException if parsing is enabled and the value is not exactly one valid JSON value + */ + @Api.Internal + protected static JsonValue extensionValue(String name, String value, boolean parseValue) { + if (!parseValue) { + return JsonString.create(value); + } + try { + JsonParser parser = JsonParser.create(value.strip()); + JsonValue result = parser.readJsonValue(); + if (!parser.hasNext()) { + return result; + } + } catch (JsonException e) { + throw new IllegalArgumentException("OpenAPI extension " + name + + " must contain exactly one valid JSON value", e); + } + throw new IllegalArgumentException("OpenAPI extension " + name + + " must contain exactly one valid JSON value"); + } + + /** + * Add a component schema from a JSON schema provider to the OpenAPI document. + * + * @param document OpenAPI document builder + * @param provider JSON schema provider + * @param name schema name + */ + @Api.Internal + protected static void componentSchema(OpenApiDocument.Builder document, JsonSchemaProvider provider, String name) { + document.components(components -> components.schema(name, provider.schema().generateObjectNoKeywords())); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/SimpleOpenApiManager.java b/openapi/openapi/src/main/java/io/helidon/openapi/SimpleOpenApiManager.java index 83ab29a7bf4..25ead85a924 100644 --- a/openapi/openapi/src/main/java/io/helidon/openapi/SimpleOpenApiManager.java +++ b/openapi/openapi/src/main/java/io/helidon/openapi/SimpleOpenApiManager.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. + * Copyright (c) 2023, 2026 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ */ package io.helidon.openapi; +import io.helidon.openapi.v30.OpenApiDocumentMapperSupport; + import org.yaml.snakeyaml.DumperOptions; import org.yaml.snakeyaml.Yaml; import org.yaml.snakeyaml.nodes.Node; @@ -58,7 +60,7 @@ private String toYaml(String rawData) { LOGGER.log(System.Logger.Level.TRACE, "Converting OpenAPI document in YAML format"); } Yaml yaml = new Yaml(YAML_DUMPER_OPTIONS); - Object loadedData = yaml.load(rawData); + Object loadedData = OpenApiDocumentMapperSupport.parseYaml(rawData); return yaml.dump(loadedData); } @@ -76,14 +78,15 @@ protected Node representScalar(Tag tag, String value, DumperOptions.ScalarStyle } if (tag.equals(Tag.BOOL) || tag.equals(Tag.FLOAT) - || tag.equals(Tag.INT)) { + || tag.equals(Tag.INT) + || tag.equals(Tag.NULL)) { return super.representScalar(tag, value, DumperOptions.ScalarStyle.PLAIN); } return super.representScalar(tag, value, style); } }; Yaml yaml = new Yaml(representer, JSON_DUMPER_OPTIONS); - Object loadedData = yaml.load(data); + Object loadedData = OpenApiDocumentMapperSupport.parseYaml(data); return yaml.dump(loadedData); } diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiDocumentSource.java b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiDocumentSource.java new file mode 100644 index 00000000000..ff1ceb1f211 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiDocumentSource.java @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.spi; + +import java.util.Objects; + +import io.helidon.common.Api; +import io.helidon.openapi.OpenApi; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.service.registry.Service; + +/** + * Source of generated OpenAPI document metadata. + *

+ * A source can be qualified with {@link Service.Named @Service.Named} so the OpenAPI feature can select it with + * {@code generated.document-sources}. Unqualified sources always contribute when they {@link #supports(OpenApiDocumentContext) + * support} the document context and cannot be filtered by {@code generated.document-sources}. + *

+ * The final composed OpenAPI document must contain Info metadata. The metadata can be contributed by a custom source, + * generated from an application type annotated with {@link OpenApi.Document @OpenApi.Document} and + * {@link OpenApi.Info @OpenApi.Info}, or supplied by static OpenAPI content when static and generated content are merged. + * An individual source does not need to provide Info metadata if another contribution supplies it. + */ +@Api.Preview +@Service.Contract +public interface OpenApiDocumentSource { + /** + * Whether this source contributes to the provided document context. + * + * @param context document context + * @return {@code true} if this source should contribute + */ + default boolean supports(OpenApiDocumentContext context) { + Objects.requireNonNull(context); + return true; + } + + /** + * Describe this source as OpenAPI document metadata. + * + * @param context document context + * @param document document builder + */ + void describe(OpenApiDocumentContext context, OpenApiDocument.Builder document); +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiVersion.java b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiVersion.java new file mode 100644 index 00000000000..b2650223d4c --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiVersion.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.spi; + +import io.helidon.common.Api; +import io.helidon.common.media.type.MediaType; +import io.helidon.config.NamedService; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.service.registry.Service; + +/** + * OpenAPI version implementation. + */ +@Api.Preview +@Service.Contract +public interface OpenApiVersion extends NamedService { + /** + * OpenAPI version family supported by this implementation. + *

+ * The value is the major and minor version prefix, such as {@code 3.1}. It is used to select an implementation for + * static documents that declare a more specific version beginning with the family followed by a period, such as + * {@code 3.1.0-rc1}. + * + * @return supported OpenAPI version family + */ + @Override + String type(); + + /** + * Exact OpenAPI document version produced by this implementation. + * + * @return OpenAPI document version + */ + String version(); + + /** + * Parse OpenAPI content into the version-neutral document model. + * + * @param context document context + * @param content OpenAPI JSON or YAML content + * @param mediaType media type guessed from the static document path + * @return parsed document model + */ + OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType); + + /** + * Render an OpenAPI document. + *

+ * All supported OpenAPI versions require an Info Object. OpenAPI 3.0 documents also require a {@code paths} field. + * Use {@link OpenApiDocument.Builder#paths(java.util.Map)} with {@code Map.of()} to render an intentionally empty + * Paths Object. OpenAPI 3.1 and 3.2 documents require at least one of {@code paths}, {@code components}, or + * {@code webhooks}. + * + * @param context document context + * @param document version-neutral document model + * @return rendered OpenAPI document content + * @throws IllegalStateException if the document lacks required metadata or does not satisfy the root requirements + * for the rendered version + */ + String render(OpenApiDocumentContext context, OpenApiDocument document); +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiVersionProvider.java b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiVersionProvider.java new file mode 100644 index 00000000000..a2a4964c653 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/spi/OpenApiVersionProvider.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.spi; + +import io.helidon.common.Api; +import io.helidon.config.ConfiguredProvider; + +/** + * {@link OpenApiVersion} provider. + */ +@Api.Preview +public interface OpenApiVersionProvider extends ConfiguredProvider { +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30DocumentMapper.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30DocumentMapper.java new file mode 100644 index 00000000000..5125ed833a1 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30DocumentMapper.java @@ -0,0 +1,1219 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Consumer; + +import io.helidon.openapi.OpenApiDocument; + +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.allowed; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.copy; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.copyAllowed; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.copyField; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.copyFieldValue; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.copyReferenceFields; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.jsonObject; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.object; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.objectList; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.objectMap; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateDocumentStructure; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateOperationIds; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateSchemas; +import static io.helidon.openapi.v30.OpenApiDocumentMapperSupport.validateSecurityRequirementNames; + +final class OpenApi30DocumentMapper { + private static final Set REFERENCE_FIELDS = Set.of("$ref"); + private static final Set FIXED_PATH_OPERATION_FIELDS = Set.of("get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace"); + + private static final Set DOCUMENT_FIELDS = Set.of("openapi", + "info", + "servers", + "paths", + "components", + "security", + "tags", + "externalDocs"); + private static final Set INFO_FIELDS = Set.of("title", + "description", + "termsOfService", + "contact", + "license", + "version"); + private static final Set CONTACT_FIELDS = Set.of("name", + "url", + "email"); + private static final Set LICENSE_FIELDS = Set.of("name", + "url"); + private static final Set SERVER_FIELDS = Set.of("url", + "description", + "variables"); + private static final Set SERVER_VARIABLE_FIELDS = Set.of("enum", + "default", + "description"); + private static final Set TAG_FIELDS = Set.of("name", + "description", + "externalDocs"); + private static final Set PATH_ITEM_FIELDS = Set.of("$ref", + "summary", + "description", + "get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "servers", + "parameters"); + private static final Set OPERATION_FIELDS = Set.of("tags", + "summary", + "description", + "externalDocs", + "operationId", + "parameters", + "requestBody", + "responses", + "callbacks", + "deprecated", + "security", + "servers"); + private static final Set PARAMETER_FIELDS = Set.of("$ref", + "name", + "in", + "description", + "required", + "deprecated", + "allowEmptyValue", + "style", + "explode", + "allowReserved", + "schema", + "example", + "examples", + "content"); + private static final Set HEADER_FIELDS = Set.of("$ref", + "description", + "required", + "deprecated", + "style", + "explode", + "schema", + "example", + "examples", + "content"); + private static final Set REQUEST_BODY_FIELDS = Set.of("$ref", + "description", + "content", + "required"); + private static final Set RESPONSE_FIELDS = Set.of("$ref", + "description", + "headers", + "content", + "links"); + private static final Set MEDIA_TYPE_FIELDS = Set.of("schema", + "example", + "examples", + "encoding"); + private static final Set ENCODING_FIELDS = Set.of("contentType", + "headers", + "style", + "explode", + "allowReserved"); + private static final Set COMPONENTS_FIELDS = Set.of("schemas", + "responses", + "parameters", + "examples", + "requestBodies", + "headers", + "securitySchemes", + "links", + "callbacks"); + private static final Set SECURITY_SCHEME_FIELDS = Set.of("$ref", + "type", + "description", + "name", + "in", + "scheme", + "bearerFormat", + "flows", + "openIdConnectUrl"); + private static final Set SECURITY_SCHEME_TYPES = Set.of("apiKey", + "http", + "oauth2", + "openIdConnect"); + private static final Set SCOPED_SECURITY_SCHEME_TYPES = Set.of("oauth2", "openIdConnect"); + private static final String SECURITY_SCHEME_REFERENCE_PREFIX = "#/components/securitySchemes/"; + private static final Set OAUTH_FLOWS_FIELDS = Set.of("implicit", + "password", + "clientCredentials", + "authorizationCode"); + private static final Set OAUTH_FLOW_FIELDS = Set.of("authorizationUrl", + "tokenUrl", + "refreshUrl", + "scopes"); + private static final Set LINK_FIELDS = Set.of("$ref", + "operationRef", + "operationId", + "parameters", + "requestBody", + "description", + "server"); + private static final Set EXAMPLE_FIELDS = Set.of("$ref", + "summary", + "description", + "value", + "externalValue"); + private static final Set EXTERNAL_DOCS_FIELDS = Set.of("description", + "url"); + private static final Set SCHEMA_FIELDS = Set.of("$ref", + "title", + "multipleOf", + "maximum", + "exclusiveMaximum", + "minimum", + "exclusiveMinimum", + "maxLength", + "minLength", + "pattern", + "maxItems", + "minItems", + "uniqueItems", + "maxProperties", + "minProperties", + "required", + "enum", + "type", + "allOf", + "oneOf", + "anyOf", + "not", + "items", + "properties", + "additionalProperties", + "description", + "format", + "default", + "nullable", + "discriminator", + "readOnly", + "writeOnly", + "xml", + "externalDocs", + "example", + "deprecated"); + private static final Set PARAMETER_LOCATIONS = Set.of("query", + "header", + "path", + "cookie"); + private static final OpenApi3xMapperRules MAPPER_RULES = OpenApi3xMapperRules.builder() + .targetVersion("3.0") + .operationResponsesRequired(true) + .responseDescriptionRequired(true) + .addDocumentFields(DOCUMENT_FIELDS) + .addInfoFields(INFO_FIELDS) + .addContactFields(CONTACT_FIELDS) + .addLicenseFields(LICENSE_FIELDS) + .addServerFields(SERVER_FIELDS) + .addServerVariableFields(SERVER_VARIABLE_FIELDS) + .addTagFields(TAG_FIELDS) + .addPathItemFields(PATH_ITEM_FIELDS) + .addFixedPathOperationFields(FIXED_PATH_OPERATION_FIELDS) + .addOperationFields(OPERATION_FIELDS) + .addParameterFields(PARAMETER_FIELDS) + .addParameterLocations(PARAMETER_LOCATIONS) + .addHeaderFields(HEADER_FIELDS) + .addRequestBodyFields(REQUEST_BODY_FIELDS) + .addResponseFields(RESPONSE_FIELDS) + .addMediaTypeFields(MEDIA_TYPE_FIELDS) + .addEncodingFields(ENCODING_FIELDS) + .addComponentsFields(COMPONENTS_FIELDS) + .addSecuritySchemeFields(SECURITY_SCHEME_FIELDS) + .addSecuritySchemeTypes(SECURITY_SCHEME_TYPES) + .addOauthFlowsFields(OAUTH_FLOWS_FIELDS) + .addOauthFlowFields(OAUTH_FLOW_FIELDS) + .addLinkFields(LINK_FIELDS) + .addExampleFields(EXAMPLE_FIELDS) + .addExternalDocsFields(EXTERNAL_DOCS_FIELDS) + .build(); + + private OpenApi30DocumentMapper() { + } + + static OpenApiDocument parse(Map document) { + validateOpenApi30(document.get("openapi")); + validateDocumentStructure(document, MAPPER_RULES); + validateSchemas(document, MAPPER_RULES); + Map mapped = document(document, SchemaMode.CANONICAL); + validateDocumentStructure(mapped, MAPPER_RULES); + validateSchemas(mapped, MAPPER_RULES); + validateOperationIds(mapped); + validateSecurityRequirementNames(mapped, MAPPER_RULES); + OpenApiDocument result = OpenApiDocumentReader.read(jsonObject(mapped)); + validateSecurityRequirementScopes(mapped); + return result; + } + + static Map render(OpenApiDocument document, String version) { + Map rendered = document(objectMap(document.toJsonObject()), SchemaMode.OPENAPI30); + validateDocumentStructure(rendered, MAPPER_RULES); + validateSchemas(rendered, MAPPER_RULES); + validateSecurityRequirementNames(rendered, MAPPER_RULES); + validateSecurityRequirementScopes(rendered); + validateOperationIds(rendered); + Map result = new LinkedHashMap<>(); + result.put("openapi", version); + rendered.forEach((key, value) -> { + if (!"openapi".equals(key)) { + result.put(key, value); + } + }); + return result; + } + + private static Map document(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, DOCUMENT_FIELDS)) { + return; + } + switch (key) { + case "info" -> object(value, object -> result.put(key, info(object))); + case "servers" -> result.put(key, serverList(value)); + case "paths" -> object(value, object -> result.put(key, paths(object, mode))); + case "components" -> object(value, object -> result.put(key, components(object, mode))); + case "tags" -> result.put(key, tagList(value)); + case "externalDocs" -> object(value, object -> result.put(key, copyAllowed(object, EXTERNAL_DOCS_FIELDS))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map info(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, INFO_FIELDS)) { + return; + } + switch (key) { + case "contact" -> object(value, object -> result.put(key, copyAllowed(object, CONTACT_FIELDS))); + case "license" -> object(value, object -> result.put(key, copyAllowed(object, LICENSE_FIELDS))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static List serverList(Object value) { + return objectList(value, OpenApi30DocumentMapper::server); + } + + private static Map server(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, SERVER_FIELDS)) { + return; + } + if ("variables".equals(key)) { + object(value, object -> result.put(key, serverVariables(object))); + } else { + copyField(result, key, source); + } + }); + return result; + } + + private static Map serverVariables(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, + object -> result.put(key, copyAllowed(object, SERVER_VARIABLE_FIELDS)))); + return result; + } + + private static List tagList(Object value) { + return objectList(value, tag -> { + Map result = new LinkedHashMap<>(); + tag.forEach((key, item) -> { + if (!allowed(key, TAG_FIELDS)) { + return; + } + if ("externalDocs".equals(key)) { + object(item, object -> result.put(key, copyAllowed(object, EXTERNAL_DOCS_FIELDS))); + } else { + copyField(result, key, tag); + } + }); + return result; + }); + } + + private static Map paths(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (key.startsWith("x-")) { + copyField(result, key, source); + } else { + object(value, object -> result.put(key, pathItem(object, mode))); + } + }); + return result; + } + + private static Map pathItem(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, PATH_ITEM_FIELDS)) { + return; + } + if (isFixedPathOperationField(key)) { + object(value, object -> result.put(key, operation(object, mode))); + return; + } + switch (key) { + case "servers" -> result.put(key, serverList(value)); + case "parameters" -> result.put(key, parameters(value, mode)); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map operation(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, OPERATION_FIELDS)) { + return; + } + switch (key) { + case "parameters" -> result.put(key, parameters(value, mode)); + case "requestBody" -> object(value, object -> result.put(key, requestBody(object, mode))); + case "responses" -> object(value, object -> result.put(key, responses(object, mode, true))); + case "callbacks" -> object(value, object -> result.put(key, callbacks(object, mode))); + case "servers" -> result.put(key, serverList(value)); + case "externalDocs" -> object(value, object -> result.put(key, copyAllowed(object, EXTERNAL_DOCS_FIELDS))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static List parameters(Object value, SchemaMode mode) { + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object item : list) { + object(item, object -> { + Map parameter = parameter(object, mode); + if (!parameter.isEmpty()) { + result.add(parameter); + } + }); + } + return result; + } + + private static Map parameter(Map source, SchemaMode mode) { + if (!source.containsKey("$ref") && !PARAMETER_LOCATIONS.contains(String.valueOf(source.get("in")))) { + return Map.of(); + } + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, PARAMETER_FIELDS)) { + return; + } + switch (key) { + case "schema" -> result.put(key, schema(value, mode)); + case "content" -> object(value, object -> result.put(key, content(object, mode))); + case "examples" -> result.put(key, examples(value)); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map requestBody(Map source, SchemaMode mode) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, REQUEST_BODY_FIELDS)) { + return; + } + if ("content".equals(key)) { + object(value, object -> result.put(key, content(object, mode))); + } else { + copyField(result, key, source); + } + }); + return result; + } + + private static Map responses(Map source, SchemaMode mode, boolean containerExtensions) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (containerExtensions && key.startsWith("x-")) { + copyField(result, key, source); + return; + } + if (containerExtensions && !OpenApiDocumentMapperSupport.isResponseCode(key)) { + throw new IllegalStateException("Invalid OpenAPI 3.0 Responses Object key: " + key + "."); + } + object(value, object -> result.put(key, response(object, mode))); + }); + return result; + } + + private static Map response(Map source, SchemaMode mode) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, RESPONSE_FIELDS)) { + return; + } + switch (key) { + case "headers" -> object(value, object -> result.put(key, headers(object, mode))); + case "content" -> object(value, object -> result.put(key, content(object, mode))); + case "links" -> object(value, object -> result.put(key, links(object))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map headers(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, header(object, mode)))); + return result; + } + + private static Map header(Map source, SchemaMode mode) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, HEADER_FIELDS)) { + return; + } + switch (key) { + case "schema" -> result.put(key, schema(value, mode)); + case "content" -> object(value, object -> result.put(key, content(object, mode))); + case "examples" -> result.put(key, examples(value)); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map content(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, mediaType(object, mode)))); + return result; + } + + private static Map mediaType(Map source, SchemaMode mode) { + if (source.containsKey("$ref")) { + throw unsupported("media type reference", String.valueOf(source.get("$ref")), "mediaType"); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, MEDIA_TYPE_FIELDS)) { + return; + } + switch (key) { + case "schema" -> result.put(key, schema(value, mode)); + case "examples" -> result.put(key, examples(value)); + case "encoding" -> object(value, object -> result.put(key, encodings(object, mode))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map encodings(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, encoding(object, mode)))); + return result; + } + + private static Map encoding(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, ENCODING_FIELDS)) { + return; + } + if ("headers".equals(key)) { + object(value, object -> result.put(key, headers(object, mode))); + } else { + copyField(result, key, source); + } + }); + return result; + } + + private static Map components(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, COMPONENTS_FIELDS)) { + return; + } + switch (key) { + case "schemas" -> object(value, object -> result.put(key, schemaMap(object, mode))); + case "responses" -> object(value, object -> result.put(key, responses(object, mode, false))); + case "parameters" -> object(value, object -> result.put(key, parameterMap(object, mode))); + case "examples" -> result.put(key, examples(value)); + case "requestBodies" -> object(value, object -> result.put(key, requestBodyMap(object, mode))); + case "headers" -> object(value, object -> result.put(key, headers(object, mode))); + case "securitySchemes" -> object(value, object -> result.put(key, securitySchemes(object))); + case "links" -> object(value, object -> result.put(key, links(object))); + case "callbacks" -> object(value, object -> result.put(key, callbacks(object, mode))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map schemaMap(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> result.put(key, schema(value, mode))); + return result; + } + + private static Map parameterMap(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> { + Map parameter = parameter(object, mode); + if (!parameter.isEmpty()) { + result.put(key, parameter); + } + })); + return result; + } + + private static Map requestBodyMap(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, requestBody(object, mode)))); + return result; + } + + private static Map securitySchemes(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, securityScheme(key, object)))); + return result; + } + + private static Map securityScheme(String name, Map source) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, SECURITY_SCHEME_FIELDS)) { + return; + } + switch (key) { + case "type" -> { + String type = String.valueOf(value); + if (!SECURITY_SCHEME_TYPES.contains(type)) { + throw unsupported("security scheme type", type, securitySchemePath(name)); + } + copyField(result, key, source); + } + case "flows" -> object(value, + object -> result.put(key, + oauthFlows(securitySchemePath(name) + ".flows", object))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map oauthFlows(String path, Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (key.startsWith("x-")) { + copyField(result, key, source); + return; + } + if (!allowed(key, OAUTH_FLOWS_FIELDS)) { + throw unsupported("OAuth flow", key, path); + } + object(value, object -> result.put(key, copyAllowed(object, OAUTH_FLOW_FIELDS))); + }); + return result; + } + + private static IllegalStateException unsupported(String kind, String value, String path) { + return new IllegalStateException("Unsupported OpenAPI 3.0 " + + kind + + " '" + + value + + "' at " + + path); + } + + private static String securitySchemePath(String name) { + return "components.securitySchemes." + name; + } + + private static Map reference(Map source) { + return copyReferenceFields(source, REFERENCE_FIELDS); + } + + private static Map links(Map source) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> { + if (object.containsKey("$ref")) { + result.put(key, reference(object)); + return; + } + Map link = new LinkedHashMap<>(); + object.forEach((linkKey, linkValue) -> { + if (!allowed(linkKey, LINK_FIELDS)) { + return; + } + if ("server".equals(linkKey)) { + object(linkValue, server -> link.put(linkKey, server(server))); + } else { + copyField(link, linkKey, object); + } + }); + result.put(key, link); + })); + return result; + } + + private static Map callbacks(Map source, SchemaMode mode) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, callback(object, mode)))); + return result; + } + + private static Map callback(Map source, SchemaMode mode) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (key.startsWith("x-")) { + copyField(result, key, source); + } else { + object(value, object -> result.put(key, pathItem(object, mode))); + } + }); + return result; + } + + private static Map examples(Object value) { + if (!(value instanceof Map map)) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> object(item, object -> { + if (object.containsKey("$ref")) { + result.put(String.valueOf(key), reference(object)); + } else { + result.put(String.valueOf(key), copyAllowed(object, EXAMPLE_FIELDS)); + } + })); + return result; + } + + private static Object schema(Object value, SchemaMode mode) { + if (value instanceof Boolean bool) { + return mode == SchemaMode.OPENAPI30 ? booleanSchema(bool) : bool; + } + if (!(value instanceof Map map)) { + return copy(value); + } + Map source = objectMap(map); + if (mode == SchemaMode.CANONICAL && source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + boolean nullable = false; + Object enumValue = null; + boolean hasEnum = false; + Object constValue = null; + boolean hasConst = false; + TypeMapping typeMapping = null; + for (Map.Entry entry : source.entrySet()) { + String key = entry.getKey(); + Object item = entry.getValue(); + if ("nullable".equals(key)) { + nullable = Boolean.TRUE.equals(item); + continue; + } + if ("type".equals(key)) { + typeMapping = mode == SchemaMode.CANONICAL ? canonicalType(item) : openApi30Type(item); + nullable |= typeMapping.nullable(); + continue; + } + if ("enum".equals(key)) { + enumValue = item; + hasEnum = true; + continue; + } + if ("const".equals(key)) { + if (mode == SchemaMode.OPENAPI30) { + constValue = copyFieldValue(key, source); + hasConst = true; + } + continue; + } + if (!allowed(key, SCHEMA_FIELDS)) { + continue; + } + switch (key) { + case "maximum" -> bound(result, source, key, "exclusiveMaximum", item, mode); + case "minimum" -> bound(result, source, key, "exclusiveMinimum", item, mode); + case "exclusiveMaximum" -> exclusiveBound(result, source, "maximum", key, item, mode); + case "exclusiveMinimum" -> exclusiveBound(result, source, "minimum", key, item, mode); + case "allOf", "oneOf", "anyOf" -> result.put(key, schemaList(item, mode)); + case "not", "items" -> result.put(key, schema(item, mode)); + case "properties" -> object(item, object -> result.put(key, schemaMap(object, mode))); + case "additionalProperties" -> result.put(key, additionalProperties(item, mode)); + case "externalDocs" -> object(item, object -> result.put(key, copyAllowed(object, EXTERNAL_DOCS_FIELDS))); + default -> copyField(result, key, source); + } + } + if (typeMapping != null) { + typeMapping.put(result); + } + if (hasEnum) { + result.put("enum", copy(enumValue)); + } + if (hasConst) { + if (hasEnum) { + List allOf = result.get("allOf") instanceof List existing + ? new ArrayList<>(existing) + : new ArrayList<>(); + allOf.add(Map.of("enum", singleValueList(constValue))); + result.put("allOf", allOf); + } else { + result.put("enum", singleValueList(constValue)); + } + } + if (nullable) { + if (mode == SchemaMode.CANONICAL) { + addNullType(result); + } else if (result.containsKey("type")) { + result.put("nullable", true); + } else if (result.containsKey("oneOf")) { + addNullOneOf(result); + } else { + result.put("nullable", true); + } + } + if (mode == SchemaMode.OPENAPI30 && result.size() > 1 && result.containsKey("$ref")) { + Map reference = new LinkedHashMap<>(); + reference.put("$ref", result.remove("$ref")); + List allOf = new ArrayList<>(); + allOf.add(reference); + if (result.get("allOf") instanceof List existing) { + allOf.addAll(existing); + } + result.put("allOf", allOf); + } + return result; + } + + private static void bound(Map target, + Map source, + String boundName, + String exclusiveBoundName, + Object value, + SchemaMode mode) { + if (mode == SchemaMode.CANONICAL && Boolean.TRUE.equals(source.get(exclusiveBoundName))) { + return; + } + Object exclusiveBound = source.get(exclusiveBoundName); + if (mode == SchemaMode.OPENAPI30 && exclusiveBound instanceof Number exclusiveNumber) { + if (!(value instanceof Number inclusiveNumber) + || exclusiveBoundWins(boundName, inclusiveNumber, exclusiveNumber)) { + return; + } + target.remove(exclusiveBoundName); + } + target.put(boundName, copy(value)); + } + + private static void exclusiveBound(Map target, + Map source, + String boundName, + String exclusiveBoundName, + Object value, + SchemaMode mode) { + if (mode == SchemaMode.OPENAPI30 && value instanceof Number) { + Object inclusiveBound = source.get(boundName); + if (inclusiveBound instanceof Number inclusiveNumber + && !exclusiveBoundWins(boundName, inclusiveNumber, (Number) value)) { + target.put(boundName, copy(inclusiveBound)); + target.remove(exclusiveBoundName); + return; + } + target.put(boundName, copy(value)); + target.put(exclusiveBoundName, true); + } else if (mode == SchemaMode.CANONICAL && value instanceof Boolean exclusive) { + if (exclusive) { + Object bound = source.get(boundName); + if (bound != null) { + target.put(exclusiveBoundName, copy(bound)); + } + } + } else { + target.put(exclusiveBoundName, copy(value)); + } + } + + private static boolean exclusiveBoundWins(String boundName, Number inclusiveBound, Number exclusiveBound) { + int compare = decimal(inclusiveBound).compareTo(decimal(exclusiveBound)); + return switch (boundName) { + case "maximum" -> compare >= 0; + case "minimum" -> compare <= 0; + default -> true; + }; + } + + private static BigDecimal decimal(Number number) { + if (number instanceof BigDecimal bigDecimal) { + return bigDecimal; + } + return new BigDecimal(number.toString()); + } + + private static Object additionalProperties(Object value, SchemaMode mode) { + if (value instanceof Boolean) { + return value; + } + return schema(value, mode); + } + + private static List schemaList(Object value, SchemaMode mode) { + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + list.forEach(item -> result.add(schema(item, mode))); + return result; + } + + private static Map booleanSchema(boolean value) { + Map result = new LinkedHashMap<>(); + if (!value) { + result.put("not", new LinkedHashMap()); + } + return result; + } + + private static TypeMapping canonicalType(Object value) { + return new TypeMapping(copy(value), null, false); + } + + private static TypeMapping openApi30Type(Object value) { + if (!(value instanceof List list)) { + if ("null".equals(value)) { + return new TypeMapping(nullOnlySchema(), null, false); + } + return new TypeMapping(copy(value), null, false); + } + List types = new ArrayList<>(); + boolean nullable = false; + for (Object type : list) { + if ("null".equals(type)) { + nullable = true; + } else { + types.add(copy(type)); + } + } + if (types.isEmpty()) { + return nullable ? new TypeMapping(nullOnlySchema(), null, false) : new TypeMapping(null, null, false); + } + if (types.size() == 1) { + return new TypeMapping(types.getFirst(), null, nullable); + } + List anyOf = new ArrayList<>(); + types.forEach(type -> anyOf.add(Map.of("type", type))); + if (nullable) { + anyOf.add(nullOnlySchema()); + } + return new TypeMapping(null, anyOf, false); + } + + private static void addNullType(Map schema) { + Object type = schema.get("type"); + if (type == null) { + return; + } + if (type instanceof List list) { + if (!list.contains("null")) { + List result = new ArrayList<>(list); + result.add("null"); + schema.put("type", result); + } + return; + } + if (!"null".equals(type)) { + List result = new ArrayList<>(); + result.add(type); + result.add("null"); + schema.put("type", result); + } + } + + private static void addNullOneOf(Map schema) { + Object value = schema.get("oneOf"); + if (!(value instanceof List list)) { + return; + } + Map nullOnlySchema = nullOnlySchema(); + if (list.contains(nullOnlySchema)) { + return; + } + List result = new ArrayList<>(list); + result.add(nullOnlySchema); + schema.put("oneOf", result); + } + + private static List singleValueList(Object value) { + List result = new ArrayList<>(); + result.add(value); + return result; + } + + private static Map nullOnlySchema() { + Map result = new LinkedHashMap<>(); + result.put("type", "object"); + result.put("nullable", true); + result.put("enum", singleValueList(null)); + return result; + } + + private static void validateOpenApi30(Object openapi) { + if (openapi == null) { + throw new IllegalStateException("Static OpenAPI document must declare an openapi version."); + } + if (!(openapi instanceof String version) || !OpenApi30Version.isSupportedVersion(version)) { + throw new IllegalStateException("OpenAPI 3.0 version implementation cannot parse static OpenAPI document version " + + openapi + "."); + } + } + + private static void validateSecurityRequirementScopes(Map document) { + Map> securitySchemes = new LinkedHashMap<>(); + objectIfPresent(document.get("components"), components -> + objectIfPresent(components.get("securitySchemes"), values -> values.forEach((name, value) -> + objectIfPresent(value, securityScheme -> securitySchemes.put(name, securityScheme))))); + + Map securitySchemeTypes = new LinkedHashMap<>(); + Set unresolvedSecuritySchemeTypes = new HashSet<>(); + securitySchemes.keySet().forEach(name -> { + List aliases = new ArrayList<>(); + Set aliasesSeen = new HashSet<>(); + String currentName = name; + String type = null; + while (true) { + if (securitySchemeTypes.containsKey(currentName)) { + type = securitySchemeTypes.get(currentName); + break; + } + if (unresolvedSecuritySchemeTypes.contains(currentName) || !aliasesSeen.add(currentName)) { + break; + } + aliases.add(currentName); + Map currentScheme = securitySchemes.get(currentName); + if (currentScheme == null) { + break; + } + Object declaredType = currentScheme.get("type"); + if (declaredType instanceof String schemeType) { + type = schemeType; + break; + } + Object referenceValue = currentScheme.get("$ref"); + if (!(referenceValue instanceof String reference)) { + break; + } + String normalizedReference = reference; + int percentIndex = reference.indexOf('%'); + if (percentIndex >= 0) { + StringBuilder normalized = new StringBuilder(reference.length()); + int copyFrom = 0; + for (int i = percentIndex; i < reference.length(); i++) { + if (reference.charAt(i) != '%' || i + 2 >= reference.length()) { + continue; + } + int high = Character.digit(reference.charAt(i + 1), 16); + int low = Character.digit(reference.charAt(i + 2), 16); + if (high < 0 || low < 0) { + continue; + } + char decoded = (char) ((high << 4) + low); + if ((decoded >= 'a' && decoded <= 'z') + || (decoded >= 'A' && decoded <= 'Z') + || (decoded >= '0' && decoded <= '9') + || decoded == '-' + || decoded == '.' + || decoded == '_' + || decoded == '~') { + normalized.append(reference, copyFrom, i).append(decoded); + i += 2; + copyFrom = i + 1; + } + } + normalized.append(reference, copyFrom, reference.length()); + normalizedReference = normalized.toString(); + } + if (!normalizedReference.startsWith(SECURITY_SCHEME_REFERENCE_PREFIX)) { + break; + } + String referencedName = normalizedReference.substring(SECURITY_SCHEME_REFERENCE_PREFIX.length()); + if (referencedName.isEmpty() || referencedName.indexOf('/') >= 0) { + break; + } + currentName = referencedName; + } + if (type == null) { + unresolvedSecuritySchemeTypes.addAll(aliases); + } else { + String resolvedType = type; + aliases.forEach(alias -> securitySchemeTypes.put(alias, resolvedType)); + } + }); + + objectIfPresent(document.get("components"), components -> { + objectIfPresent(components.get("callbacks"), callbacks -> callbacks.values().forEach(callback -> + objectIfPresent(callback, + value -> validateCallbackSecurityRequirementScopes(value, + securitySchemeTypes)))); + }); + validateSecurityRequirementScopes(document.get("security"), securitySchemeTypes); + objectIfPresent(document.get("paths"), paths -> paths.forEach((path, value) -> { + if (!path.startsWith("x-")) { + objectIfPresent(value, + pathItem -> validatePathItemSecurityRequirementScopes(pathItem, + securitySchemeTypes)); + } + })); + } + + private static void validatePathItemSecurityRequirementScopes(Map pathItem, + Map securitySchemeTypes) { + pathItem.forEach((field, value) -> { + if (isFixedPathOperationField(field)) { + objectIfPresent(value, operation -> { + validateSecurityRequirementScopes(operation.get("security"), + securitySchemeTypes); + objectIfPresent(operation.get("callbacks"), callbacks -> callbacks.values().forEach(callback -> + objectIfPresent(callback, + item -> validateCallbackSecurityRequirementScopes(item, + securitySchemeTypes)))); + }); + } + }); + } + + private static void validateCallbackSecurityRequirementScopes(Map callback, + Map securitySchemeTypes) { + callback.forEach((expression, value) -> { + if (!expression.startsWith("x-")) { + objectIfPresent(value, + pathItem -> validatePathItemSecurityRequirementScopes(pathItem, + securitySchemeTypes)); + } + }); + } + + private static void validateSecurityRequirementScopes(Object value, + Map securitySchemeTypes) { + if (!(value instanceof List requirements)) { + return; + } + requirements.forEach(requirement -> objectIfPresent(requirement, + schemes -> schemes.forEach((name, scopesValue) -> { + String type = securitySchemeTypes.get(name); + if (type != null + && !SCOPED_SECURITY_SCHEME_TYPES.contains(type) + && scopesValue instanceof List scopes + && !scopes.isEmpty()) { + throw new IllegalStateException("OpenAPI 3.0 Security Requirement Object requires an empty " + + "scope array for " + type + " security scheme " + name + "."); + } + }))); + } + + private static void objectIfPresent(Object value, Consumer> consumer) { + if (value != null) { + object(value, consumer); + } + } + + private static boolean isFixedPathOperationField(String value) { + return switch (value) { + case "get", "put", "post", "delete", "options", "head", "patch", "trace" -> true; + default -> false; + }; + } + + private enum SchemaMode { + CANONICAL, + OPENAPI30 + } + + private record TypeMapping(Object type, List anyOf, boolean nullable) { + void put(Map target) { + if (type instanceof Map map) { + map.forEach((key, value) -> target.put(String.valueOf(key), value)); + } else if (type != null) { + target.put("type", type); + } + if (anyOf != null) { + if (target.containsKey("allOf") || target.containsKey("oneOf") || target.containsKey("anyOf")) { + List allOf = target.get("allOf") instanceof List existing + ? new ArrayList<>(existing) + : new ArrayList<>(); + allOf.add(Map.of("anyOf", anyOf)); + target.put("allOf", allOf); + } else { + target.put("anyOf", anyOf); + } + } + } + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30Version.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30Version.java new file mode 100644 index 00000000000..7198c364235 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30Version.java @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import io.helidon.builder.api.RuntimeType; +import io.helidon.common.Api; +import io.helidon.common.media.type.MediaType; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.openapi.OpenApiFormat; +import io.helidon.openapi.spi.OpenApiVersion; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.Yaml; + +/** + * OpenAPI 3.0 version implementation. + */ +@Api.Preview +public final class OpenApi30Version implements OpenApiVersion, + RuntimeType.Api { + static final String TYPE = "3.0"; + private static final Pattern VERSION_PATTERN = Pattern.compile(Pattern.quote(TYPE) + "\\.[0-9]+(?:-.+)?"); + private static final DumperOptions YAML_DUMPER_OPTIONS = yamlDumperOptions(); + + private final OpenApi30VersionConfig config; + + OpenApi30Version(OpenApi30VersionConfig config) { + Objects.requireNonNull(config); + String version = config.version(); + if (!isSupportedVersion(version)) { + throw new IllegalArgumentException("OpenAPI " + TYPE + " version implementation cannot produce document version " + + version + "."); + } + this.config = config; + } + + static boolean isSupportedVersion(String version) { + return VERSION_PATTERN.matcher(version).matches(); + } + + /** + * Returns a new builder. + * + * @return new builder + */ + public static OpenApi30VersionConfig.Builder builder() { + return OpenApi30VersionConfig.builder(); + } + + /** + * Create a new OpenAPI 3.0 version implementation with default configuration. + * + * @return new version implementation + */ + public static OpenApi30Version create() { + return builder().build(); + } + + /** + * Create a new OpenAPI 3.0 version implementation with custom configuration. + * + * @param consumer configuration consumer + * @return new version implementation + */ + public static OpenApi30Version create(Consumer consumer) { + return builder() + .update(consumer) + .build(); + } + + /** + * Create a new OpenAPI 3.0 version implementation from typed configuration. + * + * @param config typed configuration + * @return new version implementation + */ + public static OpenApi30Version create(OpenApi30VersionConfig config) { + return new OpenApi30Version(config); + } + + @Override + public String version() { + return config.version(); + } + + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType) { + Objects.requireNonNull(context); + Objects.requireNonNull(content); + Objects.requireNonNull(mediaType); + if (OpenApiFormat.valueOf(mediaType) == OpenApiFormat.UNSUPPORTED) { + throw new IllegalStateException("Unsupported static OpenAPI content type: " + mediaType.text()); + } + Object loaded = OpenApiDocumentMapperSupport.parseYaml(content); + if (loaded == null) { + return OpenApiDocument.builder().build(); + } + if (loaded instanceof Map map) { + Map values = new LinkedHashMap<>(); + map.forEach((key, value) -> values.put(String.valueOf(key), value)); + return OpenApi30DocumentMapper.parse(values); + } + throw new IllegalStateException("Static OpenAPI content must be a YAML or JSON object."); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + Objects.requireNonNull(context); + Objects.requireNonNull(document); + OpenApiDocumentMapperSupport.validateDocumentRoot(document, config.version()); + Map values = OpenApi30DocumentMapper.render(document, config.version()); + return new Yaml(YAML_DUMPER_OPTIONS).dump(values); + } + + @Override + public OpenApi30VersionConfig prototype() { + return config; + } + + @Override + public String name() { + return config.name(); + } + + @Override + public String type() { + return TYPE; + } + + private static DumperOptions yamlDumperOptions() { + DumperOptions dumperOptions = new DumperOptions(); + dumperOptions.setIndent(2); + dumperOptions.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK); + return dumperOptions; + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30VersionConfigBlueprint.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30VersionConfigBlueprint.java new file mode 100644 index 00000000000..4194ed32078 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30VersionConfigBlueprint.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.Api; +import io.helidon.openapi.spi.OpenApiVersionProvider; + +/** + * OpenAPI 3.0 version configuration. + */ +@Api.Preview +@Prototype.Blueprint +@Prototype.Configured(value = OpenApi30Version.TYPE, root = false) +@Prototype.Provides(OpenApiVersionProvider.class) +interface OpenApi30VersionConfigBlueprint extends Prototype.Factory { + /** + * Name of this version configuration. + * + * @return version implementation name + */ + @Option.Default(OpenApi30Version.TYPE) + String name(); + + /** + * Exact OpenAPI 3.0 document version to produce. + * + * @return OpenAPI document version + */ + @Option.Configured + @Option.Default("3.0.3") + String version(); +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30VersionProvider.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30VersionProvider.java new file mode 100644 index 00000000000..06904db4564 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi30VersionProvider.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.Objects; + +import io.helidon.common.Api; +import io.helidon.common.Weight; +import io.helidon.config.Config; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; +import io.helidon.service.registry.Service; + +/** + * OpenAPI 3.0 version provider. + */ +@Service.Singleton +@Weight(3000) +public class OpenApi30VersionProvider implements OpenApiVersionProvider { + /** + * Required public constructor. + */ + @Api.Internal + public OpenApi30VersionProvider() { + } + + @Override + public String configKey() { + return OpenApi30Version.TYPE; + } + + @Override + public OpenApiVersion create(Config config, String name) { + return OpenApi30VersionConfig.builder() + .config(Objects.requireNonNull(config)) + .name(Objects.requireNonNull(name)) + .build(); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi3xMapperRulesBlueprint.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi3xMapperRulesBlueprint.java new file mode 100644 index 00000000000..1e13950fad2 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApi3xMapperRulesBlueprint.java @@ -0,0 +1,253 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.Set; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; +import io.helidon.common.Api; + +/** + * OpenAPI 3.x version-specific mapper rules. + */ +@Api.Internal +@Prototype.Blueprint +interface OpenApi3xMapperRulesBlueprint { + /** + * Target OpenAPI version. + * + * @return target OpenAPI version + */ + String targetVersion(); + + /** + * Whether operations must define responses. + * + * @return whether operation responses are required + */ + @Option.DefaultBoolean(false) + boolean operationResponsesRequired(); + + /** + * Whether responses must define a description. + * + * @return whether response descriptions are required + */ + @Option.DefaultBoolean(false) + boolean responseDescriptionRequired(); + + /** + * Document fields. + * + * @return document fields + */ + @Option.Singular + Set documentFields(); + + /** + * Info fields. + * + * @return info fields + */ + @Option.Singular + Set infoFields(); + + /** + * Contact fields. + * + * @return contact fields + */ + @Option.Singular + Set contactFields(); + + /** + * License fields. + * + * @return license fields + */ + @Option.Singular + Set licenseFields(); + + /** + * Server fields. + * + * @return server fields + */ + @Option.Singular + Set serverFields(); + + /** + * Server variable fields. + * + * @return server variable fields + */ + @Option.Singular + Set serverVariableFields(); + + /** + * Tag fields. + * + * @return tag fields + */ + @Option.Singular + Set tagFields(); + + /** + * Path item fields. + * + * @return path item fields + */ + @Option.Singular + Set pathItemFields(); + + /** + * Fixed path operation fields. + * + * @return fixed path operation fields + */ + @Option.Singular + Set fixedPathOperationFields(); + + /** + * Operation fields. + * + * @return operation fields + */ + @Option.Singular + Set operationFields(); + + /** + * Parameter fields. + * + * @return parameter fields + */ + @Option.Singular + Set parameterFields(); + + /** + * Parameter locations. + * + * @return parameter locations + */ + @Option.Singular + Set parameterLocations(); + + /** + * Header fields. + * + * @return header fields + */ + @Option.Singular + Set headerFields(); + + /** + * Request body fields. + * + * @return request body fields + */ + @Option.Singular + Set requestBodyFields(); + + /** + * Response fields. + * + * @return response fields + */ + @Option.Singular + Set responseFields(); + + /** + * Media type fields. + * + * @return media type fields + */ + @Option.Singular + Set mediaTypeFields(); + + /** + * Encoding fields. + * + * @return encoding fields + */ + @Option.Singular + Set encodingFields(); + + /** + * Components fields. + * + * @return components fields + */ + @Option.Singular + Set componentsFields(); + + /** + * Security scheme fields. + * + * @return security scheme fields + */ + @Option.Singular + Set securitySchemeFields(); + + /** + * Security scheme types. + * + * @return security scheme types + */ + @Option.Singular + Set securitySchemeTypes(); + + /** + * OAuth flows fields. + * + * @return OAuth flows fields + */ + @Option.Singular + Set oauthFlowsFields(); + + /** + * OAuth flow fields. + * + * @return OAuth flow fields + */ + @Option.Singular + Set oauthFlowFields(); + + /** + * Link fields. + * + * @return link fields + */ + @Option.Singular + Set linkFields(); + + /** + * Example fields. + * + * @return example fields + */ + @Option.Singular + Set exampleFields(); + + /** + * External docs fields. + * + * @return external docs fields + */ + @Option.Singular + Set externalDocsFields(); +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDialect.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDialect.java new file mode 100644 index 00000000000..80a7c937a93 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDialect.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.Objects; +import java.util.Set; + +final class OpenApiDialect { + private final OpenApi3xMapperRules rules; + + private OpenApiDialect(OpenApi3xMapperRules rules) { + this.rules = Objects.requireNonNull(rules); + } + + static OpenApiDialect create(OpenApi3xMapperRules rules) { + return new OpenApiDialect(rules); + } + + String version() { + return rules.targetVersion(); + } + + Set fixedPathOperationFields() { + return rules.fixedPathOperationFields(); + } + + Set fields(OpenApiDocumentWalker.Kind kind) { + return switch (kind) { + case DOCUMENT -> rules.documentFields(); + case INFO -> rules.infoFields(); + case CONTACT -> rules.contactFields(); + case LICENSE -> rules.licenseFields(); + case EXTERNAL_DOCS -> rules.externalDocsFields(); + case SERVER -> rules.serverFields(); + case SERVER_VARIABLE -> rules.serverVariableFields(); + case TAG -> rules.tagFields(); + case PATH_ITEM -> rules.pathItemFields(); + case OPERATION -> rules.operationFields(); + case PARAMETER -> rules.parameterFields(); + case HEADER -> rules.headerFields(); + case REQUEST_BODY -> rules.requestBodyFields(); + case RESPONSE -> rules.responseFields(); + case MEDIA_TYPE -> rules.mediaTypeFields(); + case ENCODING -> rules.encodingFields(); + case COMPONENTS -> rules.componentsFields(); + case SECURITY_SCHEME -> rules.securitySchemeFields(); + case OAUTH_FLOWS -> rules.oauthFlowsFields(); + case OAUTH_FLOW -> rules.oauthFlowFields(); + case EXAMPLE -> rules.exampleFields(); + case LINK -> rules.linkFields(); + default -> Set.of(); + }; + } + + Set oauthFlowFields() { + return rules.oauthFlowsFields(); + } + + Set parameterLocations() { + return rules.parameterLocations(); + } + + Set securitySchemeTypes() { + return rules.securitySchemeTypes(); + } + + boolean operationResponsesRequired() { + return rules.operationResponsesRequired(); + } + + boolean responseDescriptionRequired() { + return rules.responseDescriptionRequired(); + } + + boolean supportsQueryStringParameters() { + return rules.parameterLocations().contains("querystring"); + } + + boolean supportsBooleanSchemas() { + return !version().startsWith("3.0"); + } + + boolean additionalItemsHasSchemaValue() { + return version().startsWith("3.0"); + } + + boolean schemaReferenceSiblingsIgnored() { + return version().startsWith("3.0"); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentMapperSupport.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentMapperSupport.java new file mode 100644 index 00000000000..0ec2442ee1e --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentMapperSupport.java @@ -0,0 +1,1081 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.math.BigDecimal; +import java.math.BigInteger; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.function.Consumer; +import java.util.function.Function; + +import io.helidon.common.Api; +import io.helidon.json.JsonArray; +import io.helidon.json.JsonBoolean; +import io.helidon.json.JsonNull; +import io.helidon.json.JsonNumber; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; +import io.helidon.json.JsonValue; +import io.helidon.openapi.OpenApiDocument; + +import org.yaml.snakeyaml.DumperOptions; +import org.yaml.snakeyaml.LoaderOptions; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.constructor.SafeConstructor; +import org.yaml.snakeyaml.error.YAMLException; +import org.yaml.snakeyaml.nodes.MappingNode; +import org.yaml.snakeyaml.nodes.NodeId; +import org.yaml.snakeyaml.nodes.ScalarNode; +import org.yaml.snakeyaml.nodes.Tag; +import org.yaml.snakeyaml.representer.Representer; +import org.yaml.snakeyaml.resolver.Resolver; + +/** + * Shared support for OpenAPI version document mappers. + */ +@Api.Internal +public final class OpenApiDocumentMapperSupport { + private static final int MAX_YAML_ALIASES = 20; + private static final Set REFERENCE_FIELDS = Set.of("$ref", + "summary", + "description"); + + private OpenApiDocumentMapperSupport() { + } + + /** + * Parse YAML using JSON-compatible YAML 1.2 scalar resolution. + * + * @param source YAML source + * @return parsed value + */ + public static Object parseYaml(String source) { + Objects.requireNonNull(source); + LoaderOptions loaderOptions = new LoaderOptions(); + loaderOptions.setMaxAliasesForCollections(MAX_YAML_ALIASES); + DumperOptions dumperOptions = new DumperOptions(); + Object result = new Yaml(new JsonSafeConstructor(loaderOptions), + new Representer(dumperOptions), + dumperOptions, + loaderOptions, + new JsonScalarResolver()) + .load(source); + validateYamlCollectionGraph(result, new IdentityHashMap<>(), new IdentityHashMap<>()); + return result; + } + + private static void validateYamlCollectionGraph(Object value, + IdentityHashMap visiting, + IdentityHashMap complete) { + if (!(value instanceof Map) && !(value instanceof List)) { + return; + } + if (complete.containsKey(value)) { + return; + } + if (visiting.put(value, Boolean.TRUE) != null) { + throw new YAMLException("Recursive YAML collection aliases are not supported."); + } + if (value instanceof Map map) { + map.values().forEach(it -> validateYamlCollectionGraph(it, visiting, complete)); + } else { + ((List) value).forEach(it -> validateYamlCollectionGraph(it, visiting, complete)); + } + visiting.remove(value); + complete.put(value, Boolean.TRUE); + } + + /** + * Validate querystring parameters in a projected OpenAPI 3.x document. + * + * @param document projected document + * @param rules target version rules + */ + public static void validateQueryStringParameters(Map document, OpenApi3xMapperRules rules) { + Objects.requireNonNull(document); + OpenApiQueryStringValidator.validate(document, OpenApiDialect.create(rules)); + } + + /** + * Validate media types in a projected OpenAPI 3.x document. + * + * @param document projected document + * @param rules target version rules + */ + public static void validateMediaTypes(Map document, OpenApi3xMapperRules rules) { + Objects.requireNonNull(document); + OpenApiMediaTypeValidator.validate(document, OpenApiDialect.create(rules)); + } + + /** + * Validate operation IDs reachable from the API paths and webhooks. + * + * @param document document to validate + */ + public static void validateOperationIds(OpenApiDocument document) { + Objects.requireNonNull(document); + validateOperationIds(objectMap(document.toJsonObject())); + } + + /** + * Validate operation IDs reachable from the API paths and webhooks. + * + * @param document projected document to validate + */ + public static void validateOperationIds(Map document) { + Objects.requireNonNull(document); + OpenApiOperationIdValidator.validate(objectMap(document)); + } + + /** + * Validate the structure of an OpenAPI 3.x document. + *

+ * Unknown fields are ignored. Recognized fields must use the type required by the target version rules. + * + * @param document document to validate + * @param rules target version rules + */ + public static void validateDocumentStructure(Map document, OpenApi3xMapperRules rules) { + Objects.requireNonNull(document); + OpenApiStructuralValidator.validate(objectMap(document), OpenApiDialect.create(rules)); + } + + /** + * Validate Security Requirement Object names in an OpenAPI 3.x document. + * + * @param document document to validate + * @param rules target version rules + */ + public static void validateSecurityRequirementNames(Map document, OpenApi3xMapperRules rules) { + Objects.requireNonNull(document); + OpenApiDialect dialect = OpenApiDialect.create(rules); + Map projected = objectMap(document); + Set securitySchemeNames = new HashSet<>(); + if (projected.get("components") instanceof Map components + && components.get("securitySchemes") instanceof Map securitySchemes) { + securitySchemes.keySet().forEach(name -> securitySchemeNames.add(String.valueOf(name))); + } + OpenApiDocumentWalker.walk(projected, dialect, node -> { + if (node.kind() == OpenApiDocumentWalker.Kind.DOCUMENT + || node.kind() == OpenApiDocumentWalker.Kind.OPERATION) { + validateSecurityRequirementNames(node.value().get("security"), securitySchemeNames, dialect.version()); + } + return true; + }); + } + + /** + * Validate required root fields at an OpenAPI version boundary. + * + * @param document document to validate + * @param targetVersion target OpenAPI version + */ + public static void validateDocumentRoot(OpenApiDocument document, String targetVersion) { + Objects.requireNonNull(document); + OpenApiStructuralValidator.validateRoot(objectMap(document.toJsonObject()), targetVersion); + } + + /** + * Validate schemas in an OpenAPI 3.x document. + * + * @param document document to validate + * @param rules target version rules + */ + public static void validateSchemas(Map document, OpenApi3xMapperRules rules) { + Objects.requireNonNull(document); + OpenApiSchemaValidator.validate(objectMap(document), OpenApiDialect.create(rules)); + } + + /** + * Convert key-to-value data into a JSON object. + * + * @param source source values + * @return JSON object + */ + public static JsonObject jsonObject(Map source) { + Objects.requireNonNull(source); + JsonObject.Builder builder = JsonObject.builder(); + source.forEach((key, value) -> builder.set(Objects.requireNonNull(key), jsonValueOrNull(value))); + return builder.build(); + } + + /** + * Convert a value into a JSON value. + * + * @param value source value + * @return JSON value + */ + public static JsonValue jsonValue(Object value) { + Objects.requireNonNull(value); + if (value instanceof JsonValue jsonValue) { + return jsonValue; + } + if (value instanceof Map map) { + return jsonObject(objectMap(map)); + } + if (value instanceof List list) { + return JsonArray.create(list.stream() + .map(OpenApiDocumentMapperSupport::jsonValueOrNull) + .toList()); + } + if (value instanceof String string) { + return JsonString.create(string); + } + if (value instanceof Boolean bool) { + return JsonBoolean.create(bool); + } + if (value instanceof BigDecimal number) { + return JsonNumber.create(number); + } + if (value instanceof Number number) { + return jsonNumber(number); + } + return JsonString.create(String.valueOf(value)); + } + + /** + * Convert a number into a JSON number without reducing precision. + * + * @param number number + * @return JSON number + */ + public static JsonNumber jsonNumber(Number number) { + Objects.requireNonNull(number); + if (number instanceof Byte + || number instanceof Short + || number instanceof Integer + || number instanceof Long) { + return JsonNumber.create(number.longValue()); + } + if (number instanceof BigInteger bigInteger) { + return JsonNumber.create(new BigDecimal(bigInteger)); + } + return JsonNumber.create(new BigDecimal(number.toString())); + } + + /** + * Copy allowed key-to-value fields. + * + * @param source source values + * @param allowedFields allowed field names + * @return copied values + */ + public static Map copyAllowed(Map source, Set allowedFields) { + Objects.requireNonNull(source); + Objects.requireNonNull(allowedFields); + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (allowed(key, allowedFields)) { + result.put(key, copyValue(value)); + } + }); + return result; + } + + static Map copyReferenceFields(Map source, Set fixedFields) { + Objects.requireNonNull(source); + Objects.requireNonNull(fixedFields); + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (fixedFields.contains(key)) { + result.put(key, copyValue(value)); + } + }); + return result; + } + + /** + * Copy a source field to a target. + * + * @param target target values + * @param key field key + * @param source source values + */ + public static void copyField(Map target, String key, Map source) { + Objects.requireNonNull(target); + Objects.requireNonNull(key); + Objects.requireNonNull(source); + if (!source.containsKey(key)) { + throw new IllegalArgumentException("Source field does not exist: " + key); + } + target.put(key, copyValue(source.get(key))); + } + + /** + * Copy a source field value. + * + * @param key field key + * @param source source values + * @return copied value + */ + public static Object copyFieldValue(String key, Map source) { + Objects.requireNonNull(key); + Objects.requireNonNull(source); + if (!source.containsKey(key)) { + throw new IllegalArgumentException("Source field does not exist: " + key); + } + return copyValue(source.get(key)); + } + + /** + * Check if a field is allowed. + * + * @param key field name + * @param allowedFields allowed field names + * @return whether the field is allowed + */ + public static boolean allowed(String key, Set allowedFields) { + Objects.requireNonNull(key); + Objects.requireNonNull(allowedFields); + return allowedFields.contains(key) || key.startsWith("x-"); + } + + /** + * Deep-copy key-to-value or list values. + * + * @param value value to copy + * @return copied value + */ + public static Object copy(Object value) { + Objects.requireNonNull(value); + return copyValue(value); + } + + private static Object copyValue(Object value) { + if (value == null) { + return null; + } + if (value instanceof Map map) { + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> result.put(String.valueOf(Objects.requireNonNull(key)), copyValue(item))); + return result; + } + if (value instanceof List list) { + List result = new ArrayList<>(); + list.forEach(item -> result.add(copyValue(item))); + return result; + } + return value; + } + + /** + * Convert arbitrary map keys to strings. + * + * @param source source values + * @return string-keyed values + */ + public static Map objectMap(Map source) { + Objects.requireNonNull(source); + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> result.put(String.valueOf(Objects.requireNonNull(key)), + value)); + return result; + } + + /** + * Convert a JSON object to key-to-value data. + * + * @param object JSON object + * @return key-to-value data + */ + public static Map objectMap(JsonObject object) { + Objects.requireNonNull(object); + Map result = new LinkedHashMap<>(); + object.keysAsStrings() + .forEach(key -> object.value(key) + .ifPresent(value -> result.put(key, javaValue(value)))); + return result; + } + + private static Object javaValue(JsonValue value) { + Objects.requireNonNull(value); + return switch (value.type()) { + case OBJECT -> objectMap(value.asObject()); + case ARRAY -> value.asArray() + .values() + .stream() + .map(OpenApiDocumentMapperSupport::javaValue) + .toList(); + case STRING -> value.asString().value(); + case NUMBER -> value.asNumber().bigDecimalValue(); + case BOOLEAN -> value.asBoolean().value(); + case NULL -> null; + case UNKNOWN -> value.toString(); + }; + } + + private static JsonValue jsonValueOrNull(Object value) { + return value == null ? JsonNull.instance() : jsonValue(value); + } + + /** + * Run a consumer if the value is key-to-value data. + * + * @param value value to inspect + * @param consumer consumer + */ + public static void object(Object value, Consumer> consumer) { + Objects.requireNonNull(value); + Objects.requireNonNull(consumer); + if (value instanceof Map map) { + consumer.accept(objectMap(map)); + } + } + + /** + * Map a list of key-to-value data values. + * + * @param value source value + * @param mapper item mapper + * @return mapped list + */ + public static List objectList(Object value, Function, Map> mapper) { + Objects.requireNonNull(value); + Objects.requireNonNull(mapper); + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + list.forEach(item -> object(item, object -> result.add(mapper.apply(object)))); + return result; + } + + /** + * Filter and normalize an OpenAPI 3.x document using version-specific field rules. + * + * @param source source document + * @param rules version-specific rules + * @return filtered document + */ + public static Map document3x(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.documentFields())) { + return; + } + switch (key) { + case "info" -> object(value, object -> result.put(key, info(object, rules))); + case "servers" -> result.put(key, serverList(value, rules)); + case "paths" -> object(value, object -> result.put(key, paths(object, rules, true))); + case "webhooks" -> object(value, object -> result.put(key, paths(object, rules, false))); + case "components" -> object(value, object -> result.put(key, components(object, rules))); + case "tags" -> result.put(key, tagList(value, rules)); + case "externalDocs" -> object(value, object -> result.put(key, copyAllowed(object, rules.externalDocsFields()))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map info(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.infoFields())) { + return; + } + switch (key) { + case "contact" -> object(value, object -> result.put(key, copyAllowed(object, rules.contactFields()))); + case "license" -> object(value, object -> { + Map license = copyAllowed(object, rules.licenseFields()); + if (license.containsKey("identifier")) { + license.remove("url"); + } + result.put(key, license); + }); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static List serverList(Object value, OpenApi3xMapperRules rules) { + return objectList(value, server -> server(server, rules)); + } + + private static Map server(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.serverFields())) { + return; + } + if ("variables".equals(key)) { + object(value, object -> result.put(key, serverVariables(object, rules))); + } else { + copyField(result, key, source); + } + }); + return result; + } + + private static Map serverVariables(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, + object -> result.put(key, + copyAllowed(object, rules.serverVariableFields())))); + return result; + } + + private static List tagList(Object value, OpenApi3xMapperRules rules) { + return objectList(value, tag -> { + Map result = new LinkedHashMap<>(); + tag.forEach((key, item) -> { + if (!allowed(key, rules.tagFields())) { + return; + } + if ("externalDocs".equals(key)) { + object(item, object -> result.put(key, copyAllowed(object, rules.externalDocsFields()))); + } else { + copyField(result, key, tag); + } + }); + return result; + }); + } + + private static Map paths(Map source, + OpenApi3xMapperRules rules, + boolean containerExtensions) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (containerExtensions && key.startsWith("x-")) { + copyField(result, key, source); + } else { + object(value, object -> result.put(key, pathItem(object, rules))); + } + }); + return result; + } + + private static Map pathItem(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.pathItemFields())) { + return; + } + if (rules.fixedPathOperationFields().contains(key)) { + object(value, object -> result.put(key, operation(object, rules))); + return; + } + switch (key) { + case "additionalOperations" -> object(value, object -> result.put(key, additionalOperations(object, rules))); + case "servers" -> result.put(key, serverList(value, rules)); + case "parameters" -> result.put(key, parameters(value, rules)); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map additionalOperations(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, operation(object, rules)))); + return result; + } + + private static Map operation(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.operationFields())) { + return; + } + switch (key) { + case "parameters" -> result.put(key, parameters(value, rules)); + case "requestBody" -> object(value, object -> result.put(key, requestBody(object, rules))); + case "responses" -> object(value, object -> result.put(key, responses(object, rules, true))); + case "callbacks" -> object(value, object -> result.put(key, callbacks(object, rules))); + case "servers" -> result.put(key, serverList(value, rules)); + case "externalDocs" -> object(value, object -> result.put(key, copyAllowed(object, rules.externalDocsFields()))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static List parameters(Object value, OpenApi3xMapperRules rules) { + if (!(value instanceof List list)) { + return List.of(); + } + List result = new ArrayList<>(); + for (Object item : list) { + object(item, object -> { + Map parameter = parameter(object, rules); + if (!parameter.isEmpty()) { + result.add(parameter); + } + }); + } + return result; + } + + private static Map parameter(Map source, OpenApi3xMapperRules rules) { + if (!source.containsKey("$ref") && !rules.parameterLocations().contains(String.valueOf(source.get("in")))) { + return Map.of(); + } + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.parameterFields())) { + return; + } + switch (key) { + case "content" -> object(value, object -> result.put(key, content(object, rules))); + case "examples" -> result.put(key, examples(value, rules)); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map requestBody(Map source, OpenApi3xMapperRules rules) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.requestBodyFields())) { + return; + } + if ("content".equals(key)) { + object(value, object -> result.put(key, content(object, rules))); + } else { + copyField(result, key, source); + } + }); + return result; + } + + private static Map responses(Map source, + OpenApi3xMapperRules rules, + boolean containerExtensions) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (containerExtensions && key.startsWith("x-")) { + copyField(result, key, source); + return; + } + if (containerExtensions && !isResponseCode(key)) { + throw new IllegalStateException("Invalid OpenAPI " + rules.targetVersion() + + " Responses Object key: " + key + "."); + } + object(value, object -> result.put(key, response(object, rules))); + }); + return result; + } + + static boolean isResponseCode(String key) { + if ("default".equals(key)) { + return true; + } + if (key.length() != 3 || key.charAt(0) < '1' || key.charAt(0) > '5') { + return false; + } + return (key.charAt(1) >= '0' && key.charAt(1) <= '9' + && key.charAt(2) >= '0' && key.charAt(2) <= '9') + || (key.charAt(1) == 'X' && key.charAt(2) == 'X'); + } + + private static Map response(Map source, OpenApi3xMapperRules rules) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.responseFields())) { + return; + } + switch (key) { + case "headers" -> object(value, object -> result.put(key, headers(object, rules))); + case "content" -> object(value, object -> result.put(key, content(object, rules))); + case "links" -> object(value, object -> result.put(key, links(object, rules))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map headers(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, header(object, rules)))); + return result; + } + + private static Map header(Map source, OpenApi3xMapperRules rules) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.headerFields())) { + return; + } + switch (key) { + case "content" -> object(value, object -> result.put(key, content(object, rules))); + case "examples" -> result.put(key, examples(value, rules)); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map content(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, mediaType(object, rules)))); + return result; + } + + private static Map mediaType(Map source, OpenApi3xMapperRules rules) { + if (source.containsKey("$ref")) { + if (rules.mediaTypeFields().contains("$ref")) { + return reference(source); + } + throw unsupported(rules, "media type reference", String.valueOf(source.get("$ref")), "mediaType"); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.mediaTypeFields())) { + return; + } + switch (key) { + case "examples" -> result.put(key, examples(value, rules)); + case "encoding" -> object(value, object -> result.put(key, encodings(object, rules))); + case "itemEncoding" -> object(value, object -> result.put(key, encoding(object, rules))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map encoding(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.encodingFields())) { + return; + } + switch (key) { + case "headers" -> object(value, object -> result.put(key, headers(object, rules))); + case "encoding" -> object(value, object -> result.put(key, encodings(object, rules))); + case "itemEncoding" -> object(value, object -> result.put(key, encoding(object, rules))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map encodings(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, encoding(object, rules)))); + return result; + } + + private static Map components(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.componentsFields())) { + return; + } + switch (key) { + case "responses" -> object(value, object -> result.put(key, responses(object, rules, false))); + case "parameters" -> object(value, object -> result.put(key, parameterMap(object, rules))); + case "examples" -> result.put(key, examples(value, rules)); + case "requestBodies" -> object(value, object -> result.put(key, requestBodyMap(object, rules))); + case "headers" -> object(value, object -> result.put(key, headers(object, rules))); + case "securitySchemes" -> object(value, object -> result.put(key, securitySchemes(object, rules))); + case "links" -> object(value, object -> result.put(key, links(object, rules))); + case "callbacks" -> object(value, object -> result.put(key, callbacks(object, rules))); + case "pathItems" -> object(value, object -> result.put(key, paths(object, rules, false))); + case "mediaTypes" -> object(value, object -> result.put(key, mediaTypes(object, rules))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map mediaTypes(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, mediaType(object, rules)))); + return result; + } + + private static Map parameterMap(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> { + Map parameter = parameter(object, rules); + if (!parameter.isEmpty()) { + result.put(key, parameter); + } + })); + return result; + } + + private static Map requestBodyMap(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, requestBody(object, rules)))); + return result; + } + + private static Map securitySchemes(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, securityScheme(key, object, rules)))); + return result; + } + + private static Map securityScheme(String name, + Map source, + OpenApi3xMapperRules rules) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (!allowed(key, rules.securitySchemeFields())) { + return; + } + switch (key) { + case "type" -> { + String type = String.valueOf(value); + if (!rules.securitySchemeTypes().contains(type)) { + throw unsupported(rules, "security scheme type", type, securitySchemePath(name)); + } + copyField(result, key, source); + } + case "flows" -> object(value, object -> result.put(key, + oauthFlows(securitySchemePath(name) + ".flows", + object, + rules))); + default -> copyField(result, key, source); + } + }); + return result; + } + + private static Map oauthFlows(String path, Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (key.startsWith("x-")) { + copyField(result, key, source); + return; + } + if (!allowed(key, rules.oauthFlowsFields())) { + throw unsupported(rules, "OAuth flow", key, path); + } + object(value, object -> result.put(key, copyAllowed(object, rules.oauthFlowFields()))); + }); + return result; + } + + private static IllegalStateException unsupported(OpenApi3xMapperRules rules, + String kind, + String value, + String path) { + return new IllegalStateException("Unsupported OpenAPI " + + rules.targetVersion() + + " " + + kind + + " '" + + value + + "' at " + + path); + } + + private static String securitySchemePath(String name) { + return "components.securitySchemes." + name; + } + + private static Map links(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> { + if (object.containsKey("$ref")) { + result.put(key, reference(object)); + return; + } + Map link = new LinkedHashMap<>(); + object.forEach((linkKey, linkValue) -> { + if (!allowed(linkKey, rules.linkFields())) { + return; + } + if ("server".equals(linkKey)) { + object(linkValue, server -> link.put(linkKey, server(server, rules))); + } else { + copyField(link, linkKey, object); + } + }); + result.put(key, link); + })); + return result; + } + + private static Map callbacks(Map source, OpenApi3xMapperRules rules) { + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> object(value, object -> result.put(key, callback(object, rules)))); + return result; + } + + private static Map callback(Map source, OpenApi3xMapperRules rules) { + if (source.containsKey("$ref")) { + return reference(source); + } + Map result = new LinkedHashMap<>(); + source.forEach((key, value) -> { + if (key.startsWith("x-")) { + copyField(result, key, source); + } else { + object(value, object -> result.put(key, pathItem(object, rules))); + } + }); + return result; + } + + private static Map examples(Object value, OpenApi3xMapperRules rules) { + if (!(value instanceof Map map)) { + return Map.of(); + } + Map result = new LinkedHashMap<>(); + map.forEach((key, item) -> object(item, object -> { + if (object.containsKey("$ref")) { + result.put(String.valueOf(key), reference(object)); + } else { + result.put(String.valueOf(key), copyAllowed(object, rules.exampleFields())); + } + })); + return result; + } + + private static void validateSecurityRequirementNames(Object value, + Set securitySchemeNames, + String version) { + if (!(value instanceof List requirements)) { + return; + } + requirements.forEach(requirement -> { + if (requirement instanceof Map schemes) { + schemes.keySet().forEach(name -> { + if (!securitySchemeNames.contains(String.valueOf(name))) { + throw new IllegalStateException("OpenAPI " + version + + " Security Requirement Object references undeclared " + + "security scheme " + name + "."); + } + }); + } + }); + } + + private static Map reference(Map source) { + return copyReferenceFields(source, REFERENCE_FIELDS); + } + + private static final class JsonScalarResolver extends Resolver { + @Override + protected void addImplicitResolvers() { + // JSON scalar resolution is implemented without regular expressions in resolve. + } + + @Override + public Tag resolve(NodeId kind, String value, boolean implicit) { + if (kind != NodeId.scalar || !implicit) { + return super.resolve(kind, value, implicit); + } + if (value.isEmpty() || "null".equals(value)) { + return Tag.NULL; + } + if ("true".equals(value) || "false".equals(value)) { + return Tag.BOOL; + } + Tag numberTag = jsonNumberTag(value); + return numberTag == null ? Tag.STR : numberTag; + } + + private Tag jsonNumberTag(String value) { + int length = value.length(); + int index = value.charAt(0) == '-' ? 1 : 0; + if (index == length) { + return null; + } + + char first = value.charAt(index); + if (first == '0') { + index++; + } else if (first >= '1' && first <= '9') { + index++; + while (index < length && isDigit(value.charAt(index))) { + index++; + } + } else { + return null; + } + + boolean floatingPoint = false; + if (index < length && value.charAt(index) == '.') { + floatingPoint = true; + index++; + int fractionStart = index; + while (index < length && isDigit(value.charAt(index))) { + index++; + } + if (fractionStart == index) { + return null; + } + } + if (index < length && (value.charAt(index) == 'e' || value.charAt(index) == 'E')) { + floatingPoint = true; + index++; + if (index < length && (value.charAt(index) == '-' || value.charAt(index) == '+')) { + index++; + } + int exponentStart = index; + while (index < length && isDigit(value.charAt(index))) { + index++; + } + if (exponentStart == index) { + return null; + } + } + if (index != length) { + return null; + } + return floatingPoint ? Tag.FLOAT : Tag.INT; + } + + private static boolean isDigit(char value) { + return value >= '0' && value <= '9'; + } + } + + private static final class JsonSafeConstructor extends SafeConstructor { + private JsonSafeConstructor(LoaderOptions loadingConfig) { + super(loadingConfig); + } + + @Override + protected void constructMapping2ndStep(MappingNode node, Map mapping) { + node.getValue().forEach(tuple -> { + if (tuple.getKeyNode() instanceof ScalarNode) { + tuple.getKeyNode().setTag(Tag.STR); + } + }); + super.constructMapping2ndStep(node, mapping); + } + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentReader.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentReader.java new file mode 100644 index 00000000000..227b5ab48b4 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentReader.java @@ -0,0 +1,549 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import io.helidon.common.Api; +import io.helidon.json.JsonArray; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonValue; +import io.helidon.json.JsonValueType; +import io.helidon.openapi.OpenApiDocument; + +/** + * Internal reader from structured JSON into the version-neutral OpenAPI document model. + */ +@Api.Internal +public final class OpenApiDocumentReader { + private static final Set FIXED_PATH_OPERATION_FIELDS = Set.of("get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query"); + + private OpenApiDocumentReader() { + } + + /** + * Read a document. + * + * @param source source JSON object + * @return document model + */ + public static OpenApiDocument read(JsonObject source) { + OpenApiDocument.Builder builder = OpenApiDocument.builder(); + string(source, "openapi", builder::openapi); + string(source, "$self", builder::self); + string(source, "jsonSchemaDialect", builder::jsonSchemaDialect); + object(source, "info", info -> builder.info(info(info))); + array(source, "servers", servers -> servers.values() + .forEach(server -> object(server, value -> builder.server(server(value))))); + object(source, "paths", paths -> { + builder.paths(Map.of()); + paths.keysAsStrings() + .forEach(path -> { + if (path.startsWith("x-")) { + value(paths, path, value -> builder.pathExtension(path, value)); + } else { + object(paths, path, item -> builder.path(path, pathItem(item))); + } + }); + }); + object(source, "webhooks", webhooks -> { + builder.webhooks(Map.of()); + webhooks.keysAsStrings() + .forEach(name -> object(webhooks, + name, + item -> builder.webhook(name, pathItem(item)))); + }); + object(source, "components", components -> builder.components(components(components))); + securityRequirements(source).ifPresent(requirements -> requirements.forEach(builder::securityRequirement)); + array(source, "tags", tags -> tags.values() + .forEach(tag -> object(tag, value -> builder.tag(tag(value))))); + object(source, "externalDocs", docs -> builder.externalDocs(externalDocs(docs))); + source.keysAsStrings() + .stream() + .filter(name -> name.startsWith("x-")) + .forEach(name -> value(source, name, value -> builder.extension(name, value))); + return builder.build(); + } + + private static OpenApiDocument.Info info(JsonObject source) { + OpenApiDocument.InfoBuilder builder = OpenApiDocument.Info.builder(); + string(source, "title", builder::title); + string(source, "version", builder::version); + string(source, "summary", builder::summary); + string(source, "description", builder::description); + string(source, "termsOfService", builder::termsOfService); + object(source, "contact", contact -> builder.contact(contact(contact))); + object(source, "license", license -> builder.license(license(license))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Contact contact(JsonObject source) { + OpenApiDocument.ContactBuilder builder = OpenApiDocument.Contact.builder(); + string(source, "name", builder::name); + string(source, "url", builder::url); + string(source, "email", builder::email); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.License license(JsonObject source) { + OpenApiDocument.LicenseBuilder builder = OpenApiDocument.License.builder(); + string(source, "name", builder::name); + string(source, "identifier", builder::identifier); + string(source, "url", builder::url); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.ExternalDocs externalDocs(JsonObject source) { + OpenApiDocument.ExternalDocsBuilder builder = OpenApiDocument.ExternalDocs.builder(); + string(source, "url", builder::url); + string(source, "description", builder::description); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Server server(JsonObject source) { + OpenApiDocument.ServerBuilder builder = OpenApiDocument.Server.builder(); + string(source, "url", builder::url); + string(source, "description", builder::description); + string(source, "name", builder::name); + object(source, "variables", variables -> variables.keysAsStrings() + .forEach(name -> object(variables, name, variable -> builder.variable(name, serverVariable(variable))))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.ServerVariable serverVariable(JsonObject source) { + OpenApiDocument.ServerVariableBuilder builder = OpenApiDocument.ServerVariable.builder(); + string(source, "default", builder::value); + array(source, "enum", values -> builder.allowedValues(stringValues(values))); + string(source, "description", builder::description); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Tag tag(JsonObject source) { + OpenApiDocument.TagBuilder builder = OpenApiDocument.Tag.builder(); + string(source, "name", builder::name); + string(source, "summary", builder::summary); + string(source, "description", builder::description); + object(source, "externalDocs", docs -> builder.externalDocs(externalDocs(docs))); + string(source, "parent", builder::parent); + string(source, "kind", builder::kind); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.PathItem pathItem(JsonObject source) { + OpenApiDocument.PathItemBuilder builder = OpenApiDocument.PathItem.builder(); + string(source, "$ref", builder::ref); + string(source, "summary", builder::summary); + string(source, "description", builder::description); + source.keysAsStrings() + .stream() + .filter(OpenApiDocumentReader::isFixedPathOperationField) + .forEach(method -> object(source, + method, + operation -> builder.operation(method.toUpperCase(Locale.ROOT), + operation(operation)))); + object(source, "additionalOperations", operations -> operations.keysAsStrings() + .forEach(method -> object(operations, method, + operation -> builder.additionalOperation(method, operation(operation))))); + array(source, "servers", servers -> servers.values() + .forEach(server -> object(server, value -> builder.server(server(value))))); + array(source, "parameters", parameters -> parameters.values() + .forEach(parameter -> object(parameter, value -> builder.parameter(parameter(value))))); + extensions(source, builder::extension); + return builder.build(); + } + + private static boolean isFixedPathOperationField(String value) { + return FIXED_PATH_OPERATION_FIELDS.contains(value); + } + + private static OpenApiDocument.Operation operation(JsonObject source) { + OpenApiDocument.OperationBuilder builder = OpenApiDocument.Operation.builder(); + array(source, "tags", tags -> stringValues(tags).forEach(builder::tag)); + string(source, "summary", builder::summary); + string(source, "description", builder::description); + object(source, "externalDocs", docs -> builder.externalDocs(externalDocs(docs))); + string(source, "operationId", builder::operationId); + array(source, "parameters", parameters -> parameters.values() + .forEach(parameter -> object(parameter, value -> builder.parameter(parameter(value))))); + object(source, "requestBody", requestBody -> builder.requestBody(requestBody(requestBody))); + object(source, "responses", responses -> { + responses.keysAsStrings() + .stream() + .filter(status -> !status.startsWith("x-")) + .forEach(status -> object(responses, status, + response -> builder.response(status, response(response)))); + extensions(responses, builder::responseExtension); + }); + object(source, "callbacks", callbacks -> callbacks.keysAsStrings() + .forEach(name -> object(callbacks, name, callback -> builder.callback(name, callback(callback))))); + bool(source, "deprecated", builder::deprecated); + securityRequirements(source).ifPresent(builder::security); + array(source, "servers", servers -> servers.values() + .forEach(server -> object(server, value -> builder.server(server(value))))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Callback callback(JsonObject source) { + OpenApiDocument.CallbackBuilder builder = OpenApiDocument.Callback.builder(); + if (string(source, "$ref").isPresent()) { + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + source.keysAsStrings() + .stream() + .filter(expression -> !expression.startsWith("x-")) + .forEach(expression -> object(source, + expression, + pathItem -> builder.expression(expression, pathItem(pathItem)))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Parameter parameter(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.ParameterBuilder builder = OpenApiDocument.Parameter.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.ParameterBuilder builder = OpenApiDocument.Parameter.builder(); + string(source, "name", builder::name); + string(source, "in", builder::in); + string(source, "description", builder::description); + bool(source, "required", builder::required); + bool(source, "deprecated", builder::deprecated); + bool(source, "allowEmptyValue", builder::allowEmptyValue); + string(source, "style", builder::style); + bool(source, "explode", builder::explode); + bool(source, "allowReserved", builder::allowReserved); + value(source, "schema", builder::schema); + value(source, "example", builder::example); + object(source, "examples", examples -> examples.keysAsStrings() + .forEach(name -> object(examples, name, example -> builder.example(name, example(example))))); + object(source, "content", content -> content.keysAsStrings() + .forEach(mediaType -> object(content, mediaType, + value -> builder.content(mediaType, mediaType(value))))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Header header(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.HeaderBuilder builder = OpenApiDocument.Header.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.HeaderBuilder builder = OpenApiDocument.Header.builder(); + string(source, "description", builder::description); + bool(source, "required", builder::required); + bool(source, "deprecated", builder::deprecated); + string(source, "style", builder::style); + bool(source, "explode", builder::explode); + bool(source, "allowReserved", builder::allowReserved); + value(source, "schema", builder::schema); + value(source, "example", builder::example); + object(source, "examples", examples -> examples.keysAsStrings() + .forEach(name -> object(examples, name, example -> builder.example(name, example(example))))); + object(source, "content", content -> content.keysAsStrings() + .forEach(mediaType -> object(content, mediaType, + value -> builder.content(mediaType, mediaType(value))))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.RequestBody requestBody(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.RequestBodyBuilder builder = OpenApiDocument.RequestBody.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.RequestBodyBuilder builder = OpenApiDocument.RequestBody.builder(); + string(source, "description", builder::description); + object(source, "content", content -> content.keysAsStrings() + .forEach(mediaType -> object(content, mediaType, + value -> builder.content(mediaType, mediaType(value))))); + bool(source, "required", builder::required); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Response response(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.ResponseBuilder builder = OpenApiDocument.Response.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.ResponseBuilder builder = OpenApiDocument.Response.builder(); + string(source, "description", builder::description); + string(source, "summary", builder::summary); + object(source, "headers", headers -> headers.keysAsStrings() + .forEach(name -> object(headers, name, header -> builder.header(name, header(header))))); + object(source, "content", content -> content.keysAsStrings() + .forEach(mediaType -> object(content, mediaType, + value -> builder.content(mediaType, mediaType(value))))); + object(source, "links", links -> links.keysAsStrings() + .forEach(name -> object(links, name, link -> builder.link(name, link(link))))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.MediaTypeObject mediaType(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.MediaTypeObjectBuilder builder = OpenApiDocument.MediaTypeObject.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.MediaTypeObjectBuilder builder = OpenApiDocument.MediaTypeObject.builder(); + value(source, "schema", builder::schema); + value(source, "itemSchema", builder::itemSchema); + value(source, "example", builder::example); + object(source, "examples", examples -> examples.keysAsStrings() + .forEach(name -> object(examples, name, example -> builder.example(name, example(example))))); + object(source, "encoding", encodings -> encodings.keysAsStrings() + .forEach(name -> object(encodings, name, encoding -> builder.encoding(name, encoding(encoding))))); + array(source, "prefixEncoding", builder::prefixEncoding); + object(source, "itemEncoding", itemEncoding -> builder.itemEncoding(encoding(itemEncoding))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Encoding encoding(JsonObject source) { + OpenApiDocument.EncodingBuilder builder = OpenApiDocument.Encoding.builder(); + string(source, "contentType", builder::contentType); + object(source, "headers", headers -> headers.keysAsStrings() + .forEach(name -> object(headers, name, header -> builder.header(name, header(header))))); + object(source, "encoding", encodings -> encodings.keysAsStrings() + .forEach(name -> object(encodings, name, encoding -> builder.encoding(name, encoding(encoding))))); + array(source, "prefixEncoding", builder::prefixEncoding); + object(source, "itemEncoding", itemEncoding -> builder.itemEncoding(encoding(itemEncoding))); + string(source, "style", builder::style); + bool(source, "explode", builder::explode); + bool(source, "allowReserved", builder::allowReserved); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Example example(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.ExampleBuilder builder = OpenApiDocument.Example.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.ExampleBuilder builder = OpenApiDocument.Example.builder(); + string(source, "summary", builder::summary); + string(source, "description", builder::description); + value(source, "value", builder::value); + value(source, "dataValue", builder::dataValue); + string(source, "serializedValue", builder::serializedValue); + string(source, "externalValue", builder::externalValue); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Link link(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.LinkBuilder builder = OpenApiDocument.Link.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.LinkBuilder builder = OpenApiDocument.Link.builder(); + string(source, "operationRef", builder::operationRef); + string(source, "operationId", builder::operationId); + object(source, "parameters", builder::parameters); + value(source, "requestBody", builder::requestBody); + string(source, "description", builder::description); + object(source, "server", server -> builder.server(server(server))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.Components components(JsonObject source) { + OpenApiDocument.ComponentsBuilder builder = OpenApiDocument.Components.builder(); + object(source, "schemas", values -> values.keysAsStrings() + .forEach(name -> value(values, name, schema -> builder.schema(name, schema)))); + object(source, "responses", values -> values.keysAsStrings() + .forEach(name -> object(values, name, response -> builder.response(name, response(response))))); + object(source, "parameters", values -> values.keysAsStrings() + .forEach(name -> object(values, name, parameter -> builder.parameter(name, parameter(parameter))))); + object(source, "examples", values -> values.keysAsStrings() + .forEach(name -> object(values, name, example -> builder.example(name, example(example))))); + object(source, "requestBodies", values -> values.keysAsStrings() + .forEach(name -> object(values, name, requestBody -> builder.requestBody(name, requestBody(requestBody))))); + object(source, "headers", values -> values.keysAsStrings() + .forEach(name -> object(values, name, header -> builder.header(name, header(header))))); + object(source, "securitySchemes", values -> values.keysAsStrings() + .forEach(name -> object(values, name, scheme -> builder.securityScheme(name, securityScheme(scheme))))); + object(source, "links", values -> values.keysAsStrings() + .forEach(name -> object(values, name, link -> builder.link(name, link(link))))); + object(source, "callbacks", values -> values.keysAsStrings() + .forEach(name -> object(values, name, callback -> builder.callback(name, callback(callback))))); + object(source, "pathItems", values -> values.keysAsStrings() + .forEach(name -> object(values, name, pathItem -> builder.pathItem(name, pathItem(pathItem))))); + object(source, "mediaTypes", values -> values.keysAsStrings() + .forEach(name -> object(values, name, mediaType -> builder.mediaType(name, mediaType(mediaType))))); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.SecurityScheme securityScheme(JsonObject source) { + if (string(source, "$ref").isPresent()) { + OpenApiDocument.SecuritySchemeBuilder builder = OpenApiDocument.SecurityScheme.builder(); + reference(source, builder::ref, builder::summary, builder::description); + return builder.build(); + } + OpenApiDocument.SecuritySchemeBuilder builder = OpenApiDocument.SecurityScheme.builder(); + string(source, "type", builder::type); + string(source, "description", builder::description); + string(source, "name", builder::name); + string(source, "in", builder::in); + string(source, "scheme", builder::scheme); + string(source, "bearerFormat", builder::bearerFormat); + object(source, "flows", builder::flows); + string(source, "openIdConnectUrl", builder::openIdConnectUrl); + string(source, "oauth2MetadataUrl", builder::oauth2MetadataUrl); + bool(source, "deprecated", builder::deprecated); + extensions(source, builder::extension); + return builder.build(); + } + + private static OpenApiDocument.SecurityRequirement securityRequirement(JsonObject source) { + OpenApiDocument.SecurityRequirementBuilder builder = OpenApiDocument.SecurityRequirement.builder(); + source.keysAsStrings().forEach(name -> { + JsonValue scopes = source.value(name).orElseThrow(); + if (scopes.type() != JsonValueType.ARRAY) { + throw new IllegalStateException("OpenAPI security requirement object scheme " + name + + " scopes must be an array."); + } + List scopeNames = new ArrayList<>(); + scopes.asArray().values().forEach(scope -> { + if (scope.type() != JsonValueType.STRING) { + throw new IllegalStateException("OpenAPI security requirement object scheme " + name + + " scopes must contain only strings."); + } + scopeNames.add(scope.asString().value()); + }); + builder.scheme(name, scopeNames); + }); + return builder.build(); + } + + private static Optional> securityRequirements(JsonObject source) { + Optional security = source.value("security"); + if (security.isEmpty()) { + return Optional.empty(); + } + JsonValue securityValue = security.orElseThrow(); + if (securityValue.type() != JsonValueType.ARRAY) { + throw new IllegalStateException("OpenAPI security must be an array of Security Requirement Objects."); + } + List result = new ArrayList<>(); + securityValue.asArray().values().forEach(requirement -> { + if (requirement.type() != JsonValueType.OBJECT) { + throw new IllegalStateException( + "OpenAPI security array must contain only Security Requirement Objects."); + } + result.add(securityRequirement(requirement.asObject())); + }); + return Optional.of(result); + } + + private static void reference(JsonObject source, + Consumer ref, + Consumer summary, + Consumer description) { + string(source, "$ref", ref); + string(source, "summary", summary); + string(source, "description", description); + } + + private static void extensions(JsonObject source, BiConsumer consumer) { + source.keysAsStrings() + .stream() + .filter(name -> name.startsWith("x-")) + .forEach(name -> value(source, name, value -> consumer.accept(name, value))); + } + + private static List stringValues(JsonArray array) { + List result = new ArrayList<>(); + array.values().forEach(value -> { + if (value.type() == JsonValueType.STRING) { + result.add(value.asString().value()); + } + }); + return result; + } + + private static Optional string(JsonObject source, String name) { + return source.value(name) + .filter(value -> value.type() == JsonValueType.STRING) + .map(value -> value.asString().value()); + } + + private static void string(JsonObject source, String name, Consumer consumer) { + string(source, name).ifPresent(consumer); + } + + private static void bool(JsonObject source, String name, Consumer consumer) { + source.value(name) + .filter(value -> value.type() == JsonValueType.BOOLEAN) + .map(value -> value.asBoolean().value()) + .ifPresent(consumer); + } + + private static void object(JsonObject source, String name, Consumer consumer) { + source.value(name).ifPresent(value -> object(value, consumer)); + } + + private static void object(JsonValue value, Consumer consumer) { + if (value.type() == JsonValueType.OBJECT) { + consumer.accept(value.asObject()); + } + } + + private static void array(JsonObject source, String name, Consumer consumer) { + source.value(name) + .filter(value -> value.type() == JsonValueType.ARRAY) + .map(JsonValue::asArray) + .ifPresent(consumer); + } + + private static void value(JsonObject source, String name, Consumer consumer) { + source.value(name).ifPresent(consumer); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentWalker.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentWalker.java new file mode 100644 index 00000000000..61355b17106 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiDocumentWalker.java @@ -0,0 +1,815 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class OpenApiDocumentWalker { + private OpenApiDocumentWalker() { + } + + static void walk(Map document, + OpenApiDialect dialect, + Visitor visitor) { + Node root = new Node(Kind.DOCUMENT, document, "", null, null); + if (!visit(root, visitor)) { + return; + } + Set documentFields = dialect.fields(Kind.DOCUMENT); + if (documentFields.contains("info") && document.containsKey("info")) { + walkInfo("info", document.get("info"), root, dialect, visitor); + } + if (documentFields.contains("servers")) { + walkServers("servers", list(document.get("servers")), root, dialect, visitor); + } + if (documentFields.contains("paths")) { + walkPathItems("paths", object(document.get("paths")), true, root, dialect, visitor); + } + if (documentFields.contains("webhooks")) { + walkPathItems("webhooks", object(document.get("webhooks")), false, root, dialect, visitor); + } + if (documentFields.contains("tags")) { + walkTags("tags", list(document.get("tags")), root, dialect, visitor); + } + if (documentFields.contains("externalDocs") && document.containsKey("externalDocs")) { + walkExternalDocs("externalDocs", document.get("externalDocs"), root, dialect, visitor); + } + if (documentFields.contains("components") && document.containsKey("components")) { + walkComponents("components", document.get("components"), root, dialect, visitor); + } + } + + private static void walkComponents(String location, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node componentNode = new Node(Kind.COMPONENTS, value, location, "components", parent); + if (!visit(componentNode, visitor)) { + return; + } + Map components = componentNode.value(); + Set componentFields = dialect.fields(Kind.COMPONENTS); + if (componentFields.contains("pathItems")) { + walkPathItems(location + ".pathItems", + object(components.get("pathItems")), + false, + componentNode, + dialect, + visitor); + } + if (componentFields.contains("callbacks")) { + walkCallbacks(location + ".callbacks", + object(components.get("callbacks")), + componentNode, + dialect, + visitor); + } + if (componentFields.contains("parameters")) { + walkParameterMap(location + ".parameters", + object(components.get("parameters")), + componentNode, + dialect, + visitor); + } + if (componentFields.contains("headers")) { + walkHeaders(location + ".headers", + object(components.get("headers")), + componentNode, + dialect, + visitor); + } + if (componentFields.contains("requestBodies")) { + walkRequestBodies(location + ".requestBodies", + object(components.get("requestBodies")), + componentNode, + dialect, + visitor); + } + if (componentFields.contains("responses")) { + walkResponses(location + ".responses", + object(components.get("responses")), + false, + componentNode, + dialect, + visitor); + } + if (componentFields.contains("mediaTypes")) { + walkMediaTypes(location + ".mediaTypes", + object(components.get("mediaTypes")), + componentNode, + dialect, + visitor); + } + if (componentFields.contains("examples")) { + walkExamples(location + ".examples", + object(components.get("examples")), + componentNode, + visitor); + } + if (componentFields.contains("links")) { + walkLinks(location + ".links", + object(components.get("links")), + componentNode, + visitor); + } + if (componentFields.contains("securitySchemes")) { + walkSecuritySchemes(location + ".securitySchemes", + object(components.get("securitySchemes")), + componentNode, + dialect, + visitor); + } + } + + private static void walkInfo(String location, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node infoNode = new Node(Kind.INFO, value, location, "info", parent); + if (!visit(infoNode, visitor)) { + return; + } + Map info = infoNode.value(); + if (info.containsKey("contact")) { + walkSimpleObject(location + ".contact", + "contact", + info.get("contact"), + Kind.CONTACT, + infoNode, + visitor); + } + if (info.containsKey("license")) { + walkSimpleObject(location + ".license", + "license", + info.get("license"), + Kind.LICENSE, + infoNode, + visitor); + } + } + + private static void walkServers(String location, + Iterable servers, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + int index = 0; + for (Object value : servers) { + walkServer(location + "[" + index + "]", String.valueOf(index), value, parent, visitor); + index++; + } + } + + private static void walkServer(String location, + String name, + Object value, + Node parent, + Visitor visitor) { + Node serverNode = new Node(Kind.SERVER, value, location, name, parent); + if (!visit(serverNode, visitor)) { + return; + } + object(serverNode.value().get("variables")).forEach((variableName, variable) -> walkSimpleObject( + serverNode.location() + ".variables." + variableName, + variableName, + variable, + Kind.SERVER_VARIABLE, + serverNode, + visitor)); + } + + private static void walkTags(String location, + Iterable tags, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + int index = 0; + for (Object value : tags) { + Node tagNode = new Node(Kind.TAG, + value, + location + "[" + index + "]", + String.valueOf(index), + parent); + if (visit(tagNode, visitor) && tagNode.value().containsKey("externalDocs")) { + walkExternalDocs(tagNode.location() + ".externalDocs", + tagNode.value().get("externalDocs"), + tagNode, + dialect, + visitor); + } + index++; + } + } + + private static void walkExternalDocs(String location, + Object externalDocs, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + walkSimpleObject(location, + "externalDocs", + externalDocs, + Kind.EXTERNAL_DOCS, + parent, + visitor); + } + + private static void walkSecuritySchemes(String location, + Map securitySchemes, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + securitySchemes.forEach((name, value) -> walkSecurityScheme(location + "." + name, + name, + value, + parent, + dialect, + visitor)); + } + + private static void walkSecurityScheme(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node securitySchemeNode = new Node(Kind.SECURITY_SCHEME, value, location, name, parent); + if (!visit(securitySchemeNode, visitor)) { + return; + } + Map securityScheme = securitySchemeNode.value(); + if (securityScheme.containsKey("$ref") || !securityScheme.containsKey("flows")) { + return; + } + Node flowsNode = new Node(Kind.OAUTH_FLOWS, + securityScheme.get("flows"), + location + ".flows", + "flows", + securitySchemeNode); + if (!visit(flowsNode, visitor)) { + return; + } + Map flows = flowsNode.value(); + for (String flowName : dialect.oauthFlowFields()) { + if (!flows.containsKey(flowName)) { + continue; + } + walkSimpleObject(flowsNode.location() + "." + flowName, + flowName, + flows.get(flowName), + Kind.OAUTH_FLOW, + flowsNode, + visitor); + } + } + + private static void walkSimpleObject(String location, + String name, + Object value, + Kind kind, + Node parent, + Visitor visitor) { + visitor.visit(new Node(kind, value, location, name, parent)); + } + + private static void walkExamples(String location, + Map examples, + Node parent, + Visitor visitor) { + examples.forEach((name, value) -> visitor.visit( + new Node(Kind.EXAMPLE, value, location + "." + name, name, parent))); + } + + private static void walkLinks(String location, + Map links, + Node parent, + Visitor visitor) { + links.forEach((name, value) -> { + Node linkNode = new Node(Kind.LINK, value, location + "." + name, name, parent); + if (!visit(linkNode, visitor) || linkNode.value().containsKey("$ref")) { + return; + } + if (linkNode.value().containsKey("server")) { + walkServer(linkNode.location() + ".server", + "server", + linkNode.value().get("server"), + linkNode, + visitor); + } + }); + } + + private static void walkPathItems(String location, + Map pathItems, + boolean skipExtensions, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + pathItems.forEach((name, value) -> { + if (!skipExtensions || !name.startsWith("x-")) { + walkPathItem(location + "." + name, name, value, parent, dialect, visitor); + } + }); + } + + private static void walkPathItem(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node pathItemNode = new Node(Kind.PATH_ITEM, value, location, name, parent); + if (!visit(pathItemNode, visitor)) { + return; + } + Map pathItem = pathItemNode.value(); + walkParameters(location + ".parameters", + list(pathItem.get("parameters")), + pathItemNode, + dialect, + visitor); + walkServers(location + ".servers", + list(pathItem.get("servers")), + pathItemNode, + dialect, + visitor); + for (String method : dialect.fixedPathOperationFields()) { + if (pathItem.containsKey(method)) { + walkOperation(location + "." + method, + method, + pathItem.get(method), + pathItemNode, + dialect, + visitor); + } + } + if (dialect.fields(Kind.PATH_ITEM).contains("additionalOperations")) { + object(pathItem.get("additionalOperations")).forEach((method, operation) -> walkOperation( + location + ".additionalOperations." + method, + method, + operation, + pathItemNode, + dialect, + visitor)); + } + } + + private static void walkOperation(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node operationNode = new Node(Kind.OPERATION, value, location, name, parent); + if (!visit(operationNode, visitor)) { + return; + } + Map operation = operationNode.value(); + walkParameters(location + ".parameters", + list(operation.get("parameters")), + operationNode, + dialect, + visitor); + walkServers(location + ".servers", + list(operation.get("servers")), + operationNode, + dialect, + visitor); + if (operation.containsKey("requestBody")) { + walkRequestBody(location + ".requestBody", + "requestBody", + operation.get("requestBody"), + operationNode, + dialect, + visitor); + } + walkResponses(location + ".responses", + object(operation.get("responses")), + true, + operationNode, + dialect, + visitor); + if (operation.containsKey("externalDocs")) { + walkExternalDocs(location + ".externalDocs", + operation.get("externalDocs"), + operationNode, + dialect, + visitor); + } + walkCallbacks(location + ".callbacks", + object(operation.get("callbacks")), + operationNode, + dialect, + visitor); + } + + private static void walkCallbacks(String location, + Map callbacks, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + callbacks.forEach((name, value) -> { + Node callbackNode = new Node(Kind.CALLBACK, value, location + "." + name, name, parent); + if (!visit(callbackNode, visitor)) { + return; + } + Map callback = callbackNode.value(); + if (callback.containsKey("$ref")) { + return; + } + callback.forEach((expression, pathItem) -> { + if (!expression.startsWith("x-")) { + walkPathItem(callbackNode.location() + "." + expression, + expression, + pathItem, + callbackNode, + dialect, + visitor); + } + }); + }); + } + + private static void walkParameters(String location, + Iterable parameters, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + int index = 0; + for (Object value : parameters) { + walkParameter(location + "[" + index + "]", + String.valueOf(index), + value, + parent, + dialect, + visitor); + index++; + } + } + + private static void walkParameterMap(String location, + Map parameters, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + parameters.forEach((name, value) -> walkParameter(location + "." + name, + name, + value, + parent, + dialect, + visitor)); + } + + private static void walkParameter(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node parameterNode = new Node(Kind.PARAMETER, value, location, name, parent); + if (!visit(parameterNode, visitor)) { + return; + } + Map parameter = parameterNode.value(); + if (!parameter.containsKey("$ref")) { + walkContent(location + ".content", + object(parameter.get("content")), + parameterNode, + dialect, + visitor); + walkExamples(location + ".examples", + object(parameter.get("examples")), + parameterNode, + visitor); + } + } + + private static void walkHeaders(String location, + Map headers, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + headers.forEach((name, value) -> walkHeader(location + "." + name, + name, + value, + parent, + dialect, + visitor)); + } + + private static void walkHeader(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node headerNode = new Node(Kind.HEADER, value, location, name, parent); + if (!visit(headerNode, visitor)) { + return; + } + Map header = headerNode.value(); + if (!header.containsKey("$ref")) { + walkContent(location + ".content", + object(header.get("content")), + headerNode, + dialect, + visitor); + walkExamples(location + ".examples", + object(header.get("examples")), + headerNode, + visitor); + } + } + + private static void walkRequestBodies(String location, + Map requestBodies, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + requestBodies.forEach((name, value) -> walkRequestBody(location + "." + name, + name, + value, + parent, + dialect, + visitor)); + } + + private static void walkRequestBody(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node requestBodyNode = new Node(Kind.REQUEST_BODY, value, location, name, parent); + if (!visit(requestBodyNode, visitor)) { + return; + } + Map requestBody = requestBodyNode.value(); + if (!requestBody.containsKey("$ref")) { + walkContent(location + ".content", + object(requestBody.get("content")), + requestBodyNode, + dialect, + visitor); + } + } + + private static void walkResponses(String location, + Map responses, + boolean skipExtensions, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + responses.forEach((name, value) -> { + if (!skipExtensions || !name.startsWith("x-")) { + walkResponse(location + "." + name, + name, + value, + parent, + dialect, + visitor); + } + }); + } + + private static void walkResponse(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node responseNode = new Node(Kind.RESPONSE, value, location, name, parent); + if (!visit(responseNode, visitor)) { + return; + } + Map response = responseNode.value(); + if (response.containsKey("$ref")) { + return; + } + walkContent(location + ".content", + object(response.get("content")), + responseNode, + dialect, + visitor); + walkHeaders(location + ".headers", + object(response.get("headers")), + responseNode, + dialect, + visitor); + walkLinks(location + ".links", + object(response.get("links")), + responseNode, + visitor); + } + + private static void walkContent(String location, + Map content, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + content.forEach((name, value) -> walkMediaType(location + "." + name, + name, + value, + parent, + dialect, + visitor)); + } + + private static void walkMediaTypes(String location, + Map mediaTypes, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + mediaTypes.forEach((name, value) -> walkMediaType(location + "." + name, + null, + value, + parent, + dialect, + visitor)); + } + + private static void walkMediaType(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node mediaTypeNode = new Node(Kind.MEDIA_TYPE, value, location, name, parent); + if (!visit(mediaTypeNode, visitor)) { + return; + } + Map mediaType = mediaTypeNode.value(); + if (mediaType.containsKey("$ref")) { + return; + } + walkExamples(location + ".examples", + object(mediaType.get("examples")), + mediaTypeNode, + visitor); + walkEncodingMap(location + ".encoding", + object(mediaType.get("encoding")), + mediaTypeNode, + dialect, + visitor); + if (dialect.fields(Kind.MEDIA_TYPE).contains("prefixEncoding")) { + walkEncodings(location + ".prefixEncoding", + list(mediaType.get("prefixEncoding")), + mediaTypeNode, + dialect, + visitor); + } + if (dialect.fields(Kind.MEDIA_TYPE).contains("itemEncoding") && mediaType.containsKey("itemEncoding")) { + walkEncoding(location + ".itemEncoding", + "itemEncoding", + mediaType.get("itemEncoding"), + mediaTypeNode, + dialect, + visitor); + } + } + + private static void walkEncodingMap(String location, + Map encodings, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + encodings.forEach((name, value) -> walkEncoding(location + "." + name, + name, + value, + parent, + dialect, + visitor)); + } + + private static void walkEncodings(String location, + Iterable encodings, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + int index = 0; + for (Object value : encodings) { + walkEncoding(location + "[" + index + "]", + String.valueOf(index), + value, + parent, + dialect, + visitor); + index++; + } + } + + private static void walkEncoding(String location, + String name, + Object value, + Node parent, + OpenApiDialect dialect, + Visitor visitor) { + Node encodingNode = new Node(Kind.ENCODING, value, location, name, parent); + if (!visit(encodingNode, visitor)) { + return; + } + Map encoding = encodingNode.value(); + if (encoding.isEmpty()) { + return; + } + walkHeaders(location + ".headers", + object(encoding.get("headers")), + encodingNode, + dialect, + visitor); + if (dialect.fields(Kind.ENCODING).contains("encoding")) { + walkEncodingMap(location + ".encoding", + object(encoding.get("encoding")), + encodingNode, + dialect, + visitor); + } + if (dialect.fields(Kind.ENCODING).contains("prefixEncoding")) { + walkEncodings(location + ".prefixEncoding", + list(encoding.get("prefixEncoding")), + encodingNode, + dialect, + visitor); + } + if (dialect.fields(Kind.ENCODING).contains("itemEncoding") && encoding.containsKey("itemEncoding")) { + walkEncoding(location + ".itemEncoding", + "itemEncoding", + encoding.get("itemEncoding"), + encodingNode, + dialect, + visitor); + } + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return value instanceof Map ? (Map) value : Map.of(); + } + + private static List list(Object value) { + return value instanceof List result ? result : List.of(); + } + + private static boolean visit(Node node, Visitor visitor) { + return visitor.visit(node) && node.hasObjectValue(); + } + + enum Kind { + DOCUMENT, + INFO, + CONTACT, + LICENSE, + EXTERNAL_DOCS, + SERVER, + SERVER_VARIABLE, + TAG, + COMPONENTS, + PATH_ITEM, + OPERATION, + CALLBACK, + PARAMETER, + HEADER, + REQUEST_BODY, + RESPONSE, + MEDIA_TYPE, + ENCODING, + SECURITY_SCHEME, + OAUTH_FLOWS, + OAUTH_FLOW, + EXAMPLE, + LINK + } + + record Node(Kind kind, Object rawValue, String location, String name, Node parent) { + boolean hasObjectValue() { + return rawValue instanceof Map; + } + + @SuppressWarnings("unchecked") + Map value() { + return hasObjectValue() ? (Map) rawValue : Map.of(); + } + } + + interface Visitor { + boolean visit(Node node); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiMediaTypeValidator.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiMediaTypeValidator.java new file mode 100644 index 00000000000..7114640d33b --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiMediaTypeValidator.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.List; +import java.util.Locale; +import java.util.Map; + +final class OpenApiMediaTypeValidator { + private final OpenApiDialect dialect; + private final OpenApiReferenceResolver resolver; + + private OpenApiMediaTypeValidator(Map document, OpenApiDialect dialect) { + this.dialect = dialect; + this.resolver = OpenApiReferenceResolver.create(document); + } + + static void validate(Map document, OpenApiDialect dialect) { + OpenApiMediaTypeValidator validator = new OpenApiMediaTypeValidator(document, dialect); + OpenApiDocumentWalker.walk(document, dialect, validator::validateNode); + } + + private boolean validateNode(OpenApiDocumentWalker.Node node) { + if (node.kind() == OpenApiDocumentWalker.Kind.ENCODING) { + return validateEncoding(node.location(), node.value()); + } + if (node.kind() == OpenApiDocumentWalker.Kind.MEDIA_TYPE) { + OpenApiReferenceResolver.Resolution resolution = resolver.resolveComponent(node.value(), "mediaTypes"); + Map mediaType = resolution.status() == OpenApiReferenceResolver.Status.RESOLVED + ? resolution.value() + : Map.of(); + return validateMediaType(node.location(), node.name(), mediaType); + } + return true; + } + + private boolean validateMediaType(String location, + String mediaType, + Map mediaTypeObject) { + if (mediaTypeObject.containsKey("$ref")) { + return false; + } + validateEncodingFields(location, "media type", mediaTypeObject); + boolean hasPrefixEncoding = mediaTypeObject.containsKey("prefixEncoding"); + boolean hasItemEncoding = mediaTypeObject.containsKey("itemEncoding"); + boolean hasPositionalEncoding = hasPrefixEncoding || hasItemEncoding; + if (hasPositionalEncoding) { + if (mediaType != null && !isMultipart(mediaType)) { + return false; + } + if (!isSchema(mediaTypeObject.get("itemSchema")) + && !isArraySchema(mediaTypeObject.get("schema"))) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " media type at " + location + + " requires itemSchema or an array schema for positional encoding"); + } + } + return true; + } + + private boolean validateEncoding(String location, Map encodingObject) { + if (encodingObject.containsKey("$ref")) { + return false; + } + validateEncodingFields(location, "encoding", encodingObject); + return true; + } + + private void validateEncodingFields(String location, String objectType, Map object) { + boolean hasEncoding = object.containsKey("encoding"); + boolean hasPrefixEncoding = object.containsKey("prefixEncoding"); + boolean hasItemEncoding = object.containsKey("itemEncoding"); + boolean hasPositionalEncoding = hasPrefixEncoding || hasItemEncoding; + if (hasEncoding && hasPositionalEncoding) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " " + objectType + " at " + location + + " cannot combine encoding with prefixEncoding or itemEncoding"); + } + } + + private boolean isArraySchema(Object value) { + Map schema = object(value); + if (schema.isEmpty()) { + return false; + } + Object type = schema.get("type"); + if (type instanceof String string) { + return "array".equals(string); + } + if (type instanceof List list) { + return list.contains("array"); + } + if (!(schema.get("$ref") instanceof String)) { + return true; + } + OpenApiReferenceResolver.Resolution resolution = resolver.resolveComponent(schema, "schemas"); + return resolution.status() != OpenApiReferenceResolver.Status.RESOLVED + || isArraySchema(resolution.value()); + } + + private static boolean isMultipart(String mediaType) { + int parameterStart = mediaType.indexOf(';'); + String type = parameterStart < 0 ? mediaType : mediaType.substring(0, parameterStart); + return type.trim().toLowerCase(Locale.ROOT).startsWith("multipart/"); + } + + private static boolean isSchema(Object value) { + return value instanceof Map || value instanceof Boolean; + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return value instanceof Map ? (Map) value : Map.of(); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiOperationIdValidator.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiOperationIdValidator.java new file mode 100644 index 00000000000..8177d9dc728 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiOperationIdValidator.java @@ -0,0 +1,377 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class OpenApiOperationIdValidator { + private static final Set OPERATION_FIELDS = Set.of("get", + "put", + "post", + "delete", + "options", + "head", + "patch", + "trace", + "query"); + + private final OpenApiReferenceResolver resolver; + private final IdentityHashMap, Node> pathItems = new IdentityHashMap<>(); + private final IdentityHashMap, Node> callbacks = new IdentityHashMap<>(); + private final List nodes = new ArrayList<>(); + private final List roots = new ArrayList<>(); + private final ArrayDeque pending = new ArrayDeque<>(); + private final Map operationIds = new LinkedHashMap<>(); + + private OpenApiOperationIdValidator(Map document) { + resolver = OpenApiReferenceResolver.create(document); + } + + static void validate(Map document) { + OpenApiOperationIdValidator validator = new OpenApiOperationIdValidator(document); + validator.addPathItemRoots("paths", object(document.get("paths")), true); + validator.addPathItemRoots("webhooks", object(document.get("webhooks")), false); + validator.buildGraph(); + validator.assignComponents(); + validator.validateGraph(); + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return value instanceof Map map ? (Map) map : Map.of(); + } + + private void addPathItemRoots(String location, + Map pathItemValues, + boolean skipExtensions) { + pathItemValues.forEach((name, value) -> { + if (skipExtensions && name.startsWith("x-")) { + return; + } + Node node = node(Kind.PATH_ITEM, object(value)); + if (node != null) { + roots.add(new Root(node, Location.root(location + "." + name))); + } + }); + } + + private Node node(Kind kind, Map value) { + if (value.isEmpty()) { + return null; + } + IdentityHashMap, Node> indexedNodes = kind == Kind.PATH_ITEM ? pathItems : callbacks; + Node result = indexedNodes.get(value); + if (result == null) { + result = new Node(nodes.size(), kind, value); + indexedNodes.put(value, result); + nodes.add(result); + pending.addLast(result); + } + return result; + } + + private void addEdge(Node source, Node target, String segment) { + if (target == null) { + return; + } + Edge edge = new Edge(source, target, segment); + source.outgoing.add(edge); + target.incoming.add(edge); + } + + private void buildGraph() { + while (!pending.isEmpty()) { + Node node = pending.removeFirst(); + if (node.kind == Kind.PATH_ITEM) { + describePathItem(node); + } else { + describeCallback(node); + } + } + } + + private void describePathItem(Node node) { + node.value.forEach((field, value) -> { + if (OPERATION_FIELDS.contains(field)) { + describeOperation(node, "." + field, object(value)); + } + }); + object(node.value.get("additionalOperations")).forEach((method, operation) -> describeOperation( + node, + ".additionalOperations." + method, + object(operation))); + OpenApiReferenceResolver.Resolution resolution = resolver.resolveReference(node.value); + if (resolution.status() == OpenApiReferenceResolver.Status.RESOLVED + && resolution.value() != node.value) { + addEdge(node, node(Kind.PATH_ITEM, resolution.value()), ".$ref"); + } + } + + private void describeOperation(Node pathItem, String segment, Map operation) { + if (operation.isEmpty()) { + return; + } + if (operation.get("operationId") instanceof String operationId) { + pathItem.operations.add(new Operation(operationId, segment)); + } + object(operation.get("callbacks")).forEach((name, callback) -> addEdge( + pathItem, + node(Kind.CALLBACK, object(callback)), + segment + ".callbacks." + name)); + } + + private void describeCallback(Node node) { + if (node.value.get("$ref") instanceof String) { + OpenApiReferenceResolver.Resolution resolution = resolver.resolveReference(node.value); + if (resolution.status() == OpenApiReferenceResolver.Status.RESOLVED + && resolution.value() != node.value) { + addEdge(node, node(Kind.CALLBACK, resolution.value()), ".$ref"); + } + return; + } + node.value.forEach((expression, pathItem) -> { + if (!expression.startsWith("x-")) { + addEdge(node, node(Kind.PATH_ITEM, object(pathItem)), "." + expression); + } + }); + } + + private void assignComponents() { + List finished = finishOrder(); + boolean[] assigned = new boolean[nodes.size()]; + for (int i = finished.size() - 1; i >= 0; i--) { + Node start = finished.get(i); + if (assigned[start.id]) { + continue; + } + Component component = new Component(); + ArrayDeque stack = new ArrayDeque<>(); + assigned[start.id] = true; + stack.addLast(start); + while (!stack.isEmpty()) { + Node node = stack.removeLast(); + node.component = component; + for (Edge edge : node.incoming) { + Node source = edge.source; + if (!assigned[source.id]) { + assigned[source.id] = true; + stack.addLast(source); + } + } + } + } + } + + private List finishOrder() { + boolean[] visited = new boolean[nodes.size()]; + List result = new ArrayList<>(nodes.size()); + for (Node start : nodes) { + if (visited[start.id]) { + continue; + } + ArrayDeque stack = new ArrayDeque<>(); + visited[start.id] = true; + stack.addLast(new Frame(start)); + while (!stack.isEmpty()) { + Frame frame = stack.getLast(); + if (frame.nextEdge < frame.node.outgoing.size()) { + Node target = frame.node.outgoing.get(frame.nextEdge++).target; + if (!visited[target.id]) { + visited[target.id] = true; + stack.addLast(new Frame(target)); + } + } else { + result.add(stack.removeLast().node); + } + } + } + return result; + } + + private void validateGraph() { + ArrayDeque occurrences = new ArrayDeque<>(); + roots.forEach(root -> occurrences.addLast(new ComponentOccurrence(root.node.component, + root.node, + root.location))); + while (!occurrences.isEmpty()) { + ComponentOccurrence occurrence = occurrences.removeFirst(); + if (occurrence.component.visits == 2) { + continue; + } + occurrence.component.visits++; + validateComponent(occurrence, occurrences); + } + } + + private void validateComponent(ComponentOccurrence occurrence, + ArrayDeque occurrences) { + IdentityHashMap locations = new IdentityHashMap<>(); + ArrayDeque componentNodes = new ArrayDeque<>(); + locations.put(occurrence.entry, occurrence.location); + componentNodes.addLast(new NodeLocation(occurrence.entry, occurrence.location)); + while (!componentNodes.isEmpty()) { + NodeLocation current = componentNodes.removeFirst(); + for (Operation operation : current.node.operations) { + validateOperationId(operation.id, current.location.child(operation.segment)); + } + for (Edge edge : current.node.outgoing) { + Location targetLocation = current.location.child(edge.segment); + if (edge.target.component == occurrence.component) { + if (!locations.containsKey(edge.target)) { + locations.put(edge.target, targetLocation); + componentNodes.addLast(new NodeLocation(edge.target, targetLocation)); + } + } else { + occurrences.addLast(new ComponentOccurrence(edge.target.component, + edge.target, + targetLocation)); + } + } + } + } + + private void validateOperationId(String operationId, Location location) { + Location previousLocation = operationIds.putIfAbsent(operationId, location); + if (previousLocation != null) { + throw new IllegalStateException("Duplicate OpenAPI operationId " + operationId + + " at " + previousLocation.text() + + " and " + location.text()); + } + } + + private enum Kind { + PATH_ITEM, + CALLBACK + } + + private static final class Node { + private final int id; + private final Kind kind; + private final Map value; + private final List operations = new ArrayList<>(); + private final List outgoing = new ArrayList<>(); + private final List incoming = new ArrayList<>(); + private Component component; + + private Node(int id, Kind kind, Map value) { + this.id = id; + this.kind = kind; + this.value = value; + } + } + + private static final class Component { + private int visits; + } + + private static final class Edge { + private final Node source; + private final Node target; + private final String segment; + + private Edge(Node source, Node target, String segment) { + this.source = source; + this.target = target; + this.segment = segment; + } + } + + private static final class Operation { + private final String id; + private final String segment; + + private Operation(String id, String segment) { + this.id = id; + this.segment = segment; + } + } + + private static final class Root { + private final Node node; + private final Location location; + + private Root(Node node, Location location) { + this.node = node; + this.location = location; + } + } + + private static final class Frame { + private final Node node; + private int nextEdge; + + private Frame(Node node) { + this.node = node; + } + } + + private static final class ComponentOccurrence { + private final Component component; + private final Node entry; + private final Location location; + + private ComponentOccurrence(Component component, Node entry, Location location) { + this.component = component; + this.entry = entry; + this.location = location; + } + } + + private static final class NodeLocation { + private final Node node; + private final Location location; + + private NodeLocation(Node node, Location location) { + this.node = node; + this.location = location; + } + } + + private static final class Location { + private final Location parent; + private final String segment; + + private Location(Location parent, String segment) { + this.parent = parent; + this.segment = segment; + } + + private static Location root(String value) { + return new Location(null, value); + } + + private Location child(String value) { + return new Location(this, value); + } + + private String text() { + ArrayDeque segments = new ArrayDeque<>(); + for (Location current = this; current != null; current = current.parent) { + segments.addFirst(current.segment); + } + StringBuilder result = new StringBuilder(); + segments.forEach(result::append); + return result.toString(); + } + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiQueryStringValidator.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiQueryStringValidator.java new file mode 100644 index 00000000000..114dab37263 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiQueryStringValidator.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class OpenApiQueryStringValidator { + private static final Set QUERYSTRING_SCHEMA_FIELDS = Set.of("allowEmptyValue", + "style", + "explode", + "allowReserved", + "schema"); + + private final OpenApiDialect dialect; + private final OpenApiReferenceResolver resolver; + + private OpenApiQueryStringValidator(Map document, OpenApiDialect dialect) { + this.dialect = dialect; + this.resolver = OpenApiReferenceResolver.create(document); + } + + static void validate(Map document, OpenApiDialect dialect) { + OpenApiQueryStringValidator validator = new OpenApiQueryStringValidator(document, dialect); + validator.validate(document); + } + + private void validate(Map document) { + if (!dialect.supportsQueryStringParameters()) { + return; + } + object(object(document.get("components")).get("parameters")) + .forEach((name, parameter) -> validateQueryStringParameter( + "components.parameters." + name, + object(parameter))); + OpenApiDocumentWalker.walk(document, dialect, this::validateNode); + } + + private boolean validateNode(OpenApiDocumentWalker.Node node) { + switch (node.kind()) { + case PATH_ITEM -> parameters(node.location() + ".parameters", node.value().get("parameters")); + case OPERATION -> { + List> pathParameters = parameters( + node.parent().location() + ".parameters", + node.parent().value().get("parameters")); + List> operationParameters = parameters( + node.location() + ".parameters", + node.value().get("parameters")); + validateEffectiveParameterLocations(node.location(), pathParameters, operationParameters); + } + default -> { + } + } + return true; + } + + private List> parameters(String location, Object value) { + if (!(value instanceof List list)) { + return List.of(); + } + List> result = new ArrayList<>(); + for (int i = 0; i < list.size(); i++) { + Map parameter = object(list.get(i)); + OpenApiReferenceResolver.Resolution resolution = resolver.resolveComponent(parameter, "parameters"); + if (resolution.status() == OpenApiReferenceResolver.Status.RESOLVED && !resolution.value().isEmpty()) { + validateQueryStringParameter(location + "[" + i + "]", resolution.value()); + result.add(resolution.value()); + } + } + validateParameterLocations(location, result); + return result; + } + + private void validateQueryStringParameter(String location, Map parameter) { + if (!"querystring".equals(parameter.get("in"))) { + return; + } + for (String field : QUERYSTRING_SCHEMA_FIELDS) { + if (parameter.containsKey(field)) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " querystring parameter at " + location + + " cannot use schema-mode field " + field); + } + } + if (object(parameter.get("content")).size() != 1) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " querystring parameter at " + location + + " must define exactly one content entry"); + } + } + + private void validateEffectiveParameterLocations(String location, + List> pathParameters, + List> operationParameters) { + Map, Map> effective = new LinkedHashMap<>(); + pathParameters.forEach(parameter -> addEffectiveParameter(effective, parameter)); + operationParameters.forEach(parameter -> addEffectiveParameter(effective, parameter)); + validateParameterLocations(location, List.copyOf(effective.values())); + } + + private static void addEffectiveParameter(Map, Map> effective, + Map parameter) { + if (parameter.get("name") instanceof String name && parameter.get("in") instanceof String in) { + effective.put(List.of(in, name), parameter); + } + } + + private void validateParameterLocations(String location, List> parameters) { + int queryStringCount = 0; + boolean hasQuery = false; + for (Map parameter : parameters) { + if ("querystring".equals(parameter.get("in"))) { + queryStringCount++; + } else if ("query".equals(parameter.get("in"))) { + hasQuery = true; + } + } + if (queryStringCount > 1) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " parameters at " + location + + " cannot define more than one querystring parameter"); + } + if (queryStringCount == 1 && hasQuery) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " parameters at " + location + + " cannot combine query and querystring parameters"); + } + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return value instanceof Map ? (Map) value : Map.of(); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiReferenceResolver.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiReferenceResolver.java new file mode 100644 index 00000000000..722e4a21456 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiReferenceResolver.java @@ -0,0 +1,393 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class OpenApiReferenceResolver { + private final Map document; + private final Map components; + private final URI self; + private final Map, Resolution>> componentResolutions = new HashMap<>(); + private final IdentityHashMap, Resolution> referenceChainResolutions = new IdentityHashMap<>(); + + private OpenApiReferenceResolver(Map document) { + this.document = document; + this.components = object(document.get("components")); + this.self = self(document.get("$self")); + } + + static OpenApiReferenceResolver create(Map document) { + return new OpenApiReferenceResolver(document); + } + + static boolean isUriReference(String value) { + try { + URI.create(value); + return true; + } catch (IllegalArgumentException _) { + return false; + } + } + + static boolean hasIpvFutureHost(String value) { + int authorityStart = authorityStart(value); + if (authorityStart < 0) { + return false; + } + int authorityEnd = value.length(); + for (int i = authorityStart; i < value.length(); i++) { + char ch = value.charAt(i); + if (ch == '/' || ch == '?' || ch == '#') { + authorityEnd = i; + break; + } + } + int userInfoEnd = value.lastIndexOf('@', authorityEnd - 1); + int hostStart = userInfoEnd < authorityStart ? authorityStart : userInfoEnd + 1; + if (hostStart >= authorityEnd || value.charAt(hostStart) != '[') { + return false; + } + int hostEnd = value.indexOf(']', hostStart + 1); + if (hostEnd < 0 || hostEnd >= authorityEnd || !validPort(value, hostEnd + 1, authorityEnd)) { + return false; + } + return isIpvFuture(value, hostStart + 1, hostEnd); + } + + Resolution resolveComponent(Map value, String componentType) { + IdentityHashMap, Resolution> cache = componentResolutions.computeIfAbsent( + componentType, + _ -> new IdentityHashMap<>()); + Resolution cached = cache.get(value); + if (cached != null) { + return cached; + } + Map componentValues = object(components.get(componentType)); + Map current = value; + List> pending = new ArrayList<>(); + Set> visited = new HashSet<>(); + while (true) { + cached = cache.get(current); + if (cached != null) { + return cacheResolution(cache, pending, cached); + } + pending.add(current); + if (!(current.get("$ref") instanceof String ref)) { + return cacheResolution(cache, pending, new Resolution(Status.RESOLVED, current)); + } + Reference reference = reference(ref, self); + if (reference.status() != Status.RESOLVED) { + return cacheResolution(cache, pending, new Resolution(reference.status(), Map.of())); + } + List tokens = reference.tokens(); + if (tokens.size() != 3 + || !"components".equals(tokens.get(0)) + || !componentType.equals(tokens.get(1))) { + return cacheResolution(cache, pending, new Resolution(Status.MISSING, Map.of())); + } + if (!visited.add(tokens)) { + return cacheResolution(cache, pending, new Resolution(Status.CYCLIC, Map.of())); + } + Object target = componentValues.get(tokens.get(2)); + if (!(target instanceof Map targetMap)) { + return cacheResolution(cache, pending, new Resolution(Status.MISSING, Map.of())); + } + current = object(targetMap); + } + } + + Resolution resolveReference(Map value) { + if (!(value.get("$ref") instanceof String ref)) { + return new Resolution(Status.RESOLVED, value); + } + Reference reference = reference(ref, self); + if (reference.status() != Status.RESOLVED) { + return new Resolution(reference.status(), Map.of()); + } + Object target = resolve(reference.tokens()); + if (!(target instanceof Map targetMap)) { + return new Resolution(Status.MISSING, Map.of()); + } + return new Resolution(Status.RESOLVED, object(targetMap)); + } + + Resolution resolveReferenceChain(Map value) { + Resolution cached = referenceChainResolutions.get(value); + if (cached != null) { + return cached; + } + Map current = value; + List> pending = new ArrayList<>(); + Set> visited = Collections.newSetFromMap(new IdentityHashMap<>()); + while (true) { + cached = referenceChainResolutions.get(current); + if (cached != null) { + return cacheResolution(referenceChainResolutions, pending, cached); + } + if (!visited.add(current)) { + return cacheResolution(referenceChainResolutions, + pending, + new Resolution(Status.CYCLIC, Map.of())); + } + pending.add(current); + Resolution resolution = resolveReference(current); + if (resolution.status() != Status.RESOLVED || !(current.get("$ref") instanceof String)) { + return cacheResolution(referenceChainResolutions, pending, resolution); + } + current = resolution.value(); + } + } + + private static int authorityStart(String value) { + if (value.startsWith("//")) { + return 2; + } + int colon = value.indexOf(':'); + if (colon < 1 || colon + 2 >= value.length() + || value.charAt(colon + 1) != '/' + || value.charAt(colon + 2) != '/') { + return -1; + } + if (!isAlpha(value.charAt(0))) { + return -1; + } + for (int i = 1; i < colon; i++) { + char ch = value.charAt(i); + if (!isAlpha(ch) && !isDigit(ch) && ch != '+' && ch != '-' && ch != '.') { + return -1; + } + } + return colon + 3; + } + + private static boolean validPort(String value, int portStart, int authorityEnd) { + if (portStart == authorityEnd) { + return true; + } + if (value.charAt(portStart) != ':') { + return false; + } + for (int i = portStart + 1; i < authorityEnd; i++) { + if (!isDigit(value.charAt(i))) { + return false; + } + } + return true; + } + + private static boolean isIpvFuture(String value, int start, int end) { + if (end - start < 4 || (value.charAt(start) != 'v' && value.charAt(start) != 'V')) { + return false; + } + int i = start + 1; + int versionStart = i; + while (i < end && isHexDigit(value.charAt(i))) { + i++; + } + if (i == versionStart || i >= end - 1 || value.charAt(i++) != '.') { + return false; + } + while (i < end) { + char ch = value.charAt(i++); + if (!isAlpha(ch) + && !isDigit(ch) + && "-._~!$&'()*+,;=:".indexOf(ch) < 0) { + return false; + } + } + return true; + } + + private static boolean isAlpha(char ch) { + return ch >= 'a' && ch <= 'z' || ch >= 'A' && ch <= 'Z'; + } + + private static boolean isDigit(char ch) { + return ch >= '0' && ch <= '9'; + } + + private static boolean isHexDigit(char ch) { + return isDigit(ch) || ch >= 'a' && ch <= 'f' || ch >= 'A' && ch <= 'F'; + } + + private static Resolution cacheResolution(IdentityHashMap, Resolution> cache, + List> pending, + Resolution resolution) { + pending.forEach(value -> cache.put(value, resolution)); + return resolution; + } + + private static Reference reference(String ref, URI self) { + URI reference; + try { + reference = URI.create(ref); + } catch (IllegalArgumentException _) { + return new Reference(Status.MALFORMED, List.of()); + } + if (!ref.startsWith("#")) { + if (self == null) { + return new Reference(Status.EXTERNAL, List.of()); + } + reference = self.resolve(reference); + if (!self.equals(documentUri(reference))) { + return new Reference(Status.EXTERNAL, List.of()); + } + } + String fragment = reference.getFragment(); + if (fragment == null) { + return new Reference(Status.RESOLVED, List.of()); + } + Pointer pointer = pointerTokens(fragment); + if (pointer.status() != Status.RESOLVED) { + return new Reference(pointer.status(), List.of()); + } + return new Reference(Status.RESOLVED, pointer.tokens()); + } + + private static Pointer pointerTokens(String fragment) { + if (fragment == null) { + return new Pointer(Status.MALFORMED, List.of()); + } + if (fragment.isEmpty()) { + return new Pointer(Status.RESOLVED, List.of()); + } + if (fragment.charAt(0) != '/') { + return new Pointer(Status.MALFORMED, List.of()); + } + String[] rawTokens = fragment.substring(1).split("/", -1); + List result = new ArrayList<>(rawTokens.length); + for (String rawToken : rawTokens) { + String token = pointerToken(rawToken); + if (token == null) { + return new Pointer(Status.MALFORMED, List.of()); + } + result.add(token); + } + return new Pointer(Status.RESOLVED, result); + } + + private static String pointerToken(String rawToken) { + StringBuilder result = new StringBuilder(rawToken.length()); + for (int i = 0; i < rawToken.length(); i++) { + char ch = rawToken.charAt(i); + if (ch != '~') { + result.append(ch); + continue; + } + if (++i == rawToken.length()) { + return null; + } + switch (rawToken.charAt(i)) { + case '0' -> result.append('~'); + case '1' -> result.append('/'); + default -> { + return null; + } + } + } + return result.toString(); + } + + private static URI self(Object value) { + if (!(value instanceof String uri)) { + return null; + } + try { + return documentUri(URI.create(uri)); + } catch (IllegalArgumentException _) { + return null; + } + } + + private static URI documentUri(URI uri) { + String value = uri.toString(); + int fragment = value.indexOf('#'); + return URI.create(fragment < 0 ? value : value.substring(0, fragment)).normalize(); + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return value instanceof Map ? (Map) value : Map.of(); + } + + private static int arrayIndex(String token) { + if (token.isEmpty()) { + return -1; + } + if (token.charAt(0) == '0' && token.length() != 1) { + return -1; + } + for (int i = 0; i < token.length(); i++) { + char ch = token.charAt(i); + if (ch < '0' || ch > '9') { + return -1; + } + } + try { + return Integer.parseInt(token); + } catch (NumberFormatException _) { + return -1; + } + } + + private Object resolve(List tokens) { + Object current = document; + for (String token : tokens) { + if (current instanceof Map map) { + if (!map.containsKey(token)) { + return null; + } + current = map.get(token); + } else if (current instanceof List list) { + int index = arrayIndex(token); + if (index < 0 || index >= list.size()) { + return null; + } + current = list.get(index); + } else { + return null; + } + } + return current; + } + + enum Status { + RESOLVED, + EXTERNAL, + MISSING, + MALFORMED, + CYCLIC + } + + record Resolution(Status status, Map value) { + } + + private record Reference(Status status, List tokens) { + } + + private record Pointer(Status status, List tokens) { + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiSchemaValidator.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiSchemaValidator.java new file mode 100644 index 00000000000..5f9148fb7ba --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiSchemaValidator.java @@ -0,0 +1,148 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +final class OpenApiSchemaValidator { + private static final Set SCHEMA_FIELDS = Set.of("contains", + "contentSchema", + "else", + "if", + "items", + "not", + "propertyNames", + "then", + "unevaluatedItems", + "unevaluatedProperties"); + private static final Set SCHEMA_ARRAY_FIELDS = Set.of("allOf", + "anyOf", + "oneOf", + "prefixItems"); + private static final Set SCHEMA_MAP_FIELDS = Set.of("$defs", + "definitions", + "dependentSchemas", + "patternProperties", + "properties"); + + private final OpenApiDialect dialect; + + private OpenApiSchemaValidator(OpenApiDialect dialect) { + this.dialect = dialect; + } + + static void validate(Map document, OpenApiDialect dialect) { + OpenApiSchemaValidator validator = new OpenApiSchemaValidator(dialect); + OpenApiDocumentWalker.walk(document, dialect, validator::validateNode); + } + + private boolean validateNode(OpenApiDocumentWalker.Node node) { + if (node.value().containsKey("$ref") + && (node.kind() == OpenApiDocumentWalker.Kind.PARAMETER + || node.kind() == OpenApiDocumentWalker.Kind.HEADER + || node.kind() == OpenApiDocumentWalker.Kind.MEDIA_TYPE)) { + return false; + } + if (node.kind() == OpenApiDocumentWalker.Kind.COMPONENTS) { + object(node.value().get("schemas")).forEach((name, schema) -> validateSchema( + schema, + node.location() + ".schemas." + name, + false)); + } + if (node.kind() == OpenApiDocumentWalker.Kind.PARAMETER + || node.kind() == OpenApiDocumentWalker.Kind.HEADER + || node.kind() == OpenApiDocumentWalker.Kind.MEDIA_TYPE) { + if (node.value().containsKey("schema")) { + validateSchema(node.value().get("schema"), node.location() + ".schema", false); + } + if (node.value().containsKey("itemSchema")) { + validateSchema(node.value().get("itemSchema"), node.location() + ".itemSchema", false); + } + } + return true; + } + + private void validateSchema(Object value, String location, boolean booleanAllowedInOpenApi30) { + if (value instanceof Boolean) { + if (!dialect.supportsBooleanSchemas() && !booleanAllowedInOpenApi30) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " schema at " + location + + " must be an object"); + } + return; + } + if (!(value instanceof Map rawSchema)) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " schema at " + location + + " must be an object or boolean"); + } + Map schema = object(rawSchema); + if (schema.containsKey("$ref")) { + validateReference(schema.get("$ref"), location); + if (dialect.schemaReferenceSiblingsIgnored()) { + return; + } + } + for (String field : SCHEMA_FIELDS) { + if (schema.containsKey(field)) { + validateSchema(schema.get(field), location + "." + field, false); + } + } + if (dialect.additionalItemsHasSchemaValue() && schema.containsKey("additionalItems")) { + validateSchema(schema.get("additionalItems"), location + ".additionalItems", false); + } + for (String field : SCHEMA_ARRAY_FIELDS) { + if (schema.get(field) instanceof List schemas) { + for (int i = 0; i < schemas.size(); i++) { + validateSchema(schemas.get(i), location + "." + field + "[" + i + "]", false); + } + } + } + for (String field : SCHEMA_MAP_FIELDS) { + object(schema.get(field)).forEach((name, nested) -> validateSchema( + nested, + location + "." + field + "." + name, + false)); + } + if (schema.containsKey("additionalProperties")) { + validateSchema(schema.get("additionalProperties"), + location + ".additionalProperties", + true); + } + } + + private void validateReference(Object value, String location) { + if (!(value instanceof String reference)) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " Reference Object at " + location + + " field $ref must be a string"); + } + if (OpenApiReferenceResolver.hasIpvFutureHost(reference)) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " Reference Object at " + location + + " field $ref uses an IPvFuture host literal, which is not supported: " + + reference); + } + if (!OpenApiReferenceResolver.isUriReference(reference)) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " Reference Object at " + location + + " field $ref must be a URI: " + reference); + } + } + + @SuppressWarnings("unchecked") + private static Map object(Object value) { + return value instanceof Map ? (Map) value : Map.of(); + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiStructuralValidator.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiStructuralValidator.java new file mode 100644 index 00000000000..0b8e2ba395d --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/OpenApiStructuralValidator.java @@ -0,0 +1,811 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.IdentityHashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.regex.Pattern; + +final class OpenApiStructuralValidator { + private static final Set API_KEY_LOCATIONS = Set.of("query", "header", "cookie"); + private static final Pattern COMPONENT_NAME_PATTERN = Pattern.compile("[A-Za-z0-9._-]+"); + + private final OpenApiDialect dialect; + private final OpenApiReferenceResolver resolver; + private final IdentityHashMap, ResolvedPathItem> pathItemResolutions = new IdentityHashMap<>(); + + private OpenApiStructuralValidator(Map document, OpenApiDialect dialect) { + this.dialect = dialect; + this.resolver = OpenApiReferenceResolver.create(document); + } + + static void validate(Map document, OpenApiDialect dialect) { + OpenApiStructuralValidator validator = new OpenApiStructuralValidator(document, dialect); + validator.validatePaths(document.get("paths")); + validator.validateComponents(document.get("components")); + OpenApiDocumentWalker.walk(document, dialect, validator::validateNode); + } + + static void validateRoot(Map document, String targetVersion) { + if (!(document.get("info") instanceof Map)) { + throw new IllegalStateException("OpenAPI " + targetVersion + " document requires Info metadata"); + } + if (targetVersion.startsWith("3.0")) { + if (!(document.get("paths") instanceof Map)) { + throw new IllegalStateException("OpenAPI " + targetVersion + " document requires a paths field"); + } + } else if (!document.containsKey("paths") + && !document.containsKey("components") + && !document.containsKey("webhooks")) { + throw new IllegalStateException("OpenAPI " + targetVersion + + " document requires at least one of paths, components, or webhooks"); + } + } + + private static boolean isReferenceAlternative(OpenApiDocumentWalker.Node node) { + if (!node.value().containsKey("$ref")) { + return false; + } + return switch (node.kind()) { + case CALLBACK, PARAMETER, HEADER, REQUEST_BODY, RESPONSE, MEDIA_TYPE, SECURITY_SCHEME, EXAMPLE, LINK -> true; + default -> false; + }; + } + + private static boolean isPathLiteral(char value) { + return value >= 'a' && value <= 'z' + || value >= 'A' && value <= 'Z' + || value >= '0' && value <= '9' + || "-._~!$&'()*+,;=:@".indexOf(value) >= 0; + } + + private static boolean isHexDigit(char value) { + return value >= '0' && value <= '9' + || value >= 'a' && value <= 'f' + || value >= 'A' && value <= 'F'; + } + + private static String displayName(OpenApiDocumentWalker.Kind kind) { + return switch (kind) { + case DOCUMENT -> "document"; + case INFO -> "Info"; + case CONTACT -> "Contact"; + case LICENSE -> "License"; + case EXTERNAL_DOCS -> "ExternalDocs"; + case SERVER -> "Server"; + case SERVER_VARIABLE -> "ServerVariable"; + case TAG -> "Tag"; + case COMPONENTS -> "Components"; + case PATH_ITEM -> "PathItem"; + case OPERATION -> "Operation"; + case CALLBACK -> "Callback"; + case PARAMETER -> "Parameter"; + case HEADER -> "Header"; + case REQUEST_BODY -> "RequestBody"; + case RESPONSE -> "Response"; + case MEDIA_TYPE -> "MediaType"; + case ENCODING -> "Encoding"; + case SECURITY_SCHEME -> "SecurityScheme"; + case OAUTH_FLOWS -> "OAuthFlows"; + case OAUTH_FLOW -> "OAuthFlow"; + case EXAMPLE -> "Example"; + case LINK -> "Link"; + }; + } + + private static ValueType fieldType(OpenApiDocumentWalker.Kind kind, String field) { + return switch (kind) { + case DOCUMENT -> switch (field) { + case "openapi", "$self", "jsonSchemaDialect" -> ValueType.STRING; + case "info", "paths", "webhooks", "components", "externalDocs" -> ValueType.OBJECT; + case "servers", "security", "tags" -> ValueType.ARRAY; + default -> ValueType.ANY; + }; + case INFO -> switch (field) { + case "title", "summary", "description", "termsOfService", "version" -> ValueType.STRING; + case "contact", "license" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case CONTACT -> switch (field) { + case "name", "url", "email" -> ValueType.STRING; + default -> ValueType.ANY; + }; + case LICENSE -> switch (field) { + case "name", "identifier", "url" -> ValueType.STRING; + default -> ValueType.ANY; + }; + case EXTERNAL_DOCS -> switch (field) { + case "description", "url" -> ValueType.STRING; + default -> ValueType.ANY; + }; + case SERVER -> switch (field) { + case "url", "description", "name" -> ValueType.STRING; + case "variables" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case SERVER_VARIABLE -> switch (field) { + case "default", "description" -> ValueType.STRING; + case "enum" -> ValueType.STRING_ARRAY; + default -> ValueType.ANY; + }; + case TAG -> switch (field) { + case "name", "summary", "description", "parent", "kind" -> ValueType.STRING; + case "externalDocs" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case PATH_ITEM -> switch (field) { + case "$ref", "summary", "description" -> ValueType.STRING; + case "get", "put", "post", "delete", "options", "head", "patch", "trace", "query", + "additionalOperations" -> ValueType.OBJECT; + case "servers", "parameters" -> ValueType.ARRAY; + default -> ValueType.ANY; + }; + case OPERATION -> switch (field) { + case "summary", "description", "operationId" -> ValueType.STRING; + case "externalDocs", "requestBody", "responses", "callbacks" -> ValueType.OBJECT; + case "tags" -> ValueType.STRING_ARRAY; + case "parameters", "security", "servers" -> ValueType.ARRAY; + case "deprecated" -> ValueType.BOOLEAN; + default -> ValueType.ANY; + }; + case PARAMETER -> switch (field) { + case "$ref", "summary", "description", "name", "in", "style" -> ValueType.STRING; + case "required", "deprecated", "allowEmptyValue", "explode", "allowReserved" -> ValueType.BOOLEAN; + case "schema" -> ValueType.SCHEMA; + case "examples", "content" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case HEADER -> switch (field) { + case "$ref", "summary", "description", "style" -> ValueType.STRING; + case "required", "deprecated", "explode", "allowReserved" -> ValueType.BOOLEAN; + case "schema" -> ValueType.SCHEMA; + case "examples", "content" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case REQUEST_BODY -> switch (field) { + case "$ref", "summary", "description" -> ValueType.STRING; + case "content" -> ValueType.OBJECT; + case "required" -> ValueType.BOOLEAN; + default -> ValueType.ANY; + }; + case RESPONSE -> switch (field) { + case "$ref", "summary", "description" -> ValueType.STRING; + case "headers", "content", "links" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case MEDIA_TYPE -> switch (field) { + case "$ref" -> ValueType.STRING; + case "schema", "itemSchema" -> ValueType.SCHEMA; + case "examples", "encoding", "itemEncoding" -> ValueType.OBJECT; + case "prefixEncoding" -> ValueType.ARRAY; + default -> ValueType.ANY; + }; + case ENCODING -> switch (field) { + case "contentType", "style" -> ValueType.STRING; + case "headers", "encoding", "itemEncoding" -> ValueType.OBJECT; + case "prefixEncoding" -> ValueType.ARRAY; + case "explode", "allowReserved" -> ValueType.BOOLEAN; + default -> ValueType.ANY; + }; + case COMPONENTS -> switch (field) { + case "schemas", "responses", "parameters", "examples", "requestBodies", "headers", + "securitySchemes", "links", "callbacks", "pathItems", "mediaTypes" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case SECURITY_SCHEME -> switch (field) { + case "$ref", "summary", "description", "type", "name", "in", "scheme", "bearerFormat", + "openIdConnectUrl", "oauth2MetadataUrl" -> ValueType.STRING; + case "flows" -> ValueType.OBJECT; + case "deprecated" -> ValueType.BOOLEAN; + default -> ValueType.ANY; + }; + case OAUTH_FLOWS -> switch (field) { + case "implicit", "password", "clientCredentials", "authorizationCode", "deviceAuthorization" -> + ValueType.OBJECT; + default -> ValueType.ANY; + }; + case OAUTH_FLOW -> switch (field) { + case "authorizationUrl", "deviceAuthorizationUrl", "tokenUrl", "refreshUrl" -> ValueType.STRING; + case "scopes" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + case EXAMPLE -> switch (field) { + case "$ref", "summary", "description", "externalValue", "serializedValue" -> ValueType.STRING; + default -> ValueType.ANY; + }; + case LINK -> switch (field) { + case "$ref", "operationRef", "operationId", "description" -> ValueType.STRING; + case "parameters", "server" -> ValueType.OBJECT; + default -> ValueType.ANY; + }; + default -> ValueType.ANY; + }; + } + + @SuppressWarnings("unchecked") + private static Map object(Map value) { + return (Map) value; + } + + private boolean validateNode(OpenApiDocumentWalker.Node node) { + if (!node.hasObjectValue()) { + throw new IllegalStateException(description(node) + " must be an object"); + } + if (isReferenceAlternative(node)) { + validateReferenceFields(node); + return true; + } + if (node.kind() == OpenApiDocumentWalker.Kind.PATH_ITEM && node.value().containsKey("$ref")) { + validateReferenceUri(node); + } + validateFieldTypes(node); + validateRequiredFields(node); + return true; + } + + private void validateFieldTypes(OpenApiDocumentWalker.Node node) { + Set allowedFields = dialect.fields(node.kind()); + node.value().forEach((field, value) -> { + if (!field.startsWith("x-") && allowedFields.contains(field)) { + ValueType type = fieldType(node.kind(), field); + if (!type.matches(value)) { + throw new IllegalStateException(description(node) + " field " + field + + " must be " + type.description()); + } + } + }); + } + + private void validateReferenceFields(OpenApiDocumentWalker.Node node) { + if (!node.value().containsKey("$ref")) { + return; + } + validateReferenceUri(node); + if (!dialect.version().startsWith("3.0")) { + for (String field : List.of("summary", "description")) { + if (node.value().containsKey(field) && !(node.value().get(field) instanceof String)) { + throw new IllegalStateException(description(node) + " field " + field + " must be a string"); + } + } + } + } + + private void validateReferenceUri(OpenApiDocumentWalker.Node node) { + if (!(node.value().get("$ref") instanceof String reference)) { + throw new IllegalStateException(description(node) + " field $ref must be a string"); + } + if (OpenApiReferenceResolver.hasIpvFutureHost(reference)) { + throw new IllegalStateException(description(node) + + " field $ref uses an IPvFuture host literal, which is not supported: " + + reference); + } + if (!OpenApiReferenceResolver.isUriReference(reference)) { + throw new IllegalStateException(description(node) + " field $ref must be a URI: " + reference); + } + } + + private void validateRequiredFields(OpenApiDocumentWalker.Node node) { + switch (node.kind()) { + case INFO -> { + requireString(node, "title", false); + requireString(node, "version", false); + } + case LICENSE -> requireString(node, "name", false); + case EXTERNAL_DOCS -> requireString(node, "url", false); + case SERVER -> requireString(node, "url", false); + case SERVER_VARIABLE -> validateServerVariable(node); + case TAG -> requireString(node, "name", false); + case PATH_ITEM -> validateParameterUniqueness(node); + case OPERATION -> { + validateOperation(node); + validateParameterUniqueness(node); + } + case PARAMETER -> { + validateParameter(node); + validateExampleFields(node); + } + case HEADER -> { + validateSchemaOrContent(node); + validateExampleFields(node); + } + case REQUEST_BODY -> requireObject(node, "content"); + case RESPONSE -> { + if (dialect.responseDescriptionRequired()) { + requireString(node, "description", false); + } + } + case SECURITY_SCHEME -> validateSecurityScheme(node); + case OAUTH_FLOW -> validateOAuthFlow(node); + case MEDIA_TYPE -> validateExampleFields(node); + case EXAMPLE -> validateExample(node); + case LINK -> validateLink(node); + default -> { + } + } + } + + private void validateServerVariable(OpenApiDocumentWalker.Node node) { + requireString(node, "default", false); + if (node.value().get("enum") instanceof List values) { + if (values.isEmpty()) { + throw new IllegalStateException(description(node) + " enum must contain at least one value"); + } + if (!values.contains(node.value().get("default"))) { + throw new IllegalStateException(description(node) + " enum must contain its default value"); + } + } + } + + private void validateOperation(OpenApiDocumentWalker.Node node) { + if (dialect.operationResponsesRequired()) { + requireObject(node, "responses"); + } + if (node.value().get("responses") instanceof Map responses + && responses.keySet().stream() + .map(String::valueOf) + .noneMatch(OpenApiDocumentMapperSupport::isResponseCode)) { + throw new IllegalStateException(description(node) + " responses require at least one response code"); + } + } + + private void validateParameterUniqueness(OpenApiDocumentWalker.Node node) { + if (!(node.value().get("parameters") instanceof List parameters)) { + return; + } + Set> identities = new HashSet<>(); + for (Object parameterValue : parameters) { + if (!(parameterValue instanceof Map parameter)) { + continue; + } + OpenApiReferenceResolver.Resolution resolution = resolver.resolveReferenceChain(object(parameter)); + if (resolution.status() != OpenApiReferenceResolver.Status.RESOLVED + || !(resolution.value().get("name") instanceof String name) + || name.isBlank() + || !(resolution.value().get("in") instanceof String location) + || !dialect.parameterLocations().contains(location)) { + continue; + } + String normalizedName = "header".equals(location) ? name.toLowerCase(Locale.ROOT) : name; + if (!identities.add(List.of(normalizedName, location))) { + throw new IllegalStateException(description(node) + " parameters contain duplicate " + + location + " parameter " + name); + } + } + } + + private void validateParameter(OpenApiDocumentWalker.Node node) { + requireString(node, "name", false); + String location = requireString(node, "in", true); + if (!dialect.parameterLocations().contains(location)) { + throw new IllegalStateException(description(node) + " has unsupported location " + location); + } + if ("path".equals(location) && !Boolean.TRUE.equals(node.value().get("required"))) { + throw new IllegalStateException(description(node) + " path parameter requires required: true"); + } + if (!"querystring".equals(location)) { + validateSchemaOrContent(node); + } + } + + private void validatePaths(Object value) { + if (!(value instanceof Map paths)) { + return; + } + paths.forEach((key, pathItemValue) -> { + String path = String.valueOf(key); + if (path.startsWith("x-")) { + return; + } + Set templateExpressions = validatePath(path); + if (!templateExpressions.isEmpty() && pathItemValue instanceof Map pathItem) { + validatePathParameters(path, templateExpressions, object(pathItem)); + } + }); + } + + private Set validatePath(String path) { + if (!path.startsWith("/")) { + throw invalidPath(path, "must start with /"); + } + boolean validateTemplate = dialect.version().startsWith("3.2"); + Set expressions = new HashSet<>(); + int expressionStart = -1; + boolean segmentContent = false; + for (int i = 1; i < path.length(); i++) { + switch (path.charAt(i)) { + case '{' -> { + if (expressionStart >= 0) { + throw invalidPath(path, "must not contain nested path template expressions"); + } + expressionStart = i + 1; + } + case '}' -> { + if (expressionStart < 0) { + throw invalidPath(path, "contains an unmatched path template expression end"); + } + String expression = path.substring(expressionStart, i); + if (validateTemplate && expression.isEmpty()) { + throw invalidPath(path, "must not contain an empty path template expression"); + } + if (!expressions.add(expression) && validateTemplate) { + throw invalidPath(path, "must not repeat path template expression {" + expression + "}"); + } + expressionStart = -1; + segmentContent = true; + } + case '?' -> { + if (expressionStart < 0) { + throw invalidPath(path, "must not include a query string"); + } + } + case '#' -> { + if (expressionStart < 0) { + throw invalidPath(path, "must not include a fragment"); + } + } + case '/' -> { + if (validateTemplate && expressionStart < 0) { + if (!segmentContent) { + throw invalidPath(path, "must not contain an empty path segment"); + } + segmentContent = false; + } + } + case '%' -> { + if (validateTemplate && expressionStart < 0) { + if (i + 2 >= path.length() + || !isHexDigit(path.charAt(i + 1)) + || !isHexDigit(path.charAt(i + 2))) { + throw invalidPath(path, "contains an invalid percent-encoded path literal"); + } + i += 2; + segmentContent = true; + } + } + default -> { + if (validateTemplate && expressionStart < 0) { + char pathLiteral = path.charAt(i); + if (!isPathLiteral(pathLiteral)) { + throw invalidPath(path, "contains invalid path literal character at index " + i); + } + segmentContent = true; + } + } + } + } + if (expressionStart >= 0) { + throw invalidPath(path, "contains an unclosed path template expression"); + } + return expressions; + } + + private void validatePathParameters(String path, + Set templateExpressions, + Map pathItem) { + ResolvedPathItem resolvedPathItem = resolvePathItem(pathItem); + Map effectivePathItem = resolvedPathItem.value(); + PathParameters pathParameters = pathParameters(effectivePathItem.get("parameters")); + boolean pathIndeterminate = resolvedPathItem.indeterminate() || pathParameters.indeterminate(); + + for (String operationName : dialect.fixedPathOperationFields()) { + if (effectivePathItem.containsKey(operationName)) { + validateOperationPathParameters(path, + operationName, + effectivePathItem.get(operationName), + templateExpressions, + pathParameters.names(), + pathIndeterminate); + } + } + if (dialect.fields(OpenApiDocumentWalker.Kind.PATH_ITEM).contains("additionalOperations") + && effectivePathItem.get("additionalOperations") instanceof Map additionalOperations) { + additionalOperations.forEach((name, operation) -> validateOperationPathParameters( + path, + String.valueOf(name), + operation, + templateExpressions, + pathParameters.names(), + pathIndeterminate)); + } + } + + private void validateOperationPathParameters(String path, + String operationName, + Object operationValue, + Set templateExpressions, + Set pathParameterNames, + boolean pathIndeterminate) { + if (!(operationValue instanceof Map operation)) { + return; + } + PathParameters operationParameters = pathParameters(operation.get("parameters")); + Set effectivePathParameterNames = new HashSet<>(pathParameterNames); + effectivePathParameterNames.addAll(operationParameters.names()); + for (String expression : templateExpressions) { + if (!effectivePathParameterNames.contains(expression) + && !pathIndeterminate + && !operationParameters.indeterminate()) { + throw invalidPath(path, "operation " + operationName + " requires path parameter " + expression + + " for template expression {" + expression + "}"); + } + } + } + + private PathParameters pathParameters(Object value) { + if (!(value instanceof List parameters)) { + return new PathParameters(Set.of(), false); + } + Set names = new HashSet<>(); + boolean indeterminate = false; + for (Object parameterValue : parameters) { + if (!(parameterValue instanceof Map parameter)) { + continue; + } + OpenApiReferenceResolver.Resolution resolution = resolver.resolveReferenceChain(object(parameter)); + if (resolution.status() != OpenApiReferenceResolver.Status.RESOLVED) { + indeterminate = true; + } else if ("path".equals(resolution.value().get("in")) + && resolution.value().get("name") instanceof String name) { + names.add(name); + } + } + return new PathParameters(names, indeterminate); + } + + private ResolvedPathItem resolvePathItem(Map pathItem) { + ResolvedPathItem cached = pathItemResolutions.get(pathItem); + if (cached != null) { + return cached; + } + Set> visited = Collections.newSetFromMap(new IdentityHashMap<>()); + List> references = new ArrayList<>(); + Map current = pathItem; + ResolvedPathItem resolved = null; + boolean cacheable = true; + while (true) { + cached = pathItemResolutions.get(current); + if (cached != null) { + resolved = cached; + break; + } + if (!visited.add(current)) { + resolved = new ResolvedPathItem(Map.of(), false); + cacheable = false; + break; + } + references.add(current); + OpenApiReferenceResolver.Resolution resolution = resolver.resolveReference(current); + if (resolution.status() == OpenApiReferenceResolver.Status.EXTERNAL) { + resolved = new ResolvedPathItem(current, true); + break; + } + if (resolution.status() != OpenApiReferenceResolver.Status.RESOLVED || resolution.value() == current) { + resolved = new ResolvedPathItem(current, false); + break; + } + current = resolution.value(); + } + + for (int i = references.size() - 1; i >= 0; i--) { + Map effective = new LinkedHashMap<>(resolved.value()); + references.get(i).forEach((name, value) -> { + if (!"$ref".equals(name)) { + effective.put(name, value); + } + }); + resolved = new ResolvedPathItem(effective, resolved.indeterminate()); + if (cacheable) { + pathItemResolutions.put(references.get(i), resolved); + } + } + return resolved; + } + + private IllegalStateException invalidPath(String path, String reason) { + return new IllegalStateException("OpenAPI " + dialect.version() + " path " + path + " " + reason); + } + + private void validateComponents(Object value) { + if (!(value instanceof Map components)) { + return; + } + Set componentFields = dialect.fields(OpenApiDocumentWalker.Kind.COMPONENTS); + components.forEach((field, entries) -> { + String fieldName = String.valueOf(field); + if (!componentFields.contains(fieldName) || !(entries instanceof Map namedComponents)) { + return; + } + namedComponents.keySet().forEach(key -> { + if (!(key instanceof String name) || !COMPONENT_NAME_PATTERN.matcher(name).matches()) { + throw new IllegalStateException("OpenAPI " + dialect.version() + " Components " + fieldName + + " name " + key + " must match [A-Za-z0-9._-]+"); + } + }); + }); + } + + private void validateSecurityScheme(OpenApiDocumentWalker.Node node) { + String type = requireString(node, "type", true); + if (!dialect.securitySchemeTypes().contains(type)) { + throw new IllegalStateException(description(node) + " has unsupported type " + type); + } + switch (type) { + case "apiKey" -> { + requireString(node, "name", true); + String location = requireString(node, "in", true); + if (!API_KEY_LOCATIONS.contains(location)) { + throw new IllegalStateException(description(node) + " has unsupported API key location " + location); + } + } + case "http" -> requireString(node, "scheme", true); + case "oauth2" -> requireObject(node, "flows"); + case "openIdConnect" -> requireString(node, "openIdConnectUrl", false); + default -> { + } + } + } + + private void validateOAuthFlow(OpenApiDocumentWalker.Node node) { + switch (node.name()) { + case "implicit" -> requireString(node, "authorizationUrl", false); + case "password", "clientCredentials" -> requireString(node, "tokenUrl", false); + case "authorizationCode" -> { + requireString(node, "authorizationUrl", false); + requireString(node, "tokenUrl", false); + } + case "deviceAuthorization" -> { + requireString(node, "deviceAuthorizationUrl", false); + requireString(node, "tokenUrl", false); + } + default -> { + } + } + requireObject(node, "scopes"); + ((Map) node.value().get("scopes")).forEach((name, scopeDescription) -> { + if (!(scopeDescription instanceof String)) { + throw new IllegalStateException(description(node) + " scope " + name + " must have a string description"); + } + }); + } + + private void validateExample(OpenApiDocumentWalker.Node node) { + validateMutuallyExclusive(node, "value", "externalValue"); + if (dialect.fields(OpenApiDocumentWalker.Kind.EXAMPLE).contains("dataValue")) { + validateMutuallyExclusive(node, "value", "dataValue"); + validateMutuallyExclusive(node, "value", "serializedValue"); + validateMutuallyExclusive(node, "serializedValue", "externalValue"); + } + } + + private void validateExampleFields(OpenApiDocumentWalker.Node node) { + validateMutuallyExclusive(node, "example", "examples"); + } + + private void validateMutuallyExclusive(OpenApiDocumentWalker.Node node, String first, String second) { + if (node.value().containsKey(first) && node.value().containsKey(second)) { + throw new IllegalStateException(description(node) + " cannot combine " + first + " with " + second); + } + } + + private void validateLink(OpenApiDocumentWalker.Node node) { + boolean hasOperationRef = node.value().containsKey("operationRef"); + boolean hasOperationId = node.value().containsKey("operationId"); + if (hasOperationRef == hasOperationId) { + throw new IllegalStateException(description(node) + " requires exactly one of operationRef or operationId"); + } + } + + private void validateSchemaOrContent(OpenApiDocumentWalker.Node node) { + boolean hasSchema = node.value().containsKey("schema"); + boolean hasContent = node.value().containsKey("content"); + if (hasSchema == hasContent) { + throw new IllegalStateException(description(node) + " requires exactly one of schema or content"); + } + if (node.value().get("content") instanceof Map content && content.size() != 1) { + throw new IllegalStateException(description(node) + " content must contain exactly one entry"); + } + } + + private String requireString(OpenApiDocumentWalker.Node node, String field, boolean nonBlank) { + Object value = node.value().get(field); + if (!(value instanceof String string) || (nonBlank && string.isBlank())) { + throw new IllegalStateException(description(node) + " requires " + field); + } + return string; + } + + private void requireObject(OpenApiDocumentWalker.Node node, String field) { + if (!(node.value().get(field) instanceof Map)) { + throw new IllegalStateException(description(node) + " requires " + field); + } + } + + private String description(OpenApiDocumentWalker.Node node) { + String location = node.location().isEmpty() ? "document" : node.location(); + return "OpenAPI " + dialect.version() + " " + displayName(node.kind()) + " at " + location; + } + + private enum ValueType { + ANY("a value") { + @Override + boolean matches(Object value) { + return true; + } + }, + STRING("a string") { + @Override + boolean matches(Object value) { + return value instanceof String; + } + }, + BOOLEAN("a boolean") { + @Override + boolean matches(Object value) { + return value instanceof Boolean; + } + }, + OBJECT("an object") { + @Override + boolean matches(Object value) { + return value instanceof Map; + } + }, + ARRAY("an array") { + @Override + boolean matches(Object value) { + return value instanceof List; + } + }, + STRING_ARRAY("an array of strings") { + @Override + boolean matches(Object value) { + return value instanceof List list && list.stream().allMatch(String.class::isInstance); + } + }, + SCHEMA("a schema") { + @Override + boolean matches(Object value) { + return value instanceof Map || value instanceof Boolean; + } + }; + + private final String description; + + ValueType(String description) { + this.description = description; + } + + abstract boolean matches(Object value); + + String description() { + return description; + } + } + + private record PathParameters(Set names, boolean indeterminate) { + } + + private record ResolvedPathItem(Map value, boolean indeterminate) { + } +} diff --git a/openapi/openapi/src/main/java/io/helidon/openapi/v30/package-info.java b/openapi/openapi/src/main/java/io/helidon/openapi/v30/package-info.java new file mode 100644 index 00000000000..29be17abf56 --- /dev/null +++ b/openapi/openapi/src/main/java/io/helidon/openapi/v30/package-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * OpenAPI 3.0 support. + */ +package io.helidon.openapi.v30; diff --git a/openapi/openapi/src/main/java/module-info.java b/openapi/openapi/src/main/java/module-info.java index 567f773c695..caeb670a99f 100644 --- a/openapi/openapi/src/main/java/module-info.java +++ b/openapi/openapi/src/main/java/module-info.java @@ -28,18 +28,26 @@ requires static io.helidon.config.metadata; requires io.helidon.common; - requires io.helidon.config; - requires io.helidon.common.media.type; + requires transitive io.helidon.config; + requires transitive io.helidon.common.media.type; + requires transitive io.helidon.json.schema; + requires transitive io.helidon.service.registry; requires io.helidon.webserver; requires org.yaml.snakeyaml; exports io.helidon.openapi; exports io.helidon.openapi.spi; + // this is a multi-package module, as version 3.0 must be supported for backward compatibility, and we cannot extract it + // into its own module + exports io.helidon.openapi.v30; uses io.helidon.openapi.spi.OpenApiServiceProvider; uses io.helidon.openapi.spi.OpenApiManagerProvider; + uses io.helidon.openapi.spi.OpenApiVersionProvider; provides io.helidon.webserver.spi.ServerFeatureProvider with io.helidon.openapi.OpenApiFeatureProvider; + provides io.helidon.openapi.spi.OpenApiVersionProvider + with io.helidon.openapi.v30.OpenApi30VersionProvider; } diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiDocumentComposerTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiDocumentComposerTest.java new file mode 100644 index 00000000000..dce45de3959 --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiDocumentComposerTest.java @@ -0,0 +1,2459 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.net.URI; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import io.helidon.common.media.type.MediaType; +import io.helidon.common.media.type.MediaTypes; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonNull; +import io.helidon.json.JsonString; +import io.helidon.json.schema.Schema; +import io.helidon.openapi.spi.OpenApiDocumentSource; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.v30.OpenApi30Version; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApiDocumentComposerTest { + private static final int BRANCHING_REFERENCE_DEPTH = 24; + private static final int DEEP_REFERENCE_CHAIN_LENGTH = 1500; + private static final String STATIC_DOCUMENT = """ + openapi: 3.0.3 + info: + title: Static API + version: 1.0.0 + paths: + /static: + get: + operationId: staticGet + responses: + "200": + description: Static response. + """; + + private static final String STATIC_PUBLIC_OPERATION_DOCUMENT = """ + openapi: 3.0.3 + info: + title: Static API + version: 1.0.0 + security: + - staticAuth: [] + components: + securitySchemes: + staticAuth: + type: http + scheme: bearer + paths: + /public: + get: + operationId: publicGet + security: [] + responses: + "200": + description: Public response. + """; + + private static final String STATIC_NULL_EXTENSION_DOCUMENT = """ + openapi: 3.0.3 + info: + title: Static API + version: 1.0.0 + paths: {} + x-null: null + """; + + private static final String STATIC_DOCUMENT_WITH_ADDITIONAL_OPERATION = """ + openapi: 3.0.3 + info: + title: Static API + version: 1.0.0 + paths: + /static: + additionalOperations: + COPY: + operationId: staticCopy + responses: + "200": + description: Static copy response. + """; + + private static final String STATIC_MERGE_DOCUMENT = """ + openapi: 3.0.3 + info: + title: Static API + version: 1.0.0 + tags: + - name: static + description: Static resources + security: + - staticAuth: [] + paths: + /static: + get: + operationId: staticGet + x-static-operation: preserved + responses: + "200": + description: Static response. + headers: + X-Static: + description: Static response header. + required: true + deprecated: true + allowEmptyValue: true + style: simple + explode: false + allowReserved: true + schema: + type: string + example: static-value + X-Static-Examples: + schema: + type: string + examples: + named: + value: named-static-value + components: + schemas: + StaticItem: + type: object + securitySchemes: + staticAuth: + type: http + scheme: bearer + """; + + private static final String STATIC_TEMPLATE_DOCUMENT = """ + openapi: 3.0.3 + info: + title: Static API + version: 1.0.0 + paths: + /static/{id}: + parameters: + - name: id + in: path + required: true + schema: + type: string + get: + operationId: staticGet + responses: + "200": + description: Static response. + """; + + @Test + void generatedFallbackKeepsStaticDocumentWithoutParsingIt() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.STATIC_FIRST); + String content = compose(context, + new TestOpenApiVersion("3.0", "3.0.3", true), + STATIC_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source())); + + assertThat(content, is(STATIC_DOCUMENT)); + } + + @Test + void generatedFallbackUsesGeneratedSourcesWithoutStaticDocument() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.STATIC_FIRST); + String content = compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source())); + + Map parsed = parse(content); + assertThat(parsed.get("openapi"), is("3.0.3")); + assertThat(((Map) parsed.get("info")).get("title"), is("Generated API")); + assertThat(((Map) parsed.get("paths")).containsKey("/generated"), is(true)); + } + + @Test + void ignoreGeneratedReturnsEmptyWithoutStaticDocument() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.STATIC_ONLY); + String content = compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source())); + + assertThat(content, is("")); + } + + @Test + void ignoreGeneratedKeepsStaticDocument() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.STATIC_ONLY); + String content = compose(context, + context.openApiVersion(), + STATIC_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source())); + + assertThat(content, is(STATIC_DOCUMENT)); + } + + @Test + void generatedOnlyIgnoresStaticDocument() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + String content = compose(context, + context.openApiVersion(), + STATIC_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source())); + + Map parsed = parse(content); + assertThat(((Map) parsed.get("info")).get("title"), is("Generated API")); + assertThat(((Map) parsed.get("paths")).containsKey("/static"), is(false)); + assertThat(((Map) parsed.get("paths")).containsKey("/generated"), is(true)); + } + + @Test + void generatedDocumentRequiresInfo() { + for (OpenApiGeneratedMode mode : List.of(OpenApiGeneratedMode.STATIC_FIRST, + OpenApiGeneratedMode.MERGE, + OpenApiGeneratedMode.GENERATED_ONLY)) { + OpenApiDocumentContext context = context(mode); + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(operationSource())), + mode.name()); + + assertThat(thrown.getMessage(), containsString("requires Info metadata")); + } + } + + @Test + void generatedEndpointUsesInfoFromSeparateSource() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + OpenApiDocumentSource metadata = (ignored, document) -> document.info("Generated API", "1.0.0"); + + String content = compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(metadata, operationSource())); + + Map parsed = parse(content); + assertThat(map(parsed, "info").get("title"), is("Generated API")); + assertThat(map(parsed, "paths").containsKey("/generated"), is(true)); + } + + @Test + void generatedOnlyFailsOnDuplicateOperationId() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(operationSource("/first", "duplicate"), + operationSource("/second", "duplicate")))); + + assertThat(thrown.getMessage(), + is("Duplicate OpenAPI operationId duplicate at paths./first.get and paths./second.get")); + } + + @Test + void generatedOnlyFailsOnDuplicateWebhookOperationId() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate"))) + .webhook("x-events", path -> path.operation("POST", responseOperation("duplicate"))); + + assertDuplicateOperationId( + source, + "Duplicate OpenAPI operationId duplicate at paths./generated.get and webhooks.x-events.post"); + } + + @Test + void exposesXPrefixedWebhook() { + OpenApiDocument document = OpenApiDocument.builder() + .webhook("x-events", path -> path.operation("POST", responseOperation("created"))) + .build(); + + assertThat(document.webhooks().containsKey("x-events"), is(true)); + } + + @Test + void generatedOnlyFailsOnDuplicateAdditionalOperationId() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate")) + .additionalOperation("SUBSCRIBE", responseOperation("duplicate"))); + + assertDuplicateOperationId( + source, + "Duplicate OpenAPI operationId duplicate at paths./generated.get " + + "and paths./generated.additionalOperations.SUBSCRIBE"); + } + + @Test + void generatedOnlyFailsOnDuplicateCallbackOperationId() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", + path -> path.operation("GET", + operation -> operation.operationId("duplicate") + .response("200", "OK") + .callback("onEvent", + callback -> callback.expression( + "{$request.body#/callbackUrl}", + pathItem -> pathItem.operation( + "POST", + responseOperation("duplicate")))))); + + assertDuplicateOperationId( + source, + "Duplicate OpenAPI operationId duplicate at paths./generated.get " + + "and paths./generated.get.callbacks.onEvent.{$request.body#/callbackUrl}.post"); + } + + @Test + void generatedOnlyAcceptsDuplicateOperationIdInUnreferencedComponentPathItem() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate"))) + .components(components -> components.pathItem( + "Unused", + path -> path.operation("GET", responseOperation("duplicate")))); + + String content = compose(rawContext(OpenApiGeneratedMode.GENERATED_ONLY, RawOpenApiVersion.OPEN_API_31), + RawOpenApiVersion.OPEN_API_31, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + assertThat(map(map(parse(content), "paths"), "/generated").containsKey("get"), is(true)); + } + + @Test + void generatedOnlyAcceptsDuplicateOperationIdInUnreferencedComponentCallback() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate"))) + .components(components -> components.callback( + "Unused", + callback -> callback.expression( + "{$request.body#/callbackUrl}", + path -> path.operation("POST", responseOperation("duplicate"))))); + + String content = compose(rawContext(OpenApiGeneratedMode.GENERATED_ONLY, RawOpenApiVersion.OPEN_API_31), + RawOpenApiVersion.OPEN_API_31, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + assertThat(map(map(parse(content), "paths"), "/generated").containsKey("get"), is(true)); + } + + @Test + void generatedOnlyFailsOnDuplicateReferencedComponentPathItemOperationId() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate"))) + .path("/referenced", path -> path.ref("#/components/pathItems/Reusable")) + .components(components -> components.pathItem( + "Reusable", + path -> path.operation("GET", responseOperation("duplicate")))); + + assertDuplicateOperationId( + source, + RawOpenApiVersion.OPEN_API_31, + "Duplicate OpenAPI operationId duplicate at paths./generated.get " + + "and paths./referenced.$ref.get"); + } + + @Test + void generatedOnlyFailsOnDuplicateSelfReferencedComponentPathItemOperationId() { + OpenApiDocumentSource source = (_, document) -> document.self("https://example.test/api") + .info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate"))) + .path("/referenced", + path -> path.ref("https://example.test/api#/components/pathItems/Reusable")) + .components(components -> components.pathItem( + "Reusable", + path -> path.operation("GET", responseOperation("duplicate")))); + + assertDuplicateOperationId( + source, + RawOpenApiVersion.OPEN_API_32, + "Duplicate OpenAPI operationId duplicate at paths./generated.get " + + "and paths./referenced.$ref.get"); + } + + @Test + void generatedOnlyFailsOnDuplicateReferencedComponentCallbackOperationId() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate"))) + .path("/callbacks", + path -> path.operation("POST", + operation -> operation.operationId("register") + .response("200", "OK") + .callback("Alias", + callback -> callback.ref( + "#/components/callbacks/Reusable")))) + .components(components -> components.callback( + "Reusable", + callback -> callback.expression( + "{$request.body#/callbackUrl}", + path -> path.operation("POST", responseOperation("duplicate"))))); + + assertDuplicateOperationId( + source, + RawOpenApiVersion.OPEN_API_31, + "Duplicate OpenAPI operationId duplicate at paths./generated.get " + + "and paths./callbacks.post.callbacks.Alias.$ref.{$request.body#/callbackUrl}.post"); + } + + @Test + void generatedOnlyFailsOnDuplicateMultiplyReferencedComponentPathItemOperationId() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/first", path -> path.ref("#/components/pathItems/Reusable")) + .path("/second", path -> path.ref("#/components/pathItems/Reusable")) + .components(components -> components.pathItem( + "Reusable", + path -> path.operation("GET", responseOperation("duplicate")))); + + assertDuplicateOperationId( + source, + RawOpenApiVersion.OPEN_API_31, + "Duplicate OpenAPI operationId duplicate at paths./first.$ref.get " + + "and paths./second.$ref.get"); + } + + @Test + void generatedOnlyHandlesBranchingComponentReferenceDag() { + OpenApiDocumentSource source = (_, document) -> { + document.info("Generated API", "1.0.0") + .path("/dag", + path -> path.operation("GET", + operation -> operation.response("200", "OK") + .callback("left", + callback -> callback.ref(callbackReference(0))) + .callback("right", + callback -> callback.ref(callbackReference(0))))); + document.components(components -> { + // This bounded graph has only 24 named callback components but more than 16 million root-to-leaf paths. + for (int i = 0; i < BRANCHING_REFERENCE_DEPTH; i++) { + int level = i; + components.callback(callbackName(level), + callback -> callback.expression( + "{$request.body#/callbackUrl/" + level + "}", + path -> path.operation("POST", operation -> { + operation.response("200", "OK"); + if (level + 1 < BRANCHING_REFERENCE_DEPTH) { + operation.callback("left", + next -> next.ref( + callbackReference(level + 1))); + operation.callback("right", + next -> next.ref( + callbackReference(level + 1))); + } + }))); + } + }); + }; + + String content = compose(rawContext(OpenApiGeneratedMode.GENERATED_ONLY, RawOpenApiVersion.OPEN_API_31), + RawOpenApiVersion.OPEN_API_31, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + assertThat(map(parse(content), "paths").containsKey("/dag"), is(true)); + } + + @Test + void generatedOnlyHandlesDeepComponentReferenceChain() { + OpenApiDocumentSource source = (_, document) -> { + document.info("Generated API", "1.0.0") + .path("/deep", path -> path.ref(pathItemReference(0))); + document.components(components -> { + // Each bounded level adds both a Path Item and Callback reference to the old recursive call chain. + for (int i = 0; i < DEEP_REFERENCE_CHAIN_LENGTH; i++) { + int level = i; + components.pathItem(pathItemName(level), + path -> path.operation("GET", operation -> { + operation.response("200", "OK"); + if (level + 1 < DEEP_REFERENCE_CHAIN_LENGTH) { + operation.callback( + "next", + callback -> callback.ref(callbackReference(level))); + } + })); + if (level + 1 < DEEP_REFERENCE_CHAIN_LENGTH) { + components.callback(callbackName(level), + callback -> callback.expression( + "{$request.body#/callbackUrl/" + level + "}", + path -> path.ref(pathItemReference(level + 1)))); + } + } + }); + }; + + String content = compose(rawContext(OpenApiGeneratedMode.GENERATED_ONLY, RawOpenApiVersion.OPEN_API_31), + RawOpenApiVersion.OPEN_API_31, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + assertThat(content, containsString("\"/deep\"")); + } + + @Test + void generatedOnlyValidatesOperationsOnIntermediateReferencedPathItems() { + OpenApiDocumentSource source = (_, document) -> document.info("Generated API", "1.0.0") + .path("/generated", path -> path.operation("GET", responseOperation("duplicate"))) + .path("/referenced", path -> path.ref("#/components/pathItems/Intermediate")) + .components(components -> components + .pathItem("Intermediate", + path -> path.ref("#/components/pathItems/Terminal") + .operation("POST", responseOperation("duplicate"))) + .pathItem("Terminal", + path -> path.operation("GET", responseOperation("terminal")))); + + assertDuplicateOperationId( + source, + RawOpenApiVersion.OPEN_API_31, + "Duplicate OpenAPI operationId duplicate at paths./generated.get " + + "and paths./referenced.$ref.post"); + } + + @Test + void generatedOnlyHandlesComponentOperationReferenceCycle() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/cycle", path -> path.ref("#/components/pathItems/Cycle")) + .components(components -> components + .pathItem("Cycle", + path -> path.operation( + "GET", + operation -> operation.operationId("cycle") + .response("200", "OK") + .callback("loop", + callback -> callback.ref( + "#/components/callbacks/Cycle")))) + .callback("Cycle", + callback -> callback.expression( + "{$request.body#/callbackUrl}", + path -> path.ref("#/components/pathItems/Cycle")))); + + String content = compose(rawContext(OpenApiGeneratedMode.GENERATED_ONLY, RawOpenApiVersion.OPEN_API_31), + RawOpenApiVersion.OPEN_API_31, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + assertThat(map(parse(content), "paths").containsKey("/cycle"), is(true)); + } + + @Test + void generatedOnlyIgnoresExternalPathItemReferenceOperationIds() { + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/external", path -> path.ref("https://example.test/openapi.yaml#/paths/~1external")); + + String content = compose(rawContext(OpenApiGeneratedMode.GENERATED_ONLY, RawOpenApiVersion.OPEN_API_31), + RawOpenApiVersion.OPEN_API_31, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + assertThat(map(parse(content), "paths").containsKey("/external"), is(true)); + } + + @Test + void mergeAcceptsParentLinkAcrossStaticAndGeneratedTags() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.MERGE, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocument staticDocument = OpenApiDocument.builder() + .info("Static API", "1.0.0") + .tag(tag -> tag.name("static-child").parent("generated-parent")) + .build(); + OpenApiDocumentSource generated = (ignored, document) -> document + .tag(tag -> tag.name("generated-parent")); + + String content = OpenApiDocumentComposer.compose(context, + Optional.of(() -> staticDocument), + "static", + List.of(generated)); + + List tags = list(parse(content), "tags"); + assertThat(((Map) tags.get(0)).get("parent"), is("generated-parent")); + assertThat(((Map) tags.get(1)).get("name"), is("generated-parent")); + } + + @Test + void generatedOnlyRejectsMissingTagParent() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource source = (ignored, document) -> document + .info("Generated API", "1.0.0") + .tag(tag -> tag.name("child").parent("missing")); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> compose(context, + RawOpenApiVersion.OPEN_API_32, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source))); + + assertThat(thrown.getMessage(), is("OpenAPI tag child references missing parent tag missing")); + } + + @Test + void generatedOnlyOmitsMissingTagParentForOpenApi30() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + OpenApiDocumentSource source = (ignored, document) -> document + .info("Generated API", "1.0.0") + .paths(Map.of()) + .tag(tag -> tag.name("child").parent("missing")); + + String content = compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + Map childTag = (Map) list(parse(content), "tags").get(0); + assertThat(childTag.get("name"), is("child")); + assertThat(childTag.containsKey("parent"), is(false)); + } + + @Test + void generatedOnlyOmitsMissingTagParentForOpenApi31Abstraction() { + OpenApiVersion version = new TestOpenApiVersion("3.1", "3.1.1", false); + OpenApiDocumentContext context = new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + OpenApiGeneratedMode.GENERATED_ONLY, + version); + OpenApiDocumentSource source = (ignored, document) -> document + .info("Generated API", "1.0.0") + .paths(Map.of()) + .tag(tag -> tag.name("child").parent("missing")); + + String content = compose(context, + version, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + Map childTag = (Map) list(parse(content), "tags").get(0); + assertThat(childTag.get("name"), is("child")); + assertThat(childTag.containsKey("parent"), is(false)); + } + + @Test + void generatedOnlyRejectsSelfParentingTag() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource source = (ignored, document) -> document + .info("Generated API", "1.0.0") + .tag(tag -> tag.name("child").parent("child")); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> compose(context, + RawOpenApiVersion.OPEN_API_32, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source))); + + assertThat(thrown.getMessage(), is("OpenAPI tag child cannot be its own parent")); + } + + @Test + void generatedOnlyAcceptsLongTagParentChain() { + int tagCount = 10_000; + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource source = (ignored, document) -> { + document.info("Generated API", "1.0.0") + .tag(tag -> tag.name("tag-0")); + for (int i = 1; i < tagCount; i++) { + String tagName = "tag-" + i; + String parentName = "tag-" + (i - 1); + document.tag(tag -> tag.name(tagName).parent(parentName)); + } + }; + + String content = compose(context, + RawOpenApiVersion.OPEN_API_32, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source)); + + List tags = list(parse(content), "tags"); + assertThat(tags.size(), is(tagCount)); + assertThat(((Map) tags.get(tagCount - 1)).get("parent"), is("tag-" + (tagCount - 2))); + } + + @Test + void mergeRejectsTagParentCycleAcrossStaticAndGeneratedTags() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.MERGE, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocument staticDocument = OpenApiDocument.builder() + .info("Static API", "1.0.0") + .tag(tag -> tag.name("static-tag").parent("generated-tag")) + .build(); + OpenApiDocumentSource generated = (ignored, document) -> document + .tag(tag -> tag.name("generated-tag").parent("static-tag")); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> OpenApiDocumentComposer.compose(context, + Optional.of(() -> staticDocument), + "static", + List.of(generated))); + + assertThat(thrown.getMessage(), + is("OpenAPI tag parent cycle: static-tag -> generated-tag -> static-tag")); + } + + @Test + void generatedOperationIdOverrideResolvesDuplicate() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY, + Map.of("com.example.First#get()", "firstGet")); + OpenApiDocumentSource first = (documentContext, document) -> document.info("Generated API", "1.0.0") + .path("/first", + path -> path.operation("GET", + operation -> operation + .operationId(OpenApiDocumentContextSupport.operationId( + documentContext, + "com.example.First#get()", + "duplicate")) + .response("200", "OK"))); + + String content = compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, operationSource("/second", "duplicate"))); + + Map paths = map(parse(content), "paths"); + assertThat(map(map(paths, "/first"), "get").get("operationId"), is("firstGet")); + assertThat(map(map(paths, "/second"), "get").get("operationId"), is("duplicate")); + } + + @Test + void mergeStaticKeepsStaticAndGeneratedDocumentSections() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.MERGE); + + String content = compose(context, + context.openApiVersion(), + STATIC_MERGE_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(mergeSource())); + + Map parsed = parse(content); + Map paths = map(parsed, "paths"); + assertThat(paths.containsKey("/static"), is(true)); + assertThat(paths.containsKey("/generated"), is(true)); + + Map components = map(parsed, "components"); + assertThat(map(components, "schemas").containsKey("StaticItem"), is(true)); + assertThat(map(components, "schemas").containsKey("GeneratedItem"), is(true)); + assertThat(map(components, "securitySchemes").containsKey("staticAuth"), is(true)); + assertThat(map(components, "securitySchemes").containsKey("generatedAuth"), is(true)); + + Map operation = map(map(paths, "/static"), "get"); + assertThat(operation.get("x-static-operation"), is("preserved")); + + Map response = map(map(operation, "responses"), "200"); + Map staticHeader = map(map(response, "headers"), "X-Static"); + assertThat(staticHeader.get("description"), is("Static response header.")); + assertThat(staticHeader.get("required"), is(true)); + assertThat(staticHeader.get("deprecated"), is(true)); + assertThat(staticHeader.containsKey("allowEmptyValue"), is(false)); + assertThat(staticHeader.get("style"), is("simple")); + assertThat(staticHeader.get("explode"), is(false)); + assertThat(staticHeader.containsKey("allowReserved"), is(false)); + assertThat(map(staticHeader, "schema").get("type"), is("string")); + assertThat(staticHeader.get("example"), is("static-value")); + Map staticExamplesHeader = map(map(response, "headers"), "X-Static-Examples"); + assertThat(map(staticExamplesHeader, "schema").get("type"), is("string")); + assertThat(map(map(staticExamplesHeader, "examples"), "named").get("value"), is("named-static-value")); + + assertThat(((Map) list(parsed, "tags").get(0)).get("name"), is("static")); + assertThat(((Map) list(parsed, "tags").get(1)).get("name"), is("generated")); + assertThat(((Map) list(parsed, "security").get(0)).get("staticAuth"), is(List.of())); + assertThat(((Map) list(parsed, "security").get(1)).get("generatedAuth"), is(List.of("generated:read"))); + } + + @Test + void generatedDocumentReusesEquivalentSchemasAcrossSources() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "First", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components.schema( + "Second", + JsonObject.builder().set("type", "string").build())) + .path("/second", + path -> path.operation( + "GET", + operation -> operation.operationId("secondGet") + .response("200", + response -> response.description("OK") + .content(MediaTypes.APPLICATION_JSON_VALUE, + media -> media.schema(JsonObject.builder() + .set("$ref", + "#/components/schemas/Second") + .build()))))); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + Map operation = map(map(map(document, "paths"), "/second"), "get"); + Map response = map(map(operation, "responses"), "200"); + Map content = map(map(response, "content"), MediaTypes.APPLICATION_JSON_VALUE); + + assertThat(schemas.size(), is(1)); + assertThat(schemas.containsKey("First"), is(true)); + assertThat(map(content, "schema").get("$ref"), is("#/components/schemas/First")); + } + + @Test + void generatedDocumentKeepsCollisionFreeSchemasAcrossSources() { + int sourceCount = 64; + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + List sources = new ArrayList<>(sourceCount); + for (int i = 0; i < sourceCount; i++) { + boolean first = i == 0; + String schemaName = "Schema" + i; + String description = "Schema " + i; + sources.add((_, document) -> { + if (first) { + document.info("Generated API", "1.0.0") + .paths(Map.of()); + } + document.components(components -> components.schema( + schemaName, + JsonObject.builder() + .set("type", "string") + .set("description", description) + .build())); + }); + } + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + sources)); + Map schemas = map(map(document, "components"), "schemas"); + + assertThat(schemas.size(), is(sourceCount)); + assertThat(schemas.containsKey("Schema0"), is(true)); + assertThat(schemas.containsKey("Schema63"), is(true)); + } + + @Test + void generatedDocumentReusesEquivalentSchemasAfterReferenceRewriting() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components + .schema("First", JsonObject.builder().set("type", "string").build()) + .schema("Envelope", + JsonObject.builder() + .set("type", "object") + .set("properties", + JsonObject.builder() + .set("value", + JsonObject.builder() + .set("$ref", "#/components/schemas/First") + .build()) + .build()) + .build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Second", JsonObject.builder().set("type", "string").build()) + .schema("SecondEnvelope", + JsonObject.builder() + .set("type", "object") + .set("properties", + JsonObject.builder() + .set("value", + JsonObject.builder() + .set("$ref", "#/components/schemas/Second") + .build()) + .build()) + .build())) + .path("/second", + path -> path.operation( + "GET", + operation -> operation.operationId("secondGet") + .response("200", + response -> response.description("OK") + .content(MediaTypes.APPLICATION_JSON_VALUE, + media -> media.schema(JsonObject.builder() + .set("$ref", + "#/components/schemas/SecondEnvelope") + .build()))))); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + Map operation = map(map(map(document, "paths"), "/second"), "get"); + Map response = map(map(operation, "responses"), "200"); + Map content = map(map(response, "content"), MediaTypes.APPLICATION_JSON_VALUE); + + assertThat(schemas.size(), is(2)); + assertThat(schemas.containsKey("First"), is(true)); + assertThat(schemas.containsKey("Envelope"), is(true)); + assertThat(map(content, "schema").get("$ref"), is("#/components/schemas/Envelope")); + } + + @Test + void generatedDocumentReusesLongEquivalentSchemaChains() { + int schemaDepth = 128; + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + OpenApiDocumentSource first = (_, document) -> { + document.info("Generated API", "1.0.0"); + document.components(components -> { + components.schema("First0", JsonObject.builder().set("type", "string").build()); + for (int i = 1; i <= schemaDepth; i++) { + components.schema("First" + i, + JsonObject.builder() + .set("$ref", "#/components/schemas/First" + (i - 1)) + .build()); + } + }); + }; + OpenApiDocumentSource second = (_, document) -> { + document.components(components -> { + components.schema("Second0", JsonObject.builder().set("type", "string").build()); + for (int i = 1; i <= schemaDepth; i++) { + components.schema("Second" + i, + JsonObject.builder() + .set("$ref", "#/components/schemas/Second" + (i - 1)) + .build()); + } + }); + document.path("/second", + path -> path.operation( + "GET", + operation -> operation.operationId("secondGet") + .response("200", + response -> response.description("OK") + .content(MediaTypes.APPLICATION_JSON_VALUE, + media -> media.schema(JsonObject.builder() + .set("$ref", + "#/components/schemas/Second" + + schemaDepth) + .build()))))); + }; + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + Map operation = map(map(map(document, "paths"), "/second"), "get"); + Map response = map(map(operation, "responses"), "200"); + Map content = map(map(response, "content"), MediaTypes.APPLICATION_JSON_VALUE); + + assertThat(schemas.size(), is(schemaDepth + 1)); + assertThat(schemas.containsKey("Second" + schemaDepth), is(false)); + assertThat(map(content, "schema").get("$ref"), is("#/components/schemas/First" + schemaDepth)); + } + + @Test + void generatedDocumentRewritesRenamedSchemaAliasesAfterReuse() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components + .schema("Base", JsonObject.builder().set("type", "string").build()) + .schema("B", JsonObject.builder().set("type", "integer").build()) + .schema("Canonical", + JsonObject.builder() + .set("$ref", "#/components/schemas/Base") + .build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("EquivalentBase", JsonObject.builder().set("type", "string").build()) + .schema("B", + JsonObject.builder() + .set("$ref", "#/components/schemas/EquivalentBase") + .build())) + .path("/second", + path -> path.operation( + "GET", + operation -> operation.operationId("secondGet") + .response("200", + response -> response.description("OK") + .content(MediaTypes.APPLICATION_JSON_VALUE, + media -> media.schema(JsonObject.builder() + .set("$ref", + "#/components/schemas/B") + .build()))))); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + Map operation = map(map(map(document, "paths"), "/second"), "get"); + Map response = map(map(operation, "responses"), "200"); + Map content = map(map(response, "content"), MediaTypes.APPLICATION_JSON_VALUE); + + assertThat(schemas.size(), is(3)); + assertThat(schemas.containsKey("B2"), is(false)); + assertThat(map(content, "schema").get("$ref"), is("#/components/schemas/Canonical")); + } + + @Test + void generatedDocumentDoesNotRewriteRenamedSchemasTwice() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components + .schema("B", JsonObject.builder().set("type", "string").build()) + .schema("Wrapper", + JsonObject.builder() + .set("type", "object") + .set("properties", + JsonObject.builder() + .set("value", + JsonObject.builder() + .set("$ref", "#/components/schemas/B") + .build()) + .build()) + .build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("A", JsonObject.builder().set("type", "string").build()) + .schema("B", JsonObject.builder().set("type", "integer").build()) + .schema("Wrapper2", + JsonObject.builder() + .set("type", "object") + .set("properties", + JsonObject.builder() + .set("value", + JsonObject.builder() + .set("$ref", "#/components/schemas/A") + .build()) + .build()) + .build()) + .schema("Choice", + JsonObject.builder() + .setValues("oneOf", + List.of(JsonObject.builder() + .set("$ref", "#/components/schemas/A") + .build())) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("mapping", + JsonObject.builder() + .set("selected", "A") + .build()) + .build()) + .build())) + .path("/second", + path -> path.operation( + "GET", + operation -> operation.operationId("secondGet") + .response("200", + response -> response.description("OK") + .content(MediaTypes.APPLICATION_JSON_VALUE, + media -> media.schema(JsonObject.builder() + .set("$ref", + "#/components/schemas/Wrapper2") + .build()))))); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + Map operation = map(map(map(document, "paths"), "/second"), "get"); + Map response = map(map(operation, "responses"), "200"); + Map content = map(map(response, "content"), MediaTypes.APPLICATION_JSON_VALUE); + Map choice = map(schemas, "Choice"); + Map mapping = map(map(choice, "discriminator"), "mapping"); + + assertThat(schemas.size(), is(4)); + assertThat(map(schemas, "B").get("type"), is("string")); + assertThat(map(schemas, "B2").get("type"), is("integer")); + assertThat(schemas.containsKey("Wrapper2"), is(false)); + assertThat(((Map) list(choice, "oneOf").getFirst()).get("$ref"), is("#/components/schemas/B")); + assertThat(mapping.get("selected"), is("B")); + assertThat(map(content, "schema").get("$ref"), is("#/components/schemas/Wrapper")); + } + + @Test + void generatedDocumentRenamesSelfQualifiedSchemaReferences() { + String self = "https://example.test/api"; + String absoluteItemRef = self + "#/components/schemas/Item"; + String relativeItemRef = "/api#/components/schemas/Item"; + String externalItemRef = "https://example.test/other#/components/schemas/Item"; + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource first = (_, document) -> document + .self(self) + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .self(self) + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("AbsoluteEnvelope", JsonObject.builder().set("$ref", absoluteItemRef).build()) + .schema("RelativeEnvelope", JsonObject.builder().set("$ref", relativeItemRef).build()) + .schema("ExternalEnvelope", JsonObject.builder().set("$ref", externalItemRef).build()) + .schema("EmbeddedEnvelope", + JsonObject.builder() + .set("$id", "https://schemas.example/embedded.json") + .set("properties", + JsonObject.builder() + .set("qualified", + JsonObject.builder().set("$ref", absoluteItemRef).build()) + .set("local", + JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .build()) + .set("relative", + JsonObject.builder() + .set("$ref", "api#/components/schemas/Item") + .build()) + .set("originRelative", + JsonObject.builder() + .set("$ref", "/api#/components/schemas/Item") + .build()) + .set("qualifiedDynamic", + JsonObject.builder().set("$dynamicRef", absoluteItemRef).build()) + .set("localDynamic", + JsonObject.builder() + .set("$dynamicRef", "#/components/schemas/Item") + .build()) + .set("originRelativeDynamic", + JsonObject.builder() + .set("$dynamicRef", "/api#/components/schemas/Item") + .build()) + .build()) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("mapping", + JsonObject.builder() + .set("qualified", absoluteItemRef) + .set("local", "#/components/schemas/Item") + .set("originRelative", + "/api#/components/schemas/Item") + .set("byName", "Item") + .build()) + .set("defaultMapping", absoluteItemRef) + .build()) + .build())); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + + assertThat(map(schemas, "Item").get("type"), is("string")); + assertThat(map(schemas, "Item2").get("type"), is("integer")); + assertThat(map(schemas, "AbsoluteEnvelope").get("$ref"), + is(self + "#/components/schemas/Item2")); + assertThat(map(schemas, "RelativeEnvelope").get("$ref"), + is("/api#/components/schemas/Item2")); + assertThat(map(schemas, "ExternalEnvelope").get("$ref"), is(externalItemRef)); + Map embeddedEnvelope = map(schemas, "EmbeddedEnvelope"); + Map embeddedProperties = map(embeddedEnvelope, "properties"); + assertThat(map(embeddedProperties, "qualified").get("$ref"), + is(self + "#/components/schemas/Item2")); + assertThat(map(embeddedProperties, "local").get("$ref"), is("#/components/schemas/Item")); + assertThat(map(embeddedProperties, "relative").get("$ref"), is("api#/components/schemas/Item")); + assertThat(map(embeddedProperties, "originRelative").get("$ref"), + is("/api#/components/schemas/Item")); + assertThat(map(embeddedProperties, "qualifiedDynamic").get("$dynamicRef"), + is(self + "#/components/schemas/Item2")); + assertThat(map(embeddedProperties, "localDynamic").get("$dynamicRef"), + is("#/components/schemas/Item")); + assertThat(map(embeddedProperties, "originRelativeDynamic").get("$dynamicRef"), + is("/api#/components/schemas/Item")); + Map discriminator = map(embeddedEnvelope, "discriminator"); + Map mapping = map(discriminator, "mapping"); + assertThat(mapping.get("qualified"), is(self + "#/components/schemas/Item2")); + assertThat(mapping.get("local"), is("#/components/schemas/Item")); + assertThat(mapping.get("originRelative"), is("/api#/components/schemas/Item")); + assertThat(mapping.get("byName"), is("Item2")); + assertThat(discriminator.get("defaultMapping"), is(self + "#/components/schemas/Item2")); + } + + @Test + void generatedDocumentUsesComposedSelfForQualifiedSchemaReferences() { + String self = "https://example.test/api"; + String itemRef = self + "#/components/schemas/Item"; + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource first = (_, document) -> document + .self(self) + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", JsonObject.builder().set("$ref", itemRef).build())); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + + assertThat(map(schemas, "Item").get("type"), is("string")); + assertThat(map(schemas, "Item2").get("type"), is("integer")); + assertThat(map(schemas, "Envelope").get("$ref"), is(self + "#/components/schemas/Item2")); + } + + @Test + void generatedDocumentResolvesRelativeSelfAgainstWebContext() { + String relativeItemRef = "openapi#/components/schemas/Item"; + String originRelativeItemRef = "/openapi#/components/schemas/Item"; + String absoluteExternalItemRef = "https://example.test/openapi#/components/schemas/Item"; + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource first = (_, document) -> document + .self("openapi") + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("RelativeEnvelope", + JsonObject.builder().set("$ref", relativeItemRef).build()) + .schema("OriginRelativeEnvelope", + JsonObject.builder().set("$ref", originRelativeItemRef).build()) + .schema("AbsoluteExternalEnvelope", + JsonObject.builder().set("$ref", absoluteExternalItemRef).build())); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + + assertThat(map(schemas, "Item").get("type"), is("string")); + assertThat(map(schemas, "Item2").get("type"), is("integer")); + assertThat(map(schemas, "RelativeEnvelope").get("$ref"), + is("openapi#/components/schemas/Item2")); + assertThat(map(schemas, "OriginRelativeEnvelope").get("$ref"), + is("/openapi#/components/schemas/Item2")); + assertThat(map(schemas, "AbsoluteExternalEnvelope").get("$ref"), is(absoluteExternalItemRef)); + } + + @Test + void generatedDocumentResolvesEmptyPathSelfAgainstDocumentBase() { + String documentBase = "/api-description"; + String itemRef = documentBase + "#/components/schemas/Item"; + OpenApiDocumentContext context = new OpenApiDocumentContextImpl("openapi", + documentBase, + "default", + OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + + for (String self : List.of("", "#source", documentBase)) { + Map schemas = collidingSchemas(context, self, Map.of("Envelope", itemRef)); + + assertThat(self, map(schemas, "Item").get("type"), is("string")); + assertThat(self, map(schemas, "Item2").get("type"), is("integer")); + assertThat(self, map(schemas, "Envelope").get("$ref"), + is(documentBase + "#/components/schemas/Item2")); + } + + String querySelf = "?revision=2"; + Map querySchemas = collidingSchemas( + context, + querySelf, + Map.of("QualifiedEnvelope", documentBase + querySelf + "#/components/schemas/Item", + "UnqualifiedEnvelope", itemRef)); + + assertThat(map(querySchemas, "QualifiedEnvelope").get("$ref"), + is(documentBase + querySelf + "#/components/schemas/Item2")); + assertThat(map(querySchemas, "UnqualifiedEnvelope").get("$ref"), is(itemRef)); + } + + @Test + void generatedDocumentRenamesCollidingSchemasAndReferences() { + String nestedItemRef = "#/components/schemas/Item/properties/a~1b/$defs/m~0n"; + String renamedNestedItemRef = "#/components/schemas/Item2/properties/a~1b/$defs/m~0n"; + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + JsonObject literalDiscriminator = JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .set("$dynamicRef", "#/components/schemas/Item") + .setValues("oneOf", + List.of(JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .build())) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("mapping", JsonObject.builder().set("literal", "Item").build()) + .set("defaultMapping", "Item") + .build()) + .build(); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", + JsonObject.builder() + .set("type", "object") + .setValues("oneOf", + List.of(JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .build())) + .set("properties", + JsonObject.builder() + .set("item", + JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .build()) + .set("encodedItem", + JsonObject.builder() + .set("$ref", "#/%63omponents/schemas/%49tem") + .build()) + .set("nestedItem", + JsonObject.builder() + .set("$ref", nestedItemRef) + .build()) + .set("dynamicItem", + JsonObject.builder() + .set("$dynamicRef", "#/components/schemas/Item") + .build()) + .set("encodedDynamicItem", + JsonObject.builder() + .set("$dynamicRef", "#/%63omponents/schemas/%49tem") + .build()) + .set("dynamicNestedItem", + JsonObject.builder() + .set("$dynamicRef", nestedItemRef) + .build()) + .set("dynamicAnchor", + JsonObject.builder() + .set("$dynamicRef", "#item") + .build()) + .set("dynamicExternal", + JsonObject.builder() + .set("$dynamicRef", "https://example.com/schemas/Item") + .build()) + .set("embeddedResource", + JsonObject.builder() + .set("$id", "embedded.json") + .set("$ref", nestedItemRef) + .set("$dynamicRef", "#/components/schemas/Item") + .set("properties", + JsonObject.builder() + .set("nested", + JsonObject.builder() + .set("$dynamicRef", + "#/components/schemas/Item") + .build()) + .set("nestedRef", + JsonObject.builder() + .set("$ref", nestedItemRef) + .build()) + .build()) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("mapping", + JsonObject.builder() + .set("byRef", + nestedItemRef) + .set("byName", "Item") + .build()) + .set("defaultMapping", + nestedItemRef) + .build()) + .build()) + .set("example", + JsonObject.builder() + .setValues("oneOf", + List.of(JsonObject.builder() + .set("$ref", + "#/components/schemas/Item") + .build())) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("defaultMapping", "Item") + .build()) + .build()) + .set("external", + JsonObject.builder() + .setValues("oneOf", + List.of(JsonObject.builder() + .set("$ref", + "#/components/schemas/Item") + .build())) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("defaultMapping", + "https://example.com/schemas/Item") + .build()) + .build()) + .build()) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("mapping", + JsonObject.builder() + .set("second", "#/components/schemas/Item") + .set("encodedSecond", "#/%63omponents/schemas/%49tem") + .set("secondByName", "Item") + .set("external", + "https://example.com/schemas/Item") + .build()) + .set("defaultMapping", "#/%63omponents/schemas/%49tem") + .build()) + .set("example", literalDiscriminator) + .set("default", literalDiscriminator) + .set("x-payload", literalDiscriminator) + .build()) + .schema("CustomDialect", + JsonObject.builder() + .set("$schema", "https://example.com/dialect") + .set("$dynamicRef", "#/components/schemas/Item") + .build()) + .schema("Draft2020Dialect", + JsonObject.builder() + .set("$schema", "https://json-schema.org/draft/2020-12/schema") + .set("$dynamicRef", "#/components/schemas/Item") + .build()) + .schema("OasDialect", + JsonObject.builder() + .set("$schema", "https://spec.openapis.org/oas/3.1/dialect/base") + .set("$dynamicRef", "#/components/schemas/Item") + .build())) + .path("/second", + path -> path.operation( + "GET", + operation -> operation.operationId("secondGet") + .response("200", + response -> response.description("OK") + .content(MediaTypes.APPLICATION_JSON_VALUE, + media -> media.schema(JsonObject.builder() + .set("$ref", + "#/components/schemas/Envelope") + .build()))))); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map schemas = map(map(document, "components"), "schemas"); + Map operation = map(map(map(document, "paths"), "/second"), "get"); + Map response = map(map(operation, "responses"), "200"); + Map content = map(map(response, "content"), MediaTypes.APPLICATION_JSON_VALUE); + Map envelope = map(schemas, "Envelope"); + Map mapping = map(map(envelope, "discriminator"), "mapping"); + Map properties = map(envelope, "properties"); + Map example = map(envelope, "example"); + Map defaultValue = map(envelope, "default"); + Map extension = map(envelope, "x-payload"); + + assertThat(map(schemas, "Item").get("type"), is("string")); + assertThat(map(schemas, "Item2").get("type"), is("integer")); + assertThat(map(properties, "item").get("$ref"), is("#/components/schemas/Item2")); + assertThat(map(properties, "encodedItem").get("$ref"), is("#/components/schemas/Item2")); + assertThat(map(properties, "nestedItem").get("$ref"), + is(renamedNestedItemRef)); + assertThat(map(properties, "dynamicItem").get("$dynamicRef"), is("#/components/schemas/Item2")); + assertThat(map(properties, "encodedDynamicItem").get("$dynamicRef"), + is("#/components/schemas/Item2")); + assertThat(map(properties, "dynamicNestedItem").get("$dynamicRef"), + is(renamedNestedItemRef)); + assertThat(map(properties, "dynamicAnchor").get("$dynamicRef"), is("#item")); + assertThat(map(properties, "dynamicExternal").get("$dynamicRef"), + is("https://example.com/schemas/Item")); + Map embeddedResource = map(properties, "embeddedResource"); + assertThat(embeddedResource.get("$ref"), + is(nestedItemRef)); + assertThat(embeddedResource.get("$dynamicRef"), is("#/components/schemas/Item")); + assertThat(map(map(embeddedResource, "properties"), "nested").get("$dynamicRef"), + is("#/components/schemas/Item")); + assertThat(map(map(embeddedResource, "properties"), "nestedRef").get("$ref"), + is(nestedItemRef)); + Map embeddedDiscriminator = map(embeddedResource, "discriminator"); + Map embeddedMapping = map(embeddedDiscriminator, "mapping"); + assertThat(embeddedMapping.get("byRef"), + is(nestedItemRef)); + assertThat(embeddedMapping.get("byName"), is("Item2")); + assertThat(embeddedDiscriminator.get("defaultMapping"), + is(nestedItemRef)); + assertThat(map(schemas, "CustomDialect").get("$dynamicRef"), is("#/components/schemas/Item2")); + assertThat(map(schemas, "Draft2020Dialect").get("$dynamicRef"), + is("#/components/schemas/Item2")); + assertThat(map(schemas, "OasDialect").get("$dynamicRef"), is("#/components/schemas/Item2")); + assertThat(mapping.get("second"), is("#/components/schemas/Item2")); + assertThat(mapping.get("encodedSecond"), is("#/components/schemas/Item2")); + assertThat(mapping.get("secondByName"), is("Item2")); + assertThat(mapping.get("external"), is("https://example.com/schemas/Item")); + assertThat(map(envelope, "discriminator").get("defaultMapping"), is("#/components/schemas/Item2")); + assertThat(map(map(properties, "example"), "discriminator").get("defaultMapping"), is("Item2")); + assertThat(map(map(properties, "external"), "discriminator").get("defaultMapping"), + is("https://example.com/schemas/Item")); + assertThat(map(map(example, "discriminator"), "mapping").get("literal"), is("Item")); + assertThat(map(example, "discriminator").get("defaultMapping"), is("Item")); + assertThat(example.get("$ref"), is("#/components/schemas/Item")); + assertThat(example.get("$dynamicRef"), is("#/components/schemas/Item")); + assertThat(defaultValue.get("$ref"), is("#/components/schemas/Item")); + assertThat(map(map(extension, "discriminator"), "mapping").get("literal"), is("Item")); + assertThat(map(extension, "discriminator").get("defaultMapping"), is("Item")); + assertThat(extension.get("$ref"), is("#/components/schemas/Item")); + assertThat(map(content, "schema").get("$ref"), is("#/components/schemas/Envelope")); + } + + @Test + void generatedDocumentTraversesAdditionalItemsOnlyForOpenApi30() { + for (RawOpenApiVersion version : List.of(RawOpenApiVersion.OPEN_API_30, + RawOpenApiVersion.OPEN_API_31, + RawOpenApiVersion.OPEN_API_32)) { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, version); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", + JsonObject.builder() + .set("additionalItems", + JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .build()) + .build())); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map envelope = map(map(map(document, "components"), "schemas"), "Envelope"); + Map additionalItems = map(envelope, "additionalItems"); + String expectedRef = version == RawOpenApiVersion.OPEN_API_30 + ? "#/components/schemas/Item2" + : "#/components/schemas/Item"; + + assertThat(version.type(), additionalItems.get("$ref"), is(expectedRef)); + } + } + + @Test + void generatedDocumentPreservesDynamicRefsForOpenApi30() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_30); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", + JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .set("$dynamicRef", "#/components/schemas/Item") + .build())); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map envelope = map(map(map(document, "components"), "schemas"), "Envelope"); + + assertThat(envelope.get("$ref"), is("#/components/schemas/Item2")); + assertThat(envelope.get("$dynamicRef"), is("#/components/schemas/Item")); + } + + @Test + void generatedDocumentRewritesDynamicRefsForOpenApi31() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_31); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", + JsonObject.builder() + .set("$dynamicRef", "#/components/schemas/Item") + .build())); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map envelope = map(map(map(document, "components"), "schemas"), "Envelope"); + + assertThat(envelope.get("$dynamicRef"), is("#/components/schemas/Item2")); + } + + @Test + void generatedDocumentRewritesDynamicRefsForCustomDocumentDialect() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", + JsonObject.builder() + .set("$dynamicRef", "#/components/schemas/Item") + .build()) + .schema("Draft2020Envelope", + JsonObject.builder() + .set("$schema", "https://json-schema.org/draft/2020-12/schema") + .set("$dynamicRef", "#/components/schemas/Item") + .build()) + .schema("OasEnvelope", + JsonObject.builder() + .set("$schema", "https://spec.openapis.org/oas/3.1/dialect/base") + .set("$dynamicRef", "#/components/schemas/Item") + .build())); + OpenApiDocumentSource third = (_, document) -> document + .jsonSchemaDialect("https://example.com/dialect"); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second, third))); + Map schemas = map(map(document, "components"), "schemas"); + Map envelope = map(schemas, "Envelope"); + + assertThat(envelope.get("$dynamicRef"), is("#/components/schemas/Item2")); + assertThat(map(schemas, "Draft2020Envelope").get("$dynamicRef"), + is("#/components/schemas/Item2")); + assertThat(map(schemas, "OasEnvelope").get("$dynamicRef"), + is("#/components/schemas/Item2")); + } + + @Test + void generatedDocumentRewritesDynamicRefsForDraft2020DocumentDialect() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .jsonSchemaDialect("https://json-schema.org/draft/2020-12/schema") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", + JsonObject.builder() + .set("$dynamicRef", "#/components/schemas/Item") + .build())); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map envelope = map(map(map(document, "components"), "schemas"), "Envelope"); + + assertThat(envelope.get("$dynamicRef"), is("#/components/schemas/Item2")); + } + + @Test + void mergeRewritesGeneratedDynamicRefsForCustomDocumentDialect() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.MERGE, + RawOpenApiVersion.OPEN_API_32); + OpenApiDocument staticDocument = OpenApiDocument.builder() + .info("Static API", "1.0.0") + .jsonSchemaDialect("https://example.com/dialect") + .build(); + OpenApiDocumentSource first = (_, document) -> document + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .schema("Envelope", + JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .set("$dynamicRef", "#/components/schemas/Item") + .build())); + + String content = OpenApiDocumentComposer.compose(context, + Optional.of(() -> staticDocument), + "static", + List.of(first, second)); + Map envelope = map(map(map(parse(content), "components"), "schemas"), "Envelope"); + + assertThat(envelope.get("$ref"), is("#/components/schemas/Item2")); + assertThat(envelope.get("$dynamicRef"), is("#/components/schemas/Item2")); + } + + @Test + void generatedDocumentRewritesDiscriminatorUnderComponentNamedExample() { + OpenApiDocumentContext context = rawContext(OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.OPEN_API_32); + JsonObject itemSchema = JsonObject.builder() + .setValues("oneOf", + List.of(JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .build())) + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("mapping", JsonObject.builder().set("second", "Item").build()) + .set("defaultMapping", "#/components/schemas/Item") + .build()) + .build(); + JsonObject literalDataValue = JsonObject.builder() + .set("$ref", "#/components/schemas/Item") + .set("schema", + JsonObject.builder() + .set("discriminator", + JsonObject.builder() + .set("propertyName", "kind") + .set("mapping", JsonObject.builder().set("literal", "Item").build()) + .set("defaultMapping", "Item") + .build()) + .build()) + .build(); + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> document + .components(components -> components + .schema("Item", JsonObject.builder().set("type", "integer").build()) + .requestBody("example", + requestBody -> requestBody.content(MediaTypes.APPLICATION_JSON_VALUE, + media -> media + .itemSchema(itemSchema) + .example( + "literal", + OpenApiDocument.Example.builder() + .dataValue(literalDataValue) + .build())))); + + Map document = parse(compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + Map components = map(document, "components"); + Map requestBody = map(map(components, "requestBodies"), "example"); + Map content = map(requestBody, "content"); + Map mediaType = map(content, MediaTypes.APPLICATION_JSON_VALUE); + Map rewrittenSchema = map(mediaType, "itemSchema"); + Map discriminator = map(rewrittenSchema, "discriminator"); + Map dataValue = map(map(map(mediaType, "examples"), "literal"), "dataValue"); + Map literalDiscriminator = map(map(dataValue, "schema"), "discriminator"); + + assertThat(map(map(components, "schemas"), "Item2").get("type"), is("integer")); + assertThat(((Map) list(rewrittenSchema, "oneOf").getFirst()).get("$ref"), + is("#/components/schemas/Item2")); + assertThat(map(discriminator, "mapping").get("second"), is("Item2")); + assertThat(discriminator.get("defaultMapping"), is("#/components/schemas/Item2")); + assertThat(dataValue.get("$ref"), is("#/components/schemas/Item")); + assertThat(map(literalDiscriminator, "mapping").get("literal"), is("Item")); + assertThat(literalDiscriminator.get("defaultMapping"), is("Item")); + } + + @Test + void mergeStaticPreservesEmptyOperationSecurityOverride() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.MERGE); + + String content = compose(context, + context.openApiVersion(), + STATIC_PUBLIC_OPERATION_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(operationSource())); + + Map operation = map(map(map(parse(content), "paths"), "/public"), "get"); + assertThat(list(operation, "security"), is(List.of())); + } + + @Test + void mergeFailsOnDuplicateStaticAndGeneratedOperationId() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.MERGE); + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> compose( + context, + context.openApiVersion(), + STATIC_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(operationSource("/generated", "staticGet")))); + + assertThat(thrown.getMessage(), containsString("Duplicate OpenAPI operationId staticGet")); + assertThat(thrown.getMessage(), containsString("paths./static.get")); + assertThat(thrown.getMessage(), containsString("paths./generated.get")); + } + + @Test + void mergeStaticFailsWhenExplicitNullConflictsWithGeneratedValue() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.MERGE); + OpenApiDocumentSource conflicting = (_, document) -> document.extension("x-null", + JsonString.create("value")); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> compose( + context, + context.openApiVersion(), + STATIC_NULL_EXTENSION_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(conflicting))); + + assertThat(thrown.getMessage(), is("Conflicting OpenAPI document value at x-null")); + } + + @Test + void mergeStaticKeepsMatchingExplicitNullGeneratedValue() { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.MERGE); + OpenApiDocumentSource matching = (_, document) -> document.extension("x-null", JsonNull.instance()); + + String content = compose(context, + context.openApiVersion(), + STATIC_NULL_EXTENSION_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(matching)); + + Map parsed = parse(content); + assertThat(parsed.containsKey("x-null"), is(true)); + assertThat(parsed.get("x-null"), is((Object) null)); + } + + @Test + void buildersRejectJavaNullExtensionValues() { + assertThrows(NullPointerException.class, () -> OpenApiDocument.builder().extension("x-null", null)); + assertThrows(NullPointerException.class, () -> OpenApiDocument.Info.builder().extension("x-null", null)); + assertThrows(NullPointerException.class, () -> OpenApiDocument.Operation.builder().extension("x-null", null)); + } + + @Test + void documentBuilderRejectsNullPathItemMaps() { + assertThrows(NullPointerException.class, () -> OpenApiDocument.builder().paths(null)); + assertThrows(NullPointerException.class, () -> OpenApiDocument.builder().webhooks(null)); + } + + @Test + void mergeFailsWhenExplicitNullPathItemConflictsWithSourceValue() { + Map target = documentWithPathValue("/static", null); + Map source = documentWithPathValue("/static", pathItem("generatedGet")); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApiDocument.merge(target, source, "")); + + assertThat(thrown.getMessage(), is("Conflicting OpenAPI document value at paths./static")); + } + + @Test + void mergeKeepsMatchingExplicitNullPathItem() { + Map target = documentWithPathValue("/static", null); + Map source = documentWithPathValue("/static", null); + + OpenApiDocument.merge(target, source, ""); + + Map paths = map(target, "paths"); + assertThat(paths.containsKey("/static"), is(true)); + assertThat(paths.get("/static"), is((Object) null)); + } + + @Test + void mergeFailsWhenExplicitNullAdditionalOperationsConflictsWithSourceValue() { + Map targetPath = new LinkedHashMap<>(); + targetPath.put("additionalOperations", null); + Map sourcePath = new LinkedHashMap<>(); + sourcePath.put("additionalOperations", Map.of("COPY", operation("copyStatic"))); + Map target = documentWithPathValue("/static", targetPath); + Map source = documentWithPathValue("/static", sourcePath); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApiDocument.merge(target, source, "")); + + assertThat(thrown.getMessage(), + is("Conflicting OpenAPI document value at paths./static.additionalOperations")); + } + + @Test + void mergeKeepsMatchingExplicitNullAdditionalOperations() { + Map targetPath = new LinkedHashMap<>(); + targetPath.put("additionalOperations", null); + Map sourcePath = new LinkedHashMap<>(); + sourcePath.put("additionalOperations", null); + Map target = documentWithPathValue("/static", targetPath); + Map source = documentWithPathValue("/static", sourcePath); + + OpenApiDocument.merge(target, source, ""); + + Map path = map(map(target, "paths"), "/static"); + assertThat(path.containsKey("additionalOperations"), is(true)); + assertThat(path.get("additionalOperations"), is((Object) null)); + } + + @Test + void generatedSourcesCanContributeOperationsToSamePath() { + OpenApiDocumentSource first = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", + path -> path.operation("GET", + operation -> operation.operationId("generatedGet") + .response("200", "Generated response."))); + OpenApiDocumentSource second = (context, document) -> document.path("/generated", + path -> path.operation( + "POST", + responseOperation("generatedPost"))); + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + + String content = compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second)); + + Map path = map(map(parse(content), "paths"), "/generated"); + assertThat(path.containsKey("get"), is(true)); + assertThat(path.containsKey("post"), is(true)); + } + + @Test + void mergeStaticFailsOnConflictingOperation() { + OpenApiDocumentSource conflicting = (context, document) -> document.path( + "/static", + path -> path.operation("GET", + operation -> operation + .operationId("other") + .response("200", "Other response."))); + + assertThrows(IllegalStateException.class, + () -> { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.MERGE); + compose(context, + context.openApiVersion(), + STATIC_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(conflicting)); + }); + } + + @Test + void mergeStaticFailsOnConflictingNormalizedPathTemplate() { + OpenApiDocumentSource conflicting = (context, document) -> document.path( + "/static/{name}", + path -> path.parameter(parameter -> parameter + .name("name") + .in("path") + .required(true) + .schema(JsonObject.builder().set("type", "string").build())) + .operation("GET", + operation -> operation + .operationId("other") + .response("200", "Other response."))); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.MERGE); + compose(context, + context.openApiVersion(), + STATIC_TEMPLATE_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(conflicting)); + }); + + assertThat(thrown.getMessage(), + is("Conflicting OpenAPI path template at paths./static/{id} and paths./static/{name}")); + } + + @Test + void mergeStaticUsesStaticDocumentVersionParser() { + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + OpenApiVersion staticVersion = new TestOpenApiVersion("3.1", "3.1.0", false); + OpenApiDocumentContext context = new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + OpenApiGeneratedMode.MERGE, + renderVersion); + + String content = compose(context, + staticVersion, + STATIC_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(operationSource())); + + Map parsed = parse(content); + assertThat(((Map) parsed.get("paths")).containsKey("/static"), is(true)); + assertThat(((Map) parsed.get("paths")).containsKey("/generated"), is(true)); + } + + @Test + void mergeStaticKeepsAdditionalAndFixedOperations() { + OpenApiDocumentContext context = new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + OpenApiGeneratedMode.MERGE, + RawOpenApiVersion.INSTANCE); + OpenApiDocumentSource generated = (ignored, document) -> document + .path("/static", + path -> path.operation("COPY", responseOperation("copyStatic")) + .operation("POST", responseOperation("createStatic"))); + + String content = compose(context, + RawOpenApiVersion.INSTANCE, + STATIC_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(generated)); + + Map path = map(map(parse(content), "paths"), "/static"); + assertThat(map(path, "additionalOperations").containsKey("COPY"), is(true)); + assertThat(path.containsKey("post"), is(true)); + } + + @Test + void mergeStaticFailsOnConflictingAdditionalOperation() { + OpenApiDocumentContext context = new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + OpenApiGeneratedMode.MERGE, + RawOpenApiVersion.INSTANCE); + OpenApiDocumentSource generated = (ignored, document) -> document.path("/static", + path -> path.operation( + "COPY", + responseOperation("copyOther"))); + + assertThrows(IllegalStateException.class, + () -> compose(context, + RawOpenApiVersion.INSTANCE, + STATIC_DOCUMENT_WITH_ADDITIONAL_OPERATION, + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(generated))); + } + + @Test + void webhooksUseLiteralNamesInsteadOfPathTemplateNormalization() { + OpenApiDocumentContext context = new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + OpenApiGeneratedMode.GENERATED_ONLY, + RawOpenApiVersion.INSTANCE); + OpenApiDocumentSource generated = (ignored, document) -> document + .info("Generated API", "1.0.0") + .webhook("order.{created}", path -> path.operation("POST", responseOperation("orderCreated"))) + .webhook("order.{deleted}", path -> path.operation("POST", responseOperation("orderDeleted"))); + + String content = compose(context, + RawOpenApiVersion.INSTANCE, + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(generated)); + + Map webhooks = map(parse(content), "webhooks"); + assertThat(webhooks.containsKey("order.{created}"), is(true)); + assertThat(webhooks.containsKey("order.{deleted}"), is(true)); + } + + @Test + void mergeKeepsWebhookLiteralNamesInsteadOfPathTemplateNormalization() { + OpenApiDocument existing = OpenApiDocument.builder() + .info("Static API", "1.0.0") + .webhook("order.{created}", path -> path.operation("POST", responseOperation("orderCreated"))) + .build(); + OpenApiDocument merged = OpenApiDocument.builder() + .merge(existing) + .webhook("order.{deleted}", path -> path.operation("POST", responseOperation("orderDeleted"))) + .build(); + + Map webhooks = map(parse(merged.toJsonObject().toString()), "webhooks"); + assertThat(webhooks.containsKey("order.{created}"), is(true)); + assertThat(webhooks.containsKey("order.{deleted}"), is(true)); + } + + @Test + void componentSchemaUsesJsonSchemaModelWithoutRootKeywords() { + Schema schema = Schema.builder() + .id(URI.create("https://example.com/schemas/item")) + .rootObject(builder -> builder.addStringProperty("name", name -> name.description("Item name"))) + .build(); + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .paths(Map.of()) + .components(components -> components.schema("Item", schema.generateObjectNoKeywords())) + .build(); + + Map item = map(map(map(parse(OpenApi30Version.create().render(context(OpenApiGeneratedMode.STATIC_ONLY), + document)), + "components"), + "schemas"), + "Item"); + + assertThat(item.containsKey("$schema"), is(false)); + assertThat(item.containsKey("$id"), is(false)); + assertThat(item.get("type"), is("object")); + assertThat(map(item, "properties").containsKey("name"), is(true)); + } + + private static OpenApiDocumentSource source() { + return (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", + path -> path.operation("GET", + operation -> operation.operationId("generatedGet") + .response("200", "Generated response."))); + } + + private static OpenApiDocumentSource operationSource() { + return (context, document) -> document.path("/generated", + path -> path.operation("GET", + operation -> operation + .operationId("generatedGet") + .response("200", + "Generated response."))); + } + + private static OpenApiDocumentSource operationSource(String path, String operationId) { + return (context, document) -> document.path(path, + pathBuilder -> pathBuilder.operation( + "GET", + operation -> operation.operationId(operationId) + .response("200", "OK"))); + } + + private static OpenApiDocumentSource mergeSource() { + return (context, document) -> document + .tag(tag -> tag.name("generated") + .description("Generated resources")) + .components(components -> components + .schema("GeneratedItem", + JsonObject.builder() + .set("type", "object") + .build()) + .securityScheme("generatedAuth", security -> security + .type("oauth2") + .flows(JsonObject.builder() + .set("clientCredentials", + JsonObject.builder() + .set("tokenUrl", "https://id.example.test/token") + .set("scopes", + JsonObject.builder() + .set("generated:read", "Read generated") + .build()) + .build()) + .build()))) + .securityRequirement("generatedAuth", List.of("generated:read")) + .path("/generated", + path -> path.operation("GET", + operation -> operation.operationId("generatedGet") + .response("200", "Generated response."))); + } + + private static OpenApiDocument.Operation responseOperation(String operationId) { + return OpenApiDocument.Operation.builder() + .operationId(operationId) + .response("200", "OK") + .build(); + } + + private static String pathItemReference(int level) { + return "#/components/pathItems/" + pathItemName(level); + } + + private static String pathItemName(int level) { + return "Path" + level; + } + + private static String callbackReference(int level) { + return "#/components/callbacks/" + callbackName(level); + } + + private static String callbackName(int level) { + return "Callback" + level; + } + + private static Map collidingSchemas(OpenApiDocumentContext context, + String self, + Map references) { + OpenApiDocumentSource first = (_, document) -> document + .info("Generated API", "1.0.0") + .components(components -> components.schema( + "Item", + JsonObject.builder().set("type", "string").build())); + OpenApiDocumentSource second = (_, document) -> { + document.self(self) + .components(components -> { + components.schema("Item", JsonObject.builder().set("type", "integer").build()); + references.forEach((name, reference) -> components.schema( + name, + JsonObject.builder().set("$ref", reference).build())); + }); + }; + Map document = parse(compose(context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(first, second))); + return map(map(document, "components"), "schemas"); + } + + private static String compose(OpenApiDocumentContext context, + OpenApiVersion staticOpenApiVersion, + String staticContent, + MediaType staticContentMediaType, + List sources) { + return OpenApiDocumentComposer.compose( + context, + Optional.of(() -> { + OpenApiDocumentContext staticContext = new OpenApiDocumentContextImpl(context.featureName(), + context.webContext(), + context.listener(), + context.generatedMode(), + staticOpenApiVersion); + return staticOpenApiVersion.parse(staticContext, staticContent, staticContentMediaType); + }), + staticContent, + sources); + } + + private static void assertDuplicateOperationId(OpenApiDocumentSource source, String expectedMessage) { + OpenApiDocumentContext context = context(OpenApiGeneratedMode.GENERATED_ONLY); + assertDuplicateOperationId(context, source, expectedMessage); + } + + private static void assertDuplicateOperationId(OpenApiDocumentSource source, + OpenApiVersion version, + String expectedMessage) { + assertDuplicateOperationId(rawContext(OpenApiGeneratedMode.GENERATED_ONLY, version), source, expectedMessage); + } + + private static void assertDuplicateOperationId(OpenApiDocumentContext context, + OpenApiDocumentSource source, + String expectedMessage) { + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> compose( + context, + context.openApiVersion(), + "", + MediaTypes.APPLICATION_OPENAPI_YAML, + List.of(source))); + + assertThat(thrown.getMessage(), is(expectedMessage)); + } + + private static Map documentWithPathValue(String path, Object value) { + Map paths = new LinkedHashMap<>(); + paths.put(path, value); + Map document = new LinkedHashMap<>(); + document.put("paths", paths); + return document; + } + + private static Map pathItem(String operationId) { + Map pathItem = new LinkedHashMap<>(); + pathItem.put("get", operation(operationId)); + return pathItem; + } + + private static Map operation(String operationId) { + Map responses = new LinkedHashMap<>(); + responses.put("200", Map.of("description", "OK")); + Map operation = new LinkedHashMap<>(); + operation.put("operationId", operationId); + operation.put("responses", responses); + return operation; + } + + private static OpenApiDocumentContext context(OpenApiGeneratedMode mode) { + return context(mode, Map.of()); + } + + private static OpenApiDocumentContext context(OpenApiGeneratedMode mode, Map operationIds) { + return new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + mode, + OpenApi30Version.create(), + operationIds); + } + + private static OpenApiDocumentContext rawContext(OpenApiGeneratedMode mode) { + return rawContext(mode, RawOpenApiVersion.INSTANCE); + } + + private static OpenApiDocumentContext rawContext(OpenApiGeneratedMode mode, OpenApiVersion version) { + return new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + mode, + version); + } + + @SuppressWarnings("unchecked") + private static Map parse(String content) { + return new Yaml().load(content); + } + + @SuppressWarnings("unchecked") + private static Map map(Map map, String name) { + return (Map) map.get(name); + } + + @SuppressWarnings("unchecked") + private static List list(Map map, String name) { + return (List) map.get(name); + } + + private record TestOpenApiVersion(String type, String version, boolean failParse) implements OpenApiVersion { + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, + String content, + MediaType mediaType) { + if (failParse) { + throw new AssertionError("Configured render version must not parse static content."); + } + return OpenApi30Version.create().parse(context, content, mediaType); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + return OpenApi30Version.create().render(context, document); + } + + @Override + public String name() { + return type; + } + } + + private static final class RawOpenApiVersion implements OpenApiVersion { + private static final RawOpenApiVersion INSTANCE = new RawOpenApiVersion("raw", "raw"); + private static final RawOpenApiVersion OPEN_API_30 = new RawOpenApiVersion("3.0", "3.0.4"); + private static final RawOpenApiVersion OPEN_API_31 = new RawOpenApiVersion("3.1", "3.1.2"); + private static final RawOpenApiVersion OPEN_API_32 = new RawOpenApiVersion("3.2", "3.2.0"); + + private final String type; + private final String version; + + private RawOpenApiVersion(String type, String version) { + this.type = type; + this.version = version; + } + + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, + String content, + MediaType mediaType) { + OpenApiDocument document = OpenApi30Version.create().parse(context, content, mediaType); + if (content.contains("operationId: staticCopy")) { + return OpenApiDocument.builder() + .merge(document) + .path("/static", path -> path.operation("COPY", responseOperation("staticCopy"))) + .build(); + } + return document; + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + return document.toJsonObject().toString(); + } + + @Override + public String version() { + return version; + } + + @Override + public String type() { + return type; + } + + @Override + public String name() { + return type; + } + } + +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiDocumentTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiDocumentTest.java new file mode 100644 index 00000000000..fc129eded19 --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiDocumentTest.java @@ -0,0 +1,277 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; + +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApiDocumentTest { + @Test + void allowsEmptyInfoStrings() { + OpenApiDocument.Info info = OpenApiDocument.Info.builder() + .title("") + .version(" ") + .build(); + + assertThat(info.title(), is("")); + assertThat(info.version(), is(" ")); + } + + @Test + void allowsEmptyRequiredNames() { + assertDoesNotThrow(() -> OpenApiDocument.License.builder().name("").build()); + assertDoesNotThrow(() -> OpenApiDocument.Tag.builder().name(" ").build()); + assertDoesNotThrow(() -> OpenApiDocument.Parameter.builder().name("").in("query").build()); + } + + @Test + void buildersAllowPartialObjects() { + assertDoesNotThrow(() -> OpenApiDocument.Info.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.License.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.ExternalDocs.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.Server.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.ServerVariable.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.Tag.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.Parameter.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.RequestBody.builder().build()); + assertDoesNotThrow(() -> OpenApiDocument.SecurityScheme.builder().build()); + } + + @Test + void rejectsFixedMethodAsAdditionalOperation() { + IllegalArgumentException thrown = assertThrows(IllegalArgumentException.class, + () -> OpenApiDocument.PathItem.builder() + .additionalOperation("POST", + OpenApiDocument.Operation.builder() + .build())); + + assertThat(thrown.getMessage(), containsString("fixed-field HTTP method: POST")); + } + + @Test + void normalizesUppercaseFixedOperation() { + OpenApiDocument.PathItem pathItem = OpenApiDocument.PathItem.builder() + .operation("POST", OpenApiDocument.Operation.builder().build()) + .build(); + + assertThat(pathItem.operations().keySet(), is(Set.of("post"))); + assertThat(pathItem.additionalOperations().isEmpty(), is(true)); + } + + @Test + void preservesCaseSensitiveCustomAdditionalOperations() { + OpenApiDocument.Operation operation = OpenApiDocument.Operation.builder().build(); + OpenApiDocument.PathItem pathItem = OpenApiDocument.PathItem.builder() + .additionalOperation("post", operation) + .additionalOperation("PoSt", operation) + .build(); + + assertThat(pathItem.additionalOperations().keySet(), is(Set.of("post", "PoSt"))); + } + + @Test + void preservesCaseSensitiveCustomOperations() { + OpenApiDocument.Operation operation = OpenApiDocument.Operation.builder().build(); + OpenApiDocument.PathItem pathItem = OpenApiDocument.PathItem.builder() + .operation("post", operation) + .operation("PoSt", operation) + .build(); + + assertThat(pathItem.operations().isEmpty(), is(true)); + assertThat(pathItem.additionalOperations().keySet(), is(Set.of("post", "PoSt"))); + } + + @Test + void validatesHttpMethodTokens() { + OpenApiDocument.Operation operation = OpenApiDocument.Operation.builder().build(); + List invalidMethods = List.of("", "BAD METHOD", "BAD:METHOD", "BAD\u0007METHOD", "M\u00c9THOD"); + + for (String method : invalidMethods) { + assertThrows(IllegalArgumentException.class, + () -> OpenApiDocument.PathItem.builder().operation(method, operation), + "operation(Operation) should reject invalid method"); + assertThrows(IllegalArgumentException.class, + () -> OpenApiDocument.PathItem.builder().operation(method, _ -> { }), + "operation(Consumer) should reject invalid method"); + assertThrows(IllegalArgumentException.class, + () -> OpenApiDocument.PathItem.builder().additionalOperation(method, operation), + "additionalOperation(Operation) should reject invalid method"); + assertThrows(IllegalArgumentException.class, + () -> OpenApiDocument.PathItem.builder().additionalOperation(method, _ -> { }), + "additionalOperation(Consumer) should reject invalid method"); + } + + OpenApiDocument.PathItem pathItem = OpenApiDocument.PathItem.builder() + .operation("M-SEARCH", operation) + .additionalOperation("!#$%&'*+-.^_`|~", operation) + .build(); + + assertThat(pathItem.additionalOperations().keySet(), is(Set.of("M-SEARCH", "!#$%&'*+-.^_`|~"))); + } + + @Test + void indexesNamedTagsInstalledByMerge() { + AtomicInteger iteratorCalls = new AtomicInteger(); + List initialTags = new ArrayList<>() { + @Override + public Iterator iterator() { + iteratorCalls.incrementAndGet(); + return super.iterator(); + } + }; + initialTags.add(Map.of("name", "first")); + Map initialDocument = new LinkedHashMap<>(); + initialDocument.put("tags", initialTags); + + OpenApiDocument.Builder builder = OpenApiDocument.builder().mergeNode(initialDocument); + iteratorCalls.set(0); + Map nextDocument = new LinkedHashMap<>(); + nextDocument.put("tags", List.of(Map.of("name", "second"))); + builder.mergeNode(nextDocument); + + assertThat("Merging a named tag should not scan tags already indexed", iteratorCalls.get(), is(0)); + assertThat(builder.build().tags().stream().map(OpenApiDocument.Tag::name).toList(), + is(List.of("first", "second"))); + } + + @Test + void deduplicatesNamedTagsInstalledByFirstMerge() { + Map first = Map.of("name", "first", "description", "First"); + Map initialDocument = new LinkedHashMap<>(); + initialDocument.put("tags", List.of(first, + Map.of("name", "first", "description", "First"), + Map.of("name", "second"))); + + OpenApiDocument document = OpenApiDocument.builder() + .mergeNode(initialDocument) + .build(); + + assertThat(document.tags().stream().map(OpenApiDocument.Tag::name).toList(), + is(List.of("first", "second"))); + assertThat(document.tags().getFirst().description().orElseThrow(), is("First")); + } + + @Test + void rejectsConflictingNamedTagsInstalledByFirstMerge() { + Map initialDocument = new LinkedHashMap<>(); + initialDocument.put("tags", List.of(Map.of("name", "first", "description", "First"), + Map.of("name", "first", "description", "Conflicting"))); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApiDocument.builder().mergeNode(initialDocument)); + + assertThat(thrown.getMessage(), is("Conflicting OpenAPI tag at tags.first")); + } + + @Test + void deduplicatesDirectNamedTagsAndRejectsConflicts() { + OpenApiDocument.Tag first = OpenApiDocument.Tag.builder() + .name("first") + .description("First") + .build(); + OpenApiDocument.Builder builder = OpenApiDocument.builder() + .tag(first) + .tag(OpenApiDocument.Tag.builder() + .name("first") + .description("First") + .build()); + + assertThat(builder.build().tags().size(), is(1)); + + OpenApiDocument.Tag conflicting = OpenApiDocument.Tag.builder() + .name("first") + .description("Conflicting") + .build(); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> builder.tag(conflicting)); + + assertThat(thrown.getMessage(), is("Conflicting OpenAPI tag at tags.first")); + } + + @Test + void indexesTagsAddedDirectlyToBuilder() { + OpenApiDocument.Tag first = OpenApiDocument.Tag.builder() + .name("first") + .description("First") + .build(); + OpenApiDocument.Builder builder = OpenApiDocument.builder() + .tag(first) + .merge(OpenApiDocument.builder().tag(first).build()); + + assertThat(builder.build().tags().size(), is(1)); + + OpenApiDocument conflicting = OpenApiDocument.builder() + .tag("first", "Conflicting") + .build(); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> builder.merge(conflicting)); + assertThat(thrown.getMessage(), is("Conflicting OpenAPI tag at tags.first")); + } + + @Test + void mergesMatchingPathExtensionsAndRejectsConflicts() { + OpenApiDocument shared = OpenApiDocument.builder() + .pathExtension("x-routing", JsonString.create("shared")) + .build(); + OpenApiDocument.Builder builder = OpenApiDocument.builder() + .merge(shared) + .merge(shared); + OpenApiDocument conflicting = OpenApiDocument.builder() + .pathExtension("x-routing", JsonString.create("conflicting")) + .build(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> builder.merge(conflicting)); + + assertThat(thrown.getMessage(), is("Conflicting OpenAPI document value at paths.x-routing")); + } + + @Test + void mergesMatchingObjectPathExtensionsAndRejectsConflicts() { + OpenApiDocument shared = OpenApiDocument.builder() + .pathExtension("x-routing", JsonObject.builder().set("get", "shared").build()) + .build(); + OpenApiDocument.Builder builder = OpenApiDocument.builder().merge(shared); + + assertDoesNotThrow(() -> builder.merge(shared)); + + OpenApiDocument compatible = OpenApiDocument.builder() + .pathExtension("x-routing", JsonObject.builder().set("post", "compatible").build()) + .build(); + assertDoesNotThrow(() -> builder.merge(compatible)); + + OpenApiDocument conflicting = OpenApiDocument.builder() + .pathExtension("x-routing", JsonObject.builder().set("get", "conflicting").build()) + .build(); + IllegalStateException thrown = assertThrows(IllegalStateException.class, () -> builder.merge(conflicting)); + + assertThat(thrown.getMessage(), is("Conflicting OpenAPI document value at paths.x-routing.get")); + } +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFeatureTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFeatureTest.java index 6b134c016d8..3b4b4c783b7 100644 --- a/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFeatureTest.java +++ b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiFeatureTest.java @@ -19,32 +19,77 @@ import java.io.InputStream; import java.io.UncheckedIOException; import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; import java.util.Map; +import java.util.NoSuchElementException; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Supplier; import java.util.stream.Stream; +import io.helidon.common.Builder; +import io.helidon.common.LazyValue; import io.helidon.common.media.type.MediaType; import io.helidon.common.media.type.MediaTypes; import io.helidon.common.testing.http.junit5.HttpHeaderMatcher; +import io.helidon.common.types.ResolvedType; +import io.helidon.common.types.TypeName; +import io.helidon.config.Config; +import io.helidon.config.ConfigSources; import io.helidon.http.HeaderNames; import io.helidon.http.HttpMediaType; import io.helidon.http.Status; +import io.helidon.json.JsonBoolean; +import io.helidon.json.JsonNull; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; +import io.helidon.openapi.spi.OpenApiDocumentSource; +import io.helidon.openapi.spi.OpenApiManagerProvider; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; +import io.helidon.openapi.v30.OpenApi30Version; +import io.helidon.openapi.v30.OpenApi30VersionConfig; +import io.helidon.openapi.v30.OpenApi30VersionProvider; +import io.helidon.service.registry.DependencyContext; +import io.helidon.service.registry.GlobalServiceRegistry; +import io.helidon.service.registry.InterceptionMetadata; +import io.helidon.service.registry.Qualifier; +import io.helidon.service.registry.ServiceDescriptor; +import io.helidon.service.registry.ServiceRegistryConfig; +import io.helidon.service.registry.ServiceRegistryManager; import io.helidon.webclient.api.ClientResponseTyped; import io.helidon.webclient.api.WebClient; +import io.helidon.webserver.ListenerConfig; +import io.helidon.webserver.WebServer; import io.helidon.webserver.WebServerConfig; import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.spi.ServerFeature; import io.helidon.webserver.testing.junit5.RoutingTest; import io.helidon.webserver.testing.junit5.SetUpRoute; import io.helidon.webserver.testing.junit5.SetUpServer; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.yaml.snakeyaml.Yaml; import static io.helidon.common.testing.junit5.MapMatcher.mapEqualTo; +import static org.hamcrest.CoreMatchers.containsString; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; /** * Tests {@link io.helidon.openapi.OpenApiFeature}. @@ -52,13 +97,27 @@ @RoutingTest @SuppressWarnings("HttpUrlsUsage") class OpenApiFeatureTest { + private static final String OPENAPI_31_DOCUMENT = """ + openapi: 3.1.0 + info: + title: Static API + version: 1.0.0 + paths: {} + """; private final WebClient client; + private final List testRegistryManagers = new ArrayList<>(); OpenApiFeatureTest(WebClient client) { this.client = client; } + @AfterEach + void shutdownTestRegistries() { + testRegistryManagers.forEach(ServiceRegistryManager::shutdown); + testRegistryManagers.clear(); + } + @SetUpServer static void server(WebServerConfig.Builder server) { server.addFeature(OpenApiFeature.builder() @@ -77,6 +136,12 @@ static void server(WebServerConfig.Builder server) { .staticFile("src/test/resources/petstore.yaml") .webContext("/openapi-petstore") .name("openapi-petstore") + .build()) + .addFeature(OpenApiFeature.builder() + .servicesDiscoverServices(false) + .staticFile("exact-static.yaml") + .webContext("/openapi-exact-static") + .name("openapi-exact-static") .build()); } @@ -94,6 +159,15 @@ void testGreetingAsYAML() { assertThat(parse(response.entity()), mapEqualTo(parse(resource("/greeting.yml")))); } + @Test + void testExactStaticWhenFormatAndVersionMatch() { + ClientResponseTyped response = client.get("/openapi-exact-static") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class); + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), is(resource("/exact-static.yaml"))); + } + static Stream checkExplicitResponseMediaTypeViaHeaders() { return Stream.of(MediaTypes.APPLICATION_OPENAPI_YAML, MediaTypes.APPLICATION_YAML, @@ -163,21 +237,2047 @@ void testUnrestrictedCorsWithHeaders() { assertThat(parse(response.entity()), mapEqualTo(parse(resource("/time-server.yml")))); } - private static Map parse(String content) { - return new Yaml().load(content); + @Test + void serviceConstructorUsesServerFeatureConfig() { + Config config = Config.just(ConfigSources.create(Map.of("server.features.openapi.web-context", "/from-server-feature", + "openapi.web-context", "/from-top-level"))); + OpenApiFeature feature = new OpenApiFeature(GlobalServiceRegistry.registry(), config, List::of); + + assertThat(feature.prototype().webContext(), is("/from-server-feature")); + assertThat(feature.prototype().isEnabled(), is(true)); } - private static String resource(String path) { + @Test + void serviceConstructorLetsProviderHandleNamedOpenApiConfig() { + Config config = Config.just(ConfigSources.create(Map.of("server.features.admin-openapi.type", "openapi", + "server.features.admin-openapi.web-context", "/admin-openapi", + "openapi.web-context", "/from-top-level"))); + OpenApiFeature feature = new OpenApiFeature(GlobalServiceRegistry.registry(), config, List::of); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().name(), is("openapi-service-registry")); + } + + @Test + void serviceConstructorLetsProviderHandleListOpenApiConfig() { + Config config = Config.just(ConfigSources.create(""" + server: + features: + - type: openapi + web-context: /admin-openapi + openapi: + web-context: /from-top-level + """, MediaTypes.APPLICATION_YAML)); + OpenApiFeature feature = new OpenApiFeature(GlobalServiceRegistry.registry(), config, List::of); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().name(), is("openapi-service-registry")); + } + + @Test + void disabledFeatureDoesNotLoadStaticContentOrInitializeSources(@TempDir Path tempDir) throws IOException { + Path staticDirectory = tempDir.resolve("openapi-dir"); + Files.createDirectory(staticDirectory); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .isEnabled(false) + .staticFile(staticDirectory.toString()) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + failingServiceDescriptor(OpenApiDocumentSource.class, + "FailingDocumentSource", + "Disabled OpenAPI feature must not discover" + + " document sources."), + failingServiceDescriptor(OpenApiVersionProvider.class, + "FailingVersionProvider", + "Disabled OpenAPI feature must not discover" + + " version providers.")); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(feature.prototype().isEnabled(), is(false)); + } + + @Test + void disabledServiceRegistryStandbyDoesNotDiscoverProviders() { + Config config = Config.just(ConfigSources.create(Map.of("server.features.admin-openapi.type", "openapi"))); + ServiceRegistryManager registryManager = failingVersionProviderRegistry(); try { - URL resource = OpenApiFeature.class.getResource(path); - if (resource != null) { - try (InputStream is = resource.openStream()) { - return new String(is.readAllBytes()); + OpenApiFeature feature = new OpenApiFeature(registryManager.registry(), + config, + () -> { + throw new AssertionError("Disabled OpenAPI feature must not" + + " discover version providers."); + }); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().name(), is("openapi-service-registry")); + } finally { + registryManager.shutdown(); + } + } + + @Test + void disabledCanonicalFeatureDoesNotDiscoverProviders() { + Config config = Config.just(ConfigSources.create(Map.of("server.features.openapi.enabled", "false"))); + ServiceRegistryManager registryManager = failingVersionProviderRegistry(); + try { + OpenApiFeature feature = new OpenApiFeature(registryManager.registry(), + config, + () -> { + throw new AssertionError("Disabled OpenAPI feature must not" + + " discover version providers."); + }); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().openApiVersion().isEmpty(), is(true)); + } finally { + registryManager.shutdown(); + } + } + + @Test + void disabledProviderCreatedFeatureDoesNotDiscoverProviders(@TempDir Path tempDir) throws IOException { + Path staticDirectory = tempDir.resolve("openapi-dir"); + Files.createDirectory(staticDirectory); + Config config = Config.just(ConfigSources.create(Map.of("enabled", "false", + "static-file", staticDirectory.toString()))); + OpenApiFeature feature = new OpenApiFeatureProvider().create(config, "openapi"); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().openApiVersion().isEmpty(), is(true)); + } + + @Test + void disabledCreateFromConfigDoesNotDiscoverProviders(@TempDir Path tempDir) throws IOException { + Path staticDirectory = tempDir.resolve("openapi-dir"); + Files.createDirectory(staticDirectory); + Config config = Config.just(ConfigSources.create(Map.of("openapi.enabled", "false", + "openapi.static-file", staticDirectory.toString()))); + OpenApiFeature feature = OpenApiFeature.create(config.get("openapi")); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().openApiVersion().isEmpty(), is(true)); + } + + @Test + void disabledCreateFromConsumerDoesNotDiscoverProviders(@TempDir Path tempDir) throws IOException { + Path staticDirectory = tempDir.resolve("openapi-dir"); + Files.createDirectory(staticDirectory); + OpenApiFeature feature = OpenApiFeature.create(builder -> builder.isEnabled(false) + .staticFile(staticDirectory.toString())); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().openApiVersion().isEmpty(), is(true)); + } + + @Test + void disabledBuilderDoesNotDiscoverProviders(@TempDir Path tempDir) throws IOException { + Path staticDirectory = tempDir.resolve("openapi-dir"); + Files.createDirectory(staticDirectory); + OpenApiFeature feature = OpenApiFeature.builder() + .isEnabled(false) + .staticFile(staticDirectory.toString()) + .build(); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(feature.prototype().isEnabled(), is(false)); + assertThat(feature.prototype().openApiVersion().isEmpty(), is(true)); + } + + @Test + void reenabledBuilderRestoresProviderDiscovery() { + ServiceRegistryManager registryManager = documentSourceRegistry(); + try { + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .serviceRegistry(registryManager.registry()) + .isEnabled(false) + .isEnabled(true) + .buildPrototype(); + + assertThat(config.isEnabled(), is(true)); + assertThat(config.openApiVersion().orElseThrow().type(), is("3.0")); + } finally { + registryManager.shutdown(); + } + } + + @Test + void serviceConstructorDiscoversOpenApiVersionFromRegistry() { + ServiceRegistryManager manager = ServiceRegistryManager.create(); + try { + OpenApiFeature feature = new OpenApiFeature(manager.registry(), Config.empty(), List::of); + + assertThat(feature.prototype().openApiVersion().orElseThrow().type(), is("3.0")); + } finally { + manager.shutdown(); + } + } + + @Test + void injectedConstructorUsesConfiguredGeneratedDocumentSourceSelection() { + RecordingOpenApiManager openApiManager = new RecordingOpenApiManager(); + Config config = Config.just(ConfigSources.create(Map.of( + "server.features.openapi.generated.mode", "GENERATED_ONLY", + "server.features.openapi.generated.document-sources.0", SelectedOpenApi.class.getCanonicalName(), + "server.features.openapi.manager.test.enabled", "true"))); + ServiceRegistryManager registryManager = documentSourceRegistry(openApiManager); + try { + OpenApiFeature feature = new OpenApiFeature(registryManager.registry(), config, List::of); + + feature.initialize(); + + Map document = parse(openApiManager.content()); + assertThat(map(document, "info").get("title"), is("Selected API")); + assertThat(map(document, "paths").containsKey("/generated"), is(true)); + } finally { + registryManager.shutdown(); + } + } + + @Test + void missingConfiguredGeneratedDocumentSourceFailsWithoutFallback() { + RecordingOpenApiManager openApiManager = new RecordingOpenApiManager(); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .generatedDocumentSources(List.of("missing.Source")) + .openApiVersion(OpenApi30Version.create()) + .manager(openApiManager) + .buildPrototype(); + ServiceRegistryManager registryManager = documentSourceRegistry(); + try { + OpenApiFeature feature = new OpenApiFeature(registryManager.registry(), config); + + IllegalStateException ex = assertThrows(IllegalStateException.class, feature::initialize); + assertThat(ex.getMessage(), containsString("Configured OpenAPI document source missing.Source was not found")); + assertThat(openApiManager.contents(), is(List.of())); + } finally { + registryManager.shutdown(); + } + } + + @Test + void mergeStaticDocumentUsesParserForDeclaredVersion(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + CountingOpenApiVersion staticVersion = new CountingOpenApiVersion("3.1", "3.1.0"); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + List.of(generatedPathSource()), + List.of(provider("3.1", staticVersion))); + + assertThat(staticVersion.parseCount(), is(0)); + feature.initialize(); + + assertThat(staticVersion.parseCount(), is(1)); + assertThat(parse(manager.content()).get("openapi"), is("3.0.3")); + } + + @Test + void runtimeBuilderUsesSuppliedRegistryForStaticVersionParser(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + OpenApiVersion staticVersion = new TestOpenApiVersion("3.1", "3.1.0", false); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + ServiceRegistryConfig registryConfig = ServiceRegistryConfig.builder() + .discoverServices(false) + .discoverServicesFromServiceLoader(false) + .addServiceDescriptor(testDescriptor(OpenApiVersionProvider.class, + "OpenApi31VersionProvider", + provider("3.1", staticVersion))) + .build(); + ServiceRegistryManager registryManager = ServiceRegistryManager.create(registryConfig); + try { + OpenApiFeature feature = OpenApiFeature.builder() + .serviceRegistry(registryManager.registry()) + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .build(); + + feature.initialize(); + + assertThat(parse(manager.content()).get("openapi"), is("3.0.3")); + } finally { + registryManager.shutdown(); + } + } + + @Test + void mergeYamlStaticDocumentUsesRootOpenApiVersion(@TempDir Path tempDir) throws IOException { + mergeStaticDocumentUsesRootVersion(tempDir.resolve("nested-openapi.yaml"), """ + x-nested: + openapi: 9.9.9 + openapi: 3.1.0 + info: + title: Static API + version: 1.0.0 + """); + } + + @Test + void mergeYamlStaticDocumentUsesQuotedRootOpenApiVersion(@TempDir Path tempDir) throws IOException { + mergeStaticDocumentUsesRootVersion(tempDir.resolve("quoted-openapi.yaml"), """ + x-nested: + openapi: 9.9.9 + "openapi": "3.1.0" + info: + title: Static API + version: 1.0.0 + """); + } + + @Test + void mergeYamlStaticDocumentUsesFlowRootOpenApiVersion(@TempDir Path tempDir) throws IOException { + mergeStaticDocumentUsesRootVersion(tempDir.resolve("flow-openapi.yaml"), """ + {x-nested: {openapi: 9.9.9}, openapi: 3.1.0, info: {title: Static API, version: 1.0.0}} + """); + } + + @Test + void mergeJsonStaticDocumentUsesRootOpenApiVersion(@TempDir Path tempDir) throws IOException { + mergeStaticDocumentUsesRootVersion(tempDir.resolve("nested-openapi.json"), """ + {"x-nested":{"openapi":"9.9.9"},"openapi":"3.1.0","info":{"title":"Static API","version":"1.0.0"}} + """); + } + + @Test + void mergeJsonStaticDocumentUsesEscapedRootOpenApiVersion(@TempDir Path tempDir) throws IOException { + mergeStaticDocumentUsesRootVersion(tempDir.resolve("escaped-openapi.json"), """ + {"x-nested":{"openapi":"9.9.9"},"\\u006fpenapi":"3\\u002e1\\u002e0","info":{"title":"Static API","version":"1.0.0"}} + """); + } + + @Test + void mergeStaticDocumentCachesParsedStaticDocument(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + CountingOpenApiVersion staticVersion = new CountingOpenApiVersion("3.1", "3.1.0"); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + List.of(generatedPathSource()), + List.of(provider("3.1", staticVersion))); + + assertThat(staticVersion.parseCount(), is(0)); + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(staticVersion.parseCount(), is(2)); + assertThat(manager.contents().size(), is(2)); + + feature.initialize(); + + assertThat(staticVersion.parseCount(), is(2)); + assertThat(manager.contents().size(), is(2)); + } + + @Test + void initializeBeforeSetupWarmsModelsCreatedDuringSetup(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + CountingOpenApiVersion staticVersion = new CountingOpenApiVersion("3.1", "3.1.0"); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + List.of(generatedPathSource()), + List.of(provider("3.1", staticVersion))); + + feature.initialize(); + + assertThat(staticVersion.parseCount(), is(1)); + assertThat(manager.contents().size(), is(1)); + + feature.setup(new TestFeatureContext("admin")); + + assertThat(staticVersion.parseCount(), is(2)); + assertThat(manager.contents().size(), is(2)); + + feature.initialize(); + + assertThat(staticVersion.parseCount(), is(2)); + assertThat(manager.contents().size(), is(2)); + } + + @Test + void initializeBeforeSetupUsesConfiguredSockets(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + OpenApiVersion staticVersion = new ListenerOpenApiVersion("3.1", "3.1.0"); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .sockets(Set.of("admin")) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + List.of(generatedPathSource()), + List.of(provider("3.1", staticVersion))); + + feature.initialize(); + + assertThat(manager.contents().size(), is(1)); + assertThat(map(parse(manager.contents().getFirst()), "info").get("title"), is("admin")); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(manager.contents().size(), is(1)); + } + + @Test + void mergeStaticDocumentParsesStaticDocumentWithListenerContext(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + ListenerOpenApiVersion staticVersion = new ListenerOpenApiVersion("3.1", "3.1.0"); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + List.of(generatedPathSource()), + List.of(provider("3.1", staticVersion))); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + List titles = manager.contents() + .stream() + .map(OpenApiFeatureTest::parse) + .map(document -> map(document, "info")) + .map(info -> (String) info.get("title")) + .toList(); + assertThat(titles.contains(WebServer.DEFAULT_SOCKET_NAME), is(true)); + assertThat(titles.contains("admin"), is(true)); + } + + @Test + void mergeStaticDocumentUsesRootVersionWhenListenerHasNoGeneratedSource(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + OpenApiVersion staticVersion = new TestOpenApiVersion("3.1", "3.1.0", false); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + List.of(generatedSource("private", "/private")), + List.of(provider("3.1", staticVersion))); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + List versions = manager.contents() + .stream() + .map(OpenApiFeatureTest::parse) + .map(document -> (String) document.get("openapi")) + .toList(); + assertThat(versions, is(List.of("3.0.3", "3.0.3"))); + } + + @Test + void generatedOnlyIgnoresStaticDocumentVersion(@TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + Path staticFile = tempDir.resolve("missing-version.yaml"); + Files.writeString(staticFile, """ + info: + title: Broken Static API + version: 1.0.0 + """); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + documentSourceDescriptor("GeneratedDocument", null, generatedSource()), + failingServiceDescriptor(OpenApiVersionProvider.class, + "FailingVersionProvider", + "Generated-only OpenAPI must not discover" + + " static version providers.")); + + feature.initialize(); + + Map document = parse(manager.content()); + assertThat(map(document, "info").get("title"), is("Generated API")); + assertThat(map(document, "paths").containsKey("/generated"), is(true)); + } + + @Test + void staticOnlyServesStaticDocumentAsIs(@TempDir Path tempDir) throws IOException { + staticModeServesStaticDocumentAsIs(tempDir, OpenApiGeneratedMode.STATIC_ONLY); + } + + @Test + void staticFirstServesStaticDocumentAsIs(@TempDir Path tempDir) throws IOException { + staticModeServesStaticDocumentAsIs(tempDir, OpenApiGeneratedMode.STATIC_FIRST); + } + + @ParameterizedTest + @ValueSource(strings = {"STATIC_ONLY", "STATIC_FIRST"}) + void customManagerProcessesExactStaticContent(OpenApiGeneratedMode mode, + @TempDir Path tempDir) throws IOException { + TransformingOpenApiManager manager = new TransformingOpenApiManager(); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .webContext("/custom-manager") + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(mode) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, List.of(), List.of()); + WebServer webServer = WebServer.builder() + .port(0) + .addFeature(feature) + .build(); + WebClient webClient = WebClient.create(); + + try { + webServer.start(); + ClientResponseTyped response = webClient.get("http://localhost:" + webServer.port() + "/custom-manager") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class); + + assertThat(response.status(), is(Status.OK_200)); + assertThat(response.entity(), is("formatted:loaded:" + OPENAPI_31_DOCUMENT)); + } finally { + webClient.closeResource(); + webServer.stop(); + } + } + + @ParameterizedTest + @ValueSource(strings = {"STATIC_ONLY", "STATIC_FIRST"}) + void sharesStaticModelAcrossListeners(OpenApiGeneratedMode mode, + @TempDir Path tempDir) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(mode) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, List.of(), List.of()); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + assertThat(manager.contents().size(), is(1)); + } + + @Test + void generatedFallbackWithoutStaticDocumentUsesGeneratedSources() { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.STATIC_FIRST) + .openApiVersion(OpenApi30Version.create()) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, List.of(generatedSource()), List.of()); + + feature.initialize(); + + Map document = parse(manager.content()); + assertThat(map(document, "info").get("title"), is("Generated API")); + assertThat(map(document, "paths").containsKey("/generated"), is(true)); + } + + private void staticModeServesStaticDocumentAsIs(Path tempDir, OpenApiGeneratedMode mode) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + Path staticFile = tempDir.resolve("static-3.1.yaml"); + Files.writeString(staticFile, OPENAPI_31_DOCUMENT); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(mode) + .openApiVersion(renderVersion) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + documentSourceDescriptor("GeneratedDocument", null, generatedSource()), + failingServiceDescriptor(OpenApiVersionProvider.class, + "FailingVersionProvider", + mode + " OpenAPI must not discover static" + + " version providers.")); + + feature.initialize(); + + assertThat(manager.content(), is(OPENAPI_31_DOCUMENT)); + } + + @Test + void generatedOperationIdsCanBeConfigured() { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .generatedOperationIds(Map.of("com.example.GeneratedEndpoint#get()", "configuredGet")) + .openApiVersion(OpenApi30Version.create()) + .manager(manager) + .buildPrototype(); + OpenApiDocumentSource source = (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", + path -> path.operation( + "GET", + operation -> operation.operationId(OpenApiDocumentContextSupport.operationId( + context, + "com.example.GeneratedEndpoint#get()", + "generatedGet")) + .response("200", "Generated response."))); + OpenApiFeature feature = testFeature(config, List.of(source), List.of()); + + feature.initialize(); + + Map document = parse(manager.content()); + assertThat(map(map(map(document, "paths"), "/generated"), "get").get("operationId"), is("configuredGet")); + } + + @Test + void generatedDocumentContextLeavesConfigExpressionsLiteralByDefault() { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + Config sourceConfig = Config.just(ConfigSources.create(Map.of("openapi.title", "Configured API", + "openapi.host", "api.example.com"))); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .manager(manager) + .buildPrototype(); + OpenApiDocumentSource source = (context, document) -> document + .info(OpenApiDocumentContextSupport.resolveExpression(context, "${openapi.title:Generated API}"), + "1.0.0") + .paths(Map.of()) + .server(server -> server.url(OpenApiDocumentContextSupport.resolveExpression( + context, + "https://${openapi.host:localhost}"))); + OpenApiFeature feature = testFeature(sourceConfig, config, List.of(source), List.of()); + + feature.initialize(); + + Map document = parse(manager.content()); + assertThat(map(document, "info").get("title"), is("${openapi.title:Generated API}")); + assertThat(map(list(document, "servers").getFirst()).get("url"), is("https://${openapi.host:localhost}")); + } + + @Test + void generatedDocumentContextResolvesConfigExpressions() { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + Config sourceConfig = Config.just(ConfigSources.create(Map.of("openapi.title", "Configured API", + "openapi.host", "api.example.com"))); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .generatedResolveConfigExpressions(true) + .openApiVersion(OpenApi30Version.create()) + .manager(manager) + .buildPrototype(); + OpenApiDocumentSource source = (context, document) -> document + .info(OpenApiDocumentContextSupport.resolveExpression(context, "${openapi.title:Generated API}"), + "1.0.0") + .paths(Map.of()) + .server(server -> server.url(OpenApiDocumentContextSupport.resolveExpression( + context, + "https://${openapi.host:localhost}"))); + OpenApiFeature feature = testFeature(sourceConfig, config, List.of(source), List.of()); + + feature.initialize(); + + Map document = parse(manager.content()); + assertThat(map(document, "info").get("title"), is("Configured API")); + assertThat(map(list(document, "servers").getFirst()).get("url"), is("https://api.example.com")); + } + + @Test + void runtimeBuilderUsesSuppliedRegistryAndSourceConfigForGeneratedDocumentSources() { + RecordingOpenApiManager openApiManager = new RecordingOpenApiManager(); + Config sourceConfig = Config.just(ConfigSources.create(Map.of( + "openapi.title", "Configured Builder API", + "server.features.openapi.generated.mode", "GENERATED_ONLY", + "server.features.openapi.generated.resolve-config-expressions", "true", + "server.features.openapi.generated.document-sources.0", ConfigExpressionOpenApi.class.getCanonicalName()))); + ServiceRegistryManager registryManager = documentSourceRegistry(); + try { + OpenApiFeature feature = OpenApiFeature.builder() + .serviceRegistry(registryManager.registry()) + .config(sourceConfig.get("server.features.openapi")) + .servicesDiscoverServices(false) + .openApiVersion(OpenApi30Version.create()) + .manager(openApiManager) + .build(); + + feature.initialize(); + + Map document = parse(openApiManager.content()); + assertThat(map(document, "info").get("title"), is("Configured Builder API")); + assertThat(map(document, "paths").containsKey("/generated"), is(true)); + } finally { + registryManager.shutdown(); + } + } + + @Test + void configuredGeneratedDocumentSourceUsesDottedNamedByTypeName() { + RecordingOpenApiManager openApiManager = new RecordingOpenApiManager(); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .generatedDocumentSources(List.of(SelectedOpenApi.class.getCanonicalName())) + .openApiVersion(OpenApi30Version.create()) + .manager(openApiManager) + .buildPrototype(); + ServiceRegistryManager registryManager = documentSourceRegistry(); + try { + OpenApiFeature feature = new OpenApiFeature(registryManager.registry(), config); + + feature.initialize(); + + Map document = parse(openApiManager.content()); + assertThat(map(document, "info").get("title"), is("Selected API")); + assertThat(map(document, "paths").containsKey("/generated"), is(true)); + } finally { + registryManager.shutdown(); + } + } + + @Test + void multipleNamedGeneratedDocumentSourcesRequireConfiguration() { + RecordingOpenApiManager openApiManager = new RecordingOpenApiManager(); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .manager(openApiManager) + .buildPrototype(); + ServiceRegistryManager registryManager = documentSourceRegistry(); + try { + OpenApiFeature feature = new OpenApiFeature(registryManager.registry(), config); + + IllegalStateException ex = assertThrows(IllegalStateException.class, feature::initialize); + assertThat(ex.getMessage(), containsString("generated.document-sources")); + assertThat(ex.getMessage(), containsString(SelectedOpenApi.class.getCanonicalName())); + assertThat(ex.getMessage(), containsString(OtherOpenApi.class.getCanonicalName())); + } finally { + registryManager.shutdown(); + } + } + + @Test + void initializeWarmsConfiguredListenerModelsAfterSetup() { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, List.of( + generatedSource(WebServer.DEFAULT_SOCKET_NAME, "/default"), + generatedSource("admin", "/admin")), List.of()); + + feature.setup(new TestFeatureContext("admin")); + feature.initialize(); + + List> documents = manager.contents() + .stream() + .map(OpenApiFeatureTest::parse) + .toList(); + assertThat(documents.size(), is(2)); + assertThat(documents.stream().anyMatch(it -> map(it, "paths").containsKey("/default")), is(true)); + assertThat(documents.stream().anyMatch(it -> map(it, "paths").containsKey("/admin")), is(true)); + } + + @Test + void serializesListenerModelInitialization() throws Exception { + CountDownLatch firstDescribe = new CountDownLatch(1); + CountDownLatch concurrentDescribe = new CountDownLatch(1); + CountDownLatch releaseFirstDescribe = new CountDownLatch(1); + CountDownLatch startRequests = new CountDownLatch(1); + AtomicInteger activeDescribes = new AtomicInteger(); + OpenApiDocumentSource source = (context, document) -> { + int active = activeDescribes.incrementAndGet(); + try { + if (active == 1) { + firstDescribe.countDown(); + if (!releaseFirstDescribe.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to release the first OpenAPI source invocation."); + } + } else { + concurrentDescribe.countDown(); } + document.info(context.listener(), "1.0.0") + .paths(Map.of()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } finally { + activeDescribes.decrementAndGet(); } - throw new IllegalArgumentException("Resource not found: " + path); - } catch (IOException ex) { - throw new UncheckedIOException(ex); + }; + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, List.of(source), List.of()); + WebServer webServer = WebServer.builder() + .port(0) + .putSocket("admin", listener -> listener.port(0).name("admin")) + .addFeature(feature) + .build(); + WebClient concurrentClient = WebClient.create(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + webServer.start(); + var defaultRequest = executor.submit(() -> { + startRequests.await(); + return concurrentClient.get("http://localhost:" + webServer.port() + "/openapi") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class); + }); + var adminRequest = executor.submit(() -> { + startRequests.await(); + return concurrentClient.get("http://localhost:" + webServer.port("admin") + "/openapi") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class); + }); + + startRequests.countDown(); + assertThat(firstDescribe.await(10, TimeUnit.SECONDS), is(true)); + boolean overlapped = concurrentDescribe.await(2, TimeUnit.SECONDS); + releaseFirstDescribe.countDown(); + + assertThat(defaultRequest.get().status(), is(Status.OK_200)); + assertThat(adminRequest.get().status(), is(Status.OK_200)); + assertThat(overlapped, is(false)); + } finally { + releaseFirstDescribe.countDown(); + concurrentClient.closeResource(); + webServer.stop(); + } + } + + @Test + void serializesManagerLoadingAndFormatting() throws Exception { + CountDownLatch formatStarted = new CountDownLatch(1); + CountDownLatch loadDuringFormat = new CountDownLatch(1); + CountDownLatch releaseFormat = new CountDownLatch(1); + AtomicBoolean formatting = new AtomicBoolean(); + OpenApiManager manager = new OpenApiManager<>() { + @Override + public String load(String content) { + if (formatting.get()) { + loadDuringFormat.countDown(); + } + return content; + } + + @Override + public String format(String model, OpenApiFormat format) { + formatting.set(true); + formatStarted.countDown(); + try { + if (!releaseFormat.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to release OpenAPI formatting."); + } + return model; + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } finally { + formatting.set(false); + } + } + + @Override + public String name() { + return "test"; + } + + @Override + public String type() { + return "test"; + } + }; + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .manager(manager) + .buildPrototype(); + OpenApiFeature firstFeature = testFeature(config, + List.of(generatedSource(WebServer.DEFAULT_SOCKET_NAME, "/first")), + List.of()); + OpenApiFeature secondFeature = testFeature(config, + List.of(generatedSource(WebServer.DEFAULT_SOCKET_NAME, "/second")), + List.of()); + WebServer webServer = WebServer.builder() + .port(0) + .addFeature(firstFeature) + .build(); + WebClient client = WebClient.create(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + webServer.start(); + var formattedRequest = executor.submit(() -> client.get("http://localhost:" + webServer.port() + "/openapi") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class)); + assertThat(formatStarted.await(10, TimeUnit.SECONDS), is(true)); + + var modelInitialization = executor.submit(() -> { + secondFeature.initialize(); + return null; + }); + boolean overlapped = loadDuringFormat.await(2, TimeUnit.SECONDS); + releaseFormat.countDown(); + + assertThat(formattedRequest.get().status(), is(Status.OK_200)); + modelInitialization.get(); + assertThat(overlapped, is(false)); + } finally { + releaseFormat.countDown(); + client.closeResource(); + webServer.stop(); + } + } + + @Test + void formatsAfterConcurrentModelInitializationWithoutLockInversion() throws Exception { + CountDownLatch modelLoading = new CountDownLatch(1); + CountDownLatch requestReadingModel = new CountDownLatch(1); + CountDownLatch continueModelLoading = new CountDownLatch(1); + AtomicBoolean lockInverted = new AtomicBoolean(); + ReentrantLock managerLock = new ReentrantLock(); + OpenApiManager manager = new OpenApiManager<>() { + @Override + public String load(String content) { + return content; + } + + @Override + public String format(String model, OpenApiFormat format) { + return model; + } + + @Override + public String name() { + return "test"; + } + + @Override + public String type() { + return "test"; + } + }; + LazyValue delegateModel = LazyValue.create(() -> { + modelLoading.countDown(); + try { + if (!continueModelLoading.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to continue OpenAPI model loading."); + } + if (!managerLock.tryLock(2, TimeUnit.SECONDS)) { + lockInverted.set(true); + return "model"; + } + try { + return manager.load("model"); + } finally { + managerLock.unlock(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }); + AtomicInteger modelReads = new AtomicInteger(); + LazyValue model = new LazyValue<>() { + @Override + public Object get() { + if (modelReads.incrementAndGet() == 2) { + requestReadingModel.countDown(); + } + return delegateModel.get(); + } + + @Override + public boolean isLoaded() { + return delegateModel.isLoaded(); + } + }; + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .webContext("/lock-order") + .servicesDiscoverServices(false) + .buildPrototype(); + OpenApiHttpFeature httpFeature = new OpenApiHttpFeature(config, + manager, + model, + managerLock, + Optional.empty(), + OpenApiFormat.UNSUPPORTED); + WebServer webServer = WebServer.builder() + .port(0) + .routing(routing -> routing.addFeature(httpFeature)) + .build(); + WebClient webClient = WebClient.create(); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + webServer.start(); + var modelInitialization = executor.submit(model::get); + assertThat(modelLoading.await(10, TimeUnit.SECONDS), is(true)); + + var formattedRequest = executor.submit(() -> webClient.get("http://localhost:" + webServer.port() + "/lock-order") + .accept(MediaTypes.APPLICATION_OPENAPI_YAML) + .request(String.class)); + assertThat(requestReadingModel.await(10, TimeUnit.SECONDS), is(true)); + continueModelLoading.countDown(); + + modelInitialization.get(); + assertThat(formattedRequest.get().status(), is(Status.OK_200)); + assertThat(lockInverted.get(), is(false)); + } finally { + continueModelLoading.countDown(); + webClient.closeResource(); + webServer.stop(); + } + } + + @Test + void serializesModelInitializationAcrossFeatures() throws Exception { + CountDownLatch firstDescribe = new CountDownLatch(1); + CountDownLatch concurrentDescribe = new CountDownLatch(1); + CountDownLatch releaseFirstDescribe = new CountDownLatch(1); + CountDownLatch readyToInitialize = new CountDownLatch(2); + CountDownLatch startInitialization = new CountDownLatch(1); + AtomicInteger activeDescribes = new AtomicInteger(); + OpenApiDocumentSource source = (context, document) -> { + int active = activeDescribes.incrementAndGet(); + try { + if (active == 1) { + firstDescribe.countDown(); + if (!releaseFirstDescribe.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to release the first OpenAPI source invocation."); + } + } else { + concurrentDescribe.countDown(); + } + document.info(context.listener(), "1.0.0") + .paths(Map.of()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } finally { + activeDescribes.decrementAndGet(); + } + }; + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .buildPrototype(); + OpenApiFeature firstFeature = testFeature(config, List.of(source), List.of()); + OpenApiFeature secondFeature = testFeature(config, List.of(source), List.of()); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var firstInitialization = executor.submit(() -> { + readyToInitialize.countDown(); + startInitialization.await(); + firstFeature.initialize(); + return null; + }); + var secondInitialization = executor.submit(() -> { + readyToInitialize.countDown(); + startInitialization.await(); + secondFeature.initialize(); + return null; + }); + + assertThat(readyToInitialize.await(10, TimeUnit.SECONDS), is(true)); + startInitialization.countDown(); + assertThat(firstDescribe.await(10, TimeUnit.SECONDS), is(true)); + boolean overlapped = concurrentDescribe.await(2, TimeUnit.SECONDS); + releaseFirstDescribe.countDown(); + + firstInitialization.get(); + secondInitialization.get(); + assertThat(overlapped, is(false)); + } finally { + releaseFirstDescribe.countDown(); + } + } + + @Test + void serializesSharedOpenApiVersionAcrossFeatures() throws Exception { + CountDownLatch firstVersionCall = new CountDownLatch(1); + CountDownLatch concurrentVersionCall = new CountDownLatch(1); + CountDownLatch releaseFirstVersionCall = new CountDownLatch(1); + CountDownLatch readyToInitialize = new CountDownLatch(2); + CountDownLatch startInitialization = new CountDownLatch(1); + ConcurrentTrackingOpenApiVersion sharedVersion = new ConcurrentTrackingOpenApiVersion(firstVersionCall, + concurrentVersionCall, + releaseFirstVersionCall); + OpenApiFeatureConfig firstConfig = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(sharedVersion) + .manager(new RecordingOpenApiManager()) + .buildPrototype(); + OpenApiFeatureConfig secondConfig = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(sharedVersion) + .manager(new RecordingOpenApiManager()) + .buildPrototype(); + OpenApiFeature firstFeature = testFeature(firstConfig, + List.of(generatedSource(WebServer.DEFAULT_SOCKET_NAME, "/first")), + List.of()); + OpenApiFeature secondFeature = testFeature(secondConfig, + List.of(generatedSource(WebServer.DEFAULT_SOCKET_NAME, "/second")), + List.of()); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var firstInitialization = executor.submit(() -> { + readyToInitialize.countDown(); + startInitialization.await(); + firstFeature.initialize(); + return null; + }); + var secondInitialization = executor.submit(() -> { + readyToInitialize.countDown(); + startInitialization.await(); + secondFeature.initialize(); + return null; + }); + + assertThat(readyToInitialize.await(10, TimeUnit.SECONDS), is(true)); + startInitialization.countDown(); + assertThat(firstVersionCall.await(10, TimeUnit.SECONDS), is(true)); + boolean overlapped = concurrentVersionCall.await(2, TimeUnit.SECONDS); + releaseFirstVersionCall.countDown(); + + firstInitialization.get(); + secondInitialization.get(); + assertThat(overlapped, is(false)); + assertThat(sharedVersion.callCount(), is(2)); + assertThat(sharedVersion.maxConcurrentCalls(), is(1)); + } finally { + releaseFirstVersionCall.countDown(); + } + } + + @Test + void initializesIndependentFeaturesConcurrently() throws Exception { + CountDownLatch firstDescribe = new CountDownLatch(1); + CountDownLatch secondDescribe = new CountDownLatch(1); + CountDownLatch releaseFirstDescribe = new CountDownLatch(1); + CountDownLatch secondInitializationReady = new CountDownLatch(1); + CountDownLatch startSecondInitialization = new CountDownLatch(1); + OpenApiDocumentSource firstSource = (context, document) -> { + firstDescribe.countDown(); + try { + if (!releaseFirstDescribe.await(30, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to release the first OpenAPI source invocation."); + } + document.info(context.listener(), "1.0.0") + .paths(Map.of()); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } + }; + OpenApiDocumentSource secondSource = (context, document) -> { + secondDescribe.countDown(); + document.info(context.listener(), "1.0.0") + .paths(Map.of()); + }; + OpenApiFeatureConfig firstConfig = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .buildPrototype(); + OpenApiFeatureConfig secondConfig = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .generatedMode(OpenApiGeneratedMode.GENERATED_ONLY) + .openApiVersion(OpenApi30Version.create()) + .buildPrototype(); + OpenApiFeature firstFeature = testFeature(firstConfig, List.of(firstSource), List.of()); + OpenApiFeature secondFeature = testFeature(secondConfig, List.of(secondSource), List.of()); + + try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { + var firstInitialization = executor.submit(() -> { + firstFeature.initialize(); + return null; + }); + assertThat(firstDescribe.await(10, TimeUnit.SECONDS), is(true)); + + var secondInitialization = executor.submit(() -> { + secondInitializationReady.countDown(); + startSecondInitialization.await(); + secondFeature.initialize(); + return null; + }); + assertThat(secondInitializationReady.await(10, TimeUnit.SECONDS), is(true)); + startSecondInitialization.countDown(); + boolean overlapped = secondDescribe.await(10, TimeUnit.SECONDS); + releaseFirstDescribe.countDown(); + + firstInitialization.get(); + secondInitialization.get(); + assertThat(overlapped, is(true)); + } finally { + releaseFirstDescribe.countDown(); + } + } + + @Test + void openApi30ParserRequiresOpenApi30Document() { + OpenApiDocumentContext context = context(OpenApi30Version.create()); + + assertThrows(IllegalStateException.class, + () -> OpenApi30Version.create().parse(context, + OPENAPI_31_DOCUMENT, + MediaTypes.APPLICATION_OPENAPI_YAML)); + } + + @Test + void openApi30ParserConvertsToCanonicalSchema() { + OpenApiDocumentContext context = context(OpenApi30Version.create()); + OpenApiDocument document = OpenApi30Version.create() + .parse(context, resource("/static-3.0.yaml"), MediaTypes.APPLICATION_OPENAPI_YAML); + + Map status = schemaProperty(document, "StaticItem", "status"); + + assertThat(status.containsKey("nullable"), is(false)); + assertThat(status.get("type"), is(List.of("string", "null"))); + assertThat(((List) status.get("enum")).contains(null), is(false)); + } + + @Test + void openApi30VersionRejectsNullArguments() { + OpenApi30Version version = OpenApi30Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = OpenApiDocument.builder().build(); + + assertThrows(NullPointerException.class, () -> OpenApi30Version.create((OpenApi30VersionConfig) null)); + assertThrows(NullPointerException.class, () -> version.parse(null, "", MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThrows(NullPointerException.class, () -> version.parse(context, null, MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThrows(NullPointerException.class, () -> version.parse(context, "", null)); + assertThrows(NullPointerException.class, () -> version.render(null, document)); + assertThrows(NullPointerException.class, () -> version.render(context, null)); + } + + @Test + void openApiDocumentSourceRejectsNullContext() { + OpenApiDocumentSource source = (context, document) -> { + }; + + assertThrows(NullPointerException.class, () -> source.supports(null)); + } + + @Test + void openApiProvidersRejectNullConfigAndName() { + assertThrows(NullPointerException.class, () -> new OpenApiFeatureProvider().create(null, "openapi")); + assertThrows(NullPointerException.class, () -> new OpenApiFeatureProvider().create(Config.empty(), null)); + assertThrows(NullPointerException.class, () -> new OpenApi30VersionProvider().create(null, "3.0")); + assertThrows(NullPointerException.class, () -> new OpenApi30VersionProvider().create(Config.empty(), null)); + } + + @Test + void openApi30RendererDropsOrTranslatesNewerVersionFields() { + OpenApiDocumentContext context = context(OpenApi30Version.create()); + OpenApiDocument document = newerVersionDocument(); + + Map rendered = parse(OpenApi30Version.create().render(context, document)); + + assertThat(rendered.get("openapi"), is("3.0.3")); + assertThat(rendered.containsKey("jsonSchemaDialect"), is(false)); + assertThat(rendered.containsKey("$self"), is(false)); + assertThat(rendered.containsKey("webhooks"), is(false)); + + Map info = map(rendered, "info"); + assertThat(info.containsKey("summary"), is(false)); + assertThat(map(info, "license").containsKey("identifier"), is(false)); + + Map server = (Map) ((List) rendered.get("servers")).getFirst(); + assertThat(server.containsKey("name"), is(false)); + + Map tag = (Map) ((List) rendered.get("tags")).getFirst(); + assertThat(tag.containsKey("summary"), is(false)); + assertThat(tag.containsKey("kind"), is(false)); + assertThat(tag.containsKey("parent"), is(false)); + + Map staticPath = map(map(rendered, "paths"), "/static/{id}"); + assertThat(staticPath.containsKey("query"), is(false)); + assertThat(staticPath.containsKey("additionalOperations"), is(false)); + + Map status = schemaProperty(rendered, "StaticItem", "status"); + assertThat(status.get("type"), is("string")); + assertThat(status.get("nullable"), is(true)); + assertThat(((List) status.get("enum")).contains(null), is(true)); + + Map union = schemaProperty(rendered, "StaticItem", "union"); + assertThat(union.containsKey("type"), is(false)); + assertThat(union.containsKey("nullable"), is(false)); + List anyOf = list(union, "anyOf"); + assertThat(anyOf.size(), is(3)); + assertThat(map(anyOf.get(0)).get("type"), is("string")); + assertThat(map(anyOf.get(1)).get("type"), is("integer")); + assertThat(map(anyOf.get(2)).get("nullable"), is(true)); + assertThat(list(map(anyOf.get(2)), "enum"), is(singleValueList(null))); + + Map bounded = schemaProperty(rendered, "StaticItem", "bounded"); + assertThat(((Number) bounded.get("maximum")).doubleValue(), is(10.0)); + assertThat(bounded.get("exclusiveMaximum"), is(true)); + assertThat(((Number) bounded.get("minimum")).doubleValue(), is(1.0)); + assertThat(bounded.get("exclusiveMinimum"), is(true)); + + Map payload = schemaProperty(rendered, "StaticItem", "payload"); + assertThat(payload, is(Map.of())); + + Map mode = schemaProperty(rendered, "StaticItem", "mode"); + assertThat(mode.containsKey("const"), is(false)); + assertThat(mode.get("enum"), is(List.of("modern"))); + + Map securityScheme = map(map(map(rendered, "components"), "securitySchemes"), "bearerAuth"); + assertThat(securityScheme.containsKey("deprecated"), is(false)); + } + + private static Map parse(String content) { + return new Yaml().load(content); + } + + private static OpenApiDocument newerVersionDocument() { + return OpenApiDocument.builder() + .openapi("3.2.0") + .info(info -> info.title("Static 3.2 API") + .version("3.2.0") + .license(license -> license.name("Apache License 2.0") + .identifier("Apache-2.0") + .url("https://www.apache.org/licenses/LICENSE-2.0")) + .summary("Static fixture with OpenAPI 3.2-only fields.")) + .server(server -> server.url("https://api.example.test") + .name("primary")) + .tag(tag -> tag.name("static") + .description("Static resources") + .summary("Static") + .kind("nav") + .parent("root")) + .path("/static/{id}", + path -> path.parameter(parameter -> parameter + .name("id") + .in("path") + .required(true) + .schema(JsonObject.builder().set("type", "string").build())) + .operation("GET", + operation -> operation.operationId("staticGet") + .response("200", "Static response."))) + .components(components -> components + .schema("StaticItem", + JsonObject.builder() + .set("type", "object") + .set("properties", properties -> properties + .set("status", statusSchema()) + .set("union", JsonObject.builder() + .setValues("type", List.of(JsonString.create("string"), + JsonString.create("integer"), + JsonString.create("null"))) + .build()) + .set("bounded", JsonObject.builder() + .set("exclusiveMaximum", 10) + .set("exclusiveMinimum", 1) + .build()) + .set("payload", JsonBoolean.TRUE) + .set("mode", JsonObject.builder() + .set("const", "modern") + .build())) + .build()) + .securityScheme("bearerAuth", security -> security.type("http") + .scheme("bearer") + .deprecated(true))) + .build(); + } + + private static JsonObject statusSchema() { + return JsonObject.builder() + .setValues("type", List.of(JsonString.create("string"), JsonString.create("null"))) + .setValues("enum", List.of(JsonString.create("new"), JsonString.create("done"), JsonNull.instance())) + .build(); + } + + private static OpenApiDocumentContext context(OpenApiVersion version) { + return new OpenApiDocumentContextImpl("openapi", + "/openapi", + "default", + OpenApiGeneratedMode.STATIC_ONLY, + version); + } + + private static Map schemaProperty(OpenApiDocument document, String schemaName, String propertyName) { + return schemaProperty(parse(document.toJsonObject().toString()), schemaName, propertyName); + } + + @SuppressWarnings("unchecked") + private static Map schemaProperty(Map document, String schemaName, String propertyName) { + return (Map) map(map(map(map(document, "components"), "schemas"), schemaName), "properties") + .get(propertyName); + } + + @SuppressWarnings("unchecked") + private static Map map(Map map, String name) { + return (Map) map.get(name); + } + + @SuppressWarnings("unchecked") + private static Map map(Object object) { + return (Map) object; + } + + @SuppressWarnings("unchecked") + private static List list(Map map, String name) { + return (List) map.get(name); + } + + private static List singleValueList(Object value) { + List result = new ArrayList<>(); + result.add(value); + return result; + } + + private static String resource(String path) { + try { + URL resource = OpenApiFeature.class.getResource(path); + if (resource != null) { + try (InputStream is = resource.openStream()) { + return new String(is.readAllBytes()); + } + } + throw new IllegalArgumentException("Resource not found: " + path); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + } + + private OpenApiFeature testFeature(OpenApiFeatureConfig config, + List documentSources, + List openApiVersionProviders) { + return testFeature(Config.empty(), config, documentSources, openApiVersionProviders); + } + + private OpenApiFeature testFeature(Config sourceConfig, + OpenApiFeatureConfig config, + List documentSources, + List openApiVersionProviders) { + List> descriptors = new ArrayList<>(); + for (int i = 0; i < documentSources.size(); i++) { + descriptors.add(documentSourceDescriptor("TestDocumentSource" + i, null, documentSources.get(i))); + } + for (int i = 0; i < openApiVersionProviders.size(); i++) { + descriptors.add(testDescriptor(OpenApiVersionProvider.class, + "TestOpenApiVersionProvider" + i, + openApiVersionProviders.get(i))); + } + return testFeature(sourceConfig, config, descriptors); + } + + private OpenApiFeature testFeature(OpenApiFeatureConfig config, ServiceDescriptor... descriptors) { + return testFeature(Config.empty(), config, List.of(descriptors)); + } + + private OpenApiFeature testFeature(Config sourceConfig, + OpenApiFeatureConfig config, + List> descriptors) { + ServiceRegistryConfig.Builder registryConfig = ServiceRegistryConfig.builder() + .discoverServices(false) + .discoverServicesFromServiceLoader(false) + .serviceDescriptors(descriptors); + ServiceRegistryManager registryManager = ServiceRegistryManager.create(registryConfig.build()); + testRegistryManagers.add(registryManager); + return new OpenApiFeature(registryManager.registry(), sourceConfig, config); + } + + private static OpenApiVersionProvider provider(String type, OpenApiVersion version) { + return new OpenApiVersionProvider() { + @Override + public String configKey() { + return type; + } + + @Override + public OpenApiVersion create(Config config, String name) { + return version; + } + }; + } + + private static OpenApiDocumentSource generatedSource() { + return (context, document) -> document.info("Generated API", "1.0.0") + .path("/generated", + path -> path.operation("GET", + operation -> operation.operationId("generatedGet") + .response("200", "Generated response."))); + } + + private static OpenApiDocumentSource generatedPathSource() { + return (context, document) -> document.path("/generated", + path -> path.operation("GET", + operation -> operation.operationId("generatedGet") + .response("200", + "Generated response."))); + } + + private static OpenApiDocumentSource generatedSource(String listener, String path) { + return new OpenApiDocumentSource() { + @Override + public boolean supports(OpenApiDocumentContext context) { + return listener.equals(context.listener()); + } + + @Override + public void describe(OpenApiDocumentContext context, OpenApiDocument.Builder document) { + document.info("Generated API", "1.0.0") + .path(path, + targetPath -> targetPath.operation("GET", + operation -> operation + .operationId(path.substring(1) + "Get") + .response("200", "Generated response."))); + } + }; + } + + private static ServiceRegistryManager documentSourceRegistry() { + return documentSourceRegistry(null); + } + + private static ServiceRegistryManager documentSourceRegistry(RecordingOpenApiManager manager) { + ServiceRegistryConfig.Builder builder = ServiceRegistryConfig.builder() + .discoverServices(false) + .discoverServicesFromServiceLoader(false) + .addServiceDescriptor(testDescriptor( + OpenApiVersionProvider.class, + "OpenApi30VersionProvider", + new OpenApi30VersionProvider())) + .addServiceDescriptor(documentSourceDescriptor( + "SelectedDocument", + SelectedOpenApi.class.getCanonicalName(), + (context, document) -> document.info("Selected API", "1.0.0"))) + .addServiceDescriptor(documentSourceDescriptor( + "OtherDocument", + OtherOpenApi.class.getCanonicalName(), + (context, document) -> document.info("Other API", "1.0.0"))) + .addServiceDescriptor(documentSourceDescriptor( + "ConfiguredDocument", + ConfigExpressionOpenApi.class.getCanonicalName(), + (context, document) -> document.info(OpenApiDocumentContextSupport.resolveExpression( + context, + "${openapi.title:Fallback API}"), "1.0.0"))) + .addServiceDescriptor(documentSourceDescriptor( + "Endpoint", + null, + (context, document) -> document.path( + "/generated", + path -> path.operation("GET", + operation -> operation + .operationId("get") + .response("200", "OK"))))); + if (manager != null) { + builder.addServiceDescriptor(testDescriptor( + OpenApiManagerProvider.class, + "RecordingOpenApiManagerProvider", + managerProvider(manager))); + } + return ServiceRegistryManager.create(builder.build()); + } + + private static ServiceRegistryManager failingVersionProviderRegistry() { + ServiceRegistryConfig config = ServiceRegistryConfig.builder() + .discoverServices(false) + .discoverServicesFromServiceLoader(false) + .addServiceDescriptor(testDescriptor( + OpenApiVersionProvider.class, + "FailingOpenApiVersionProvider", + new FailingOpenApiVersionProvider())) + .build(); + return ServiceRegistryManager.create(config); + } + + private static ServiceDescriptor documentSourceDescriptor(String type, + String name, + OpenApiDocumentSource source) { + return new TestServiceDescriptor<>(OpenApiDocumentSource.class, type, source, name); + } + + private static ServiceDescriptor testDescriptor(Class contractType, String type, T instance) { + return new TestServiceDescriptor<>(contractType, type, instance, null); + } + + private static ServiceDescriptor failingServiceDescriptor(Class contractType, + String type, + String message) { + return new TestServiceDescriptor<>(() -> { + throw new AssertionError(message); + }, contractType, type, null); + } + + private static OpenApiManagerProvider managerProvider(RecordingOpenApiManager manager) { + return new OpenApiManagerProvider() { + @Override + public String configKey() { + return manager.type(); + } + + @Override + public OpenApiManager create(Config config, String name) { + return manager; + } + }; + } + + private void mergeStaticDocumentUsesRootVersion(Path staticFile, String content) throws IOException { + RecordingOpenApiManager manager = new RecordingOpenApiManager(); + OpenApiVersion renderVersion = new TestOpenApiVersion("3.0", "3.0.3", true); + OpenApiVersion staticVersion = new TestOpenApiVersion("3.1", "3.1.0", false); + Files.writeString(staticFile, content); + OpenApiFeatureConfig config = OpenApiFeatureConfig.builder() + .servicesDiscoverServices(false) + .staticFile(staticFile.toString()) + .generatedMode(OpenApiGeneratedMode.MERGE) + .openApiVersion(renderVersion) + .manager(manager) + .buildPrototype(); + OpenApiFeature feature = testFeature(config, + List.of(generatedPathSource()), + List.of(provider("3.1", staticVersion))); + + feature.initialize(); + + assertThat(parse(manager.content()).get("openapi"), is("3.0.3")); + } + + private static final class SelectedOpenApi { + } + + private static final class OtherOpenApi { + } + + private static final class ConfigExpressionOpenApi { + } + + private static final class FailingOpenApiVersionProvider implements OpenApiVersionProvider { + @Override + public String configKey() { + return "failing"; + } + + @Override + public OpenApiVersion create(Config config, String name) { + throw new AssertionError("Disabled OpenAPI feature must not create version providers."); + } + } + + private record TestOpenApiVersion(String type, String version, boolean failParse) implements OpenApiVersion { + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType) { + if (failParse) { + throw new AssertionError("Configured render version must not parse static content."); + } + return OpenApiDocument.builder() + .openapi(version) + .info("Static API", "1.0.0") + .paths(Map.of()) + .build(); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + return OpenApi30Version.create().render(context, document); + } + + @Override + public String name() { + return type; + } + } + + private static final class ConcurrentTrackingOpenApiVersion implements OpenApiVersion { + private final CountDownLatch firstCall; + private final CountDownLatch concurrentCall; + private final CountDownLatch releaseFirstCall; + private final AtomicInteger activeCalls = new AtomicInteger(); + private final AtomicInteger calls = new AtomicInteger(); + private final AtomicInteger maxConcurrentCalls = new AtomicInteger(); + + private ConcurrentTrackingOpenApiVersion(CountDownLatch firstCall, + CountDownLatch concurrentCall, + CountDownLatch releaseFirstCall) { + this.firstCall = firstCall; + this.concurrentCall = concurrentCall; + this.releaseFirstCall = releaseFirstCall; + } + + @Override + public String version() { + return "3.0.3"; + } + + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType) { + return invoke(() -> OpenApiDocument.builder() + .openapi(version()) + .info("Static API", "1.0.0") + .build()); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + return invoke(() -> OpenApi30Version.create().render(context, document)); + } + + @Override + public String name() { + return "3.0"; + } + + @Override + public String type() { + return "3.0"; + } + + private T invoke(Supplier invocation) { + calls.incrementAndGet(); + int active = activeCalls.incrementAndGet(); + maxConcurrentCalls.accumulateAndGet(active, Math::max); + firstCall.countDown(); + if (active > 1) { + concurrentCall.countDown(); + } + try { + if (!releaseFirstCall.await(10, TimeUnit.SECONDS)) { + throw new AssertionError("Timed out waiting to release the first OpenAPI version invocation."); + } + return invocation.get(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new AssertionError(e); + } finally { + activeCalls.decrementAndGet(); + } + } + + private int maxConcurrentCalls() { + return maxConcurrentCalls.get(); + } + + private int callCount() { + return calls.get(); + } + } + + private static final class CountingOpenApiVersion implements OpenApiVersion { + private final String type; + private final String version; + private int parseCount; + + private CountingOpenApiVersion(String type, String version) { + this.type = type; + this.version = version; + } + + @Override + public String version() { + return version; + } + + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType) { + parseCount++; + return OpenApiDocument.builder() + .openapi(version) + .info("Static API", "1.0.0") + .build(); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + return OpenApi30Version.create().render(context, document); + } + + @Override + public String name() { + return type; + } + + @Override + public String type() { + return type; + } + + int parseCount() { + return parseCount; + } + } + + private static final class ListenerOpenApiVersion implements OpenApiVersion { + private final String type; + private final String version; + + private ListenerOpenApiVersion(String type, String version) { + this.type = type; + this.version = version; + } + + @Override + public String version() { + return version; + } + + @Override + public OpenApiDocument parse(OpenApiDocumentContext context, String content, MediaType mediaType) { + return OpenApiDocument.builder() + .openapi(version) + .info(context.listener(), "1.0.0") + .build(); + } + + @Override + public String render(OpenApiDocumentContext context, OpenApiDocument document) { + return OpenApi30Version.create().render(context, document); + } + + @Override + public String name() { + return type; + } + + @Override + public String type() { + return type; + } + } + + private static final class RecordingOpenApiManager implements OpenApiManager { + private final List contents = new ArrayList<>(); + + @Override + public String load(String content) { + contents.add(content); + return content; + } + + @Override + public String format(String model, OpenApiFormat format) { + return model; + } + + @Override + public String name() { + return "test"; + } + + @Override + public String type() { + return "test"; + } + + String content() { + return contents.getLast(); + } + + List contents() { + return contents; + } + } + + private static final class TransformingOpenApiManager implements OpenApiManager { + @Override + public String load(String content) { + return "loaded:" + content; + } + + @Override + public String format(String model, OpenApiFormat format) { + return "formatted:" + model; + } + + @Override + public String name() { + return "transforming"; + } + + @Override + public String type() { + return "transforming"; + } + } + + private static final class TestServiceDescriptor implements ServiceDescriptor { + private final ResolvedType contract; + private final TypeName serviceType; + private final TypeName descriptorType; + private final Set qualifiers; + private final Supplier instanceSupplier; + + private TestServiceDescriptor(Class contractType, String type, T instance, String name) { + this(() -> instance, contractType, type, name); + } + + private TestServiceDescriptor(Supplier instanceSupplier, Class contractType, String type, String name) { + this.contract = ResolvedType.create(contractType); + this.serviceType = TypeName.create("io.helidon.openapi.OpenApiFeatureTest." + type); + this.descriptorType = TypeName.create("io.helidon.openapi.OpenApiFeatureTest." + + type + + "__ServiceDescriptor"); + this.qualifiers = name == null ? Set.of() : Set.of(Qualifier.createNamed(name)); + this.instanceSupplier = instanceSupplier; + } + + @Override + public Object instantiate(DependencyContext ctx, InterceptionMetadata metadata) { + return instanceSupplier.get(); + } + + @Override + public TypeName serviceType() { + return serviceType; + } + + @Override + public TypeName descriptorType() { + return descriptorType; + } + + @Override + public Set contracts() { + return Set.of(contract); + } + + @Override + public Set qualifiers() { + return qualifiers; + } + } + + private static final class TestFeatureContext implements ServerFeature.ServerFeatureContext { + private final Set sockets; + + private TestFeatureContext(String... sockets) { + this.sockets = Set.of(sockets); + } + + @Override + public WebServerConfig serverConfig() { + return WebServerConfig.create(); + } + + @Override + public Set sockets() { + return sockets; + } + + @Override + public boolean socketExists(String socketName) { + return WebServer.DEFAULT_SOCKET_NAME.equals(socketName) || sockets.contains(socketName); + } + + @Override + public ServerFeature.SocketBuilders socket(String socketName) { + if (!socketExists(socketName)) { + throw new NoSuchElementException("Socket " + socketName + " is not defined"); + } + return new TestSocketBuilders(); + } + } + + private static final class TestSocketBuilders implements ServerFeature.SocketBuilders { + @Override + public ListenerConfig listener() { + return ListenerConfig.create(); + } + + @Override + public HttpRouting.Builder httpRouting() { + return HttpRouting.builder(); + } + + @Override + public ServerFeature.RoutingBuilders routingBuilders() { + return new ServerFeature.RoutingBuilders() { + @Override + public boolean hasRouting(Class builderType) { + return false; + } + + @Override + public > T routingBuilder(Class builderType) { + if (builderType == HttpRouting.Builder.class) { + return builderType.cast(HttpRouting.builder()); + } + throw new NoSuchElementException("Routing not available for type: " + builderType); + } + }; } } } diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiSourceBaseTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiSourceBaseTest.java new file mode 100644 index 00000000000..a9c05989f8e --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/OpenApiSourceBaseTest.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi; + +import java.math.BigDecimal; + +import io.helidon.json.JsonObject; +import io.helidon.json.JsonValue; +import io.helidon.json.JsonValueType; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApiSourceBaseTest { + + @Test + void exampleValueParsesJsonObject() { + JsonValue value = TestSource.example("{\"message\":\"Accepted\"}"); + + assertThat(value.type(), is(JsonValueType.OBJECT)); + assertThat(value.asObject().stringValue("message").orElseThrow(), is("Accepted")); + } + + @Test + void exampleValueParsesJsonArray() { + JsonValue value = TestSource.example("[\"one\",\"two\"]"); + + assertThat(value.type(), is(JsonValueType.ARRAY)); + assertThat(value.asArray().get(1).orElseThrow().asString().value(), is("two")); + } + + @Test + void exampleValueParsesJsonScalar() { + JsonValue value = TestSource.example("42"); + + assertThat(value.type(), is(JsonValueType.NUMBER)); + assertThat(value.asNumber().bigDecimalValue(), is(new BigDecimal("42"))); + } + + @Test + void exampleValueFallsBackToStringForNonJsonText() { + JsonValue value = TestSource.example("hello/world"); + + assertThat(value.type(), is(JsonValueType.STRING)); + assertThat(value.asString().value(), is("hello/world")); + } + + @Test + void exampleValueFallsBackToStringForPartialJsonText() { + JsonValue value = TestSource.example("42 is the answer"); + + assertThat(value.type(), is(JsonValueType.STRING)); + assertThat(value.asString().value(), is("42 is the answer")); + } + + @Test + void extensionValuePreservesStringWhenParsingIsDisabled() { + JsonValue value = TestSource.extension("x-test", "true", false); + + assertThat(value.type(), is(JsonValueType.STRING)); + assertThat(value.asString().value(), is("true")); + } + + @Test + void extensionValueParsesExactlyOneJsonValue() { + JsonValue value = TestSource.extension( + "x-test", + "{\"enabled\":true,\"retries\":3,\"tags\":[\"generated\",\"openapi\"],\"none\":null}", + true); + + assertThat(value.type(), is(JsonValueType.OBJECT)); + JsonObject object = value.asObject(); + assertThat(object.booleanValue("enabled").orElseThrow(), is(true)); + assertThat(object.intValue("retries").orElseThrow(), is(3)); + assertThat(object.arrayValue("tags").orElseThrow().get(1).orElseThrow().asString().value(), is("openapi")); + assertThat(object.value("none").orElseThrow().type(), is(JsonValueType.NULL)); + } + + @Test + void extensionValueRejectsInvalidJson() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> TestSource.extension("x-test", "{invalid}", true)); + + assertThat(exception.getMessage(), containsString("OpenAPI extension x-test")); + assertThat(exception.getMessage(), containsString("exactly one valid JSON value")); + } + + @Test + void extensionValueRejectsTrailingContent() { + IllegalArgumentException exception = assertThrows( + IllegalArgumentException.class, + () -> TestSource.extension("x-test", "true false", true)); + + assertThat(exception.getMessage(), containsString("OpenAPI extension x-test")); + assertThat(exception.getMessage(), containsString("exactly one valid JSON value")); + } + + private static final class TestSource extends OpenApiSourceBase { + private static JsonValue example(String value) { + return exampleValue(value); + } + + private static JsonValue extension(String name, String value, boolean parseValue) { + return extensionValue(name, value, parseValue); + } + + @Override + public void describe(OpenApiDocumentContext context, OpenApiDocument.Builder document) { + } + } +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/SimpleOpenApiManagerTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/SimpleOpenApiManagerTest.java index c83daf95063..b3ed3726f32 100644 --- a/openapi/openapi/src/test/java/io/helidon/openapi/SimpleOpenApiManagerTest.java +++ b/openapi/openapi/src/test/java/io/helidon/openapi/SimpleOpenApiManagerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 Oracle and/or its affiliates. + * Copyright (c) 2023, 2026 Oracle and/or its affiliates. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -38,16 +38,22 @@ void testJsonFormatting() { SimpleOpenApiManager manager = new SimpleOpenApiManager(); String raw = "plain-boolean: true\n" + "quoted-boolean: \"true\"\n" + + "yaml-boolean: on\n" + + "date: 2026-08-13\n" + "integer: 100\n" + "float: 1.1\n" + + "null: null\n" + "binary: !!binary \"" + HELLO_BASE64 + "\"\n"; String formatted = manager.format(raw, OpenApiFormat.JSON); JsonReader reader = Json.createReader(new StringReader(formatted)); JsonObject jsonObject = reader.readObject(); assertThat(jsonObject.getBoolean("plain-boolean"), is(true)); assertThat(jsonObject.getString("quoted-boolean"), is("true")); + assertThat(jsonObject.getString("yaml-boolean"), is("on")); + assertThat(jsonObject.getString("date"), is("2026-08-13")); assertThat(jsonObject.getInt("integer"), is(100)); assertThat(jsonObject.getJsonNumber("float").doubleValue(), is(1.1D)); + assertThat(jsonObject.isNull("null"), is(true)); assertThat(jsonObject.getString("binary"), is(HELLO_BASE64)); } diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApi30DocumentMapperTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApi30DocumentMapperTest.java new file mode 100644 index 00000000000..ae201ad7b7f --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApi30DocumentMapperTest.java @@ -0,0 +1,1499 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.helidon.json.JsonNull; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonString; +import io.helidon.openapi.OpenApiDocument; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApi30DocumentMapperTest { + private static final long LARGE_INTEGRAL_VALUE = 9_007_199_254_740_993L; + + @Test + void validatesOpenApiVersion() { + OpenApi30DocumentMapper.parse(document("3.0.4-rc1")); + + for (String invalidVersion : List.of("3.0", "3.0.", "3.0.not-a-version", "3.0.1-", "3.0.1.0")) { + IllegalStateException ex = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(document(invalidVersion)), + invalidVersion); + assertThat(invalidVersion, ex.getMessage(), containsString(invalidVersion)); + } + } + + @Test + void validatesPathNamesOnParseAndRender() { + Map invalidPaths = Map.of( + "items", "must start with /", + "/items?active=true", "must not include a query string", + "/items#details", "must not include a fragment"); + invalidPaths.forEach((path, expectedMessage) -> { + IllegalStateException parsed = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(documentWithPath(path))); + assertThat(parsed.getMessage(), containsString(path)); + assertThat(parsed.getMessage(), containsString(expectedMessage)); + + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path(path, _ -> { }) + .build(); + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(document, "3.0.3")); + assertThat(rendered.getMessage(), containsString(path)); + assertThat(rendered.getMessage(), containsString(expectedMessage)); + }); + + for (String validPath : List.of("/items/{itemId}", + "/items/{item?mode#fragment}", + "/items/{itemId}/{itemId}")) { + OpenApi30DocumentMapper.parse(documentWithPath(validPath)); + OpenApi30DocumentMapper.render(OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path(validPath, _ -> { }) + .build(), + "3.0.3"); + } + } + + @Test + void requiresPathParametersForTemplateExpressions() { + String path = "/items/{id}"; + Map missing = documentWithPathItem(path, Map.of( + "get", Map.of("responses", Map.of("200", Map.of("description", "OK"))))); + + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(missing)); + assertThat(parsed.getMessage(), containsString(path)); + assertThat(parsed.getMessage(), containsString("template expression {id}")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(openApiDocument(missing), "3.0.3")); + assertThat(rendered.getMessage(), containsString(path)); + assertThat(rendered.getMessage(), containsString("template expression {id}")); + + Map pathLevel = documentWithPathItem(path, Map.of( + "parameters", List.of(pathParameter("id")), + "get", Map.of("responses", Map.of("200", Map.of("description", "OK"))))); + OpenApi30DocumentMapper.render(OpenApi30DocumentMapper.parse(pathLevel), "3.0.3"); + + Map operationLevel = documentWithPathItem(path, Map.of( + "get", Map.of( + "parameters", List.of(pathParameter("id")), + "responses", Map.of("200", Map.of("description", "OK"))))); + OpenApi30DocumentMapper.render(OpenApi30DocumentMapper.parse(operationLevel), "3.0.3"); + } + + @Test + void validatesParameterListUniqueness() { + String path = "/items/{id}"; + Map parameter = pathParameter("id"); + Map duplicate = documentWithPathItem(path, Map.of( + "get", Map.of( + "parameters", List.of(parameter, parameter), + "responses", Map.of("200", Map.of("description", "OK"))))); + + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(duplicate)); + assertThat(parsed.getMessage(), containsString("duplicate path parameter id")); + + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(openApiDocument(duplicate), "3.0.3")); + assertThat(rendered.getMessage(), containsString("duplicate path parameter id")); + + Map override = documentWithPathItem(path, Map.of( + "parameters", List.of(parameter), + "get", Map.of( + "parameters", List.of(parameter), + "responses", Map.of("200", Map.of("description", "OK"))))); + OpenApi30DocumentMapper.render(OpenApi30DocumentMapper.parse(override), "3.0.3"); + } + + @Test + void validatesComponentNamesOnParseAndRender() { + String invalidName = "bad/name"; + IllegalStateException parsed = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(documentWithSchemaName(invalidName))); + assertThat(parsed.getMessage(), containsString(invalidName)); + assertThat(parsed.getMessage(), containsString("must match [A-Za-z0-9._-]+")); + + OpenApiDocument invalidDocument = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components.schema( + invalidName, + JsonObject.builder().set("type", "string").build())) + .build(); + IllegalStateException rendered = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(invalidDocument, "3.0.3")); + assertThat(rendered.getMessage(), containsString(invalidName)); + assertThat(rendered.getMessage(), containsString("must match [A-Za-z0-9._-]+")); + + String validName = "Valid.Name_1-2"; + OpenApi30DocumentMapper.parse(documentWithSchemaName(validName)); + OpenApi30DocumentMapper.render(OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components.schema( + validName, + JsonObject.builder().set("type", "string").build())) + .build(), + "3.0.3"); + } + + @Test + void handlesVersionSpecificResponseRequirements() { + IllegalStateException missingResponses = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(documentWithOperation("3.0.3", Map.of("summary", "Items")))); + assertThat(missingResponses.getMessage(), containsString("responses")); + + for (Map responses : List.>of( + Map.of(), + Map.of("x-note", "No response code"))) { + IllegalStateException missingResponseCode = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(documentWithOperation( + "3.0.3", + Map.of("responses", responses)))); + assertThat(missingResponseCode.getMessage(), containsString("response code")); + } + + IllegalStateException missingDescription = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(documentWithOperation( + "3.0.3", + Map.of("responses", Map.of("200", Map.of("headers", Map.of())))))); + assertThat(missingDescription.getMessage(), containsString("description")); + + IllegalStateException invalidResponseKey = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(documentWithOperation( + "3.0.3", + Map.of("responses", Map.of( + "200", Map.of("description", "OK"), + "bogus", Map.of("description", "Invalid")))))); + assertThat(invalidResponseKey.getMessage(), containsString("3.0")); + assertThat(invalidResponseKey.getMessage(), containsString("bogus")); + + OpenApiDocument operationWithoutResponses = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation("GET", operation -> operation.summary("Items"))) + .build(); + IllegalStateException renderedWithoutResponses = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(operationWithoutResponses, "3.0.3")); + assertThat(renderedWithoutResponses.getMessage(), containsString("responses")); + + OpenApiDocument responsesWithoutCode = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.responseExtension("x-note", JsonString.create("No response code")))) + .build(); + IllegalStateException renderedWithoutResponseCode = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(responsesWithoutCode, "3.0.3")); + assertThat(renderedWithoutResponseCode.getMessage(), containsString("response code")); + + OpenApiDocument responseWithoutDescription = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.response("200", response -> response.summary("Items")))) + .build(); + IllegalStateException renderedWithoutDescription = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(responseWithoutDescription, "3.0.3")); + assertThat(renderedWithoutDescription.getMessage(), containsString("description")); + + OpenApiDocument responseWithInvalidKey = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/items", path -> path.operation( + "GET", + operation -> operation.response("200", "OK").response("bogus", "Invalid"))) + .build(); + IllegalStateException renderedWithInvalidResponseKey = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(responseWithInvalidKey, "3.0.3")); + assertThat(renderedWithInvalidResponseKey.getMessage(), containsString("3.0")); + assertThat(renderedWithInvalidResponseKey.getMessage(), containsString("bogus")); + + for (String description : List.of("", " ")) { + OpenApiDocument document = OpenApi30DocumentMapper.parse(documentWithOperation( + "3.0.3", + Map.of("responses", Map.of("200", Map.of("description", description))))); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map response = map(map(map(map(rendered, "paths"), "/items"), "get"), "responses"); + + assertThat(map(response, "200").get("description"), is(description)); + } + } + + @Test + void rejectsMalformedSecurityRequirements() { + Map invalidSecurityValues = new LinkedHashMap<>(); + invalidSecurityValues.put("security value is not an array", Map.of("OAuth", List.of())); + invalidSecurityValues.put("security requirement is not an object", List.of("OAuth")); + invalidSecurityValues.put("scheme scopes are not an array", List.of(Map.of("OAuth", "read"))); + invalidSecurityValues.put("scheme scope is not a string", List.of(Map.of("OAuth", List.of("read", 42)))); + + invalidSecurityValues.forEach((description, invalidSecurity) -> { + Map topLevelDocument = new LinkedHashMap<>(document("3.0.3")); + topLevelDocument.put("security", invalidSecurity); + IllegalStateException topLevel = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(topLevelDocument), + description + " at document level"); + assertThat(description, topLevel.getMessage(), containsString("security")); + + Map operationDocument = new LinkedHashMap<>(document("3.0.3")); + operationDocument.put("paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of("200", Map.of("description", "OK")), + "security", invalidSecurity)))); + IllegalStateException operation = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(operationDocument), + description + " at operation level"); + assertThat(description, operation.getMessage(), containsString("security")); + }); + } + + @Test + void acceptsSecurityScopesOnlyForScopedSchemes() { + Map source = documentWithSecurityRequirements( + List.of(Map.of("ApiKey", List.of()), + Map.of("OAuth", List.of("catalog:read"))), + List.of(Map.of("Http", List.of()), + Map.of("OpenId", List.of("profile")))); + + OpenApiDocument document = OpenApi30DocumentMapper.parse(source); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + + assertThat(rendered.get("security"), is(source.get("security"))); + assertThat(map(map(map(rendered, "paths"), "/items"), "get").get("security"), + is(map(map(map(source, "paths"), "/items"), "get").get("security"))); + } + + @Test + void rejectsUndeclaredSecurityRequirementSchemes() { + Map> invalidDocuments = new LinkedHashMap<>(); + invalidDocuments.put("document", documentWithSecurityRequirements( + Map.of(), + List.of(Map.of("missingAuth", List.of())), + List.of())); + invalidDocuments.put("operation", documentWithSecurityRequirements( + Map.of(), + List.of(), + List.of(Map.of("missingAuth", List.of())))); + + invalidDocuments.forEach((location, source) -> { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(source), + location + " parsing"); + assertThat(parsed.getMessage(), containsString("undeclared security scheme missingAuth")); + + OpenApiDocument document = openApiDocument(source); + IllegalStateException rendered = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(document, "3.0.3"), + location + " rendering"); + assertThat(rendered.getMessage(), containsString("undeclared security scheme missingAuth")); + }); + } + + @Test + void rejectsNonEmptyScopesForUnscopedSchemes() { + Map> invalidDocuments = new LinkedHashMap<>(); + invalidDocuments.put("ApiKey", documentWithSecurityRequirements( + List.of(Map.of("ApiKey", List.of("catalog:read"))), + List.of(Map.of("OpenId", List.of("profile"))))); + invalidDocuments.put("Http", documentWithSecurityRequirements( + List.of(Map.of("OAuth", List.of("catalog:read"))), + List.of(Map.of("Http", List.of("profile"))))); + + invalidDocuments.forEach((scheme, source) -> { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(source), + scheme + " parsing"); + assertThat(parsed.getMessage(), containsString("empty scope array")); + assertThat(parsed.getMessage(), containsString(scheme)); + + OpenApiDocument document = openApiDocument(source); + IllegalStateException rendered = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(document, "3.0.3"), + scheme + " rendering"); + assertThat(rendered.getMessage(), containsString("empty scope array")); + assertThat(rendered.getMessage(), containsString(scheme)); + }); + } + + @Test + void rejectsNonEmptyScopesForAliasesToUnscopedSchemes() { + Map securitySchemes = new LinkedHashMap<>(); + securitySchemes.put("ApiKey", Map.of( + "type", "apiKey", + "name", "X-API-Key", + "in", "header")); + securitySchemes.put("ApiKeyAlias", Map.of( + "$ref", "#/co%6Dponents/securitySchemes/Api%4Bey")); + securitySchemes.put("Http", Map.of( + "type", "http", + "scheme", "bearer")); + securitySchemes.put("HttpAlias", Map.of( + "$ref", "#/co%6dponents/securitySchemes/Http")); + securitySchemes.put("MultiHopHttpAlias", Map.of( + "$ref", "#/components/securitySchemes/Http%41lias")); + + Map> invalidDocuments = new LinkedHashMap<>(); + invalidDocuments.put("ApiKeyAlias", documentWithSecurityRequirements( + securitySchemes, + List.of(Map.of("ApiKeyAlias", List.of("catalog:read"))), + List.of())); + invalidDocuments.put("MultiHopHttpAlias", documentWithSecurityRequirements( + securitySchemes, + List.of(), + List.of(Map.of("MultiHopHttpAlias", List.of("profile"))))); + + invalidDocuments.forEach((alias, source) -> { + IllegalStateException parsed = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(source), + alias + " parsing"); + assertThat(parsed.getMessage(), containsString("empty scope array")); + assertThat(parsed.getMessage(), containsString(alias)); + + OpenApiDocument document = openApiDocument(source); + IllegalStateException rendered = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(document, "3.0.3"), + alias + " rendering"); + assertThat(rendered.getMessage(), containsString("empty scope array")); + assertThat(rendered.getMessage(), containsString(alias)); + }); + } + + @Test + void acceptsSecurityScopesForAliasesToScopedSchemes() { + Map securitySchemes = new LinkedHashMap<>(); + securitySchemes.put("OAuth", Map.of( + "type", "oauth2", + "flows", Map.of( + "implicit", Map.of( + "authorizationUrl", "https://idp.example.com/authorize", + "scopes", Map.of("catalog:read", "Read the catalog"))))); + securitySchemes.put("OAuthAlias", Map.of( + "$ref", "#/components/securitySchemes/OAuth")); + securitySchemes.put("OpenId", Map.of( + "type", "openIdConnect", + "openIdConnectUrl", "https://idp.example.com/.well-known/openid-configuration")); + securitySchemes.put("OpenIdAlias", Map.of( + "$ref", "#/components/securitySchemes/OpenId")); + securitySchemes.put("MultiHopOpenIdAlias", Map.of( + "$ref", "#/components/securitySchemes/OpenIdAlias")); + Map source = documentWithSecurityRequirements( + securitySchemes, + List.of(Map.of("OAuthAlias", List.of("catalog:read"))), + List.of(Map.of("MultiHopOpenIdAlias", List.of("profile")))); + + OpenApiDocument document = OpenApi30DocumentMapper.parse(source); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + + assertThat(rendered.get("security"), is(source.get("security"))); + assertThat(map(map(map(rendered, "paths"), "/items"), "get").get("security"), + is(map(map(map(source, "paths"), "/items"), "get").get("security"))); + } + + @Test + void preservesSecurityScopesForUnresolvedAliases() { + Map securitySchemes = new LinkedHashMap<>(); + securitySchemes.put("ApiKey", Map.of( + "type", "apiKey", + "name", "X-API-Key", + "in", "header")); + securitySchemes.put("CycleA", Map.of( + "$ref", "#/components/securitySchemes/CycleB")); + securitySchemes.put("CycleB", Map.of( + "$ref", "#/components/securitySchemes/CycleA")); + securitySchemes.put("Missing", Map.of( + "$ref", "#/components/securitySchemes/NotPresent")); + securitySchemes.put("External", Map.of( + "$ref", "security.yaml#/components/securitySchemes/External")); + securitySchemes.put("Relative", Map.of( + "$ref", "../security.yaml#/components/securitySchemes/Relative")); + Map source = documentWithSecurityRequirements( + securitySchemes, + List.of(Map.of("CycleA", List.of("cycle")), + Map.of("Missing", List.of("missing"))), + List.of(Map.of("External", List.of("external")), + Map.of("Relative", List.of("relative")))); + + OpenApiDocument document = OpenApi30DocumentMapper.parse(source); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + + assertThat(rendered.get("security"), is(source.get("security"))); + assertThat(map(map(map(rendered, "paths"), "/items"), "get").get("security"), + is(map(map(map(source, "paths"), "/items"), "get").get("security"))); + assertThat(map(map(rendered, "components"), "securitySchemes"), is(securitySchemes)); + } + + @Test + void preservesLargeIntegralNumbers() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(document("3.0.3")); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + + assertThat(String.valueOf(schemaProperty(rendered, "large").get("default")), is(String.valueOf(LARGE_INTEGRAL_VALUE))); + } + + @Test + void canonicalizesBooleanExclusiveBounds() { + Map bounded = new LinkedHashMap<>(); + bounded.put("type", "number"); + bounded.put("maximum", 10); + bounded.put("exclusiveMaximum", true); + bounded.put("minimum", 1); + bounded.put("exclusiveMinimum", true); + Map inclusive = new LinkedHashMap<>(); + inclusive.put("type", "number"); + inclusive.put("maximum", 100); + inclusive.put("exclusiveMaximum", false); + inclusive.put("minimum", 0); + inclusive.put("exclusiveMinimum", false); + + OpenApiDocument document = OpenApi30DocumentMapper.parse(document("3.0.3", bounded, inclusive)); + Map canonical = parse(document.toJsonObject().toString()); + Map boundedSchema = schemaProperty(canonical, "bounded"); + Map inclusiveSchema = schemaProperty(canonical, "inclusive"); + + assertThat(boundedSchema.containsKey("maximum"), is(false)); + assertThat(boundedSchema.get("exclusiveMaximum"), is(10)); + assertThat(boundedSchema.containsKey("minimum"), is(false)); + assertThat(boundedSchema.get("exclusiveMinimum"), is(1)); + assertThat(inclusiveSchema.get("maximum"), is(100)); + assertThat(inclusiveSchema.containsKey("exclusiveMaximum"), is(false)); + assertThat(inclusiveSchema.get("minimum"), is(0)); + assertThat(inclusiveSchema.containsKey("exclusiveMinimum"), is(false)); + } + + @Test + void openApi30RenderUsesStricterNumericExclusiveOrInclusiveBounds() { + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components + .schema("ExclusiveUpperWins", schema("exclusiveMaximum", 5, "maximum", 10)) + .schema("InclusiveUpperWins", schema("exclusiveMaximum", 15, "maximum", 10)) + .schema("ExclusiveLowerWins", schema("exclusiveMinimum", 5, "minimum", 0)) + .schema("InclusiveLowerWins", schema("exclusiveMinimum", 5, "minimum", 10))) + .build(); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map exclusiveUpper = schema(rendered, "ExclusiveUpperWins"); + Map inclusiveUpper = schema(rendered, "InclusiveUpperWins"); + Map exclusiveLower = schema(rendered, "ExclusiveLowerWins"); + Map inclusiveLower = schema(rendered, "InclusiveLowerWins"); + + assertThat(String.valueOf(exclusiveUpper.get("maximum")), is("5")); + assertThat(exclusiveUpper.get("exclusiveMaximum"), is(true)); + assertThat(String.valueOf(inclusiveUpper.get("maximum")), is("10")); + assertThat(inclusiveUpper.containsKey("exclusiveMaximum"), is(false)); + assertThat(String.valueOf(exclusiveLower.get("minimum")), is("5")); + assertThat(exclusiveLower.get("exclusiveMinimum"), is(true)); + assertThat(String.valueOf(inclusiveLower.get("minimum")), is("10")); + assertThat(inclusiveLower.containsKey("exclusiveMinimum"), is(false)); + } + + @Test + void preservesNullExtensionValues() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(documentWithNullExtension("3.0.3")); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + + assertThat(rendered.containsKey("x-null"), is(true)); + assertThat(rendered.get("x-null"), is((Object) null)); + } + + @Test + void filtersUnsupportedHeaderFields() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of( + "description", "OK", + "headers", Map.of( + "X-Test", Map.of( + "allowEmptyValue", true, + "allowReserved", true, + "schema", Map.of("type", "string")))))))))); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map responses = map(map(map(map(rendered, "paths"), "/items"), "get"), "responses"); + Map header = map(map(map(responses, "200"), "headers"), "X-Test"); + + assertThat(header.containsKey("allowEmptyValue"), is(false)); + assertThat(header.containsKey("allowReserved"), is(false)); + } + + @Test + void preservesResponseExtensions() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of( + "/static", Map.of( + "x-path-meta", "keep", + "get", Map.of( + "responses", Map.of( + "x-provider-meta", true, + "x-provider-object", Map.of("enabled", true), + "200", Map.of( + "description", "OK", + "headers", Map.of( + "X-Trace", Map.of( + "description", "Trace header", + "schema", Map.of("type", "string"), + "x-header", true)), + "content", Map.of( + "application/json", Map.of( + "schema", Map.of("type", "object"), + "examples", Map.of( + "StaticExample", Map.of( + "value", Map.of("message", "ok"), + "x-example", "keep")), + "encoding", Map.of( + "payload", Map.of( + "contentType", "application/json", + "x-encoding", "keep")), + "x-media", true)), + "links", Map.of( + "StaticLink", Map.of( + "operationId", "followUp", + "x-link", "keep")), + "x-static-response", "preserved"))))), + "components", Map.of( + "x-components", true, + "responses", Map.of( + "x-Problem", Map.of( + "description", "Problem details", + "summary", "OpenAPI 3.2 summary", + "x-response", "preserved")), + "parameters", Map.of( + "GatewayPolicy", Map.of( + "name", "policy", + "in", "query", + "schema", Map.of("type", "string"), + "x-gateway-policy", "preserved")), + "requestBodies", Map.of( + "CodegenRequest", Map.of( + "content", Map.of( + "application/json", Map.of( + "schema", Map.of("type", "object"))), + "x-codegen-request", true)), + "securitySchemes", Map.of( + "AmazonAuth", Map.of( + "type", "http", + "scheme", "bearer", + "x-amazon-apigateway-authtype", "custom"))))); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map staticPath = map(map(rendered, "paths"), "/static"); + Map response = map(map(staticPath, "get"), "responses"); + Map okResponse = map(response, "200"); + Map content = map(map(okResponse, "content"), "application/json"); + Map example = map(map(content, "examples"), "StaticExample"); + Map encoding = map(map(content, "encoding"), "payload"); + Map link = map(map(okResponse, "links"), "StaticLink"); + Map components = map(rendered, "components"); + Map componentResponse = map(map(components, "responses"), "x-Problem"); + Map parameter = map(map(components, "parameters"), "GatewayPolicy"); + Map requestBody = map(map(components, "requestBodies"), "CodegenRequest"); + Map securityScheme = map(map(components, "securitySchemes"), "AmazonAuth"); + + assertThat(document.paths().get("/static").operations().get("get").responses().containsKey("x-provider-object"), + is(false)); + assertThat(staticPath.get("x-path-meta"), is("keep")); + assertThat(response.get("x-provider-meta"), is(true)); + assertThat(map(response, "x-provider-object").get("enabled"), is(true)); + assertThat(okResponse.get("x-static-response"), is("preserved")); + assertThat(map(map(okResponse, "headers"), "X-Trace").get("x-header"), is(true)); + assertThat(content.get("x-media"), is(true)); + assertThat(example.get("x-example"), is("keep")); + assertThat(encoding.get("x-encoding"), is("keep")); + assertThat(link.get("x-link"), is("keep")); + assertThat(components.get("x-components"), is(true)); + assertThat(componentResponse.get("description"), is("Problem details")); + assertThat(componentResponse.containsKey("summary"), is(false)); + assertThat(componentResponse.get("x-response"), is("preserved")); + assertThat(parameter.get("x-gateway-policy"), is("preserved")); + assertThat(requestBody.get("x-codegen-request"), is(true)); + assertThat(securityScheme.get("x-amazon-apigateway-authtype"), is("custom")); + } + + @Test + void preservesContainerExtensions() { + Map callbackPost = new LinkedHashMap<>(); + callbackPost.put("post", Map.of( + "responses", Map.of( + "200", Map.of("description", "OK")))); + + Map callback = new LinkedHashMap<>(); + callback.put("x-callback-scalar", "keep"); + callback.put("x-callback-object", Map.of("enabled", true)); + callback.put("{$request.body#/callbackUrl}", callbackPost); + + Map callbacks = new LinkedHashMap<>(); + callbacks.put("onEvent", callback); + callbacks.put("x-named-callback", Map.of("{$request.body#/fallbackUrl}", callbackPost)); + callbacks.put("referencedCallback", Map.of("$ref", "#/components/callbacks/ReusableCallback")); + + Map operation = new LinkedHashMap<>(); + operation.put("responses", Map.of("204", Map.of("description", "Done."))); + operation.put("callbacks", callbacks); + + Map pathItem = new LinkedHashMap<>(); + pathItem.put("get", operation); + + Map pathsSource = new LinkedHashMap<>(); + pathsSource.put("x-gateway-root", true); + pathsSource.put("x-gateway-object", Map.of("stage", "prod")); + pathsSource.put("/callback", pathItem); + + Map flowsSource = new LinkedHashMap<>(); + flowsSource.put("x-flow-scalar", "keep"); + flowsSource.put("x-flow-object", Map.of("enabled", true)); + flowsSource.put("clientCredentials", Map.of( + "tokenUrl", "https://idp.example.com/token", + "scopes", Map.of())); + + Map securityScheme = new LinkedHashMap<>(); + securityScheme.put("type", "oauth2"); + securityScheme.put("flows", flowsSource); + + Map documentSource = new LinkedHashMap<>(); + documentSource.put("openapi", "3.0.3"); + documentSource.put("info", Map.of( + "title", "Static API", + "version", "1.0.0")); + documentSource.put("paths", pathsSource); + documentSource.put("components", Map.of( + "callbacks", Map.of("ReusableCallback", callback), + "securitySchemes", Map.of( + "OAuth", securityScheme))); + + OpenApiDocument document = OpenApi30DocumentMapper.parse(documentSource); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map paths = map(rendered, "paths"); + Map renderedCallbacks = map(map(map(paths, "/callback"), "get"), "callbacks"); + Map renderedCallback = map(renderedCallbacks, "onEvent"); + Map componentCallback = map(map(map(rendered, "components"), "callbacks"), "ReusableCallback"); + Map flows = map(map(map(map(rendered, "components"), "securitySchemes"), "OAuth"), "flows"); + + assertThat(document.paths().containsKey("x-gateway-object"), is(false)); + assertThat(document.paths().get("/callback").operations().get("get").callbacks().get("onEvent") + .expressions().containsKey("{$request.body#/callbackUrl}"), is(true)); + assertThat(document.paths().get("/callback").operations().get("get").callbacks() + .containsKey("x-named-callback"), is(true)); + assertThat(paths.get("x-gateway-root"), is(true)); + assertThat(map(paths, "x-gateway-object").get("stage"), is("prod")); + assertThat(renderedCallback.get("x-callback-scalar"), is("keep")); + assertThat(map(renderedCallback, "x-callback-object").get("enabled"), is(true)); + assertThat(map(renderedCallback, "{$request.body#/callbackUrl}").containsKey("post"), is(true)); + assertThat(map(renderedCallbacks, "x-named-callback").containsKey("{$request.body#/fallbackUrl}"), is(true)); + assertThat(map(renderedCallbacks, "referencedCallback").get("$ref"), + is("#/components/callbacks/ReusableCallback")); + assertThat(componentCallback.containsKey("{$request.body#/callbackUrl}"), is(true)); + assertThat(flows.get("x-flow-scalar"), is("keep")); + assertThat(map(flows, "x-flow-object").get("enabled"), is(true)); + } + + @Test + @SuppressWarnings("unchecked") + void preservesHighLevelStaticDocumentExtensions() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0", + "contact", Map.of( + "name", "API Team", + "x-contact", "keep"), + "license", Map.of( + "name", "Apache-2.0", + "x-license", "keep")), + "externalDocs", Map.of( + "url", "https://api.example.com/docs", + "x-external-docs", "keep"), + "servers", List.of(Map.of( + "url", "https://api.example.com", + "variables", Map.of( + "region", Map.of( + "default", "us", + "x-server-variable", "keep")), + "x-server", "keep")), + "tags", List.of(Map.of( + "name", "pets", + "externalDocs", Map.of( + "url", "https://api.example.com/tags/pets", + "x-tag-docs", "keep"), + "x-tag", "keep")), + "paths", Map.of())); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map info = map(rendered, "info"); + Map server = ((List>) rendered.get("servers")).getFirst(); + Map serverVariable = map(map(server, "variables"), "region"); + Map tag = ((List>) rendered.get("tags")).getFirst(); + + assertThat(map(info, "contact").get("x-contact"), is("keep")); + assertThat(map(info, "license").get("x-license"), is("keep")); + assertThat(map(rendered, "externalDocs").get("x-external-docs"), is("keep")); + assertThat(server.get("x-server"), is("keep")); + assertThat(serverVariable.get("x-server-variable"), is("keep")); + assertThat(tag.get("x-tag"), is("keep")); + assertThat(map(tag, "externalDocs").get("x-tag-docs"), is("keep")); + } + + @Test + void openApi30RenderFiltersExampleFields() { + Map examples = examples(OpenApi30DocumentMapper.render(documentWithExamples(), "3.0.3")); + + assertExampleFields(examples, "valueExample", Set.of("summary", "value")); + assertExampleFields(examples, "externalExample", Set.of("summary", "externalValue")); + assertExampleFields(examples, "dataSerializedExample", Set.of("summary")); + assertExampleFields(examples, "dataExternalExample", Set.of("summary", "externalValue")); + } + + @Test + void openApi31RenderFiltersExampleFields() { + Map examples = examples(render3x(documentWithExamples(), + exampleRules("3.1.0", + Set.of("summary", + "description", + "value", + "externalValue")))); + + assertExampleFields(examples, "valueExample", Set.of("summary", "value")); + assertExampleFields(examples, "externalExample", Set.of("summary", "externalValue")); + assertExampleFields(examples, "dataSerializedExample", Set.of("summary")); + assertExampleFields(examples, "dataExternalExample", Set.of("summary", "externalValue")); + } + + @Test + void openApi32RenderPreservesExampleFields() { + Map examples = examples(render3x(documentWithExamples(), + exampleRules("3.2.0", + Set.of("summary", + "description", + "value", + "dataValue", + "serializedValue", + "externalValue")))); + + assertExampleFields(examples, "valueExample", Set.of("summary", "value")); + assertExampleFields(examples, "externalExample", Set.of("summary", "externalValue")); + assertExampleFields(examples, "dataSerializedExample", Set.of("summary", "dataValue", "serializedValue")); + assertExampleFields(examples, "dataExternalExample", Set.of("summary", "dataValue", "externalValue")); + } + + @Test + void openApi30RenderUsesAllOfForReferenceSiblings() { + String ref = "#/components/schemas/Base"; + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components + .schema("ReferenceOnly", JsonObject.builder() + .set("$ref", ref) + .build()) + .schema("ConstrainedReference", JsonObject.builder() + .set("$ref", ref) + .set("pattern", "[a-z]+") + .build()) + .schema("ComposedReference", JsonObject.builder() + .set("$ref", ref) + .set("description", "Composed reference") + .setValues("allOf", List.of(JsonObject.builder() + .set("type", "string") + .build())) + .build())) + .build(); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map referenceOnly = schema(rendered, "ReferenceOnly"); + Map constrainedReference = schema(rendered, "ConstrainedReference"); + Map composedReference = schema(rendered, "ComposedReference"); + + assertThat(referenceOnly, is(Map.of("$ref", ref))); + assertThat(constrainedReference.containsKey("$ref"), is(false)); + assertThat(constrainedReference.get("pattern"), is("[a-z]+")); + assertThat(constrainedReference.get("allOf"), is(List.of(Map.of("$ref", ref)))); + assertThat(composedReference.containsKey("$ref"), is(false)); + assertThat(composedReference.get("description"), is("Composed reference")); + assertThat(composedReference.get("allOf"), is(List.of( + Map.of("$ref", ref), + Map.of("type", "string")))); + } + + @Test + void openApi30ParseIgnoresReferenceSiblings() { + String ref = "#/components/schemas/Base"; + OpenApiDocument document = OpenApi30DocumentMapper.parse(Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of( + "schemas", Map.of( + "Referenced", Map.of( + "$ref", ref, + "pattern", "[a-z]+", + "properties", Map.of("ignored", false)))))); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + + assertThat(schema(rendered, "Referenced"), is(Map.of("$ref", ref))); + } + + @Test + void openApi30RenderPreservesMultiTypeAndCompositionSemantics() { + List numericTypes = List.of(JsonString.create("integer"), JsonString.create("number")); + List mixedTypes = List.of(JsonString.create("string"), JsonString.create("integer")); + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components + .schema("NumericUnion", JsonObject.builder() + .setValues("type", numericTypes) + .build()) + .schema("ComposedUnion", JsonObject.builder() + .setValues("type", mixedTypes) + .setValues("anyOf", List.of(JsonObject.builder() + .set("description", "Existing anyOf") + .build())) + .setValues("oneOf", List.of(JsonObject.builder() + .set("pattern", "[a-z]+") + .build())) + .build()) + .schema("NullableComposedUnion", JsonObject.builder() + .setValues("type", List.of(JsonString.create("string"), JsonString.create("null"))) + .setValues("oneOf", List.of( + JsonObject.builder().set("type", "string").build(), + JsonObject.builder().set("type", "null").build())) + .build())) + .build(); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map numericUnion = schema(rendered, "NumericUnion"); + Map composedUnion = schema(rendered, "ComposedUnion"); + Map nullableComposedUnion = schema(rendered, "NullableComposedUnion"); + + assertThat(numericUnion, is(Map.of("anyOf", List.of( + Map.of("type", "integer"), + Map.of("type", "number"))))); + assertThat(composedUnion.get("anyOf"), is(List.of(Map.of("description", "Existing anyOf")))); + assertThat(composedUnion.get("oneOf"), is(List.of(Map.of("pattern", "[a-z]+")))); + assertThat(composedUnion.get("allOf"), is(List.of(Map.of("anyOf", List.of( + Map.of("type", "string"), + Map.of("type", "integer")))))); + assertThat(nullableComposedUnion.get("type"), is("string")); + assertThat(nullableComposedUnion.get("nullable"), is(true)); + List oneOf = (List) nullableComposedUnion.get("oneOf"); + assertThat(oneOf.size(), is(2)); + assertThat(((Map) oneOf.get(0)).get("type"), is("string")); + Map nullSchema = (Map) oneOf.get(1); + assertThat(nullSchema.get("type"), is("object")); + assertThat(nullSchema.get("nullable"), is(true)); + List enumValues = (List) nullSchema.get("enum"); + assertThat(enumValues.size(), is(1)); + assertThat(enumValues.getFirst(), is((Object) null)); + } + + @Test + void openApi30RenderPreservesNullConstAsEnum() { + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components.schema("NullConst", + JsonObject.builder() + .setNull("const") + .build())) + .build(); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map schema = schema(rendered, "NullConst"); + List values = (List) schema.get("enum"); + + assertThat(values.size(), is(1)); + assertThat(values.getFirst(), is((Object) null)); + } + + @Test + void openApi30RenderPreservesConstAndEnumConstraints() { + for (String constant : List.of("A", "C")) { + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components.schema("Constrained", + JsonObject.builder() + .set("const", constant) + .setValues("enum", List.of( + JsonString.create("A"), + JsonString.create("B"))) + .setValues("allOf", List.of( + JsonObject.builder() + .set("description", + "Existing constraint") + .build())) + .build())) + .build(); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map schema = schema(rendered, "Constrained"); + List allOf = (List) schema.get("allOf"); + + assertThat(schema.containsKey("const"), is(false)); + assertThat(schema.get("enum"), is(List.of("A", "B"))); + assertThat(allOf, is(List.of( + Map.of("description", "Existing constraint"), + Map.of("enum", List.of(constant))))); + } + } + + @Test + void openApi30RenderPreservesNullOnlyTypeAsNullableSchema() { + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components.schema("NullOnly", + JsonObject.builder() + .set("type", "null") + .build())) + .build(); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map schema = schema(rendered, "NullOnly"); + List values = (List) schema.get("enum"); + + assertThat(schema.get("type"), is("object")); + assertThat(schema.get("nullable"), is(true)); + assertThat(values.size(), is(1)); + assertThat(values.getFirst(), is((Object) null)); + } + + @Test + void openApi30RenderPreservesNullableNullOnlyEnumAsNullableSchema() { + OpenApiDocument document = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .components(components -> components.schema("NullOnlyEnum", + JsonObject.builder() + .setValues("type", List.of( + JsonString.create("string"), + JsonString.create("null"))) + .setValues("enum", List.of(JsonNull.instance())) + .build())) + .build(); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map schema = schema(rendered, "NullOnlyEnum"); + List values = (List) schema.get("enum"); + + assertThat(schema.get("type"), is("string")); + assertThat(schema.get("nullable"), is(true)); + assertThat(values.size(), is(1)); + assertThat(values.getFirst(), is((Object) null)); + } + + @Test + void preservesNullableEnumConstraints() { + List enumWithNull = new ArrayList<>(); + enumWithNull.add("ready"); + enumWithNull.add(null); + + OpenApiDocument document = OpenApi30DocumentMapper.parse(Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "components", Map.of( + "schemas", Map.of( + "ExcludesNull", Map.of( + "type", "string", + "nullable", true, + "enum", List.of("ready")), + "IncludesNull", Map.of( + "type", "string", + "nullable", true, + "enum", enumWithNull))))); + + Map canonical = parse(document.toJsonObject().toString()); + Map canonicalExcludesNull = schema(canonical, "ExcludesNull"); + Map canonicalIncludesNull = schema(canonical, "IncludesNull"); + + assertThat(canonicalExcludesNull.get("type"), is(List.of("string", "null"))); + assertThat(canonicalExcludesNull.get("enum"), is(List.of("ready"))); + assertThat(canonicalIncludesNull.get("type"), is(List.of("string", "null"))); + assertThat(canonicalIncludesNull.get("enum"), is(enumWithNull)); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map renderedExcludesNull = schema(rendered, "ExcludesNull"); + Map renderedIncludesNull = schema(rendered, "IncludesNull"); + + assertThat(renderedExcludesNull.get("type"), is("string")); + assertThat(renderedExcludesNull.get("nullable"), is(true)); + assertThat(renderedExcludesNull.get("enum"), is(List.of("ready"))); + assertThat(renderedIncludesNull.get("type"), is("string")); + assertThat(renderedIncludesNull.get("nullable"), is(true)); + assertThat(renderedIncludesNull.get("enum"), is(enumWithNull)); + } + + @Test + void filtersReferenceObjectFields() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "components", Map.of( + "responses", Map.of( + "test", Map.of( + "$ref", "#/components/responses/real", + "summary", "Reference summary", + "description", "Reference description", + "x-reference", "Reference extension", + "additional", "Additional property"))))); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map reference = map(map(map(rendered, "components"), "responses"), "test"); + + assertThat(reference.keySet(), is(Set.of("$ref"))); + } + + @Test + void validatesReferenceUris() { + String malformed = "http://[bad"; + Map malformedExample = documentWithExampleReference(malformed); + IllegalStateException parsedExample = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(malformedExample)); + assertThat(parsedExample.getMessage(), containsString(malformed)); + assertThat(parsedExample.getMessage(), containsString("must be a URI")); + + IllegalStateException renderedExample = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(openApiDocument(malformedExample), "3.0.3")); + assertThat(renderedExample.getMessage(), containsString(malformed)); + assertThat(renderedExample.getMessage(), containsString("must be a URI")); + + Map malformedSchema = documentWithSchemaReference(malformed); + assertThrows(IllegalStateException.class, () -> OpenApi30DocumentMapper.parse(malformedSchema)); + assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(openApiDocument(malformedSchema), "3.0.3")); + + Map malformedPathItem = documentWithPathItem("/items", Map.of("$ref", malformed)); + IllegalStateException parsedPathItem = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(malformedPathItem)); + assertThat(parsedPathItem.getMessage(), containsString("must be a URI")); + IllegalStateException renderedPathItem = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(openApiDocument(malformedPathItem), "3.0.3")); + assertThat(renderedPathItem.getMessage(), containsString("must be a URI")); + + String ipvFuture = "http://[v1.fe]/description.yaml#/components/examples/Example"; + Map ipvFutureDocument = documentWithExampleReference(ipvFuture); + IllegalStateException unsupportedOnParse = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(ipvFutureDocument)); + assertThat(unsupportedOnParse.getMessage(), containsString("IPvFuture host literal")); + assertThat(unsupportedOnParse.getMessage(), containsString("not supported")); + + IllegalStateException unsupportedOnRender = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(openApiDocument(ipvFutureDocument), "3.0.3")); + assertThat(unsupportedOnRender.getMessage(), containsString("IPvFuture host literal")); + assertThat(unsupportedOnRender.getMessage(), containsString("not supported")); + + Map ipvFuturePathItem = documentWithPathItem("/items", Map.of("$ref", ipvFuture)); + IllegalStateException unsupportedPathItemOnParse = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.parse(ipvFuturePathItem)); + assertThat(unsupportedPathItemOnParse.getMessage(), containsString("IPvFuture host literal")); + IllegalStateException unsupportedPathItemOnRender = assertThrows( + IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(openApiDocument(ipvFuturePathItem), "3.0.3")); + assertThat(unsupportedPathItemOnRender.getMessage(), containsString("IPvFuture host literal")); + + for (String valid : List.of("https://example.test/openapi.yaml#/components/examples/Example", + "../openapi.yaml#/components/examples/Example", + "#/components/examples/Example", + "other.yaml#anchor")) { + OpenApiDocument document = OpenApi30DocumentMapper.parse(documentWithExampleReference(valid)); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + assertThat(map(map(map(rendered, "components"), "examples"), "test").get("$ref"), is(valid)); + } + } + + @Test + void filtersInfoContactFields() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0", + "contact", Map.of( + "name", "API Team", + "url", "https://api.example.com", + "email", "api@example.com", + "identifier", "unsupported")))); + + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map contact = map(map(rendered, "info"), "contact"); + + assertThat(contact.get("name"), is("API Team")); + assertThat(contact.get("url"), is("https://api.example.com")); + assertThat(contact.get("email"), is("api@example.com")); + assertThat(contact.containsKey("identifier"), is(false)); + } + + @Test + void openApi30PreservesMediaTypeEncodingMap() { + OpenApiDocument document = OpenApi30DocumentMapper.parse(documentWithEncoding("3.0.3")); + Map rendered = OpenApi30DocumentMapper.render(document, "3.0.3"); + Map encoding = encoding(rendered); + + assertThat(map(encoding, "profileImage").get("contentType"), is("image/png")); + assertThat(map(map(encoding, "profileImage"), "headers").containsKey("X-Image-Name"), is(true)); + } + + @Test + void openApi30RejectsMutualTlsSecurityScheme() { + OpenApiDocument document = openApiDocument(documentWithSecurityScheme(mutualTlsSecurityScheme())); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(document, "3.0.3")); + + assertThat(thrown.getMessage(), containsString("mutualTLS")); + } + + @Test + void openApi30RejectsDeviceAuthorizationFlow() { + OpenApiDocument document = openApiDocument(documentWithSecurityScheme(deviceAuthorizationSecurityScheme())); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> OpenApi30DocumentMapper.render(document, "3.0.3")); + + assertThat(thrown.getMessage(), containsString("deviceAuthorization")); + } + + private static JsonObject schema(String firstBound, int firstValue, String secondBound, int secondValue) { + return JsonObject.builder() + .set("type", "number") + .set(firstBound, firstValue) + .set(secondBound, secondValue) + .build(); + } + + private static Map document(String version) { + return document(version, Map.of("type", "integer", + "format", "int64", + "default", LARGE_INTEGRAL_VALUE), + Map.of("type", "string")); + } + + private static Map documentWithOperation(String version, Map operation) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of("/items", Map.of("get", operation))); + } + + private static Map documentWithPath(String path) { + return documentWithPathItem(path, Map.of()); + } + + private static Map documentWithPathItem(String path, Map pathItem) { + return Map.of("openapi", "3.0.3", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(path, pathItem)); + } + + private static Map pathParameter(String name) { + return Map.of("name", name, + "in", "path", + "required", true, + "schema", Map.of("type", "string")); + } + + private static Map documentWithSchemaName(String name) { + return Map.of("openapi", "3.0.3", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "components", Map.of("schemas", Map.of(name, Map.of("type", "string")))); + } + + private static Map documentWithSchemaReference(String reference) { + return Map.of("openapi", "3.0.3", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("schemas", Map.of("test", Map.of("$ref", reference)))); + } + + private static Map documentWithExampleReference(String reference) { + return Map.of("openapi", "3.0.3", + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of(), + "components", Map.of("examples", Map.of("test", Map.of("$ref", reference)))); + } + + private static Map document(String version, Map bounded, Map inclusive) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "components", Map.of("schemas", Map.of("StaticItem", Map.of( + "type", "object", + "properties", Map.of("large", Map.of( + "type", "integer", + "format", "int64", + "default", LARGE_INTEGRAL_VALUE), + "bounded", bounded, + "inclusive", inclusive))))); + } + + private static Map documentWithNullExtension(String version) { + Map result = new LinkedHashMap<>(); + result.put("openapi", version); + result.put("info", Map.of("title", "Static API", + "version", "1.0.0")); + result.put("x-null", null); + return result; + } + + private static Map documentWithEncoding(String version) { + return Map.of("openapi", version, + "info", Map.of("title", "Static API", + "version", "1.0.0"), + "paths", Map.of("/upload", Map.of("post", Map.of( + "requestBody", Map.of("content", Map.of("multipart/form-data", Map.of( + "schema", Map.of("type", "object"), + "encoding", Map.of("profileImage", Map.of( + "contentType", "image/png", + "headers", Map.of("X-Image-Name", Map.of( + "description", "Image name", + "schema", Map.of("type", "string")))))))), + "responses", Map.of("204", Map.of("description", "Done.")))))); + } + + private static OpenApiDocument openApiDocument(Map document) { + return OpenApiDocumentReader.read(OpenApiDocumentMapperSupport.jsonObject(document)); + } + + private static OpenApiDocument documentWithExamples() { + OpenApiDocument.Example value = OpenApiDocument.Example.builder() + .summary("Value") + .value(JsonString.create("one")) + .build(); + OpenApiDocument.Example external = OpenApiDocument.Example.builder() + .summary("External") + .externalValue("examples/external.json") + .build(); + OpenApiDocument.Example dataSerialized = OpenApiDocument.Example.builder() + .summary("Data serialized") + .dataValue(exampleData("serialized")) + .serializedValue("{\"kind\":\"serialized\"}") + .build(); + OpenApiDocument.Example dataExternal = OpenApiDocument.Example.builder() + .summary("Data external") + .dataValue(exampleData("external")) + .externalValue("examples/data.json") + .build(); + OpenApiDocument.Response response = OpenApiDocument.Response.builder() + .description("OK") + .content("application/json", content -> content + .example("valueExample", value) + .example("externalExample", external) + .example("dataSerializedExample", dataSerialized) + .example("dataExternalExample", dataExternal)) + .build(); + + return OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .path("/examples", path -> path.operation("GET", + operation -> operation.response("200", + response))) + .build(); + } + + private static JsonObject exampleData(String kind) { + return JsonObject.builder() + .set("kind", kind) + .build(); + } + + private static Map render3x(OpenApiDocument document, OpenApi3xMapperRules rules) { + return OpenApiDocumentMapperSupport.document3x(OpenApiDocumentMapperSupport.objectMap(document.toJsonObject()), + rules); + } + + private static OpenApi3xMapperRules exampleRules(String targetVersion, Set exampleFields) { + return OpenApi3xMapperRules.builder() + .targetVersion(targetVersion) + .addDocumentFields(Set.of("paths")) + .addPathItemFields(Set.of("get")) + .addFixedPathOperationFields(Set.of("get")) + .addOperationFields(Set.of("responses")) + .addResponseFields(Set.of("description", "content")) + .addMediaTypeFields(Set.of("examples")) + .addExampleFields(exampleFields) + .build(); + } + + @SuppressWarnings("unchecked") + private static Map examples(Map document) { + Object examples = map(map(map(map(map(map(map(document, "paths"), "/examples"), "get"), "responses"), "200"), + "content"), + "application/json") + .get("examples"); + if (examples instanceof Map map) { + return (Map) map; + } + return Map.of(); + } + + private static void assertExampleFields(Map examples, String name, Set fields) { + assertThat(map(examples, name).keySet(), is(fields)); + } + + private static Map documentWithSecurityScheme(Map securityScheme) { + Map result = new LinkedHashMap<>(); + result.put("openapi", "3.2.0"); + result.put("info", Map.of("title", "Static API", + "version", "1.0.0")); + result.put("components", Map.of("securitySchemes", Map.of("test", securityScheme))); + return result; + } + + private static Map documentWithSecurityRequirements( + List>> documentSecurity, + List>> operationSecurity) { + return documentWithSecurityRequirements( + Map.of( + "ApiKey", Map.of( + "type", "apiKey", + "name", "X-API-Key", + "in", "header"), + "Http", Map.of( + "type", "http", + "scheme", "bearer"), + "OAuth", Map.of( + "type", "oauth2", + "flows", Map.of( + "implicit", Map.of( + "authorizationUrl", "https://idp.example.com/authorize", + "scopes", Map.of( + "catalog:read", "Read the catalog")))), + "OpenId", Map.of( + "type", "openIdConnect", + "openIdConnectUrl", + "https://idp.example.com/.well-known/openid-configuration")), + documentSecurity, + operationSecurity); + } + + private static Map documentWithSecurityRequirements( + Map securitySchemes, + List>> documentSecurity, + List>> operationSecurity) { + return Map.of( + "openapi", "3.0.3", + "info", Map.of( + "title", "Static API", + "version", "1.0.0"), + "components", Map.of("securitySchemes", securitySchemes), + "security", documentSecurity, + "paths", Map.of( + "/items", Map.of( + "get", Map.of( + "responses", Map.of( + "200", Map.of("description", "OK")), + "security", operationSecurity)))); + } + + private static Map mutualTlsSecurityScheme() { + Map result = new LinkedHashMap<>(); + result.put("type", "mutualTLS"); + return result; + } + + private static Map deviceAuthorizationSecurityScheme() { + Map flow = new LinkedHashMap<>(); + flow.put("deviceAuthorizationUrl", "https://idp.example.com/device"); + flow.put("tokenUrl", "https://idp.example.com/token"); + flow.put("scopes", Map.of()); + + Map flows = new LinkedHashMap<>(); + flows.put("deviceAuthorization", flow); + + Map result = new LinkedHashMap<>(); + result.put("type", "oauth2"); + result.put("flows", flows); + return result; + } + + @SuppressWarnings("unchecked") + private static Map schemaProperty(Map document, String propertyName) { + return (Map) map(map(map(map(document, "components"), "schemas"), "StaticItem"), "properties") + .get(propertyName); + } + + @SuppressWarnings("unchecked") + private static Map schema(Map document, String schemaName) { + return (Map) map(map(document, "components"), "schemas").get(schemaName); + } + + private static Map encoding(Map document) { + return map(map(map(map(map(map(map(document, "paths"), "/upload"), "post"), + "requestBody"), "content"), "multipart/form-data"), "encoding"); + } + + @SuppressWarnings("unchecked") + private static Map parse(String content) { + return new Yaml().load(content); + } + + @SuppressWarnings("unchecked") + private static Map map(Map map, String name) { + return (Map) map.get(name); + } +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApi30VersionTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApi30VersionTest.java new file mode 100644 index 00000000000..76a572f7f60 --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApi30VersionTest.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.List; +import java.util.Map; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.openapi.OpenApiDocument; +import io.helidon.openapi.OpenApiDocumentContext; +import io.helidon.openapi.OpenApiGeneratedMode; +import io.helidon.openapi.spi.OpenApiVersion; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.Yaml; +import org.yaml.snakeyaml.error.YAMLException; + +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApi30VersionTest { + @Test + void preservesEmptyRequiredNames() { + OpenApi30Version version = OpenApi30Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, + """ + openapi: 3.0.4 + info: + title: API + version: "1" + license: + name: "" + tags: + - name: " " + paths: + /items: + get: + parameters: + - name: "" + in: query + schema: {type: string} + responses: + "200": {description: OK} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = new Yaml().load(version.render(context, document)); + Map info = (Map) rendered.get("info"); + assertThat(((Map) info.get("license")).get("name"), is("")); + assertThat(((Map) ((List) rendered.get("tags")).getFirst()).get("name"), is(" ")); + Map operation = (Map) ((Map) ((Map) rendered.get("paths")).get("/items")).get("get"); + assertThat(((Map) ((List) operation.get("parameters")).getFirst()).get("name"), is("")); + } + + @Test + void preservesEmptyInfoStrings() { + OpenApi30Version version = OpenApi30Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, + """ + openapi: 3.0.4 + info: + title: "" + version: " " + paths: {} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + assertThat(document.info().orElseThrow().title(), is("")); + assertThat(document.info().orElseThrow().version(), is(" ")); + + Map renderedInfo = (Map) new Yaml().>load(version.render(context, document)).get("info"); + assertThat(renderedInfo.get("title"), is("")); + assertThat(renderedInfo.get("version"), is(" ")); + } + + @Test + void preservesEmptyRequiredUriReferences() { + OpenApi30Version version = OpenApi30Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument document = version.parse(context, + """ + openapi: 3.0.4 + info: {title: API, version: "1"} + paths: {} + components: + securitySchemes: + oauth: + type: oauth2 + flows: + authorizationCode: + authorizationUrl: "" + tokenUrl: "" + scopes: {} + openId: + type: openIdConnect + openIdConnectUrl: "" + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = new Yaml().load(version.render(context, document)); + Map securitySchemes = (Map) ((Map) rendered.get("components")).get("securitySchemes"); + Map oauth = (Map) securitySchemes.get("oauth"); + Map authorizationCode = (Map) ((Map) oauth.get("flows")).get("authorizationCode"); + assertThat(authorizationCode.get("authorizationUrl"), is("")); + assertThat(authorizationCode.get("tokenUrl"), is("")); + assertThat(((Map) securitySchemes.get("openId")).get("openIdConnectUrl"), is("")); + } + + @Test + void requiresInfoWhenRendering() { + OpenApi30Version version = OpenApi30Version.create(); + OpenApiDocument withoutInfo = OpenApiDocument.builder() + .paths(Map.of()) + .build(); + + IllegalStateException thrown = assertThrows( + IllegalStateException.class, + () -> version.render(context(version), withoutInfo)); + assertThat(thrown.getMessage(), containsString("requires Info metadata")); + } + + @Test + void requiresPathsWhenRendering() { + OpenApi30Version version = OpenApi30Version.create(); + OpenApiDocumentContext context = context(version); + OpenApiDocument infoOnly = OpenApiDocument.builder() + .info("Generated API", "1.0.0") + .build(); + + IllegalStateException thrown = assertThrows(IllegalStateException.class, + () -> version.render(context, infoOnly)); + assertThat(thrown.getMessage(), containsString("requires a paths field")); + + OpenApiDocument emptyPaths = version.parse(context, + """ + openapi: 3.0.3 + info: + title: Generated API + version: 1.0.0 + paths: {} + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + Map rendered = new Yaml().load(version.render(context, emptyPaths)); + assertThat(rendered.containsKey("paths"), is(true)); + } + + @Test + void validatesConfiguredVersion() { + assertThat(OpenApi30Version.builder().version("3.0.99").build().version(), is("3.0.99")); + assertThat(OpenApi30Version.builder().version("3.0.4-rc1").build().version(), is("3.0.4-rc1")); + + for (String invalidVersion : List.of("3.0", "3.0.", "3.0.not-a-version", "3.0.1-", "3.0.1.0", "3.1.0")) { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, + () -> OpenApi30Version.builder() + .version(invalidVersion) + .build(), + invalidVersion); + assertThat(invalidVersion, ex.getMessage(), containsString("3.0")); + assertThat(invalidVersion, ex.getMessage(), containsString(invalidVersion)); + } + } + + @Test + void rejectsRecursiveYamlCollectionAliases() { + OpenApi30Version version = OpenApi30Version.create(); + + YAMLException thrown = assertThrows(YAMLException.class, + () -> version.parse(context(version), + """ + openapi: 3.0.4 + info: {title: API, version: 1} + paths: {} + x-cycle: &cycle {self: *cycle} + """, + MediaTypes.APPLICATION_OPENAPI_YAML)); + assertThat(thrown.getMessage(), containsString("Recursive YAML collection alias")); + } + + @Test + void acceptsSharedNonRecursiveYamlCollectionAliases() { + OpenApi30Version version = OpenApi30Version.create(); + OpenApiDocument document = version.parse(context(version), + """ + openapi: 3.0.4 + info: {title: API, version: "1"} + paths: {} + x-shared: &shared {value: shared} + x-first: *shared + x-second: *shared + """, + MediaTypes.APPLICATION_OPENAPI_YAML); + + Map rendered = new Yaml().load(version.render(context(version), document)); + assertThat(rendered.get("x-first"), is(Map.of("value", "shared"))); + assertThat(rendered.get("x-second"), is(Map.of("value", "shared"))); + } + + private static OpenApiDocumentContext context(OpenApiVersion version) { + return new TestOpenApiDocumentContext(version); + } + + private record TestOpenApiDocumentContext(OpenApiVersion openApiVersion) implements OpenApiDocumentContext { + @Override + public String featureName() { + return "openapi"; + } + + @Override + public String webContext() { + return "/openapi"; + } + + @Override + public String listener() { + return "default"; + } + + @Override + public OpenApiGeneratedMode generatedMode() { + return OpenApiGeneratedMode.STATIC_ONLY; + } + } +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApiDocumentMapperSupportTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApiDocumentMapperSupportTest.java new file mode 100644 index 00000000000..3d5d7651d48 --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApiDocumentMapperSupportTest.java @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import io.helidon.json.JsonNull; +import io.helidon.json.JsonObject; +import io.helidon.json.JsonValueType; + +import org.junit.jupiter.api.Test; +import org.yaml.snakeyaml.error.YAMLException; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.CoreMatchers.sameInstance; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.junit.jupiter.api.Assertions.assertThrows; + +class OpenApiDocumentMapperSupportTest { + + @Test + void rejectsNullInputs() { + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.parseYaml(null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.jsonObject(null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.jsonValue(null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.jsonNumber(null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.copyAllowed(null, Set.of("x"))); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.copyAllowed(Map.of(), null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.allowed(null, Set.of("x"))); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.allowed("x", null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.copy(null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.objectMap((Map) null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.objectMap((JsonObject) null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.object(null, object -> { })); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.object(Map.of(), null)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.objectList(null, object -> object)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.objectList(List.of(), null)); + } + + @Test + void parsesJsonCompatibleYamlScalars() { + Object loaded = OpenApiDocumentMapperSupport.parseYaml(""" + strings: + on: on + off: off + yes: yes + no: no + date: 2026-08-13 + leadingZero: 012 + uppercaseBoolean: TRUE + 012: numericKey + <<: mergeKey + values: + boolean: true + null: null + integer: 12 + decimal: 1.25 + exponent: 1e2 + """); + Map root = OpenApiDocumentMapperSupport.objectMap((Map) loaded); + Map strings = OpenApiDocumentMapperSupport.objectMap((Map) root.get("strings")); + Map values = OpenApiDocumentMapperSupport.objectMap((Map) root.get("values")); + + assertThat(strings, is(Map.of("on", "on", + "off", "off", + "yes", "yes", + "no", "no", + "date", "2026-08-13", + "leadingZero", "012", + "uppercaseBoolean", "TRUE", + "012", "numericKey", + "<<", "mergeKey"))); + assertThat(values.get("boolean"), is(true)); + assertThat(values.get("null"), nullValue()); + assertThat(values.get("integer"), is(12)); + assertThat(values.get("decimal"), is(1.25)); + assertThat(values.get("exponent"), is(100.0)); + } + + @Test + void rejectsExcessiveCollectionAliases() { + String yaml = """ + value: &value [value] + level01: &level01 [*value, *value] + level02: &level02 [*level01, *level01] + level03: &level03 [*level02, *level02] + level04: &level04 [*level03, *level03] + level05: &level05 [*level04, *level04] + level06: &level06 [*level05, *level05] + level07: &level07 [*level06, *level06] + level08: &level08 [*level07, *level07] + level09: &level09 [*level08, *level08] + level10: &level10 [*level09, *level09] + level11: [*level10, *level10] + """; + + assertThrows(YAMLException.class, () -> OpenApiDocumentMapperSupport.parseYaml(yaml)); + } + + @Test + void rejectsNullMapKeys() { + Map values = new LinkedHashMap<>(); + values.put(null, "value"); + + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.jsonObject(values)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.copy(values)); + assertThrows(NullPointerException.class, () -> OpenApiDocumentMapperSupport.objectMap(values)); + } + + @Test + void preservesJsonNullData() { + Map values = new LinkedHashMap<>(); + values.put("value", null); + Map copied = new LinkedHashMap<>(); + + JsonObject jsonObject = OpenApiDocumentMapperSupport.jsonObject(values); + OpenApiDocumentMapperSupport.copyField(copied, "value", values); + + assertThat(OpenApiDocumentMapperSupport.jsonValue(JsonNull.instance()), sameInstance(JsonNull.instance())); + assertThat(jsonObject.value("value").map(jsonValue -> jsonValue.type()).orElseThrow(), is(JsonValueType.NULL)); + assertThat(OpenApiDocumentMapperSupport.objectMap(jsonObject).get("value"), is((Object) null)); + assertThat(copied.containsKey("value"), is(true)); + assertThat(copied.get("value"), is((Object) null)); + } +} diff --git a/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApiReferenceResolverTest.java b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApiReferenceResolverTest.java new file mode 100644 index 00000000000..31ce56853bb --- /dev/null +++ b/openapi/openapi/src/test/java/io/helidon/openapi/v30/OpenApiReferenceResolverTest.java @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.v30; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.CoreMatchers.sameInstance; +import static org.hamcrest.Matchers.lessThanOrEqualTo; +import static org.hamcrest.MatcherAssert.assertThat; + +class OpenApiReferenceResolverTest { + private static final int CHAIN_LENGTH = 100; + private static final Map TARGET = Map.of("name", "query", "in", "querystring"); + + @Test + void resolvesInlineChainedAndEncodedComponentReferences() { + OpenApiReferenceResolver resolver = resolver(Map.of( + "Query.String", TARGET, + "Alias", reference("#/components/parameters/Query%2EString"))); + + assertResolution(resolver.resolveComponent(TARGET, "parameters"), + OpenApiReferenceResolver.Status.RESOLVED, + TARGET); + assertResolution(resolver.resolveComponent(reference("#/components/parameters/Alias"), "parameters"), + OpenApiReferenceResolver.Status.RESOLVED, + TARGET); + } + + @Test + void identifiesIpvFutureHosts() { + assertThat(OpenApiReferenceResolver.hasIpvFutureHost( + "http://[v1.fe]/description.yaml#/components/examples/Example"), + is(true)); + assertThat(OpenApiReferenceResolver.hasIpvFutureHost("//user@[Vf.a:b]:8443/description.yaml"), is(true)); + assertThat(OpenApiReferenceResolver.hasIpvFutureHost("http://[2001:db8::1]/description.yaml"), is(false)); + assertThat(OpenApiReferenceResolver.hasIpvFutureHost("description/[v1.fe].yaml"), is(false)); + assertThat(OpenApiReferenceResolver.hasIpvFutureHost("http://[v.fe]/description.yaml"), is(false)); + } + + @Test + void reportsUnresolvedComponentReferences() { + OpenApiReferenceResolver resolver = resolver(Map.of( + "First", reference("#/components/parameters/Second"), + "Second", reference("#/components/parameters/First"))); + + assertStatus(resolver.resolveComponent(reference("https://example.test/parameter"), "parameters"), + OpenApiReferenceResolver.Status.EXTERNAL); + assertStatus(resolver.resolveComponent(reference("#/components/parameters/Missing"), "parameters"), + OpenApiReferenceResolver.Status.MISSING); + assertStatus(resolver.resolveComponent(reference("#not-a-pointer"), "parameters"), + OpenApiReferenceResolver.Status.MALFORMED); + assertStatus(resolver.resolveComponent(reference("#/components/parameters/Bad~2Name"), "parameters"), + OpenApiReferenceResolver.Status.MALFORMED); + assertStatus(resolver.resolveComponent(reference("#/components/parameters/First"), "parameters"), + OpenApiReferenceResolver.Status.CYCLIC); + assertStatus(resolver.resolveComponent(reference("#/components/parameters/Second"), "parameters"), + OpenApiReferenceResolver.Status.CYCLIC); + } + + @Test + void resolvesGeneralLocalReferences() { + Map pathItem = Map.of("get", Map.of("operationId", "getItems")); + Map alias = reference("#/paths/~1items"); + OpenApiReferenceResolver resolver = OpenApiReferenceResolver.create(Map.of( + "paths", Map.of("/items", pathItem), + "components", Map.of("pathItems", Map.of( + "Alias", alias, + "Encoded~Name", pathItem)))); + + assertResolution(resolver.resolveReference(reference("#/components/pathItems/Alias")), + OpenApiReferenceResolver.Status.RESOLVED, + alias); + assertResolution(resolver.resolveReference(alias), + OpenApiReferenceResolver.Status.RESOLVED, + pathItem); + assertResolution(resolver.resolveReference(reference("#/components/pathItems/Encoded~0Name")), + OpenApiReferenceResolver.Status.RESOLVED, + pathItem); + } + + @Test + void cachesSelfQualifiedComponentReferenceChains() { + CountingMap parameters = new CountingMap(); + List> chain = new ArrayList<>(); + for (int i = 0; i < CHAIN_LENGTH; i++) { + Map value = i + 1 == CHAIN_LENGTH + ? TARGET + : reference("https://example.test/api#/components/parameters/Alias" + (i + 1)); + parameters.put("Alias" + i, value); + chain.add(value); + } + OpenApiReferenceResolver resolver = OpenApiReferenceResolver.create(Map.of( + "$self", "https://example.test/api", + "components", Map.of("parameters", parameters))); + + chain.forEach(value -> assertResolution(resolver.resolveComponent(value, "parameters"), + OpenApiReferenceResolver.Status.RESOLVED, + TARGET)); + + assertThat(parameters.lookups(), lessThanOrEqualTo(CHAIN_LENGTH)); + } + + @Test + void cachesGeneralReferenceChains() { + CountingMap parameters = new CountingMap(); + List> chain = new ArrayList<>(); + for (int i = 0; i < CHAIN_LENGTH; i++) { + Map value = i + 1 == CHAIN_LENGTH + ? TARGET + : reference("#/components/parameters/Alias" + (i + 1)); + parameters.put("Alias" + i, value); + chain.add(value); + } + OpenApiReferenceResolver resolver = resolver(parameters); + + chain.forEach(value -> assertResolution(resolver.resolveReferenceChain(value), + OpenApiReferenceResolver.Status.RESOLVED, + TARGET)); + + assertThat(parameters.lookups(), lessThanOrEqualTo(CHAIN_LENGTH)); + } + + @Test + void isolatesComponentResolutionCachesByTypeAndIdentity() { + Map first = new LinkedHashMap<>(TARGET); + Map second = new LinkedHashMap<>(TARGET); + Map alias = reference("#/components/parameters/Target"); + OpenApiReferenceResolver resolver = OpenApiReferenceResolver.create(Map.of( + "components", Map.of("parameters", Map.of("Target", first)))); + + assertResolution(resolver.resolveComponent(alias, "parameters"), + OpenApiReferenceResolver.Status.RESOLVED, + first); + assertStatus(resolver.resolveComponent(alias, "schemas"), + OpenApiReferenceResolver.Status.MISSING); + assertThat(resolver.resolveComponent(first, "parameters").value(), sameInstance(first)); + assertThat(resolver.resolveComponent(second, "parameters").value(), sameInstance(second)); + } + + @Test + void rejectsInvalidArrayIndices() { + OpenApiReferenceResolver resolver = OpenApiReferenceResolver.create(Map.of("values", List.of(TARGET))); + + assertResolution(resolver.resolveReference(reference("#/values/0")), + OpenApiReferenceResolver.Status.RESOLVED, + TARGET); + assertStatus(resolver.resolveReference(reference("#/values/00")), + OpenApiReferenceResolver.Status.MISSING); + assertStatus(resolver.resolveReference(reference("#/values/+0")), + OpenApiReferenceResolver.Status.MISSING); + assertStatus(resolver.resolveReference(reference("#/values/-0")), + OpenApiReferenceResolver.Status.MISSING); + assertStatus(resolver.resolveReference(reference("#/values/2147483648")), + OpenApiReferenceResolver.Status.MISSING); + assertStatus(resolver.resolveReference(reference("#/values/1")), + OpenApiReferenceResolver.Status.MISSING); + } + + @Test + void resolvesReferencesToSelfDocument() { + OpenApiReferenceResolver resolver = OpenApiReferenceResolver.create(Map.of( + "$self", "https://example.test/api", + "components", Map.of("parameters", Map.of("Query", TARGET)))); + + assertResolution(resolver.resolveReference( + reference("https://example.test/api#/components/parameters/Query")), + OpenApiReferenceResolver.Status.RESOLVED, + TARGET); + assertResolution(resolver.resolveReference(reference("/api#/components/parameters/Query")), + OpenApiReferenceResolver.Status.RESOLVED, + TARGET); + assertStatus(resolver.resolveReference( + reference("https://example.test/other#/components/parameters/Query")), + OpenApiReferenceResolver.Status.EXTERNAL); + } + + @Test + void resolvesOneLocalReferenceAtATime() { + Map terminal = Map.of("get", Map.of("operationId", "getItems")); + Map intermediate = reference("#/components/pathItems/Terminal"); + OpenApiReferenceResolver resolver = OpenApiReferenceResolver.create(Map.of( + "components", Map.of("pathItems", Map.of( + "Intermediate", intermediate, + "Terminal", terminal)))); + + assertResolution(resolver.resolveComponent(reference("#/components/pathItems/Intermediate"), "pathItems"), + OpenApiReferenceResolver.Status.RESOLVED, + terminal); + assertResolution(resolver.resolveReference(reference("#/components/pathItems/Intermediate")), + OpenApiReferenceResolver.Status.RESOLVED, + intermediate); + assertResolution(resolver.resolveReference(intermediate), + OpenApiReferenceResolver.Status.RESOLVED, + terminal); + assertResolution(resolver.resolveReference(terminal), + OpenApiReferenceResolver.Status.RESOLVED, + terminal); + } + + @Test + void reportsUnresolvedGeneralLocalReferences() { + OpenApiReferenceResolver resolver = OpenApiReferenceResolver.create(Map.of("components", Map.of())); + + assertStatus(resolver.resolveReference(reference("https://example.test/path-item")), + OpenApiReferenceResolver.Status.EXTERNAL); + assertStatus(resolver.resolveReference(reference("#/components/pathItems/Missing")), + OpenApiReferenceResolver.Status.MISSING); + assertStatus(resolver.resolveReference(reference("#not-a-pointer")), + OpenApiReferenceResolver.Status.MALFORMED); + } + + private static OpenApiReferenceResolver resolver(Map parameters) { + return OpenApiReferenceResolver.create(Map.of("components", Map.of("parameters", parameters))); + } + + private static Map reference(String ref) { + return Map.of("$ref", ref); + } + + private static void assertResolution(OpenApiReferenceResolver.Resolution resolution, + OpenApiReferenceResolver.Status status, + Map value) { + assertThat(resolution.status(), is(status)); + assertThat(resolution.value(), is(value)); + } + + private static void assertStatus(OpenApiReferenceResolver.Resolution resolution, + OpenApiReferenceResolver.Status status) { + assertThat(resolution.status(), is(status)); + assertThat(resolution.value(), is(Map.of())); + } + + private static final class CountingMap extends LinkedHashMap { + private int lookups; + + @Override + public Object get(Object key) { + lookups++; + return super.get(key); + } + + private int lookups() { + return lookups; + } + } +} diff --git a/openapi/openapi/src/test/resources/exact-static.yaml b/openapi/openapi/src/test/resources/exact-static.yaml new file mode 100644 index 00000000000..449cf33afc8 --- /dev/null +++ b/openapi/openapi/src/test/resources/exact-static.yaml @@ -0,0 +1,25 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +openapi: 3.0.3 +info: + title: Exact Static API + version: 1.0.0 +paths: + /exact: + get: + responses: + "200": + description: Exact response. diff --git a/openapi/openapi/src/test/resources/static-3.0.yaml b/openapi/openapi/src/test/resources/static-3.0.yaml new file mode 100644 index 00000000000..3f39b245728 --- /dev/null +++ b/openapi/openapi/src/test/resources/static-3.0.yaml @@ -0,0 +1,76 @@ +# +# Copyright (c) 2026 Oracle and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +openapi: 3.0.3 +info: + title: Static 3.0 API + version: 1.0.0 +tags: + - name: static + description: Static document operations. +paths: + /static/{id}: + get: + tags: + - static + operationId: staticGet + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + "200": + description: Static response. + headers: + X-Request-Id: + description: Request correlation id. + schema: + type: string + content: + application/json: + schema: + $ref: "#/components/schemas/StaticItem" + examples: + active: + value: + id: "42" + status: active +components: + schemas: + StaticItem: + type: object + required: + - id + properties: + id: + type: string + status: + type: string + nullable: true + enum: + - active + - inactive + payload: + type: object + additionalProperties: true + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT +security: + - bearerAuth: [] diff --git a/openapi/pom.xml b/openapi/pom.xml index 8cfeca4de2b..15f42ddad9a 100644 --- a/openapi/pom.xml +++ b/openapi/pom.xml @@ -31,6 +31,8 @@ openapi + openapi-31 + openapi-32 diff --git a/openapi/tests/jpms/pom.xml b/openapi/tests/jpms/pom.xml new file mode 100644 index 00000000000..5be37545c2d --- /dev/null +++ b/openapi/tests/jpms/pom.xml @@ -0,0 +1,44 @@ + + + + + 4.0.0 + + io.helidon.openapi.tests + helidon-openapi-tests-project + 27.0.0-SNAPSHOT + + helidon-openapi-tests-jpms + + Helidon OpenAPI Tests JPMS + + + + true + + + + + io.helidon.openapi + helidon-openapi + + + diff --git a/openapi/tests/jpms/src/main/java/io/helidon/openapi/tests/jpms/TestOpenApiVersionProvider.java b/openapi/tests/jpms/src/main/java/io/helidon/openapi/tests/jpms/TestOpenApiVersionProvider.java new file mode 100644 index 00000000000..32b86f8c2b5 --- /dev/null +++ b/openapi/tests/jpms/src/main/java/io/helidon/openapi/tests/jpms/TestOpenApiVersionProvider.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.helidon.openapi.tests.jpms; + +import io.helidon.config.Config; +import io.helidon.openapi.spi.OpenApiVersion; +import io.helidon.openapi.spi.OpenApiVersionProvider; + +final class TestOpenApiVersionProvider implements OpenApiVersionProvider { + @Override + public String configKey() { + return "test"; + } + + @Override + public OpenApiVersion create(Config config, String name) { + throw new UnsupportedOperationException(); + } +} diff --git a/openapi/tests/jpms/src/main/java/module-info.java b/openapi/tests/jpms/src/main/java/module-info.java new file mode 100644 index 00000000000..d5315f31309 --- /dev/null +++ b/openapi/tests/jpms/src/main/java/module-info.java @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@SuppressWarnings("helidon:api:preview") +module io.helidon.openapi.tests.jpms { + requires io.helidon.openapi; +} diff --git a/openapi/tests/pom.xml b/openapi/tests/pom.xml index 3fdda21f3c5..a0a8d0835db 100644 --- a/openapi/tests/pom.xml +++ b/openapi/tests/pom.xml @@ -35,6 +35,7 @@ gh-5792 + jpms