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