
dev.dsf.fhir.adapter.ThymeleafTemplateServiceImpl Maven / Gradle / Ivy
package dev.dsf.fhir.adapter;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.StringReader;
import java.io.StringWriter;
import java.net.MalformedURLException;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Supplier;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.stream.StreamResult;
import javax.xml.transform.stream.StreamSource;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Bundle.BundleLinkComponent;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Resource;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.web.util.HtmlUtils;
import org.thymeleaf.TemplateEngine;
import org.thymeleaf.context.Context;
import org.thymeleaf.templatemode.TemplateMode;
import org.thymeleaf.templateresolver.ClassLoaderTemplateResolver;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.model.api.annotation.ResourceDef;
import ca.uhn.fhir.parser.IParser;
import dev.dsf.common.auth.conf.Identity;
import dev.dsf.common.ui.theme.Theme;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.PathSegment;
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.core.UriInfo;
public class ThymeleafTemplateServiceImpl implements ThymeleafTemplateService, InitializingBean
{
private static final String RESOURCE_NAMES = "Account|ActivityDefinition|AdverseEvent|AllergyIntolerance|Appointment|AppointmentResponse|AuditEvent|Basic|Binary"
+ "|BiologicallyDerivedProduct|BodyStructure|Bundle|CapabilityStatement|CarePlan|CareTeam|CatalogEntry|ChargeItem|ChargeItemDefinition|Claim|ClaimResponse"
+ "|ClinicalImpression|CodeSystem|Communication|CommunicationRequest|CompartmentDefinition|Composition|ConceptMap|Condition|Consent|Contract|Coverage"
+ "|CoverageEligibilityRequest|CoverageEligibilityResponse|DetectedIssue|Device|DeviceDefinition|DeviceMetric|DeviceRequest|DeviceUseStatement"
+ "|DiagnosticReport|DocumentManifest|DocumentReference|DomainResource|EffectEvidenceSynthesis|Encounter|Endpoint|EnrollmentRequest|EnrollmentResponse"
+ "|EpisodeOfCare|EventDefinition|Evidence|EvidenceVariable|ExampleScenario|ExplanationOfBenefit|FamilyMemberHistory|Flag|Goal|GraphDefinition|Group"
+ "|GuidanceResponse|HealthcareService|ImagingStudy|Immunization|ImmunizationEvaluation|ImmunizationRecommendation|ImplementationGuide|InsurancePlan"
+ "|Invoice|Library|Linkage|List|Location|Measure|MeasureReport|Media|Medication|MedicationAdministration|MedicationDispense|MedicationKnowledge"
+ "|MedicationRequest|MedicationStatement|MedicinalProduct|MedicinalProductAuthorization|MedicinalProductContraindication|MedicinalProductIndication"
+ "|MedicinalProductIngredient|MedicinalProductInteraction|MedicinalProductManufactured|MedicinalProductPackaged|MedicinalProductPharmaceutical"
+ "|MedicinalProductUndesirableEffect|MessageDefinition|MessageHeader|MolecularSequence|NamingSystem|NutritionOrder|Observation|ObservationDefinition"
+ "|OperationDefinition|OperationOutcome|Organization|OrganizationAffiliation|Parameters|Patient|PaymentNotice|PaymentReconciliation|Person|PlanDefinition"
+ "|Practitioner|PractitionerRole|Procedure|Provenance|Questionnaire|QuestionnaireResponse|RelatedPerson|RequestGroup|ResearchDefinition"
+ "|ResearchElementDefinition|ResearchStudy|ResearchSubject|Resource|RiskAssessment|RiskEvidenceSynthesis|Schedule|SearchParameter|ServiceRequest|Slot"
+ "|Specimen|SpecimenDefinition|StructureDefinition|StructureMap|Subscription|Substance|SubstanceNucleicAcid|SubstancePolymer|SubstanceProtein"
+ "|SubstanceReferenceInformation|SubstanceSourceMaterial|SubstanceSpecification|SupplyDelivery|SupplyRequest|Task|TerminologyCapabilities|TestReport"
+ "|TestScript|ValueSet|VerificationResult|VisionPrescription";
private static final String UUID = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
private static final Pattern URL_PATTERN = Pattern
.compile("(https?)://[-a-zA-Z0-9+&@#/%?=~_|!:,.;]*[-a-zA-Z0-9+&@#/%=~_.|]");
private static final Pattern XML_REFERENCE_UUID_PATTERN = Pattern
.compile("<reference value=\"((" + RESOURCE_NAMES + ")/" + UUID + ")\"/>");
private static final Pattern JSON_REFERENCE_UUID_PATTERN = Pattern
.compile("\"reference\": \"((" + RESOURCE_NAMES + ")/" + UUID + ")\",");
private static final Pattern XML_ID_UUID_AND_VERSION_PATTERN = Pattern.compile(
"<id value=\"(" + UUID + ")\"/>\\n([ ]*)<meta>\\n([ ]*)<versionId value=\"([0-9]+)\"/>");
private static final Pattern JSON_ID_UUID_AND_VERSION_PATTERN = Pattern
.compile("\"id\": \"(" + UUID + ")\",\\n([ ]*)\"meta\": \\{\\n([ ]*)\"versionId\": \"([0-9]+)\",");
private final String serverBaseUrl;
private final Theme theme;
private final FhirContext fhirContext;
private final boolean modCssExists;
private final Map, List> contextsByResourceType;
private final TransformerFactory transformerFactory = TransformerFactory.newInstance();
private final TemplateEngine templateEngine = new TemplateEngine();
/**
* @param serverBaseUrl
* not null
* @param theme
* may be null
* @param fhirContext
* not null
* @param contexts
* may be null
* @param cacheEnabled
* @param modCssExists
*/
public ThymeleafTemplateServiceImpl(String serverBaseUrl, Theme theme, FhirContext fhirContext,
List extends ThymeleafContext> contexts, boolean cacheEnabled, boolean modCssExists)
{
this.serverBaseUrl = serverBaseUrl;
this.theme = theme;
this.fhirContext = fhirContext;
this.modCssExists = modCssExists;
contextsByResourceType = contexts == null ? Map.of()
: contexts.stream().collect(Collectors.groupingBy(ThymeleafContext::getResourceType));
ClassLoaderTemplateResolver resolver = new ClassLoaderTemplateResolver();
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setPrefix("/template/");
resolver.setSuffix(".html");
resolver.setCacheable(cacheEnabled);
templateEngine.setTemplateResolver(resolver);
}
@Override
public void afterPropertiesSet() throws Exception
{
Objects.requireNonNull(serverBaseUrl, "serverBaseUrl");
Objects.requireNonNull(fhirContext, "fhirContext");
// theme may be null
}
@Override
public void writeTo(Resource resource, Class> type, MediaType mediaType, UriInfo uriInfo,
SecurityContext securityContext, OutputStream outputStream) throws IOException
{
Context context = new Context();
context.setVariable("basePath", getServerBaseUrlPathWithLeadingSlash());
context.setVariable("modCssExists", modCssExists);
context.setVariable("theme", theme == null ? null : theme.toString());
context.setVariable("title", getTitle(uriInfo));
context.setVariable("heading", getHeading(resource, uriInfo));
context.setVariable("username",
securityContext.getUserPrincipal() instanceof Identity i ? i.getDisplayName() : null);
context.setVariable("openid", "OPENID".equals(securityContext.getAuthenticationScheme()));
context.setVariable("xml", toXml(mediaType, resource));
context.setVariable("json", toJson(mediaType, resource));
context.setVariable("resourceId", ElementId.from(resource));
getContext(type, uriInfo).ifPresent(tContext ->
{
context.setVariable("htmlFragment", tContext.getHtmlFragment());
tContext.setVariables(context::setVariable, resource);
});
OutputStreamWriter writer = new OutputStreamWriter(outputStream, StandardCharsets.UTF_8);
templateEngine.process("main", context, writer);
}
private Optional getContext(Class> type, UriInfo uriInfo)
{
return contextsByResourceType.getOrDefault(type, List.of()).stream().filter(g ->
{
Optional lastSegment = uriInfo.getPathSegments().stream().filter(Objects::nonNull)
.map(PathSegment::getPath).filter(Objects::nonNull).filter(s -> !s.isBlank())
.reduce((first, second) -> second);
return lastSegment.map(g::isResourceSupported).orElse(false);
}).findFirst();
}
private String getServerBaseUrlPathWithLeadingSlash()
{
try
{
return new URL(serverBaseUrl).getPath();
}
catch (MalformedURLException e)
{
throw new RuntimeException(e);
}
}
private String getTitle(UriInfo uriInfo)
{
if (uriInfo == null || uriInfo.getPath() == null || uriInfo.getPath().isBlank())
return "DSF";
else if (uriInfo.getPath().endsWith("/"))
return "DSF: " + HtmlUtils.htmlEscape(uriInfo.getPath().substring(0, uriInfo.getPath().length() - 1));
else
return "DSF: " + HtmlUtils.htmlEscape(uriInfo.getPath());
}
private String getHeading(Resource resource, UriInfo uriInfo)
{
URI uri = getResourceUri(resource, uriInfo);
String[] pathSegments = uri.getPath().split("/");
String u = serverBaseUrl;
StringBuilder heading = new StringBuilder("" + u + "");
String[] basePathSegments = getServerBaseUrlPathWithLeadingSlash().split("/");
for (int i = basePathSegments.length; i < pathSegments.length; i++)
{
String pathSegment = HtmlUtils.htmlEscape(pathSegments[i]);
u += "/" + pathSegment;
heading.append("/" + pathSegment + "");
}
if (uri.getQuery() != null)
{
String queryValue = HtmlUtils.htmlEscape(uri.getQuery());
u += "?" + queryValue;
heading.append("?"
+ queryValue.replace("&", "&").replace("-", "‑") + " ");
}
else if (uriInfo.getQueryParameters().containsKey("_summary"))
{
String summaryValue = HtmlUtils.htmlEscape(uriInfo.getQueryParameters().getFirst("_summary"));
u += "?_summary=" + summaryValue;
heading.append("?_summary=" + summaryValue + "");
}
return heading.toString();
}
private URI getResourceUri(Resource resource, UriInfo uriInfo)
{
return getResourceUrlString(resource, uriInfo).map(this::toURI)
.orElse(toURI(serverBaseUrl + "/" + uriInfo.getPath()));
}
private URI toURI(String str)
{
try
{
return new URI(str);
}
catch (URISyntaxException e)
{
throw new RuntimeException(e);
}
}
private Optional getResourceUrlString(Resource resource, UriInfo uriInfo)
{
if (resource.getIdElement().hasIdPart())
{
if (!uriInfo.getPath().contains("_history"))
return Optional.of(String.format("%s/%s/%s", serverBaseUrl, resource.getIdElement().getResourceType(),
resource.getIdElement().getIdPart()));
else
return Optional.of(
String.format("%s/%s/%s/_history/%s", serverBaseUrl, resource.getIdElement().getResourceType(),
resource.getIdElement().getIdPart(), resource.getIdElement().getVersionIdPart()));
}
else if (resource instanceof Bundle b && !resource.getIdElement().hasIdPart())
return b.getLink().stream().filter(c -> "self".equals(c.getRelation())).findFirst()
.map(BundleLinkComponent::getUrl);
else
return Optional.empty();
}
private String toXml(MediaType mediaType, Resource resource) throws IOException
{
IParser parser = getParser(mediaType, fhirContext::newXmlParser);
String content = parser.encodeResourceToString(resource);
content = content.replace("&", "&").replace("'", "'").replace(">", ">")
.replace("<", "<").replace(""", """);
content = simplifyXml(content);
content = content.replace("<", "<").replace(">", ">");
Matcher versionMatcher = XML_ID_UUID_AND_VERSION_PATTERN.matcher(content);
content = versionMatcher.replaceAll(result ->
{
Optional resourceName = getResourceName(resource, result.group(1));
return resourceName.map(rN -> "<id value=\""
+ result.group(1) + "\"/>\n" + result.group(2) + "<meta>\n" + result.group(3)
+ "<versionId value=\"" + "" + result.group(4) + "" + "\"/>").orElse(result.group(0));
});
Matcher urlMatcher = URL_PATTERN.matcher(content);
content = urlMatcher.replaceAll(result -> "" + result.group() + "");
Matcher referenceUuidMatcher = XML_REFERENCE_UUID_PATTERN.matcher(content);
content = referenceUuidMatcher.replaceAll(
result -> "<reference value=\"" + result.group(1) + "\">");
return content;
}
private IParser getParser(MediaType mediaType, Supplier parserFactor)
{
/* Parsers are not guaranteed to be thread safe */
IParser p = parserFactor.get();
p.setStripVersionsFromReferences(false);
p.setOverrideResourceIdWithBundleEntryFullUrl(false);
if (mediaType != null)
{
switch (mediaType.getParameters().getOrDefault("summary", "false"))
{
case "true" -> p.setSummaryMode(true);
case "text" -> p.setEncodeElements(Set.of("*.text", "*.id", "*.meta", "*.(mandatory)"));
case "data" -> p.setSuppressNarratives(true);
}
}
p.setPrettyPrint(true);
return p;
}
private Optional getResourceName(Resource resource, String uuid)
{
if (resource instanceof Bundle)
{
// if persistent Bundle resource
if (Objects.equals(uuid, resource.getIdElement().getIdPart()))
return Optional.of(resource.getClass().getAnnotation(ResourceDef.class).name());
else
return ((Bundle) resource).getEntry().stream().filter(c ->
{
if (c.hasResource())
return uuid.equals(c.getResource().getIdElement().getIdPart());
else
return uuid.equals(new IdType(c.getResponse().getLocation()).getIdPart());
}).map(c ->
{
if (c.hasResource())
return c.getResource().getClass().getAnnotation(ResourceDef.class).name();
else
return new IdType(c.getResponse().getLocation()).getResourceType();
}).findFirst();
}
else
return Optional.of(resource.getClass().getAnnotation(ResourceDef.class).name());
}
private Transformer newTransformer() throws TransformerConfigurationException
{
Transformer transformer = transformerFactory.newTransformer();
transformer.setOutputProperty(OutputKeys.METHOD, "xml");
transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
transformer.setOutputProperty(OutputKeys.INDENT, "yes");
transformer.setOutputProperty("{http://xml.apache.org/xalan}indent-amount", "3");
return transformer;
}
private String simplifyXml(String xml)
{
try
{
Transformer transformer = newTransformer();
StringWriter writer = new StringWriter();
transformer.transform(new StreamSource(new StringReader(xml)), new StreamResult(writer));
return writer.toString();
}
catch (TransformerException e)
{
throw new RuntimeException(e);
}
}
private String toJson(MediaType mediaType, Resource resource) throws IOException
{
IParser parser = getParser(mediaType, fhirContext::newJsonParser);
String content = parser.encodeResourceToString(resource).replace("<", "<").replace(">", ">");
Matcher urlMatcher = URL_PATTERN.matcher(content);
content = urlMatcher.replaceAll(result -> "" + result.group() + "");
Matcher referenceUuidMatcher = JSON_REFERENCE_UUID_PATTERN.matcher(content);
content = referenceUuidMatcher.replaceAll(
result -> "\"reference\": \"" + result.group(1) + "\",");
Matcher idUuidMatcher = JSON_ID_UUID_AND_VERSION_PATTERN.matcher(content);
content = idUuidMatcher.replaceAll(result ->
{
Optional resourceName = getResourceName(resource, result.group(1));
return resourceName.map(rN -> "\"id\": \"" + result.group(1)
+ "\",\n" + result.group(2) + "\"meta\": {\n" + result.group(3) + "\"versionId\": \""
+ ""
+ result.group(4) + "" + "\",").orElse(result.group(0));
});
return content;
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy