diff --git a/.tool-versions b/.tool-versions new file mode 100644 index 000000000000..b1207eb79500 --- /dev/null +++ b/.tool-versions @@ -0,0 +1,3 @@ +// .tool-versions configures the asdf version manager https://asdf-vm.com/ +maven 3.8.1 +java openjdk-17 diff --git a/hapi-fhir-base/src/main/java/org/hl7/fhir/instance/model/api/IBaseResource.java b/hapi-fhir-base/src/main/java/org/hl7/fhir/instance/model/api/IBaseResource.java index 57f2a1fe5eaf..62b87523d5f8 100644 --- a/hapi-fhir-base/src/main/java/org/hl7/fhir/instance/model/api/IBaseResource.java +++ b/hapi-fhir-base/src/main/java/org/hl7/fhir/instance/model/api/IBaseResource.java @@ -31,7 +31,7 @@ /** * For now, this is a simple marker interface indicating that a class is a resource type. - * There are two concrete types of implementations of this interrface. The first are + * There are two concrete types of implementations of this interface. The first are * HL7.org's Resource structures (e.g. * org.hl7.fhir.instance.model.Patient) and * the second are HAPI's Resource structures, e.g. diff --git a/hapi-fhir-server-openapi/src/main/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptor.java b/hapi-fhir-server-openapi/src/main/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptor.java index 4cc99e24b1bf..70218ad7b6ed 100644 --- a/hapi-fhir-server-openapi/src/main/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptor.java +++ b/hapi-fhir-server-openapi/src/main/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptor.java @@ -26,10 +26,14 @@ import ca.uhn.fhir.interceptor.api.Hook; import ca.uhn.fhir.interceptor.api.Pointcut; import ca.uhn.fhir.rest.api.Constants; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.server.IServerAddressStrategy; import ca.uhn.fhir.rest.server.IServerConformanceProvider; +import ca.uhn.fhir.rest.server.ResourceBinding; import ca.uhn.fhir.rest.server.RestfulServer; import ca.uhn.fhir.rest.server.RestfulServerUtils; +import ca.uhn.fhir.rest.server.method.BaseMethodBinding; +import ca.uhn.fhir.rest.server.method.ReadMethodBinding; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import ca.uhn.fhir.util.ClasspathUtil; import ca.uhn.fhir.util.ExtensionConstants; @@ -65,6 +69,8 @@ import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.apache.commons.collections4.keyvalue.MultiKey; +import org.apache.commons.collections4.map.MultiKeyMap; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.StringUtils; import org.hl7.fhir.convertors.factory.VersionConvertorFactory_30_40; @@ -110,6 +116,7 @@ import java.math.BigDecimal; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collection; import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashMap; @@ -442,6 +449,31 @@ private String extractPageName(ServletRequestDetails theRequestDetails, String t return page; } + /** + * When implementing your own HAPI server, you can create a class extending this one and override + * this method if you have something in the operations that you want to customize in the + * generated OpenAPI Spec. + * + * @param openApi - The OpenAPI Spec object that is in the process of being generated. + * @param operation - The Operation that is in the process of being generated. + * @param baseMethodBinding - Object containing metadata about the method that processes this operation. + */ + protected void customizeOperation(OpenAPI openApi, Operation operation, BaseMethodBinding baseMethodBinding) { + // Here just to be overridden by extending classes. + } + + /** + * When implementing your own HAPI server, you can create a class extending this one and override + * this method if you have something in the operations that you want to customize in the + * generated OpenAPI Spec. + * + * @param openApi - The OpenAPI Spec object that is in the process of being generated. + * @param requestDetails - Details about the request being processed. + */ + protected void customizeOpenApi(OpenAPI openApi, ServletRequestDetails requestDetails) { + // Here just to be overridden by extending classes. + } + protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { String page = extractPageName(theRequestDetails, null); @@ -454,6 +486,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { capabilitiesProvider = (IServerConformanceProvider) restfulServer.getServerConformanceProvider(); } + final MultiKeyMap operationLookup = buildOperationLookup(restfulServer); + OpenAPI openApi = new OpenAPI(); openApi.setInfo(new Info()); @@ -474,6 +508,10 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { Paths paths = new Paths(); openApi.setPaths(paths); + ensureComponentsSchemasPopulated(openApi); + + customizeOpenApi(openApi, theRequestDetails); + if (page == null || page.equals(PAGE_SYSTEM) || page.equals(PAGE_ALL)) { Tag serverTag = new Tag(); serverTag.setName(PAGE_SYSTEM); @@ -495,7 +533,7 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { Operation transaction = getPathItem(paths, "/", PathItem.HttpMethod.POST); transaction.addTagsItem(PAGE_SYSTEM); transaction.setSummary("server-transaction: Execute a FHIR Transaction (or FHIR Batch) Bundle"); - addFhirResourceResponse(ctx, openApi, transaction, null); + addFhirResourceResponse(ctx, openApi, transaction, "Bundle"); addFhirResourceRequestBody(openApi, transaction, ctx, null); } @@ -505,13 +543,14 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { systemHistory.addTagsItem(PAGE_SYSTEM); systemHistory.setSummary( "server-history: Fetch the resource change history across all resource types on the server"); - addFhirResourceResponse(ctx, openApi, systemHistory, null); + addFhirResourceResponse(ctx, openApi, systemHistory, "Bundle"); } // System-level Operations for (CapabilityStatement.CapabilityStatementRestResourceOperationComponent nextOperation : cs.getRestFirstRep().getOperation()) { addFhirOperation(ctx, openApi, theRequestDetails, capabilitiesProvider, paths, null, nextOperation); + // TODO: customize operations } } @@ -536,10 +575,15 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { // Instance Read if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.READ)) { Operation operation = getPathItem(paths, "/" + resourceType + "/{id}", PathItem.HttpMethod.GET); + operation.addTagsItem(resourceType); operation.setSummary("read-instance: Read " + resourceType + " instance"); addResourceIdParameter(operation); - addFhirResourceResponse(ctx, openApi, operation, null); + + addFhirResourceResponse(ctx, openApi, operation, resourceType); + + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.READ.name())); } // Instance VRead @@ -550,7 +594,10 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { operation.setSummary("vread-instance: Read " + resourceType + " instance with specific version"); addResourceIdParameter(operation); addResourceVersionIdParameter(operation); - addFhirResourceResponse(ctx, openApi, operation, null); + addFhirResourceResponse(ctx, openApi, operation, resourceType); + + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.VREAD.name())); } // Type Create @@ -560,6 +607,9 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { operation.setSummary("create-type: Create a new " + resourceType + " instance"); addFhirResourceRequestBody(openApi, operation, ctx, genericExampleSupplier(ctx, resourceType)); addFhirResourceResponse(ctx, openApi, operation, null); + + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.CREATE.name())); } // Instance Update @@ -571,6 +621,9 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceIdParameter(operation); addFhirResourceRequestBody(openApi, operation, ctx, genericExampleSupplier(ctx, resourceType)); addFhirResourceResponse(ctx, openApi, operation, null); + + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.UPDATE.name())); } // Type history @@ -579,18 +632,28 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { operation.addTagsItem(resourceType); operation.setSummary( "type-history: Fetch the resource change history for all resources of type " + resourceType); - addFhirResourceResponse(ctx, openApi, operation, null); + addFhirResourceResponse(ctx, openApi, operation, "Bundle"); + + customizeOperation( + openApi, + operation, + operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_TYPE.name())); } // Instance history - if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.HISTORYTYPE)) { + if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.HISTORYINSTANCE)) { Operation operation = getPathItem(paths, "/" + resourceType + "/{id}/_history", PathItem.HttpMethod.GET); operation.addTagsItem(resourceType); operation.setSummary("instance-history: Fetch the resource change history for all resources of type " + resourceType); addResourceIdParameter(operation); - addFhirResourceResponse(ctx, openApi, operation, null); + addFhirResourceResponse(ctx, openApi, operation, "Bundle"); + + customizeOperation( + openApi, + operation, + operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_INSTANCE.name())); } // Instance Patch @@ -601,6 +664,9 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceIdParameter(operation); addFhirResourceRequestBody(openApi, operation, FHIR_CONTEXT_CANONICAL, patchExampleSupplier()); addFhirResourceResponse(ctx, openApi, operation, null); + + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.PATCH.name())); } // Instance Delete @@ -610,22 +676,31 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { operation.setSummary("instance-delete: Perform a logical delete on a resource instance"); addResourceIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, null); + + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.DELETE.name())); } // Search if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.SEARCHTYPE)) { + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType, RestOperationTypeEnum.SEARCH_TYPE.name()); + addSearchOperation( openApi, getPathItem(paths, "/" + resourceType, PathItem.HttpMethod.GET), ctx, resourceType, - nextResource); + nextResource, + baseMethodBinding); addSearchOperation( openApi, getPathItem(paths, "/" + resourceType + "/_search", PathItem.HttpMethod.GET), ctx, resourceType, - nextResource); + nextResource, + baseMethodBinding); } // Resource-level Operations @@ -633,12 +708,42 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { nextResource.getOperation()) { addFhirOperation( ctx, openApi, theRequestDetails, capabilitiesProvider, paths, resourceType, nextOperation); + + // TODO: customize operations } } return openApi; } + /** + * Iterate through the resource bindings on the server to build a lookup of resource + operation name + * to the method binding that will process that operation. + */ + private MultiKeyMap buildOperationLookup(RestfulServer restfulServer) { + + final MultiKeyMap map = new MultiKeyMap<>(); + final Collection resourceBindings = restfulServer.getResourceBindings(); + for (ResourceBinding resourceBinding : resourceBindings) { + final String resourceName = resourceBinding.getResourceName(); + + for (BaseMethodBinding methodBinding : resourceBinding.getMethodBindings()) { + final RestOperationTypeEnum restOperationType = methodBinding.getRestOperationType(); + final MultiKey key = new MultiKey<>(resourceName, restOperationType.name()); + + map.put(key, methodBinding); + + if (RestOperationTypeEnum.VREAD.equals(restOperationType)) { + map.put(resourceName, RestOperationTypeEnum.READ.name(), methodBinding); + } else if (RestOperationTypeEnum.READ.equals(restOperationType) + && ((ReadMethodBinding) methodBinding).isVread()) { + map.put(resourceName, RestOperationTypeEnum.VREAD.name(), methodBinding); + } + } + } + return map; + } + @Nonnull protected String createResourceDescription( CapabilityStatement.CapabilityStatementRestResourceComponent theResource) { @@ -674,11 +779,12 @@ protected void addSearchOperation( final Operation operation, final FhirContext ctx, final String resourceType, - final CapabilityStatement.CapabilityStatementRestResourceComponent nextResource) { + final CapabilityStatement.CapabilityStatementRestResourceComponent nextResource, + final BaseMethodBinding baseMethodBinding) { operation.addTagsItem(resourceType); operation.setDescription("This is a search type"); operation.setSummary("search-type: Search for " + resourceType + " instances"); - addFhirResourceResponse(ctx, openApi, operation, null); + addFhirResourceResponse(ctx, openApi, operation, "Bundle"); for (final CapabilityStatement.CapabilityStatementRestResourceSearchParamComponent nextSearchParam : nextResource.getSearchParam()) { @@ -691,6 +797,8 @@ protected void addSearchOperation( parametersItem.setDescription(nextSearchParam.getDocumentation()); parametersItem.setSchema(toSchema(nextSearchParam.getType())); } + + customizeOperation(openApi, operation, baseMethodBinding); } private Supplier patchExampleSupplier() { diff --git a/hapi-fhir-server-openapi/src/test/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptorTest.java b/hapi-fhir-server-openapi/src/test/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptorTest.java index bbfbbb1a4bd2..93c8b2459eea 100644 --- a/hapi-fhir-server-openapi/src/test/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptorTest.java +++ b/hapi-fhir-server-openapi/src/test/java/ca/uhn/fhir/rest/openapi/OpenApiInterceptorTest.java @@ -16,9 +16,11 @@ import ca.uhn.fhir.rest.api.EncodingEnum; import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.api.PatchTypeEnum; +import ca.uhn.fhir.rest.api.RestOperationTypeEnum; import ca.uhn.fhir.rest.api.ValidationModeEnum; import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.server.interceptor.ResponseHighlighterInterceptor; +import ca.uhn.fhir.rest.server.method.BaseMethodBinding; import ca.uhn.fhir.rest.server.provider.HashMapResourceProvider; import ca.uhn.fhir.rest.server.provider.ServerCapabilityStatementProvider; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; @@ -29,6 +31,8 @@ import io.swagger.v3.core.util.Yaml; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.PathItem; +import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.media.MediaType; import jakarta.annotation.Nonnull; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.io.IOUtils; @@ -243,6 +247,16 @@ public void testFetchSwagger() throws IOException { assertEquals("LastN Short", lastNPath.getGet().getSummary()); assertThat(lastNPath.getGet().getParameters()).hasSize(4); assertEquals("Subject description", lastNPath.getGet().getParameters().get(0).getDescription()); + + final MediaType readObservationMediaType = parsed.getPaths().get("/Observation/{id}").getGet().getResponses().get("200").getContent().get(Constants.CT_FHIR_JSON_NEW); + assertNotNull(readObservationMediaType); + assertThat(readObservationMediaType.getExample()).isInstanceOf(String.class); + assertThat((String) readObservationMediaType.getExample()).contains("\"resourceType\": \"Observation\""); + + final MediaType searchObservationMediaType = parsed.getPaths().get("/Observation").getGet().getResponses().get("200").getContent().get(Constants.CT_FHIR_JSON_NEW); + assertNotNull(searchObservationMediaType); + assertThat(searchObservationMediaType.getExample()).isInstanceOf(String.class); + assertThat((String) searchObservationMediaType.getExample()).contains("\"resourceType\": \"Bundle\""); } @Test @@ -378,6 +392,52 @@ public void testStandardRedirectScriptIsAccessible() throws IOException { } } + @Test + public void testInterceptorSubclass() throws IOException { + myServer.getRestfulServer().registerInterceptor(new CustomOpenApiInterceptor()); + + String resp; + HttpGet get = new HttpGet("http://localhost:" + myServer.getPort() + "/fhir/metadata?_pretty=true"); + try (CloseableHttpResponse response = myClient.execute(get)) { + resp = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); + ourLog.info("CapabilityStatement: {}", resp); + } + + get = new HttpGet("http://localhost:" + myServer.getPort() + "/fhir/api-docs"); + try (CloseableHttpResponse response = myClient.execute(get)) { + resp = IOUtils.toString(response.getEntity().getContent(), StandardCharsets.UTF_8); + ourLog.info("Response: {}", response.getStatusLine()); + ourLog.debug("Response: {}", resp); + } + + OpenAPI parsed = Yaml.mapper().readValue(resp, OpenAPI.class); + assertThat(parsed).isNotNull().isInstanceOf(OpenAPI.class); + assertThat(parsed.getInfo()).isNotNull().isInstanceOf(Info.class); + assertThat(parsed.getInfo().getDescription()).isEqualTo("This is my custom description for my application!"); + assertThat(parsed.getPaths()).hasEntrySatisfying("/Patient", item -> { + assertThat(item.getGet().getDeprecated()).isTrue(); + assertThat(item.getGet().getDescription()).isEqualTo("Custom description for resource Patient interaction SEARCH_TYPE"); + assertThat(item.getPost().getDeprecated()).isTrue(); + assertThat(item.getPost().getDescription()).isEqualTo("Custom description for resource Patient interaction CREATE"); + }); + assertThat(parsed.getPaths()).hasEntrySatisfying("/Patient/{id}", item -> { + assertThat(item.getGet().getDeprecated()).isTrue(); + assertThat(item.getGet().getDescription()).isEqualTo("Custom description for resource Patient interaction READ"); + assertThat(item.getPatch().getDeprecated()).isTrue(); + assertThat(item.getPatch().getDescription()).isEqualTo("Custom description for resource Patient interaction PATCH"); + assertThat(item.getPut().getDeprecated()).isTrue(); + assertThat(item.getPut().getDescription()).isEqualTo("Custom description for resource Patient interaction UPDATE"); + }); + assertThat(parsed.getPaths()).hasEntrySatisfying("/Patient/{id}/_history", item -> { + assertThat(item.getGet().getDeprecated()).isTrue(); + assertThat(item.getGet().getDescription()).isEqualTo("Custom description for resource Patient interaction HISTORY_INSTANCE"); + }); + assertThat(parsed.getPaths()).hasEntrySatisfying("/Patient/{id}/_history/{version_id}", item -> { + assertThat(item.getGet().getDeprecated()).isTrue(); + assertThat(item.getGet().getDescription()).isEqualTo("Custom description for resource Patient interaction VREAD"); + }); + } + protected String fetchSwaggerUi(String url) throws IOException { String resp; HttpGet get = new HttpGet(url); @@ -507,4 +567,24 @@ public MethodOutcome validate( } + static class CustomOpenApiInterceptor extends OpenApiInterceptor { + @Override + protected void customizeOperation(OpenAPI openApi, io.swagger.v3.oas.models.Operation operation, BaseMethodBinding baseMethodBinding) { + operation.setDeprecated(true); + RestOperationTypeEnum type = baseMethodBinding.getRestOperationType(); + if (RestOperationTypeEnum.VREAD.equals(type) && + operation.getParameters().stream().noneMatch(param -> "version_id".equals(param.getName()))) { + type = RestOperationTypeEnum.READ; + } + operation.setDescription("Custom description for resource " + baseMethodBinding.getResourceName() + " interaction " + type); + } + @Override + protected void customizeOpenApi(OpenAPI openApi, ServletRequestDetails requestDetails) { + if (openApi.getInfo() == null) { + openApi.setInfo(new Info()); + } + openApi.getInfo().setDescription("This is my custom description for my application!"); + } + } + } diff --git a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java index 9724bdc50464..95915a9c63bf 100644 --- a/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java +++ b/hapi-fhir-server/src/main/java/ca/uhn/fhir/rest/server/RestfulServer.java @@ -839,8 +839,8 @@ public void setPlainProviders(Collection theProviders) { * implementation * * @param requestFullPath the full request path - * @param servletContextPath the servelet context path - * @param servletPath the servelet path + * @param servletContextPath the servlet context path + * @param servletPath the servlet path * @return created resource path */ // NOTE: Don't make this a static method!! People want to override it