From 36ee249e964a75f34af8f37318757e6ceb155b08 Mon Sep 17 00:00:00 2001 From: Ryan May Date: Mon, 17 Aug 2026 16:14:16 +0100 Subject: [PATCH 1/2] updated to include validation against capabilitystatement --- Dockerfile | 2 +- hapi.application.yaml | 31 +++- request.json | 48 ++++++ ...tyStatementProfileFallbackInterceptor.java | 144 ++++++++++++++++++ src/main/java/com/nhs/OpenApiCustomizer.java | 19 ++- .../java/com/nhs/ProfileValidationConfig.java | 37 +++++ 6 files changed, 269 insertions(+), 12 deletions(-) create mode 100644 request.json create mode 100644 src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java create mode 100644 src/main/java/com/nhs/ProfileValidationConfig.java diff --git a/Dockerfile b/Dockerfile index 5776ccd..3cf70eb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM hapiproject/hapi:v8.10.0-1 AS base +FROM hapiproject/hapi:v8.10.0-3 AS base FROM eclipse-temurin:21-jdk # Copy the war from the base image diff --git a/hapi.application.yaml b/hapi.application.yaml index c0b67a6..347ea96 100644 --- a/hapi.application.yaml +++ b/hapi.application.yaml @@ -203,14 +203,28 @@ hapi: validate_resource_status_for_package_upload: false # seting to false allows validation against draft profiles install_transitive_ig_dependencies: true # installs the dependencies of the package depencies (recursively) if fetchDependencies is set to true for the package implementationguides: - #pathology: - #name: fhir.r4.nhsengland.pathology - #version: 0.2.4-alpha - #reloadExisting: false - #fetchDependencies: true #installs package dependencies - #installMode: STORE_AND_INSTALL - #dependencyExcludes: ["hl7.terminology.r4", "hl7.fhir.uv.extensions.r4"] #excluded as there's a null error in hl7.terminology.r4 6.3.0 & hl7.fhir.uv.extensions.r4#5.2.0 Unknown resource name "DeviceUsage" (this name is not known in FHIR version "R4"). Add updated versions of these packages separately if needed. - # swiss: + # pathology: + # name: fhir.r4.nhsengland.pathology + # version: 0.2.4-alpha + # reloadExisting: false + # fetchDependencies: true #installs package dependencies + # installMode: STORE_AND_INSTALL + # dependencyExcludes: ["hl7.terminology.r4", "hl7.fhir.uv.extensions.r4"] #excluded as there's a null error in hl7.terminology.r4 6.3.0 & hl7.fhir.uv.extensions.r4#5.2.0 Unknown resource name "DeviceUsage" (this name is not known in FHIR version "R4"). Add updated versions of these packages separately if needed. + # core: + # name: hl7.fhir.r4.core + # version: 4.0.1 + # reloadExisting: false + # fetchDependencies: true #installs package dependencies + # installMode: STORE_AND_INSTALL + # dependencyExcludes: ["hl7.terminology.r4", "hl7.fhir.uv.extensions.r4"] + ukcore: + name: fhir.r4.ukcore.stu3.currentbuild + version: 0.29.0-pre-release + reloadExisting: false + fetchDependencies: true #installs package dependencies + installMode: STORE_AND_INSTALL + dependencyExcludes: ["hl7.terminology.r4", "hl7.fhir.uv.extensions.r4"] + # swiss: # name: swiss.mednet.fhir # version: 0.8.0 # reloadExisting: false @@ -529,3 +543,4 @@ hapi: refuse_to_fetch_third_party_urls: false fhir_version: R4 + \ No newline at end of file diff --git a/request.json b/request.json new file mode 100644 index 0000000..e573474 --- /dev/null +++ b/request.json @@ -0,0 +1,48 @@ +{ + "resourceType": "Parameters", + "parameter": [ + { + "name": "valueSet", + "resource": { + "resourceType": "ValueSet", + "id": "UKCore-SourceOfServiceRequest", + "url": "https://fhir.hl7.org.uk/ValueSet/UKCore-SourceOfServiceRequest", + "version": "1.3.0", + "name": "UKCoreSourceOfServiceRequest", + "title": "UK Core Source Of Service Request", + "status": "active", + "date": "2026-07-07", + "publisher": "HL7 UK", + "contact": [ + { + "name": "HL7 UK", + "telecom": [ + { + "system": "email", + "value": "ukcore@hl7.org.uk", + "use": "work", + "rank": 1 + } + ] + } + ], + "description": "A set of codes that describe the source of the service request. Selected from the Referred by person and Self-referral hierarchies of the SNOMED CT UK coding system: \n - descendantOf 309013001 | Referred by person | \n - descendantOf 307834000 | Referral by person |", + "copyright": "Copyright © 2021+ HL7 UK Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. HL7® FHIR® standard Copyright © 2011+ HL7 The HL7® FHIR® standard is used under the FHIR license. You may obtain a copy of the FHIR license at https://www.hl7.org/fhir/license.html.", + "compose": { + "include": [ + { + "system": "http://snomed.info/sct", + "filter": [ + { + "property": "constraint", + "op": "=", + "value": "descendantOf 309013001 OR descendantOf 307834000" + } + ] + } + ] + } + } + } + ] +} \ No newline at end of file diff --git a/src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java b/src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java new file mode 100644 index 0000000..50a8ca0 --- /dev/null +++ b/src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java @@ -0,0 +1,144 @@ +package com.nhs; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.api.Hook; +import ca.uhn.fhir.interceptor.api.Interceptor; +import ca.uhn.fhir.interceptor.api.Pointcut; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import ca.uhn.fhir.rest.api.server.RequestDetails; +import ca.uhn.fhir.util.ParametersUtil; +import org.hl7.fhir.instance.model.api.IBaseResource; +import org.hl7.fhir.r4.model.CapabilityStatement; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * Runs only against $validate calls. Does NOT perform validation itself - it simply ensures + * the resource being validated has a meta.profile set before HAPI's built-in $validate operation + * processes it, using this priority: + * + * 1. The resource's own declared meta.profile, if present - left untouched. + * 2. The profile declared for this resource type in the server's stored CapabilityStatement + * (assumes exactly one CapabilityStatement resource is present in the repository). + * 3. The base/core FHIR StructureDefinition for the resource type, as a last resort - in + * practice this just means we leave meta.profile empty and let $validate fall back to + * base structural validation, which is its normal behaviour with no profile declared. + * + * Does NOT hook resource create/update storage pointcuts, so it has no effect on plain + * POST/PUT calls used to load assets - only on $validate. + */ +@Interceptor +public class CapabilityStatementProfileFallbackInterceptor { + + private static final Logger ourLog = LoggerFactory.getLogger(CapabilityStatementProfileFallbackInterceptor.class); + + private static final String VALIDATE_OPERATION_NAME = "$validate"; + + private final FhirContext myFhirContext; + private final DaoRegistry myDaoRegistry; + + // Lazily loaded and cached once non-empty - one CapabilityStatement per run, loaded during + // the asset phase which always completes before $validate calls begin. + private volatile Map myResourceTypeToProfile; + + public CapabilityStatementProfileFallbackInterceptor(FhirContext theFhirContext, DaoRegistry theDaoRegistry) { + myFhirContext = theFhirContext; + myDaoRegistry = theDaoRegistry; + } + + @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) + public void preHandle(RequestDetails theRequestDetails) { + if (!VALIDATE_OPERATION_NAME.equals(theRequestDetails.getOperation())) { + return; + } + + IBaseResource target = extractTargetResource(theRequestDetails); + if (target == null) { + ourLog.warn("$validate call received but no target resource could be extracted from RequestDetails - " + + "profile fallback will not run for this request"); + return; + } + + if (!target.getMeta().getProfile().isEmpty()) { + // Resource already declares a profile - honour it, don't override. + return; + } + + String resourceType = myFhirContext.getResourceType(target); + String profile = resolveFromCapabilityStatement(resourceType); + if (profile != null) { + target.getMeta().addProfile(profile); + ourLog.debug("No meta.profile declared for {} - defaulting to {} from CapabilityStatement", + resourceType, profile); + } + // If null: no CapabilityStatement entry for this type either - leave meta.profile empty + // and let $validate fall through to base structural validation for the type. + } + + /** + * $validate can be called either with the resource posted directly, or wrapped in a + * Parameters resource alongside "profile"/"mode" parameters. Handle both. + */ + private IBaseResource extractTargetResource(RequestDetails theRequestDetails) { + IBaseResource resource = theRequestDetails.getResource(); + if (resource == null) { + return null; + } + + String type = myFhirContext.getResourceType(resource); + if ("Parameters".equals(type)) { + Optional wrapped = ParametersUtil.getNamedParameterResource( + myFhirContext, (org.hl7.fhir.instance.model.api.IBaseParameters) resource, "resource"); + return wrapped.orElse(null); + } + return resource; + } + + private synchronized String resolveFromCapabilityStatement(String theResourceType) { + if (myResourceTypeToProfile == null || myResourceTypeToProfile.isEmpty()) { + myResourceTypeToProfile = loadProfileMapFromStoredCapabilityStatement(); + } + return myResourceTypeToProfile.get(theResourceType); + } + + @SuppressWarnings("unchecked") + private Map loadProfileMapFromStoredCapabilityStatement() { + try { + IFhirResourceDao dao = + (IFhirResourceDao) myDaoRegistry.getResourceDao("CapabilityStatement"); + + IBundleProvider results = dao.search(SearchParameterMap.newSynchronous()); + int size = results.size() != null ? results.size() : 1; + List resources = (List) (List) results.getResources(0, size); + + if (resources.isEmpty()) { + ourLog.warn("No CapabilityStatement found in the repository yet - profile fallback unavailable"); + return Collections.emptyMap(); + } + if (resources.size() > 1) { + ourLog.warn("Multiple CapabilityStatement resources found - using the first one returned"); + } + + CapabilityStatement cs = resources.get(0); + return cs.getRest().stream() + .flatMap(rest -> rest.getResource().stream()) + .filter(CapabilityStatement.CapabilityStatementRestResourceComponent::hasProfile) + .collect(Collectors.toMap( + CapabilityStatement.CapabilityStatementRestResourceComponent::getType, + CapabilityStatement.CapabilityStatementRestResourceComponent::getProfile, + (a, b) -> a)); + } catch (Exception e) { + ourLog.warn("Failed to load CapabilityStatement for profile fallback: {}", e.getMessage()); + return Collections.emptyMap(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/nhs/OpenApiCustomizer.java b/src/main/java/com/nhs/OpenApiCustomizer.java index 819bedb..5c32ecd 100644 --- a/src/main/java/com/nhs/OpenApiCustomizer.java +++ b/src/main/java/com/nhs/OpenApiCustomizer.java @@ -46,9 +46,21 @@ protected void doFilterInternal( String originalBody = new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8); String contentType = wrapper.getContentType(); String acceptHeader = request.getHeader("Accept"); - boolean isYaml = (contentType != null && contentType.contains("yaml")) - || (acceptHeader != null && acceptHeader.contains("yaml")) - || !request.getRequestURI().contains("format=json"); + boolean isYaml; + if (contentType != null && contentType.contains("json")) { + isYaml = false; + } else if (contentType != null && contentType.contains("yaml")) { + isYaml = true; + } else if (acceptHeader != null && acceptHeader.contains("json")) { + isYaml = false; + } else if (acceptHeader != null && acceptHeader.contains("yaml")) { + isYaml = true; + } else { + // No explicit signal from Content-Type, Accept, or query param — + // HAPI 8.10 defaults to YAML here, but Swagger UI (and most tooling) + // assumes JSON unless told otherwise. Force JSON as our default. + isYaml = request.getRequestURI().contains("format=yaml"); +} try { ObjectMapper reader = isYaml ? yamlMapper : jsonMapper; @@ -58,6 +70,7 @@ protected void doFilterInternal( addXmlContentTypes(root); byte[] modified = writer.writeValueAsBytes(root); + response.setContentType(isYaml ? "application/yaml" : "application/json"); response.setContentLength(modified.length); response.getOutputStream().write(modified); diff --git a/src/main/java/com/nhs/ProfileValidationConfig.java b/src/main/java/com/nhs/ProfileValidationConfig.java new file mode 100644 index 0000000..f86bdb8 --- /dev/null +++ b/src/main/java/com/nhs/ProfileValidationConfig.java @@ -0,0 +1,37 @@ +package com.nhs; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.interceptor.api.IInterceptorService; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +@Configuration +public class ProfileValidationConfig { + + @Autowired + private FhirContext myFhirContext; + + @Autowired + private DaoRegistry myDaoRegistry; + + @Autowired + private IInterceptorService myInterceptorService; + + @Bean + public CapabilityStatementProfileFallbackInterceptor capabilityStatementProfileFallbackInterceptor() { + return new CapabilityStatementProfileFallbackInterceptor(myFhirContext, myDaoRegistry); + } + + @EventListener(ContextRefreshedEvent.class) + public void registerInterceptor(ContextRefreshedEvent event) { + CapabilityStatementProfileFallbackInterceptor interceptor = + event.getApplicationContext().getBean(CapabilityStatementProfileFallbackInterceptor.class); + if (!myInterceptorService.getAllRegisteredInterceptors().contains(interceptor)) { + myInterceptorService.registerInterceptor(interceptor); + } + } +} \ No newline at end of file From e8534b414bfd4acd46cb909cf0b985b3063acda6 Mon Sep 17 00:00:00 2001 From: Ryan May Date: Thu, 20 Aug 2026 09:25:48 +0100 Subject: [PATCH 2/2] Set up to validate against CapabilityStatements --- hapi.application.yaml | 1 + request.json | 48 --- .../com/nhs/CachedBodyHttpServletRequest.java | 82 ++++++ ...tyStatementProfileFallbackInterceptor.java | 144 --------- .../CapabilityStatementValidationConfig.java | 34 +++ .../java/com/nhs/ProfileValidationConfig.java | 37 --- .../nhs/ValidateProfileDefaultingFilter.java | 277 ++++++++++++++++++ 7 files changed, 394 insertions(+), 229 deletions(-) delete mode 100644 request.json create mode 100644 src/main/java/com/nhs/CachedBodyHttpServletRequest.java delete mode 100644 src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java create mode 100644 src/main/java/com/nhs/CapabilityStatementValidationConfig.java delete mode 100644 src/main/java/com/nhs/ProfileValidationConfig.java create mode 100644 src/main/java/com/nhs/ValidateProfileDefaultingFilter.java diff --git a/hapi.application.yaml b/hapi.application.yaml index 347ea96..7c574bb 100644 --- a/hapi.application.yaml +++ b/hapi.application.yaml @@ -5,6 +5,7 @@ logging: level: ca.uhn.fhir.jpa.packages: DEBUG ca.uhn.fhir.log.terminology_troubleshooting: DEBUG + com.nhs: DEBUG server: diff --git a/request.json b/request.json deleted file mode 100644 index e573474..0000000 --- a/request.json +++ /dev/null @@ -1,48 +0,0 @@ -{ - "resourceType": "Parameters", - "parameter": [ - { - "name": "valueSet", - "resource": { - "resourceType": "ValueSet", - "id": "UKCore-SourceOfServiceRequest", - "url": "https://fhir.hl7.org.uk/ValueSet/UKCore-SourceOfServiceRequest", - "version": "1.3.0", - "name": "UKCoreSourceOfServiceRequest", - "title": "UK Core Source Of Service Request", - "status": "active", - "date": "2026-07-07", - "publisher": "HL7 UK", - "contact": [ - { - "name": "HL7 UK", - "telecom": [ - { - "system": "email", - "value": "ukcore@hl7.org.uk", - "use": "work", - "rank": 1 - } - ] - } - ], - "description": "A set of codes that describe the source of the service request. Selected from the Referred by person and Self-referral hierarchies of the SNOMED CT UK coding system: \n - descendantOf 309013001 | Referred by person | \n - descendantOf 307834000 | Referral by person |", - "copyright": "Copyright © 2021+ HL7 UK Licensed under the Apache License, Version 2.0 (the \"License\"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. HL7® FHIR® standard Copyright © 2011+ HL7 The HL7® FHIR® standard is used under the FHIR license. You may obtain a copy of the FHIR license at https://www.hl7.org/fhir/license.html.", - "compose": { - "include": [ - { - "system": "http://snomed.info/sct", - "filter": [ - { - "property": "constraint", - "op": "=", - "value": "descendantOf 309013001 OR descendantOf 307834000" - } - ] - } - ] - } - } - } - ] -} \ No newline at end of file diff --git a/src/main/java/com/nhs/CachedBodyHttpServletRequest.java b/src/main/java/com/nhs/CachedBodyHttpServletRequest.java new file mode 100644 index 0000000..ed2a758 --- /dev/null +++ b/src/main/java/com/nhs/CachedBodyHttpServletRequest.java @@ -0,0 +1,82 @@ +package com.nhs; + +import jakarta.servlet.ReadListener; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +/** + * Buffers the request body so it can be read, inspected, and optionally replaced + * before the request continues down the filter chain to the real servlet. + */ +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + + private byte[] myBody; + + public CachedBodyHttpServletRequest(HttpServletRequest theRequest) throws IOException { + super(theRequest); + try (var in = theRequest.getInputStream()) { + myBody = in.readAllBytes(); + } + } + + public byte[] getBodyBytes() { + return myBody; + } + + public String getBodyAsString() { + return new String(myBody, StandardCharsets.UTF_8); + } + + /** Replace the buffered body - subsequent reads (by the real servlet) will see this instead. */ + public void setBody(byte[] theNewBody) { + myBody = theNewBody; + } + + @Override + public int getContentLength() { + return myBody.length; + } + + @Override + public long getContentLengthLong() { + return myBody.length; + } + + @Override + public ServletInputStream getInputStream() { + ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(myBody); + return new ServletInputStream() { + @Override + public boolean isFinished() { + return byteArrayInputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(ReadListener theReadListener) { + // not needed for synchronous use + } + + @Override + public int read() { + return byteArrayInputStream.read(); + } + }; + } + + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8)); + } +} \ No newline at end of file diff --git a/src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java b/src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java deleted file mode 100644 index 50a8ca0..0000000 --- a/src/main/java/com/nhs/CapabilityStatementProfileFallbackInterceptor.java +++ /dev/null @@ -1,144 +0,0 @@ -package com.nhs; - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.interceptor.api.Hook; -import ca.uhn.fhir.interceptor.api.Interceptor; -import ca.uhn.fhir.interceptor.api.Pointcut; -import ca.uhn.fhir.jpa.api.dao.DaoRegistry; -import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; -import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; -import ca.uhn.fhir.rest.api.server.IBundleProvider; -import ca.uhn.fhir.rest.api.server.RequestDetails; -import ca.uhn.fhir.util.ParametersUtil; -import org.hl7.fhir.instance.model.api.IBaseResource; -import org.hl7.fhir.r4.model.CapabilityStatement; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.stream.Collectors; - -/** - * Runs only against $validate calls. Does NOT perform validation itself - it simply ensures - * the resource being validated has a meta.profile set before HAPI's built-in $validate operation - * processes it, using this priority: - * - * 1. The resource's own declared meta.profile, if present - left untouched. - * 2. The profile declared for this resource type in the server's stored CapabilityStatement - * (assumes exactly one CapabilityStatement resource is present in the repository). - * 3. The base/core FHIR StructureDefinition for the resource type, as a last resort - in - * practice this just means we leave meta.profile empty and let $validate fall back to - * base structural validation, which is its normal behaviour with no profile declared. - * - * Does NOT hook resource create/update storage pointcuts, so it has no effect on plain - * POST/PUT calls used to load assets - only on $validate. - */ -@Interceptor -public class CapabilityStatementProfileFallbackInterceptor { - - private static final Logger ourLog = LoggerFactory.getLogger(CapabilityStatementProfileFallbackInterceptor.class); - - private static final String VALIDATE_OPERATION_NAME = "$validate"; - - private final FhirContext myFhirContext; - private final DaoRegistry myDaoRegistry; - - // Lazily loaded and cached once non-empty - one CapabilityStatement per run, loaded during - // the asset phase which always completes before $validate calls begin. - private volatile Map myResourceTypeToProfile; - - public CapabilityStatementProfileFallbackInterceptor(FhirContext theFhirContext, DaoRegistry theDaoRegistry) { - myFhirContext = theFhirContext; - myDaoRegistry = theDaoRegistry; - } - - @Hook(Pointcut.SERVER_INCOMING_REQUEST_PRE_HANDLED) - public void preHandle(RequestDetails theRequestDetails) { - if (!VALIDATE_OPERATION_NAME.equals(theRequestDetails.getOperation())) { - return; - } - - IBaseResource target = extractTargetResource(theRequestDetails); - if (target == null) { - ourLog.warn("$validate call received but no target resource could be extracted from RequestDetails - " - + "profile fallback will not run for this request"); - return; - } - - if (!target.getMeta().getProfile().isEmpty()) { - // Resource already declares a profile - honour it, don't override. - return; - } - - String resourceType = myFhirContext.getResourceType(target); - String profile = resolveFromCapabilityStatement(resourceType); - if (profile != null) { - target.getMeta().addProfile(profile); - ourLog.debug("No meta.profile declared for {} - defaulting to {} from CapabilityStatement", - resourceType, profile); - } - // If null: no CapabilityStatement entry for this type either - leave meta.profile empty - // and let $validate fall through to base structural validation for the type. - } - - /** - * $validate can be called either with the resource posted directly, or wrapped in a - * Parameters resource alongside "profile"/"mode" parameters. Handle both. - */ - private IBaseResource extractTargetResource(RequestDetails theRequestDetails) { - IBaseResource resource = theRequestDetails.getResource(); - if (resource == null) { - return null; - } - - String type = myFhirContext.getResourceType(resource); - if ("Parameters".equals(type)) { - Optional wrapped = ParametersUtil.getNamedParameterResource( - myFhirContext, (org.hl7.fhir.instance.model.api.IBaseParameters) resource, "resource"); - return wrapped.orElse(null); - } - return resource; - } - - private synchronized String resolveFromCapabilityStatement(String theResourceType) { - if (myResourceTypeToProfile == null || myResourceTypeToProfile.isEmpty()) { - myResourceTypeToProfile = loadProfileMapFromStoredCapabilityStatement(); - } - return myResourceTypeToProfile.get(theResourceType); - } - - @SuppressWarnings("unchecked") - private Map loadProfileMapFromStoredCapabilityStatement() { - try { - IFhirResourceDao dao = - (IFhirResourceDao) myDaoRegistry.getResourceDao("CapabilityStatement"); - - IBundleProvider results = dao.search(SearchParameterMap.newSynchronous()); - int size = results.size() != null ? results.size() : 1; - List resources = (List) (List) results.getResources(0, size); - - if (resources.isEmpty()) { - ourLog.warn("No CapabilityStatement found in the repository yet - profile fallback unavailable"); - return Collections.emptyMap(); - } - if (resources.size() > 1) { - ourLog.warn("Multiple CapabilityStatement resources found - using the first one returned"); - } - - CapabilityStatement cs = resources.get(0); - return cs.getRest().stream() - .flatMap(rest -> rest.getResource().stream()) - .filter(CapabilityStatement.CapabilityStatementRestResourceComponent::hasProfile) - .collect(Collectors.toMap( - CapabilityStatement.CapabilityStatementRestResourceComponent::getType, - CapabilityStatement.CapabilityStatementRestResourceComponent::getProfile, - (a, b) -> a)); - } catch (Exception e) { - ourLog.warn("Failed to load CapabilityStatement for profile fallback: {}", e.getMessage()); - return Collections.emptyMap(); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/nhs/CapabilityStatementValidationConfig.java b/src/main/java/com/nhs/CapabilityStatementValidationConfig.java new file mode 100644 index 0000000..b0cd599 --- /dev/null +++ b/src/main/java/com/nhs/CapabilityStatementValidationConfig.java @@ -0,0 +1,34 @@ +package com.nhs; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class CapabilityStatementValidationConfig { + + @Autowired + private FhirContext myFhirContext; + + @Autowired + private DaoRegistry myDaoRegistry; + + @Bean + public FilterRegistrationBean validateProfileDefaultingFilter() { + ValidateProfileDefaultingFilter filter = + new ValidateProfileDefaultingFilter(myFhirContext, myDaoRegistry); + + FilterRegistrationBean registration = + new FilterRegistrationBean<>(filter); + + registration.addUrlPatterns("/fhir/*"); + registration.setOrder(1); + registration.setName("validateProfileDefaultingFilter"); + + System.out.println("[CONFIG] ValidateProfileDefaultingFilter registered for /fhir/*"); + return registration; + } +} \ No newline at end of file diff --git a/src/main/java/com/nhs/ProfileValidationConfig.java b/src/main/java/com/nhs/ProfileValidationConfig.java deleted file mode 100644 index f86bdb8..0000000 --- a/src/main/java/com/nhs/ProfileValidationConfig.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.nhs; - -import ca.uhn.fhir.context.FhirContext; -import ca.uhn.fhir.interceptor.api.IInterceptorService; -import ca.uhn.fhir.jpa.api.dao.DaoRegistry; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; - -@Configuration -public class ProfileValidationConfig { - - @Autowired - private FhirContext myFhirContext; - - @Autowired - private DaoRegistry myDaoRegistry; - - @Autowired - private IInterceptorService myInterceptorService; - - @Bean - public CapabilityStatementProfileFallbackInterceptor capabilityStatementProfileFallbackInterceptor() { - return new CapabilityStatementProfileFallbackInterceptor(myFhirContext, myDaoRegistry); - } - - @EventListener(ContextRefreshedEvent.class) - public void registerInterceptor(ContextRefreshedEvent event) { - CapabilityStatementProfileFallbackInterceptor interceptor = - event.getApplicationContext().getBean(CapabilityStatementProfileFallbackInterceptor.class); - if (!myInterceptorService.getAllRegisteredInterceptors().contains(interceptor)) { - myInterceptorService.registerInterceptor(interceptor); - } - } -} \ No newline at end of file diff --git a/src/main/java/com/nhs/ValidateProfileDefaultingFilter.java b/src/main/java/com/nhs/ValidateProfileDefaultingFilter.java new file mode 100644 index 0000000..3b7c112 --- /dev/null +++ b/src/main/java/com/nhs/ValidateProfileDefaultingFilter.java @@ -0,0 +1,277 @@ +package com.nhs; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.api.dao.DaoRegistry; +import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; +import ca.uhn.fhir.jpa.searchparam.SearchParameterMap; +import ca.uhn.fhir.rest.api.server.IBundleProvider; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.hl7.fhir.r4.model.CapabilityStatement; +import org.springframework.web.filter.OncePerRequestFilter; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.Node; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; + +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.transform.OutputKeys; +import javax.xml.transform.Transformer; +import javax.xml.transform.TransformerFactory; +import javax.xml.transform.dom.DOMSource; +import javax.xml.transform.stream.StreamResult; +import java.io.IOException; +import java.io.StringReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Collectors; + +/** + * Intercepts POST {base}/{ResourceType}/$validate calls. If the resource being validated + * doesn't already declare a meta.profile, injects one before the request reaches HAPI's + * built-in $validate operation, using this priority: + * + * 1. The resource's own declared meta.profile - left untouched if present. + * 2. The profile declared for this resource type in the server's stored CapabilityStatement + * (assumes exactly one CapabilityStatement resource is present in the repository). + * 3. Neither - request passes through unmodified. + * + * Handles both JSON (raw resource, and Parameters-wrapped) and XML (raw resource) bodies. + */ +public class ValidateProfileDefaultingFilter extends OncePerRequestFilter { + + private static final Pattern VALIDATE_PATH_PATTERN = Pattern.compile("/([A-Z][A-Za-z]+)/\\$validate/?$"); + private static final String FHIR_NS = "http://hl7.org/fhir"; + + private final FhirContext myFhirContext; + private final DaoRegistry myDaoRegistry; + private final ObjectMapper myJsonMapper = new ObjectMapper(); + + private volatile Map myResourceTypeToProfile; + + public ValidateProfileDefaultingFilter(FhirContext theFhirContext, DaoRegistry theDaoRegistry) { + myFhirContext = theFhirContext; + myDaoRegistry = theDaoRegistry; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String resourceType = extractResourceType(request.getRequestURI()); + String contentType = request.getContentType(); + + if (resourceType == null || contentType == null) { + filterChain.doFilter(request, response); + return; + } + + boolean isJson = contentType.contains("json"); + boolean isXml = contentType.contains("xml"); + + if (!isJson && !isXml) { + filterChain.doFilter(request, response); + return; + } + + CachedBodyHttpServletRequest wrapped = new CachedBodyHttpServletRequest(request); + + try { + if (isJson) { + rewriteJson(wrapped, resourceType); + } else { + rewriteXml(wrapped, resourceType); + } + } catch (Exception e) { + // Don't block validation if our rewriting logic fails - just pass the original body through. + System.err.println("[VALIDATE-DEFAULT] Failed to inspect/rewrite body: " + e.getMessage()); + } + + filterChain.doFilter(wrapped, response); + } + + private String extractResourceType(String requestUri) { + Matcher m = VALIDATE_PATH_PATTERN.matcher(requestUri); + return m.find() ? m.group(1) : null; + } + + // ---------------------------------------------------------------- JSON + + private void rewriteJson(CachedBodyHttpServletRequest wrapped, String resourceType) throws IOException { + JsonNode root = myJsonMapper.readTree(wrapped.getBodyBytes()); + ObjectNode target = findTargetResourceNode(root); + + if (target == null || hasProfileJson(target)) { + return; + } + + String profile = resolveFromCapabilityStatement(resourceType); + if (profile == null) { + return; + } + + addProfileJson(target, profile); + wrapped.setBody(myJsonMapper.writeValueAsBytes(root)); + System.out.println("[VALIDATE-DEFAULT] No meta.profile on " + resourceType + " (JSON) - defaulting to " + profile); + } + + /** Handles both a raw resource body, and a Parameters-wrapped body with a "resource" parameter. */ + private ObjectNode findTargetResourceNode(JsonNode root) { + if (root == null || !root.isObject()) { + return null; + } + JsonNode resourceTypeNode = root.get("resourceType"); + if (resourceTypeNode != null && "Parameters".equals(resourceTypeNode.asText())) { + JsonNode parameters = root.get("parameter"); + if (parameters != null && parameters.isArray()) { + for (JsonNode param : parameters) { + JsonNode name = param.get("name"); + if (name != null && "resource".equals(name.asText())) { + JsonNode resourceNode = param.get("resource"); + return resourceNode != null && resourceNode.isObject() ? (ObjectNode) resourceNode : null; + } + } + } + return null; + } + return (ObjectNode) root; + } + + private boolean hasProfileJson(ObjectNode target) { + JsonNode meta = target.get("meta"); + if (meta == null) { + return false; + } + JsonNode profile = meta.get("profile"); + return profile != null && profile.isArray() && !profile.isEmpty(); + } + + private void addProfileJson(ObjectNode target, String profileUrl) { + ObjectNode meta = target.has("meta") && target.get("meta").isObject() + ? (ObjectNode) target.get("meta") + : target.putObject("meta"); + + ArrayNode profiles = meta.has("profile") && meta.get("profile").isArray() + ? (ArrayNode) meta.get("profile") + : meta.putArray("profile"); + + profiles.add(profileUrl); + } + + // ----------------------------------------------------------------- XML + + private void rewriteXml(CachedBodyHttpServletRequest wrapped, String resourceType) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + // Harden against XXE - this only ever parses resources we control/generate in CI, but no reason not to. + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new InputSource(new StringReader(wrapped.getBodyAsString()))); + + Element root = doc.getDocumentElement(); + Element metaEl = getDirectChildElement(root, "meta"); + + boolean hasProfile = metaEl != null && metaEl.getElementsByTagNameNS(FHIR_NS, "profile").getLength() > 0; + if (hasProfile) { + return; + } + + String profile = resolveFromCapabilityStatement(resourceType); + if (profile == null) { + return; + } + + if (metaEl == null) { + metaEl = doc.createElementNS(FHIR_NS, "meta"); + // FHIR element order: id, meta, implicitRules, language, ... - insert right after id if present, + // otherwise as the very first child. + Element idEl = getDirectChildElement(root, "id"); + Node insertBeforeNode = idEl != null ? idEl.getNextSibling() : root.getFirstChild(); + root.insertBefore(metaEl, insertBeforeNode); + } + + Element profileEl = doc.createElementNS(FHIR_NS, "profile"); + profileEl.setAttribute("value", profile); + metaEl.appendChild(profileEl); + + TransformerFactory tf = TransformerFactory.newInstance(); + Transformer transformer = tf.newTransformer(); + transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8"); + StringWriter writer = new StringWriter(); + transformer.transform(new DOMSource(doc), new StreamResult(writer)); + + wrapped.setBody(writer.toString().getBytes(StandardCharsets.UTF_8)); + System.out.println("[VALIDATE-DEFAULT] No meta.profile on " + resourceType + " (XML) - defaulting to " + profile); + } + + private Element getDirectChildElement(Element parent, String localName) { + NodeList children = parent.getChildNodes(); + for (int i = 0; i < children.getLength(); i++) { + Node node = children.item(i); + if (node.getNodeType() == Node.ELEMENT_NODE + && localName.equals(node.getLocalName()) + && FHIR_NS.equals(node.getNamespaceURI())) { + return (Element) node; + } + } + return null; + } + + // ------------------------------------------------------- CapabilityStatement lookup (shared) + + private synchronized String resolveFromCapabilityStatement(String resourceType) { + if (myResourceTypeToProfile == null || myResourceTypeToProfile.isEmpty()) { + myResourceTypeToProfile = loadProfileMapFromStoredCapabilityStatement(); + } + return myResourceTypeToProfile.get(resourceType); + } + + @SuppressWarnings("unchecked") + private Map loadProfileMapFromStoredCapabilityStatement() { + try { + IFhirResourceDao dao = + (IFhirResourceDao) myDaoRegistry.getResourceDao("CapabilityStatement"); + + IBundleProvider results = dao.search(SearchParameterMap.newSynchronous()); + int size = results.size() != null ? results.size() : 1; + List resources = (List) (List) results.getResources(0, size); + + if (resources.isEmpty()) { + return Collections.emptyMap(); + } + + CapabilityStatement cs = resources.get(0); + return cs.getRest().stream() + .flatMap(rest -> rest.getResource().stream()) + .filter(CapabilityStatement.CapabilityStatementRestResourceComponent::hasProfile) + .collect(Collectors.toMap( + CapabilityStatement.CapabilityStatementRestResourceComponent::getType, + CapabilityStatement.CapabilityStatementRestResourceComponent::getProfile, + (a, b) -> a)); + } catch (Exception e) { + System.err.println("[VALIDATE-DEFAULT] Failed to load CapabilityStatement: " + e.getMessage()); + return Collections.emptyMap(); + } + } +} \ No newline at end of file