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 0922647..31c39c3 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: @@ -529,3 +530,4 @@ hapi: refuse_to_fetch_third_party_urls: false fhir_version: R4 + \ 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/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/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/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