From d6887ece9a4afd93b6165be662f3e242f8bfa134 Mon Sep 17 00:00:00 2001 From: Antoine Nicolas Date: Fri, 13 Feb 2026 11:06:09 +0400 Subject: [PATCH 1/6] Introduce initial OpenAPI documentation capabilities --- org.restlet.ext.openapi/pom.xml | 53 ++++ .../openapi/OpenApiAnnotationProcessor.java | 63 ++++ .../openapi/OpenApiSpecificationRestlet.java | 92 ++++++ .../org/restlet/ext/openapi/Operations.java | 20 ++ .../org/restlet/ext/openapi/PathItems.java | 28 ++ .../openapi/RestletOpenApiApplication.java | 80 +++++ .../ext/openapi/RestletOpenApiContext.java | 35 +++ .../openapi/RestletOpenApiContextBuilder.java | 42 +++ .../ext/openapi/RestletOpenApiReader.java | 293 ++++++++++++++++++ pom.xml | 2 + 10 files changed, 708 insertions(+) create mode 100644 org.restlet.ext.openapi/pom.xml create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java create mode 100644 org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java diff --git a/org.restlet.ext.openapi/pom.xml b/org.restlet.ext.openapi/pom.xml new file mode 100644 index 0000000000..421db26713 --- /dev/null +++ b/org.restlet.ext.openapi/pom.xml @@ -0,0 +1,53 @@ + + + 4.0.0 + + org.restlet + org.restlet.parent + 2.7.0-SNAPSHOT + ../pom.xml + + + org.restlet.ext.openapi + bundle + Restlet Framework - OpenAPI extension + Support for the OpenAPI specification. + + + + io.swagger.core.v3 + swagger-core + ${lib-swagger-version} + + + + io.swagger.core.v3 + swagger-annotations + ${lib-swagger-version} + + + + io.swagger.core.v3 + swagger-integration + ${lib-swagger-version} + + + + org.restlet + org.restlet + ${project.version} + + + + + + + org.apache.felix + maven-bundle-plugin + true + + + + \ No newline at end of file diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java new file mode 100644 index 0000000000..ccc8d897fd --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java @@ -0,0 +1,63 @@ +package org.restlet.ext.openapi; + +import io.swagger.v3.core.util.AnnotationsUtils; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.parameters.Parameter; + +import java.util.Optional; + +class OpenApiAnnotationProcessor { + + static void documentOpenApiDefinition( + OpenAPI openAPIDefinition, + OpenAPIDefinition openAPIDefinitionAnnotation + ) { + if (openAPIDefinitionAnnotation.info() != null) { + AnnotationsUtils.getInfo(openAPIDefinitionAnnotation.info()) + .ifPresent(openAPIDefinition::setInfo); + } + } + + static void documentOperation( + Operation operation, + io.swagger.v3.oas.annotations.Operation operationAnnotation + ) { + if (operationAnnotation.summary() != null && !operationAnnotation.summary().isEmpty()) { + operation.setSummary(operationAnnotation.summary()); + } + + if (operationAnnotation.description() != null && !operationAnnotation.description().isEmpty()) { + operation.setDescription(operationAnnotation.description()); + } + + if (operationAnnotation.parameters() != null) { + for (io.swagger.v3.oas.annotations.Parameter parameterAnnotation : operationAnnotation.parameters()) { + resolveParameterFromAnnotation(parameterAnnotation) + .ifPresent(operation::addParametersItem); + } + } + } + + private static Optional resolveParameterFromAnnotation(io.swagger.v3.oas.annotations.Parameter parameterAnnotation) { + return switch (parameterAnnotation.in()) { + case DEFAULT, PATH -> Optional.empty(); + case COOKIE, HEADER, QUERY -> { + Parameter parameter = new Parameter() + .name(parameterAnnotation.name()) + .description(parameterAnnotation.description() == null + ? null + : parameterAnnotation.description().isEmpty() ? null : parameterAnnotation.description() + ) + .in(parameterAnnotation.in().name().toLowerCase()) + .required(parameterAnnotation.required()); + + AnnotationsUtils.getSchemaFromAnnotation(parameterAnnotation.schema(), null) + .ifPresent(parameter::schema); + + yield Optional.of(parameter); + } + }; + } +} diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java new file mode 100644 index 0000000000..102245467e --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java @@ -0,0 +1,92 @@ +package org.restlet.ext.openapi; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.util.DefaultPrettyPrinter; +import io.swagger.v3.oas.integration.OpenApiConfigurationException; +import io.swagger.v3.oas.integration.SwaggerConfiguration; +import io.swagger.v3.oas.integration.api.OpenAPIConfiguration; +import org.restlet.Request; +import org.restlet.Response; +import org.restlet.Restlet; +import org.restlet.data.MediaType; +import org.restlet.data.Method; +import org.restlet.data.Status; +import org.restlet.engine.resource.VariantInfo; +import org.restlet.representation.Representation; +import org.restlet.representation.StringRepresentation; +import org.restlet.representation.Variant; +import org.restlet.routing.Router; + +import java.util.List; + +class OpenApiSpecificationRestlet extends Restlet { + private static final VariantInfo VARIANT_JSON = new VariantInfo( + MediaType.APPLICATION_JSON + ); + + private static final VariantInfo VARIANT_APPLICATION_YAML = new VariantInfo( + MediaType.APPLICATION_YAML + ); + + private final Router router; + + OpenApiSpecificationRestlet(Router router) { + super(router.getContext()); + this.router = router; + } + + @Override + public void handle(Request request, Response response) { + super.handle(request, response); + + if (Method.GET.equals(request.getMethod())) { + response.setEntity(getOpenApiDefinitionAsRepresentation(request)); + } else { + response.setStatus(Status.CLIENT_ERROR_METHOD_NOT_ALLOWED); + } + } + + private Representation getOpenApiDefinitionAsRepresentation(Request request) { + List allowedVariants = List.of(VARIANT_APPLICATION_YAML, VARIANT_JSON); + + Variant preferredVariant = getApplication() + .getConnegService() + .getPreferredVariant( + allowedVariants, + request, + getApplication().getMetadataService() + ); + + OpenAPIConfiguration oasConfig = new SwaggerConfiguration() + .prettyPrint(true); + + try { + var context = new RestletOpenApiContextBuilder() + .router(router) + .openApiConfiguration(oasConfig) + .buildContext(true); + + var openApiRead = context.read(); + + if (VARIANT_JSON.isCompatible(preferredVariant)) { + var openApiAsJson = context.getOutputJsonMapper() + .writer(new DefaultPrettyPrinter()) + .writeValueAsString(openApiRead); + + return new StringRepresentation(openApiAsJson, MediaType.APPLICATION_JSON); + } else { + var openApiAsYaml = context.getOutputYamlMapper() + .writer(new DefaultPrettyPrinter()) + .writeValueAsString(openApiRead); + + return new StringRepresentation(openApiAsYaml, MediaType.APPLICATION_YAML); + } + } catch (OpenApiConfigurationException | JsonProcessingException e) { + throw new RuntimeException(e); + } + } + + void attach(Router router, String path) { + router.attach(path, this); + } +} diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java new file mode 100644 index 0000000000..0b1737c853 --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java @@ -0,0 +1,20 @@ +package org.restlet.ext.openapi; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.oas.models.responses.ApiResponses; + +class Operations { + private Operations() { + } + + static void addApiResponse(Operation operation, String apiResponseName, ApiResponse apiResponse) { + if (operation.getResponses() == null) { + operation.responses( + new ApiResponses().addApiResponse(apiResponseName, apiResponse) + ); + } else { + operation.getResponses().addApiResponse(apiResponseName, apiResponse); + } + } +} diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java new file mode 100644 index 0000000000..a948dc5263 --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java @@ -0,0 +1,28 @@ +package org.restlet.ext.openapi; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import org.restlet.data.Method; + +class PathItems { + private PathItems() { + } + + static void setOperation(PathItem pathItem, Method restletMethod, Operation operation) { + if (restletMethod.equals(Method.POST)) { + pathItem.setPost(operation); + } else if (restletMethod.equals(Method.GET)) { + pathItem.setGet(operation); + } else if (restletMethod.equals(Method.DELETE)) { + pathItem.setDelete(operation); + } else if (restletMethod.equals(Method.PUT)) { + pathItem.setPut(operation); + } else if (restletMethod.equals(Method.PATCH)) { + pathItem.setPatch(operation); + } else if (restletMethod.equals(Method.OPTIONS)) { + pathItem.setOptions(operation); + } else { + throw new IllegalArgumentException("Unsupported Restlet Method: " + restletMethod.getName()); + } + } +} diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java new file mode 100644 index 0000000000..3f3c169087 --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java @@ -0,0 +1,80 @@ +package org.restlet.ext.openapi; + +import org.restlet.Application; +import org.restlet.Restlet; +import org.restlet.routing.Filter; +import org.restlet.routing.Router; + +public class RestletOpenApiApplication extends Application { + /** + * Default path for the OpenAPI specification. Can be overridden by overriding the + * {@link #getOpenapiSpecificationPath()} method. + */ + private static final String OPENAPI_SPECIFICATION_DEFAULT_PATH = "/openapi"; + + /** + * Indicates if this application has already been documented or not. + */ + private volatile boolean documented; + + @Override + public Restlet getInboundRoot() { + Restlet inboundRoot = super.getInboundRoot(); + + if (!documented) { + synchronized (this) { + if (!documented) { + Router rootRouter = getNextRouter(inboundRoot); + + if (!documented && rootRouter != null) { + attachOpenApiSpecificationRestlet(rootRouter); + documented = true; + } + + } + } + } + + return inboundRoot; + } + + /** + * Path where the OpenAPI specification will be available. By default, it is "/openapi". + */ + protected String getOpenapiSpecificationPath() { + return OPENAPI_SPECIFICATION_DEFAULT_PATH; + } + + /** + * Returns the next router available. + * + * @param current The current Restlet to inspect. + * @return The first router available. + */ + private static Router getNextRouter(Restlet current) { + Router result = null; + if (current instanceof Router) { + result = (Router) current; + } else if (current instanceof Filter) { + result = getNextRouter(((Filter) current).getNext()); + } + + return result; + } + + private void attachOpenApiSpecificationRestlet(Router router) { + getOpenApiSpecificationRestlet(router).attach(router, getOpenapiSpecificationPath()); + documented = true; + } + + /** + * The dedicated {@link Restlet} able to generate the Swagger specification formats. + * + * @return The {@link Restlet} able to generate the Swagger specification formats. + */ + OpenApiSpecificationRestlet getOpenApiSpecificationRestlet( + Router router + ) { + return new OpenApiSpecificationRestlet(router); + } +} diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java new file mode 100644 index 0000000000..1d1a438d48 --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java @@ -0,0 +1,35 @@ +package org.restlet.ext.openapi; + +import io.swagger.v3.oas.integration.GenericOpenApiContext; +import io.swagger.v3.oas.integration.api.OpenAPIConfiguration; +import io.swagger.v3.oas.integration.api.OpenApiContext; +import io.swagger.v3.oas.integration.api.OpenApiReader; +import org.apache.commons.lang3.StringUtils; +import org.restlet.routing.Router; + +class RestletOpenApiContext extends GenericOpenApiContext implements OpenApiContext { + private final Router router; + + RestletOpenApiContext(Router router) { + this.router = router; + } + + @Override + protected OpenApiReader buildReader(OpenAPIConfiguration openApiConfiguration) throws Exception { + OpenApiReader reader; + + if (StringUtils.isNotBlank(openApiConfiguration.getReaderClass())) { + Class cls = getClass().getClassLoader().loadClass(openApiConfiguration.getReaderClass()); + reader = (OpenApiReader) cls.getDeclaredConstructor().newInstance(); + } else { + reader = new RestletOpenApiReader(); + } + + if (reader instanceof RestletOpenApiReader restletOpenApiReader) { + restletOpenApiReader.setRouter(router); + } + + reader.setConfiguration(openApiConfiguration); + return reader; + } +} diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java new file mode 100644 index 0000000000..d93edcfadf --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java @@ -0,0 +1,42 @@ +package org.restlet.ext.openapi; + +import io.swagger.v3.oas.integration.GenericOpenApiContextBuilder; +import io.swagger.v3.oas.integration.OpenApiConfigurationException; +import io.swagger.v3.oas.integration.OpenApiContextLocator; +import io.swagger.v3.oas.integration.api.OpenApiContext; +import org.apache.commons.lang3.StringUtils; +import org.restlet.routing.Router; + +class RestletOpenApiContextBuilder extends GenericOpenApiContextBuilder { + private Router router; + + RestletOpenApiContextBuilder router(Router router) { + this.router = router; + return this; + } + + @Override + public OpenApiContext buildContext(boolean init) throws OpenApiConfigurationException { + if (StringUtils.isBlank(ctxId)) { + ctxId = OpenApiContext.OPENAPI_CONTEXT_ID_DEFAULT; + } + + OpenApiContext ctx = OpenApiContextLocator.getInstance().getOpenApiContext(ctxId); + + if (ctx == null) { + OpenApiContext rootCtx = OpenApiContextLocator.getInstance() + .getOpenApiContext(OpenApiContext.OPENAPI_CONTEXT_ID_DEFAULT); + + ctx = new RestletOpenApiContext(router) + .id(ctxId) + .openApiConfiguration(openApiConfiguration) + .parent(rootCtx); + + if (init) { + ctx.init(); + } + } + + return ctx; + } +} diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java new file mode 100644 index 0000000000..f2b9069676 --- /dev/null +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java @@ -0,0 +1,293 @@ +package org.restlet.ext.openapi; + +import io.swagger.v3.core.converter.AnnotatedType; +import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; +import io.swagger.v3.core.util.ReflectionUtils; +import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.integration.api.OpenAPIConfiguration; +import io.swagger.v3.oas.integration.api.OpenApiReader; +import io.swagger.v3.oas.models.Components; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.responses.ApiResponse; +import org.restlet.engine.resource.AnnotationInfo; +import org.restlet.engine.resource.AnnotationUtils; +import org.restlet.engine.resource.MethodAnnotationInfo; +import org.restlet.representation.Variant; +import org.restlet.resource.Finder; +import org.restlet.resource.ResourceException; +import org.restlet.resource.ServerResource; +import org.restlet.routing.Route; +import org.restlet.routing.Router; +import org.restlet.routing.TemplateRoute; +import org.restlet.service.MetadataService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; + +class RestletOpenApiReader implements OpenApiReader { + private static final Logger LOGGER = LoggerFactory.getLogger(RestletOpenApiReader.class); + + private final MetadataService metadataService = new MetadataService(); + + private Router router; + + private OpenAPIConfiguration config; + + private Paths paths = new Paths(); + + private OpenAPI openAPI = new OpenAPI(); + + private Components components = new Components(); + + public void setRouter(Router router) { + this.router = router; + } + + @Override + public void setConfiguration(OpenAPIConfiguration openApiConfiguration) { + this.config = openApiConfiguration; + } + + @Override + public OpenAPI read(Set> classes, Map resources) { + return processRouter(router); + } + + private OpenAPI processRouter(Router router) { + OpenAPIDefinition openAPIDefinitionAnnotation = ReflectionUtils.getAnnotation( + router.getApplication().getClass(), + OpenAPIDefinition.class + ); + + if (openAPIDefinitionAnnotation != null) { + OpenApiAnnotationProcessor.documentOpenApiDefinition(openAPI, openAPIDefinitionAnnotation); + } + + List allRoutes = new ArrayList<>(router.getRoutes()); + if (router.getDefaultRoute() != null) { + allRoutes.add(router.getDefaultRoute()); + } + + for (Route route : allRoutes) { + processRoute(route); + } + + openAPI.setComponents(components); + + return openAPI; + } + + private void processRoute(Route route) { + if (route instanceof TemplateRoute templateRoute) { + String path = templateRoute.getTemplate().getPattern(); + + if (route.getNext() instanceof Finder finder) { + ServerResource serverResource = finder.find(null, null); + + if (serverResource != null) { + List pathVariableNames = templateRoute.getTemplate().getVariableNames(); + + processServerResource(serverResource, path, pathVariableNames); + } + } + } else { + LOGGER.info("Route type ignored: {}", route.getClass()); + } + } + + private void processServerResource( + ServerResource serverResource, + String operationPath, + List pathVariableNames + ) { + List annotations = serverResource.isAnnotated() + ? AnnotationUtils.getInstance().getAnnotations(serverResource.getClass()) + : null; + + if (annotations == null) { + return; + } + + for (AnnotationInfo annotationInfo : annotations) { + if (annotationInfo instanceof MethodAnnotationInfo methodAnnotationInfo) { + PathItem pathItem = Optional.ofNullable(openAPI.getPaths()) + .map(paths -> paths.get(operationPath)) + .orElseGet(PathItem::new); + + Operation operation = buildOperationFromRestletMethod(methodAnnotationInfo); + + completePathParameters(operation, pathVariableNames); + completeOperation(serverResource, operation, methodAnnotationInfo); + + PathItems.setOperation(pathItem, methodAnnotationInfo.getRestletMethod(), operation); + + paths.addPathItem(operationPath, pathItem); + if (openAPI.getPaths() != null) { + this.paths.putAll(openAPI.getPaths()); + } + + openAPI.setPaths(this.paths); + } + } + } + + private void completePathParameters( + Operation operation, + List pathVariableNames + ) { + if (pathVariableNames != null) { + for (String pathVariableName : pathVariableNames) { + operation.addParametersItem( + new io.swagger.v3.oas.models.parameters.Parameter() + .name(pathVariableName) + .in("path") + .required(true) + ); + } + } + } + + private Operation buildOperationFromRestletMethod( + final MethodAnnotationInfo methodAnnotationInfo + ) { + Operation operation = new Operation(); + operation.setOperationId(methodAnnotationInfo.getJavaMethod().getName()); + return operation; + } + + private void completeOperation( + final ServerResource serverResource, + final Operation operation, + MethodAnnotationInfo methodAnnotationInfo + ) { + try { + var methodOperationAnnotation = ReflectionUtils.getAnnotation( + methodAnnotationInfo.getJavaMethod(), + io.swagger.v3.oas.annotations.Operation.class + ); + + if (methodOperationAnnotation != null) { + OpenApiAnnotationProcessor.documentOperation(operation, methodOperationAnnotation); + } + + completeOperationInput(serverResource, operation, methodAnnotationInfo); + completeOperationSuccessfulOutput(serverResource, operation, methodAnnotationInfo); + } catch (IOException e) { + throw new ResourceException(e); + } + } + + private void completeOperationInput( + ServerResource serverResource, + Operation operation, + MethodAnnotationInfo methodAnnotationInfo + ) throws IOException { + Type[] parameterTypes = methodAnnotationInfo.getJavaMethod().getGenericParameterTypes(); + if (parameterTypes.length == 0) { + return; + } + + Type firstParameterType = parameterTypes[0]; + + List requestVariants = methodAnnotationInfo.getRequestVariants( + metadataService, + serverResource.getConverterService() + ); + + if (requestVariants == null || requestVariants.isEmpty()) { + return; + } + + Variant firstVariant = requestVariants.getFirst(); + + processTypeToContent(firstParameterType, List.of(firstVariant)) + .ifPresent(content -> { + operation.requestBody( + new io.swagger.v3.oas.models.parameters.RequestBody() + .content(content) + ); + }); + } + + private void completeOperationSuccessfulOutput( + ServerResource serverResource, + Operation operation, + MethodAnnotationInfo methodAnnotationInfo + ) throws IOException { + List responseVariants = methodAnnotationInfo.getResponseVariants( + metadataService, + serverResource.getConverterService() + ); + + Type javaMethodReturnType = methodAnnotationInfo.getJavaMethod().getGenericReturnType(); + + boolean shouldIgnoreClass = methodAnnotationInfo.getJavaMethod().getReturnType() == Void.class + || methodAnnotationInfo.getJavaMethod().getReturnType() == void.class; + + if (!shouldIgnoreClass) { + processMethodReturnType(operation, javaMethodReturnType, responseVariants); + } + } + + private void processMethodReturnType( + Operation operation, + Type returnType, + List responseVariants + ) { + Variant firstVariant = responseVariants.getFirst(); + + processTypeToContent(returnType, List.of(firstVariant)) + .ifPresent(content -> Operations.addApiResponse( + operation, + "200", + new ApiResponse().content(content) + )); + } + + private Optional processTypeToContent(Type type, List variants) { + ResolvedSchema resolvedSchema = ModelConverters.getInstance(config.toConfiguration()) + .resolveAsResolvedSchema( + new AnnotatedType(type) + .resolveAsRef(true) + .components(components) + ); + + if (resolvedSchema.schema == null) { + return Optional.empty(); + } + + Content content = new Content(); + MediaType mediaType = new MediaType().schema(resolvedSchema.schema); + + for (Variant variant : variants) { + if (variant.getMediaType() == null) { + LOGGER.warn("Variant has no media type: {}", variant); + continue; + } + + content.addMediaType(variant.getMediaType().toString(), mediaType); + } + + @SuppressWarnings("rawtypes") // Imposed by the ModelConverters API + Map schemaMap = resolvedSchema.referencedSchemas; + if (schemaMap != null) { + schemaMap.forEach((key, schema) -> components.addSchemas(key, schema)); + } + + return Optional.of(content); + } +} diff --git a/pom.xml b/pom.xml index 9b16b769e9..3b0169772f 100644 --- a/pom.xml +++ b/pom.xml @@ -62,6 +62,7 @@ 4.3.1 2.0.17 6.2.8 + 2.2.40 3.1.3.RELEASE 2.4.1 @@ -76,6 +77,7 @@ org.restlet.ext.jaas org.restlet.ext.jackson org.restlet.ext.json + org.restlet.ext.openapi org.restlet.ext.slf4j org.restlet.ext.spring org.restlet.ext.thymeleaf From e0c3778fd4e6044d332196733af76a93d733083b Mon Sep 17 00:00:00 2001 From: Antoine Nicolas Date: Wed, 18 Feb 2026 17:25:16 +0400 Subject: [PATCH 2/6] Add end-to-end test and make sure API spec is valid --- org.restlet.ext.openapi/pom.xml | 27 ++++++++-- .../openapi/RestletOpenApiApplication.java | 2 +- .../ext/openapi/RestletOpenApiReader.java | 24 ++++++++- .../ext/openapi/LibraryApplication.java | 31 +++++++++++ .../ext/openapi/OpenApiGenerationTest.java | 54 +++++++++++++++++++ .../ext/openapi/OpenApiSpecifications.java | 36 +++++++++++++ .../src/test/resources/library-openapi.yaml | 28 ++++++++++ pom.xml | 6 ++- 8 files changed, 201 insertions(+), 7 deletions(-) create mode 100644 org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/LibraryApplication.java create mode 100644 org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java create mode 100644 org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java create mode 100644 org.restlet.ext.openapi/src/test/resources/library-openapi.yaml diff --git a/org.restlet.ext.openapi/pom.xml b/org.restlet.ext.openapi/pom.xml index 421db26713..6f42eb1357 100644 --- a/org.restlet.ext.openapi/pom.xml +++ b/org.restlet.ext.openapi/pom.xml @@ -19,19 +19,19 @@ io.swagger.core.v3 swagger-core - ${lib-swagger-version} + ${lib-swagger-core-version} io.swagger.core.v3 swagger-annotations - ${lib-swagger-version} + ${lib-swagger-core-version} io.swagger.core.v3 swagger-integration - ${lib-swagger-version} + ${lib-swagger-core-version} @@ -39,6 +39,27 @@ org.restlet ${project.version} + + + org.junit.jupiter + junit-jupiter-api + ${lib-junit-version} + test + + + + org.restlet + org.restlet.ext.jackson + ${project.version} + test + + + + io.swagger.parser.v3 + swagger-parser + ${lib-swagger-parser-version} + test + diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java index 3f3c169087..d6d72d0dfc 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java @@ -10,7 +10,7 @@ public class RestletOpenApiApplication extends Application { * Default path for the OpenAPI specification. Can be overridden by overriding the * {@link #getOpenapiSpecificationPath()} method. */ - private static final String OPENAPI_SPECIFICATION_DEFAULT_PATH = "/openapi"; + static final String OPENAPI_SPECIFICATION_DEFAULT_PATH = "/openapi"; /** * Indicates if this application has already been documented or not. diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java index f2b9069676..622c65e16f 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; @@ -77,6 +78,8 @@ private OpenAPI processRouter(Router router) { OpenApiAnnotationProcessor.documentOpenApiDefinition(openAPI, openAPIDefinitionAnnotation); } + completeOpenApiInfo(); + List allRoutes = new ArrayList<>(router.getRoutes()); if (router.getDefaultRoute() != null) { allRoutes.add(router.getDefaultRoute()); @@ -145,6 +148,19 @@ private void processServerResource( } } + private void completeOpenApiInfo() { + if (openAPI.getInfo() == null) { + openAPI.setInfo(new Info() + .title("Generated API") + .version("1.0.0") + ); + } else if (openAPI.getInfo().getTitle() == null) { + openAPI.getInfo().setTitle("Generated API"); + } else if (openAPI.getInfo().getVersion() == null) { + openAPI.getInfo().setVersion("1.0.0"); + } + } + private void completePathParameters( Operation operation, List pathVariableNames @@ -248,13 +264,19 @@ private void processMethodReturnType( Type returnType, List responseVariants ) { + if (responseVariants == null || responseVariants.isEmpty()) { + return; + } + Variant firstVariant = responseVariants.getFirst(); processTypeToContent(returnType, List.of(firstVariant)) .ifPresent(content -> Operations.addApiResponse( operation, "200", - new ApiResponse().content(content) + new ApiResponse() + .content(content) + .description("Successful response") )); } diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/LibraryApplication.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/LibraryApplication.java new file mode 100644 index 0000000000..1f5d49a68b --- /dev/null +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/LibraryApplication.java @@ -0,0 +1,31 @@ +package org.restlet.ext.openapi; + +import org.restlet.resource.Get; +import org.restlet.resource.ServerResource; +import org.restlet.routing.Router; + +import java.util.List; + +public class LibraryApplication { + public record Book(String id, String title, String author) { + } + + public static class BooksResource extends ServerResource { + @Get + public List getBooks() { + return List.of( + new Book("1", "The Great Gatsby", "F. Scott Fitzgerald"), + new Book("2", "To Kill a Mockingbird", "Harper Lee") + ); + } + } + + public static class Restlet extends RestletOpenApiApplication { + @Override + public org.restlet.Restlet createInboundRoot() { + var router = new Router(getContext()); + router.attach("/books", BooksResource.class); + return router; + } + } +} diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java new file mode 100644 index 0000000000..520f2ed93e --- /dev/null +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java @@ -0,0 +1,54 @@ +package org.restlet.ext.openapi; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.fail; +import static org.restlet.ext.openapi.RestletOpenApiApplication.OPENAPI_SPECIFICATION_DEFAULT_PATH; + +import org.junit.jupiter.api.Test; +import org.restlet.Component; +import org.restlet.data.Protocol; +import org.restlet.ext.openapi.OpenApiSpecifications.ValidationResult.Invalid; +import org.restlet.representation.Representation; +import org.restlet.resource.ClientResource; + +public class OpenApiGenerationTest { + + @Test + public void testLibraryApplicationOpenApi() throws Exception { + var application = new LibraryApplication.Restlet(); + var component = new Component(); + + var server = component.getServers().add( + Protocol.HTTP, + 0 // 0 = let the OS find an ephemeral port + ); + + component.getDefaultHost().attach(application); + component.start(); + + int actualPort = server.getEphemeralPort(); + + System.out.println("Server started on: http://localhost:" + actualPort); + + ClientResource clientResource = new ClientResource( + "http://localhost:" + actualPort + OPENAPI_SPECIFICATION_DEFAULT_PATH + ); + + Representation representation = clientResource.get(); + + if (!clientResource.getStatus().isSuccess()) { + fail("Failed to retrieve OpenAPI specification: " + clientResource.getStatus()); + } + + String actualYamlResponse = representation.getText(); + String expectedYamlResponse = OpenApiSpecifications.readFromClasspath("/library-openapi.yaml"); + + assertEquals(expectedYamlResponse, actualYamlResponse); + + var validationResult = OpenApiSpecifications.validate(actualYamlResponse); + + if (validationResult instanceof Invalid(var validationErrors)) { + fail("Generated OpenAPI specification is invalid: " + validationErrors); + } + } +} diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java new file mode 100644 index 0000000000..486259d42b --- /dev/null +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java @@ -0,0 +1,36 @@ +package org.restlet.ext.openapi; + +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.SwaggerParseResult; + +import java.util.List; + +class OpenApiSpecifications { + + static String readFromClasspath(String resourcePath) throws Exception { + try (var inputStream = OpenApiSpecifications.class.getResourceAsStream(resourcePath)) { + if (inputStream == null) { + throw new IllegalArgumentException("Resource not found in classpath: " + resourcePath); + } + return new String(inputStream.readAllBytes()); + } + } + + static ValidationResult validate(String yamlSpecification) throws Exception { + SwaggerParseResult result = new OpenAPIV3Parser().readContents(yamlSpecification, null, null); + + if (result.getMessages().isEmpty()) { + return new ValidationResult.Valid(); + } else { + return new ValidationResult.Invalid(result.getMessages()); + } + } + + sealed interface ValidationResult { + record Valid() implements ValidationResult { + } + + record Invalid(List errors) implements ValidationResult { + } + } +} diff --git a/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml new file mode 100644 index 0000000000..b0955c3255 --- /dev/null +++ b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml @@ -0,0 +1,28 @@ +openapi: 3.0.1 +info: + title: Generated API + version: 1.0.0 +paths: + /books: + get: + operationId: getBooks + responses: + "200": + description: Successful response + content: + application/json: + schema: + type: array + items: + $ref: "#/components/schemas/Book" +components: + schemas: + Book: + type: object + properties: + id: + type: string + title: + type: string + author: + type: string diff --git a/pom.xml b/pom.xml index 3b0169772f..c3b4e5e535 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,8 @@ 4.3.1 2.0.17 6.2.8 - 2.2.40 + 2.2.40 + 2.1.38 3.1.3.RELEASE 2.4.1 @@ -223,7 +224,8 @@ - + false ${release} From b80e0abcb3f6300c1ffb18d5bb16da1198e14b70 Mon Sep 17 00:00:00 2001 From: Antoine Nicolas Date: Wed, 18 Feb 2026 19:17:04 +0400 Subject: [PATCH 3/6] Switch to OpenAPI 3.1.0 --- .../ext/openapi/RestletOpenApiReader.java | 21 ++++++++++++++----- .../{LibraryApplication.java => Library.java} | 4 ++-- .../ext/openapi/OpenApiGenerationTest.java | 2 +- .../src/test/resources/library-openapi.yaml | 6 +++--- pom.xml | 2 +- 5 files changed, 23 insertions(+), 12 deletions(-) rename org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/{LibraryApplication.java => Library.java} (88%) diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java index 622c65e16f..46f3bfbd78 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; import io.swagger.v3.oas.models.Paths; +import io.swagger.v3.oas.models.SpecVersion; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.media.Content; import io.swagger.v3.oas.models.media.MediaType; @@ -78,7 +79,7 @@ private OpenAPI processRouter(Router router) { OpenApiAnnotationProcessor.documentOpenApiDefinition(openAPI, openAPIDefinitionAnnotation); } - completeOpenApiInfo(); + completeOpenApiInfo(router); List allRoutes = new ArrayList<>(router.getRoutes()); if (router.getDefaultRoute() != null) { @@ -90,6 +91,8 @@ private OpenAPI processRouter(Router router) { } openAPI.setComponents(components); + openAPI.setOpenapi("3.1.0"); + openAPI.setSpecVersion(SpecVersion.V31); return openAPI; } @@ -148,14 +151,22 @@ private void processServerResource( } } - private void completeOpenApiInfo() { + private void completeOpenApiInfo(Router router) { + var applicationClassName = router.getApplication().getClass().getSimpleName(); + + var applicationName = applicationClassName.endsWith("Application") + ? applicationClassName.substring(0, applicationClassName.length() - "Application".length()) + : applicationClassName; + + var defaultTitle = applicationName + " REST API"; + if (openAPI.getInfo() == null) { openAPI.setInfo(new Info() - .title("Generated API") + .title(defaultTitle) .version("1.0.0") ); } else if (openAPI.getInfo().getTitle() == null) { - openAPI.getInfo().setTitle("Generated API"); + openAPI.getInfo().setTitle(defaultTitle); } else if (openAPI.getInfo().getVersion() == null) { openAPI.getInfo().setVersion("1.0.0"); } @@ -276,7 +287,7 @@ private void processMethodReturnType( "200", new ApiResponse() .content(content) - .description("Successful response") + .description("Success") )); } diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/LibraryApplication.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java similarity index 88% rename from org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/LibraryApplication.java rename to org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java index 1f5d49a68b..017a7a4610 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/LibraryApplication.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java @@ -6,7 +6,7 @@ import java.util.List; -public class LibraryApplication { +public class Library { public record Book(String id, String title, String author) { } @@ -20,7 +20,7 @@ public List getBooks() { } } - public static class Restlet extends RestletOpenApiApplication { + public static class LibraryApplication extends RestletOpenApiApplication { @Override public org.restlet.Restlet createInboundRoot() { var router = new Router(getContext()); diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java index 520f2ed93e..b5e937f18c 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java @@ -15,7 +15,7 @@ public class OpenApiGenerationTest { @Test public void testLibraryApplicationOpenApi() throws Exception { - var application = new LibraryApplication.Restlet(); + var application = new Library.LibraryApplication(); var component = new Component(); var server = component.getServers().add( diff --git a/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml index b0955c3255..1dc8ac1124 100644 --- a/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml +++ b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml @@ -1,6 +1,6 @@ -openapi: 3.0.1 +openapi: 3.1.0 info: - title: Generated API + title: Library REST API version: 1.0.0 paths: /books: @@ -8,7 +8,7 @@ paths: operationId: getBooks responses: "200": - description: Successful response + description: Success content: application/json: schema: diff --git a/pom.xml b/pom.xml index c3b4e5e535..2e2df7d70f 100644 --- a/pom.xml +++ b/pom.xml @@ -62,7 +62,7 @@ 4.3.1 2.0.17 6.2.8 - 2.2.40 + 2.2.43 2.1.38 3.1.3.RELEASE 2.4.1 From a147c4619903ba8973dae072d7eee20816f27f9e Mon Sep 17 00:00:00 2001 From: Antoine Nicolas Date: Tue, 24 Feb 2026 11:54:35 +0400 Subject: [PATCH 4/6] :eyes: code-review comments --- .../openapi/OpenApiAnnotationProcessor.java | 15 ++++- ...plication.java => OpenApiApplication.java} | 17 +++-- .../openapi/OpenApiSpecificationRestlet.java | 13 +++- .../org/restlet/ext/openapi/Operations.java | 13 +++- .../org/restlet/ext/openapi/PathItems.java | 13 +++- .../ext/openapi/RestletOpenApiContext.java | 13 +++- .../openapi/RestletOpenApiContextBuilder.java | 13 +++- .../ext/openapi/RestletOpenApiReader.java | 67 ++++++++++--------- .../java/org/restlet/ext/openapi/Library.java | 12 +++- .../ext/openapi/OpenApiGenerationTest.java | 11 ++- .../ext/openapi/OpenApiSpecifications.java | 12 +++- 11 files changed, 148 insertions(+), 51 deletions(-) rename org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/{RestletOpenApiApplication.java => OpenApiApplication.java} (82%) diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java index ccc8d897fd..c0c774b509 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import io.swagger.v3.core.util.AnnotationsUtils; @@ -8,9 +17,9 @@ import java.util.Optional; -class OpenApiAnnotationProcessor { +public class OpenApiAnnotationProcessor { - static void documentOpenApiDefinition( + public static void documentOpenApiDefinition( OpenAPI openAPIDefinition, OpenAPIDefinition openAPIDefinitionAnnotation ) { @@ -20,7 +29,7 @@ static void documentOpenApiDefinition( } } - static void documentOperation( + public static void documentOperation( Operation operation, io.swagger.v3.oas.annotations.Operation operationAnnotation ) { diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiApplication.java similarity index 82% rename from org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java rename to org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiApplication.java index d6d72d0dfc..da344d5acd 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiApplication.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiApplication.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import org.restlet.Application; @@ -5,10 +14,10 @@ import org.restlet.routing.Filter; import org.restlet.routing.Router; -public class RestletOpenApiApplication extends Application { +public class OpenApiApplication extends Application { /** * Default path for the OpenAPI specification. Can be overridden by overriding the - * {@link #getOpenapiSpecificationPath()} method. + * {@link #getOpenApiSpecificationPath()} method. */ static final String OPENAPI_SPECIFICATION_DEFAULT_PATH = "/openapi"; @@ -41,7 +50,7 @@ public Restlet getInboundRoot() { /** * Path where the OpenAPI specification will be available. By default, it is "/openapi". */ - protected String getOpenapiSpecificationPath() { + protected String getOpenApiSpecificationPath() { return OPENAPI_SPECIFICATION_DEFAULT_PATH; } @@ -63,7 +72,7 @@ private static Router getNextRouter(Restlet current) { } private void attachOpenApiSpecificationRestlet(Router router) { - getOpenApiSpecificationRestlet(router).attach(router, getOpenapiSpecificationPath()); + getOpenApiSpecificationRestlet(router).attach(router, getOpenApiSpecificationPath()); documented = true; } diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java index 102245467e..ef8c6b5db5 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiSpecificationRestlet.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import com.fasterxml.jackson.core.JsonProcessingException; @@ -19,7 +28,7 @@ import java.util.List; -class OpenApiSpecificationRestlet extends Restlet { +public class OpenApiSpecificationRestlet extends Restlet { private static final VariantInfo VARIANT_JSON = new VariantInfo( MediaType.APPLICATION_JSON ); @@ -30,7 +39,7 @@ class OpenApiSpecificationRestlet extends Restlet { private final Router router; - OpenApiSpecificationRestlet(Router router) { + public OpenApiSpecificationRestlet(Router router) { super(router.getContext()); this.router = router; } diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java index 0b1737c853..f7703f8e9a 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/Operations.java @@ -1,14 +1,23 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.responses.ApiResponse; import io.swagger.v3.oas.models.responses.ApiResponses; -class Operations { +public class Operations { private Operations() { } - static void addApiResponse(Operation operation, String apiResponseName, ApiResponse apiResponse) { + public static void addApiResponse(Operation operation, String apiResponseName, ApiResponse apiResponse) { if (operation.getResponses() == null) { operation.responses( new ApiResponses().addApiResponse(apiResponseName, apiResponse) diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java index a948dc5263..a782237af0 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/PathItems.java @@ -1,14 +1,23 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.PathItem; import org.restlet.data.Method; -class PathItems { +public class PathItems { private PathItems() { } - static void setOperation(PathItem pathItem, Method restletMethod, Operation operation) { + public static void setOperation(PathItem pathItem, Method restletMethod, Operation operation) { if (restletMethod.equals(Method.POST)) { pathItem.setPost(operation); } else if (restletMethod.equals(Method.GET)) { diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java index 1d1a438d48..cbe64f5d83 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContext.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import io.swagger.v3.oas.integration.GenericOpenApiContext; @@ -7,10 +16,10 @@ import org.apache.commons.lang3.StringUtils; import org.restlet.routing.Router; -class RestletOpenApiContext extends GenericOpenApiContext implements OpenApiContext { +public class RestletOpenApiContext extends GenericOpenApiContext implements OpenApiContext { private final Router router; - RestletOpenApiContext(Router router) { + public RestletOpenApiContext(Router router) { this.router = router; } diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java index d93edcfadf..f4a4839eb4 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiContextBuilder.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import io.swagger.v3.oas.integration.GenericOpenApiContextBuilder; @@ -7,10 +16,10 @@ import org.apache.commons.lang3.StringUtils; import org.restlet.routing.Router; -class RestletOpenApiContextBuilder extends GenericOpenApiContextBuilder { +public class RestletOpenApiContextBuilder extends GenericOpenApiContextBuilder { private Router router; - RestletOpenApiContextBuilder router(Router router) { + public RestletOpenApiContextBuilder router(Router router) { this.router = router; return this; } diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java index 46f3bfbd78..c1f5b97e82 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import io.swagger.v3.core.converter.AnnotatedType; @@ -18,6 +27,7 @@ import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.responses.ApiResponse; +import org.restlet.Context; import org.restlet.engine.resource.AnnotationInfo; import org.restlet.engine.resource.AnnotationUtils; import org.restlet.engine.resource.MethodAnnotationInfo; @@ -29,8 +39,6 @@ import org.restlet.routing.Router; import org.restlet.routing.TemplateRoute; import org.restlet.service.MetadataService; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import java.io.IOException; import java.lang.reflect.Type; @@ -40,8 +48,7 @@ import java.util.Optional; import java.util.Set; -class RestletOpenApiReader implements OpenApiReader { - private static final Logger LOGGER = LoggerFactory.getLogger(RestletOpenApiReader.class); +public class RestletOpenApiReader implements OpenApiReader { private final MetadataService metadataService = new MetadataService(); @@ -49,11 +56,11 @@ class RestletOpenApiReader implements OpenApiReader { private OpenAPIConfiguration config; - private Paths paths = new Paths(); + private final Paths paths = new Paths(); - private OpenAPI openAPI = new OpenAPI(); + private final OpenAPI openApi = new OpenAPI(); - private Components components = new Components(); + private final Components components = new Components(); public void setRouter(Router router) { this.router = router; @@ -76,7 +83,7 @@ private OpenAPI processRouter(Router router) { ); if (openAPIDefinitionAnnotation != null) { - OpenApiAnnotationProcessor.documentOpenApiDefinition(openAPI, openAPIDefinitionAnnotation); + OpenApiAnnotationProcessor.documentOpenApiDefinition(openApi, openAPIDefinitionAnnotation); } completeOpenApiInfo(router); @@ -90,11 +97,11 @@ private OpenAPI processRouter(Router router) { processRoute(route); } - openAPI.setComponents(components); - openAPI.setOpenapi("3.1.0"); - openAPI.setSpecVersion(SpecVersion.V31); + openApi.setComponents(components); + openApi.setOpenapi("3.1.0"); + openApi.setSpecVersion(SpecVersion.V31); - return openAPI; + return openApi; } private void processRoute(Route route) { @@ -111,7 +118,7 @@ private void processRoute(Route route) { } } } else { - LOGGER.info("Route type ignored: {}", route.getClass()); + Context.getCurrentLogger().info("Route type ignored: " + route.getClass()); } } @@ -130,7 +137,7 @@ private void processServerResource( for (AnnotationInfo annotationInfo : annotations) { if (annotationInfo instanceof MethodAnnotationInfo methodAnnotationInfo) { - PathItem pathItem = Optional.ofNullable(openAPI.getPaths()) + PathItem pathItem = Optional.ofNullable(openApi.getPaths()) .map(paths -> paths.get(operationPath)) .orElseGet(PathItem::new); @@ -142,11 +149,11 @@ private void processServerResource( PathItems.setOperation(pathItem, methodAnnotationInfo.getRestletMethod(), operation); paths.addPathItem(operationPath, pathItem); - if (openAPI.getPaths() != null) { - this.paths.putAll(openAPI.getPaths()); + if (openApi.getPaths() != null) { + this.paths.putAll(openApi.getPaths()); } - openAPI.setPaths(this.paths); + openApi.setPaths(this.paths); } } } @@ -160,15 +167,15 @@ private void completeOpenApiInfo(Router router) { var defaultTitle = applicationName + " REST API"; - if (openAPI.getInfo() == null) { - openAPI.setInfo(new Info() + if (openApi.getInfo() == null) { + openApi.setInfo(new Info() .title(defaultTitle) .version("1.0.0") ); - } else if (openAPI.getInfo().getTitle() == null) { - openAPI.getInfo().setTitle(defaultTitle); - } else if (openAPI.getInfo().getVersion() == null) { - openAPI.getInfo().setVersion("1.0.0"); + } else if (openApi.getInfo().getTitle() == null) { + openApi.getInfo().setTitle(defaultTitle); + } else if (openApi.getInfo().getVersion() == null) { + openApi.getInfo().setVersion("1.0.0"); } } @@ -242,12 +249,10 @@ private void completeOperationInput( Variant firstVariant = requestVariants.getFirst(); processTypeToContent(firstParameterType, List.of(firstVariant)) - .ifPresent(content -> { - operation.requestBody( - new io.swagger.v3.oas.models.parameters.RequestBody() - .content(content) - ); - }); + .ifPresent(content -> operation.requestBody( + new io.swagger.v3.oas.models.parameters.RequestBody() + .content(content) + )); } private void completeOperationSuccessfulOutput( @@ -308,7 +313,7 @@ private Optional processTypeToContent(Type type, List variants for (Variant variant : variants) { if (variant.getMediaType() == null) { - LOGGER.warn("Variant has no media type: {}", variant); + Context.getCurrentLogger().warning("Variant has no media type: " + variant); continue; } @@ -318,7 +323,7 @@ private Optional processTypeToContent(Type type, List variants @SuppressWarnings("rawtypes") // Imposed by the ModelConverters API Map schemaMap = resolvedSchema.referencedSchemas; if (schemaMap != null) { - schemaMap.forEach((key, schema) -> components.addSchemas(key, schema)); + schemaMap.forEach(components::addSchemas); } return Optional.of(content); diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java index 017a7a4610..cce002737a 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import org.restlet.resource.Get; @@ -11,6 +20,7 @@ public record Book(String id, String title, String author) { } public static class BooksResource extends ServerResource { + @SuppressWarnings("unused") @Get public List getBooks() { return List.of( @@ -20,7 +30,7 @@ public List getBooks() { } } - public static class LibraryApplication extends RestletOpenApiApplication { + public static class LibraryApplication extends OpenApiApplication { @Override public org.restlet.Restlet createInboundRoot() { var router = new Router(getContext()); diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java index b5e937f18c..bb98e472b0 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java @@ -1,8 +1,17 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.fail; -import static org.restlet.ext.openapi.RestletOpenApiApplication.OPENAPI_SPECIFICATION_DEFAULT_PATH; +import static org.restlet.ext.openapi.OpenApiApplication.OPENAPI_SPECIFICATION_DEFAULT_PATH; import org.junit.jupiter.api.Test; import org.restlet.Component; diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java index 486259d42b..efed16b141 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiSpecifications.java @@ -1,3 +1,12 @@ +/** + * Copyright 2005-2026 Qlik + * + * The contents of this file is subject to the terms of the Apache 2.0 open source license available at + * http://www.opensource.org/licenses/apache-2.0 + * + * Restlet is a registered trademark of QlikTech International AB. + */ + package org.restlet.ext.openapi; import io.swagger.v3.parser.OpenAPIV3Parser; @@ -7,6 +16,7 @@ class OpenApiSpecifications { + @SuppressWarnings("SameParameterValue") static String readFromClasspath(String resourcePath) throws Exception { try (var inputStream = OpenApiSpecifications.class.getResourceAsStream(resourcePath)) { if (inputStream == null) { @@ -16,7 +26,7 @@ static String readFromClasspath(String resourcePath) throws Exception { } } - static ValidationResult validate(String yamlSpecification) throws Exception { + static ValidationResult validate(String yamlSpecification) { SwaggerParseResult result = new OpenAPIV3Parser().readContents(yamlSpecification, null, null); if (result.getMessages().isEmpty()) { From 76fb404422fde18302b74e67b462a3ae54455d77 Mon Sep 17 00:00:00 2001 From: Antoine Nicolas Date: Tue, 24 Feb 2026 16:49:08 +0400 Subject: [PATCH 5/6] properly read responses from @Operation annotation --- .../openapi/OpenApiAnnotationProcessor.java | 52 +++++++++++++++++++ .../ext/openapi/RestletOpenApiReader.java | 6 +-- .../java/org/restlet/ext/openapi/Library.java | 30 ++++++++++- .../src/test/resources/library-openapi.yaml | 16 ++++++ 4 files changed, 100 insertions(+), 4 deletions(-) diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java index c0c774b509..014a9fef9a 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/OpenApiAnnotationProcessor.java @@ -11,6 +11,8 @@ import io.swagger.v3.core.util.AnnotationsUtils; import io.swagger.v3.oas.annotations.OpenAPIDefinition; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; import io.swagger.v3.oas.models.parameters.Parameter; @@ -47,6 +49,44 @@ public static void documentOperation( .ifPresent(operation::addParametersItem); } } + + if (operationAnnotation.responses() != null) { + for (ApiResponse apiResponseAnnotation : operationAnnotation.responses()) { + documentOperationResponse(operation, apiResponseAnnotation); + } + } + } + + public static void documentOperationResponse( + Operation operation, + ApiResponse apiResponseAnnotation + ) { + var defaultDescription = apiResponseAnnotation.responseCode() + " response"; + + io.swagger.v3.oas.models.responses.ApiResponse apiResponse = + new io.swagger.v3.oas.models.responses.ApiResponse() + .description(apiResponseAnnotation.description() == null + ? defaultDescription + : apiResponseAnnotation.description().isEmpty() + ? defaultDescription + : apiResponseAnnotation.description() + ); + + if (!isContentEmpty(apiResponseAnnotation)) { + AnnotationsUtils.getContent( + apiResponseAnnotation.content(), + null, + null, + null, + null, + null + ).ifPresent(apiResponse::content); + } + + AnnotationsUtils.getHeaders(apiResponseAnnotation.headers(), null) + .ifPresent(apiResponse::headers); + + Operations.addApiResponse(operation, apiResponseAnnotation.responseCode(), apiResponse); } private static Optional resolveParameterFromAnnotation(io.swagger.v3.oas.annotations.Parameter parameterAnnotation) { @@ -69,4 +109,16 @@ private static Optional resolveParameterFromAnnotation(io.swagger.v3. } }; } + + private static boolean isContentEmpty(ApiResponse apiResponseAnnotation) { + if (apiResponseAnnotation.content() == null || apiResponseAnnotation.content().length == 0) { + return true; + } + + Content content = apiResponseAnnotation.content()[0]; + + return content.mediaType().isEmpty() && + content.schema().implementation() == Void.class && + content.schema().ref().isEmpty(); + } } diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java index c1f5b97e82..6c02f34197 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java @@ -214,12 +214,12 @@ private void completeOperation( io.swagger.v3.oas.annotations.Operation.class ); + completeOperationInput(serverResource, operation, methodAnnotationInfo); + completeOperationSuccessfulOutput(serverResource, operation, methodAnnotationInfo); + if (methodOperationAnnotation != null) { OpenApiAnnotationProcessor.documentOperation(operation, methodOperationAnnotation); } - - completeOperationInput(serverResource, operation, methodAnnotationInfo); - completeOperationSuccessfulOutput(serverResource, operation, methodAnnotationInfo); } catch (IOException e) { throw new ResourceException(e); } diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java index cce002737a..cb1caae338 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java @@ -9,7 +9,15 @@ package org.restlet.ext.openapi; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.headers.Header; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import org.restlet.data.Reference; +import org.restlet.data.Status; import org.restlet.resource.Get; +import org.restlet.resource.Post; import org.restlet.resource.ServerResource; import org.restlet.routing.Router; @@ -19,8 +27,8 @@ public class Library { public record Book(String id, String title, String author) { } + @SuppressWarnings("unused") public static class BooksResource extends ServerResource { - @SuppressWarnings("unused") @Get public List getBooks() { return List.of( @@ -28,6 +36,26 @@ public List getBooks() { new Book("2", "To Kill a Mockingbird", "Harper Lee") ); } + + @Post + @Operation( + summary = "Add a new book", + responses = { + @ApiResponse( + responseCode = "201", + headers = @Header( + name = "Location", + schema = @Schema(type = "string") + ), + content = @Content() + ) + } + ) + public void addBook(Book book) { + getResponse().setStatus(Status.SUCCESS_CREATED); + Reference locationRef = getRequest().getResourceRef().addSegment(book.id); + getResponse().setLocationRef(locationRef); + } } public static class LibraryApplication extends OpenApiApplication { diff --git a/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml index 1dc8ac1124..2ec67d6216 100644 --- a/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml +++ b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml @@ -15,6 +15,22 @@ paths: type: array items: $ref: "#/components/schemas/Book" + post: + summary: Add a new book + operationId: addBook + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/Book" + responses: + "201": + description: 201 response + headers: + Location: + style: simple + schema: + type: string components: schemas: Book: From 5d4d16cea55770bfe6b6b288286201ae06548a31 Mon Sep 17 00:00:00 2001 From: Antoine Nicolas Date: Tue, 24 Feb 2026 17:06:58 +0400 Subject: [PATCH 6/6] make sure to generate valid spec --- .../ext/openapi/RestletOpenApiReader.java | 1 + .../java/org/restlet/ext/openapi/Library.java | 22 ++++++++++++++----- .../ext/openapi/OpenApiGenerationTest.java | 19 ++++++++++++++-- .../src/test/resources/library-openapi.yaml | 16 ++++++++++++++ 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java index 6c02f34197..5b152275f7 100644 --- a/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java +++ b/org.restlet.ext.openapi/src/main/java/org/restlet/ext/openapi/RestletOpenApiReader.java @@ -190,6 +190,7 @@ private void completePathParameters( .name(pathVariableName) .in("path") .required(true) + .schema(new Schema<>().type("string")) ); } } diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java index cb1caae338..f54df7b2de 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/Library.java @@ -23,18 +23,29 @@ import java.util.List; +@SuppressWarnings("unused") public class Library { + private static final List BOOKS = List.of( + new Book("1", "The Great Gatsby", "F. Scott Fitzgerald"), + new Book("2", "To Kill a Mockingbird", "Harper Lee") + ); + public record Book(String id, String title, String author) { } - @SuppressWarnings("unused") + public static class BookResource extends ServerResource { + @Get + public Book getBook() { + String bookId = getAttribute("bookId"); + getContext().getLogger().info("Retrieving book with ID: " + bookId); + return BOOKS.getFirst(); + } + } + public static class BooksResource extends ServerResource { @Get public List getBooks() { - return List.of( - new Book("1", "The Great Gatsby", "F. Scott Fitzgerald"), - new Book("2", "To Kill a Mockingbird", "Harper Lee") - ); + return BOOKS; } @Post @@ -63,6 +74,7 @@ public static class LibraryApplication extends OpenApiApplication { public org.restlet.Restlet createInboundRoot() { var router = new Router(getContext()); router.attach("/books", BooksResource.class); + router.attach("/books/{bookId}", BookResource.class); return router; } } diff --git a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java index bb98e472b0..c799bb235a 100644 --- a/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java +++ b/org.restlet.ext.openapi/src/test/java/org/restlet/ext/openapi/OpenApiGenerationTest.java @@ -13,6 +13,9 @@ import static org.junit.jupiter.api.Assertions.fail; import static org.restlet.ext.openapi.OpenApiApplication.OPENAPI_SPECIFICATION_DEFAULT_PATH; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import org.junit.jupiter.api.Test; import org.restlet.Component; import org.restlet.data.Protocol; @@ -21,6 +24,7 @@ import org.restlet.resource.ClientResource; public class OpenApiGenerationTest { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(new YAMLFactory()); @Test public void testLibraryApplicationOpenApi() throws Exception { @@ -49,8 +53,10 @@ public void testLibraryApplicationOpenApi() throws Exception { fail("Failed to retrieve OpenAPI specification: " + clientResource.getStatus()); } - String actualYamlResponse = representation.getText(); - String expectedYamlResponse = OpenApiSpecifications.readFromClasspath("/library-openapi.yaml"); + String actualYamlResponse = parseAndFormatYaml(representation.getText()); + String expectedYamlResponse = parseAndFormatYaml( + OpenApiSpecifications.readFromClasspath("/library-openapi.yaml") + ); assertEquals(expectedYamlResponse, actualYamlResponse); @@ -60,4 +66,13 @@ public void testLibraryApplicationOpenApi() throws Exception { fail("Generated OpenAPI specification is invalid: " + validationErrors); } } + + private String parseAndFormatYaml(String yaml) { + try { + var tree = OBJECT_MAPPER.readTree(yaml); + return OBJECT_MAPPER.writerWithDefaultPrettyPrinter().writeValueAsString(tree); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } } diff --git a/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml index 2ec67d6216..027adfdf8d 100644 --- a/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml +++ b/org.restlet.ext.openapi/src/test/resources/library-openapi.yaml @@ -31,6 +31,22 @@ paths: style: simple schema: type: string + /books/{bookId}: + get: + operationId: getBook + parameters: + - name: bookId + in: path + required: true + schema: + type: string + responses: + "200": + description: Success + content: + application/json: + schema: + $ref: "#/components/schemas/Book" components: schemas: Book: