From d6887ece9a4afd93b6165be662f3e242f8bfa134 Mon Sep 17 00:00:00 2001 From: Antoine Nicolas Date: Fri, 13 Feb 2026 11:06:09 +0400 Subject: [PATCH 1/3] 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/3] 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/3] 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