From d9ca4a1bb5084136aa45ee88190567a646287701 Mon Sep 17 00:00:00 2001 From: Nathan Loyer Date: Wed, 22 Jan 2025 17:53:23 -0500 Subject: [PATCH 1/4] allow openapi spec generation to be customizable --- .tool-versions | 3 + .../instance/model/api/IBaseResource.java | 2 +- .../fhir/rest/openapi/OpenApiInterceptor.java | 132 ++++++++++++++++-- .../rest/openapi/OpenApiInterceptorTest.java | 5 + .../uhn/fhir/rest/server/RestfulServer.java | 4 +- 5 files changed, 133 insertions(+), 13 deletions(-) create mode 100644 .tool-versions 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..33e7cac0eb00 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; @@ -110,6 +114,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 +447,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 +484,9 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { capabilitiesProvider = (IServerConformanceProvider) restfulServer.getServerConformanceProvider(); } + final HashMap> operationLookup = + buildOperationLookup(restfulServer); + OpenAPI openApi = new OpenAPI(); openApi.setInfo(new Info()); @@ -474,6 +507,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 +532,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 +542,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 +574,17 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { // Instance Read if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.READ)) { Operation operation = getPathItem(paths, "/" + resourceType + "/{id}", PathItem.HttpMethod.GET); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.READ); + 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, baseMethodBinding); } // Instance VRead @@ -550,7 +595,11 @@ 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); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.VREAD); + customizeOperation(openApi, operation, baseMethodBinding); } // Type Create @@ -560,6 +609,10 @@ 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); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.CREATE); + customizeOperation(openApi, operation, baseMethodBinding); } // Instance Update @@ -571,6 +624,10 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceIdParameter(operation); addFhirResourceRequestBody(openApi, operation, ctx, genericExampleSupplier(ctx, resourceType)); addFhirResourceResponse(ctx, openApi, operation, null); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.UPDATE); + customizeOperation(openApi, operation, baseMethodBinding); } // Type history @@ -579,18 +636,26 @@ 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"); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.HISTORY_TYPE); + customizeOperation(openApi, operation, baseMethodBinding); } // 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"); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.HISTORY_INSTANCE); + customizeOperation(openApi, operation, baseMethodBinding); } // Instance Patch @@ -601,6 +666,10 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceIdParameter(operation); addFhirResourceRequestBody(openApi, operation, FHIR_CONTEXT_CANONICAL, patchExampleSupplier()); addFhirResourceResponse(ctx, openApi, operation, null); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.PATCH); + customizeOperation(openApi, operation, baseMethodBinding); } // Instance Delete @@ -610,22 +679,32 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { operation.setSummary("instance-delete: Perform a logical delete on a resource instance"); addResourceIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, null); + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.DELETE); + customizeOperation(openApi, operation, baseMethodBinding); } // Search if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.SEARCHTYPE)) { + + final BaseMethodBinding baseMethodBinding = + operationLookup.get(resourceType).get(RestOperationTypeEnum.SEARCH_TYPE); + 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 +712,42 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { nextResource.getOperation()) { addFhirOperation( ctx, openApi, theRequestDetails, capabilitiesProvider, paths, resourceType, nextOperation); + + // TODO: customize operations } } return openApi; } + private HashMap> buildOperationLookup( + RestfulServer restfulServer) { + final HashMap> map = new HashMap<>(); + final Collection resourceBindings = restfulServer.getResourceBindings(); + for (ResourceBinding resourceBinding : resourceBindings) { + if (!map.containsKey(resourceBinding.getResourceName())) { + map.put(resourceBinding.getResourceName(), new HashMap<>()); + } + + final Map resourceMap = + map.get(resourceBinding.getResourceName()); + + final List methodBindings = resourceBinding.getMethodBindings(); + for (BaseMethodBinding methodBinding : methodBindings) { + final RestOperationTypeEnum restOperationType = methodBinding.getRestOperationType(); + resourceMap.put(restOperationType, methodBinding); + + if (RestOperationTypeEnum.VREAD.equals(restOperationType)) { + resourceMap.put(RestOperationTypeEnum.READ, methodBinding); + } else if (RestOperationTypeEnum.READ.equals(restOperationType) + && ((ReadMethodBinding) methodBinding).isVread()) { + resourceMap.put(RestOperationTypeEnum.VREAD, methodBinding); + } + } + } + return map; + } + @Nonnull protected String createResourceDescription( CapabilityStatement.CapabilityStatementRestResourceComponent theResource) { @@ -674,7 +783,8 @@ 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"); @@ -691,6 +801,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..142e44f9c2da 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 @@ -29,6 +29,7 @@ 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.media.MediaType; import jakarta.annotation.Nonnull; import jakarta.servlet.http.HttpServletRequest; import org.apache.commons.io.IOUtils; @@ -43,6 +44,7 @@ import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IPrimitiveType; +import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r5.model.ActorDefinition; import org.htmlunit.html.DomElement; import org.htmlunit.html.HtmlDivision; @@ -243,6 +245,9 @@ 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 schema = parsed.getPaths().get("/Observation/{id}").getGet().getResponses().get("200").getContent().get(Constants.CT_FHIR_JSON_NEW); + assertNotNull(schema); } @Test 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 From f47b27abcac2563b28cb388529116455d78d7d16 Mon Sep 17 00:00:00 2001 From: Nathan Loyer Date: Wed, 22 Jan 2025 18:37:14 -0500 Subject: [PATCH 2/4] test that the resource type is added to the example --- .../ca/uhn/fhir/rest/openapi/OpenApiInterceptor.java | 2 +- .../fhir/rest/openapi/OpenApiInterceptorTest.java | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) 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 33e7cac0eb00..3d628080717f 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 @@ -788,7 +788,7 @@ protected void addSearchOperation( 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()) { 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 142e44f9c2da..b52e616eed02 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 @@ -44,7 +44,6 @@ import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IIdType; import org.hl7.fhir.instance.model.api.IPrimitiveType; -import org.hl7.fhir.r4.model.Patient; import org.hl7.fhir.r5.model.ActorDefinition; import org.htmlunit.html.DomElement; import org.htmlunit.html.HtmlDivision; @@ -246,8 +245,15 @@ public void testFetchSwagger() throws IOException { assertThat(lastNPath.getGet().getParameters()).hasSize(4); assertEquals("Subject description", lastNPath.getGet().getParameters().get(0).getDescription()); - final MediaType schema = parsed.getPaths().get("/Observation/{id}").getGet().getResponses().get("200").getContent().get(Constants.CT_FHIR_JSON_NEW); - assertNotNull(schema); + 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 From e5bf62bde98bfea4389d175e598409e270722c36 Mon Sep 17 00:00:00 2001 From: Nathan Loyer Date: Tue, 28 Jan 2025 13:43:24 -0500 Subject: [PATCH 3/4] test the openapi customization functionality --- .../fhir/rest/openapi/OpenApiInterceptor.java | 71 ++++++++----------- .../rest/openapi/OpenApiInterceptorTest.java | 39 ++++++++++ 2 files changed, 68 insertions(+), 42 deletions(-) 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 3d628080717f..5ac6263e8c9b 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 @@ -69,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; @@ -484,8 +486,7 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { capabilitiesProvider = (IServerConformanceProvider) restfulServer.getServerConformanceProvider(); } - final HashMap> operationLookup = - buildOperationLookup(restfulServer); + final MultiKeyMap operationLookup = buildOperationLookup(restfulServer); OpenAPI openApi = new OpenAPI(); @@ -575,16 +576,13 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.READ)) { Operation operation = getPathItem(paths, "/" + resourceType + "/{id}", PathItem.HttpMethod.GET); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.READ); - operation.addTagsItem(resourceType); operation.setSummary("read-instance: Read " + resourceType + " instance"); addResourceIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, resourceType); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.READ)); } // Instance VRead @@ -597,9 +595,7 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceVersionIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, resourceType); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.VREAD); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.VREAD)); } // Type Create @@ -610,9 +606,7 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceRequestBody(openApi, operation, ctx, genericExampleSupplier(ctx, resourceType)); addFhirResourceResponse(ctx, openApi, operation, null); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.CREATE); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.CREATE)); } // Instance Update @@ -625,9 +619,7 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceRequestBody(openApi, operation, ctx, genericExampleSupplier(ctx, resourceType)); addFhirResourceResponse(ctx, openApi, operation, null); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.UPDATE); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.UPDATE)); } // Type history @@ -638,9 +630,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { "type-history: Fetch the resource change history for all resources of type " + resourceType); addFhirResourceResponse(ctx, openApi, operation, "Bundle"); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.HISTORY_TYPE); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_TYPE)); } // Instance history @@ -653,9 +644,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, "Bundle"); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.HISTORY_INSTANCE); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_INSTANCE)); } // Instance Patch @@ -667,9 +657,7 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceRequestBody(openApi, operation, FHIR_CONTEXT_CANONICAL, patchExampleSupplier()); addFhirResourceResponse(ctx, openApi, operation, null); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.PATCH); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.PATCH)); } // Instance Delete @@ -680,16 +668,14 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, null); - final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.DELETE); - customizeOperation(openApi, operation, baseMethodBinding); + customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.DELETE)); } // Search if (typeRestfulInteractions.contains(CapabilityStatement.TypeRestfulInteraction.SEARCHTYPE)) { final BaseMethodBinding baseMethodBinding = - operationLookup.get(resourceType).get(RestOperationTypeEnum.SEARCH_TYPE); + operationLookup.get(resourceType, RestOperationTypeEnum.SEARCH_TYPE); addSearchOperation( openApi, @@ -720,28 +706,29 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { return openApi; } - private HashMap> buildOperationLookup( - RestfulServer restfulServer) { - final HashMap> map = new HashMap<>(); + /** + * 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) { - if (!map.containsKey(resourceBinding.getResourceName())) { - map.put(resourceBinding.getResourceName(), new HashMap<>()); - } - - final Map resourceMap = - map.get(resourceBinding.getResourceName()); + final String resourceName = resourceBinding.getResourceName(); - final List methodBindings = resourceBinding.getMethodBindings(); - for (BaseMethodBinding methodBinding : methodBindings) { + for (BaseMethodBinding methodBinding : resourceBinding.getMethodBindings()) { final RestOperationTypeEnum restOperationType = methodBinding.getRestOperationType(); - resourceMap.put(restOperationType, methodBinding); + final MultiKey key = new MultiKey<>(resourceName, restOperationType.name()); + + map.put(key, methodBinding); if (RestOperationTypeEnum.VREAD.equals(restOperationType)) { - resourceMap.put(RestOperationTypeEnum.READ, methodBinding); + map.put(resourceName, RestOperationTypeEnum.READ.name(), methodBinding); } else if (RestOperationTypeEnum.READ.equals(restOperationType) && ((ReadMethodBinding) methodBinding).isVread()) { - resourceMap.put(RestOperationTypeEnum.VREAD, methodBinding); + map.put(resourceName, RestOperationTypeEnum.VREAD.name(), methodBinding); } } } 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 b52e616eed02..772cca63d695 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 @@ -19,6 +19,7 @@ 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 +30,7 @@ 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; @@ -389,6 +391,31 @@ 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()); + } + protected String fetchSwaggerUi(String url) throws IOException { String resp; HttpGet get = new HttpGet(url); @@ -518,4 +545,16 @@ 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); + } + @Override + protected void customizeOpenApi(OpenAPI openApi, ServletRequestDetails requestDetails) { + final Info info = openApi.getInfo(); + info.setDescription("This is my custom description for my application!"); + } + } + } From 9e4c0e9f804da7f2e6a0d3417d43141b079ddb1a Mon Sep 17 00:00:00 2001 From: Nathan Loyer Date: Wed, 29 Jan 2025 11:51:01 -0500 Subject: [PATCH 4/4] fix a bug and improve tests --- .../fhir/rest/openapi/OpenApiInterceptor.java | 29 +++++++++------ .../rest/openapi/OpenApiInterceptorTest.java | 36 +++++++++++++++++-- 2 files changed, 52 insertions(+), 13 deletions(-) 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 5ac6263e8c9b..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 @@ -582,7 +582,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceResponse(ctx, openApi, operation, resourceType); - customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.READ)); + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.READ.name())); } // Instance VRead @@ -595,7 +596,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceVersionIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, resourceType); - customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.VREAD)); + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.VREAD.name())); } // Type Create @@ -606,7 +608,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceRequestBody(openApi, operation, ctx, genericExampleSupplier(ctx, resourceType)); addFhirResourceResponse(ctx, openApi, operation, null); - customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.CREATE)); + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.CREATE.name())); } // Instance Update @@ -619,7 +622,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceRequestBody(openApi, operation, ctx, genericExampleSupplier(ctx, resourceType)); addFhirResourceResponse(ctx, openApi, operation, null); - customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.UPDATE)); + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.UPDATE.name())); } // Type history @@ -631,7 +635,9 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceResponse(ctx, openApi, operation, "Bundle"); customizeOperation( - openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_TYPE)); + openApi, + operation, + operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_TYPE.name())); } // Instance history @@ -645,7 +651,9 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceResponse(ctx, openApi, operation, "Bundle"); customizeOperation( - openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_INSTANCE)); + openApi, + operation, + operationLookup.get(resourceType, RestOperationTypeEnum.HISTORY_INSTANCE.name())); } // Instance Patch @@ -657,7 +665,8 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addFhirResourceRequestBody(openApi, operation, FHIR_CONTEXT_CANONICAL, patchExampleSupplier()); addFhirResourceResponse(ctx, openApi, operation, null); - customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.PATCH)); + customizeOperation( + openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.PATCH.name())); } // Instance Delete @@ -668,14 +677,15 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { addResourceIdParameter(operation); addFhirResourceResponse(ctx, openApi, operation, null); - customizeOperation(openApi, operation, operationLookup.get(resourceType, RestOperationTypeEnum.DELETE)); + 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); + operationLookup.get(resourceType, RestOperationTypeEnum.SEARCH_TYPE.name()); addSearchOperation( openApi, @@ -713,7 +723,6 @@ protected OpenAPI generateOpenApi(ServletRequestDetails theRequestDetails) { private MultiKeyMap buildOperationLookup(RestfulServer restfulServer) { final MultiKeyMap map = new MultiKeyMap<>(); - ; final Collection resourceBindings = restfulServer.getResourceBindings(); for (ResourceBinding resourceBinding : resourceBindings) { final String resourceName = resourceBinding.getResourceName(); 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 772cca63d695..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,6 +16,7 @@ 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; @@ -413,7 +414,28 @@ public void testInterceptorSubclass() throws IOException { 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(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 { @@ -549,11 +571,19 @@ 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) { - final Info info = openApi.getInfo(); - info.setDescription("This is my custom description for my application!"); + if (openApi.getInfo() == null) { + openApi.setInfo(new Info()); + } + openApi.getInfo().setDescription("This is my custom description for my application!"); } }