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