Add additional resource docs to generated OpenAPI documentation (#5359)

* Add additional resource docs to generated OpenAPI documentation

* Test fix
This commit is contained in:
James Agnew 2023-10-10 20:36:33 -04:00 committed by GitHub
parent 424f26b897
commit 88e9780004
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 128 additions and 29 deletions

View File

@ -0,0 +1,8 @@
---
type: add
issue: 5355
title: "The generated OpenAPI documentation produced by OpenApiInterceptor will now include
additional details in the individual resource type documentation, including the values of
*CapabilityStatement.rest.resource.documentation*,
*CapabilityStatement.rest.resource.profile*, and
*CapabilityStatement.rest.resource.supportedProfile*."

View File

@ -64,6 +64,7 @@ import org.hl7.fhir.convertors.factory.VersionConvertorFactory_43_50;
import org.hl7.fhir.instance.model.api.IBaseConformance; import org.hl7.fhir.instance.model.api.IBaseConformance;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IPrimitiveType; import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.r4.model.CanonicalType;
import org.hl7.fhir.r4.model.CapabilityStatement; import org.hl7.fhir.r4.model.CapabilityStatement;
import org.hl7.fhir.r4.model.CodeableConcept; import org.hl7.fhir.r4.model.CodeableConcept;
import org.hl7.fhir.r4.model.Coding; import org.hl7.fhir.r4.model.Coding;
@ -108,10 +109,12 @@ import java.util.Properties;
import java.util.Set; import java.util.Set;
import java.util.function.Supplier; import java.util.function.Supplier;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import javax.annotation.Nonnull;
import javax.servlet.ServletContext; import javax.servlet.ServletContext;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import static ca.uhn.fhir.rest.server.util.NarrativeUtil.sanitizeHtmlFragment;
import static org.apache.commons.lang3.ObjectUtils.defaultIfNull; import static org.apache.commons.lang3.ObjectUtils.defaultIfNull;
import static org.apache.commons.lang3.StringUtils.defaultString; import static org.apache.commons.lang3.StringUtils.defaultString;
import static org.apache.commons.lang3.StringUtils.isBlank; import static org.apache.commons.lang3.StringUtils.isBlank;
@ -356,7 +359,7 @@ public class OpenApiInterceptor {
String copyright = cs.getCopyright(); String copyright = cs.getCopyright();
if (isNotBlank(copyright)) { if (isNotBlank(copyright)) {
copyright = myFlexmarkRenderer.render(myFlexmarkParser.parse(copyright)); copyright = renderMarkdown(copyright);
context.setVariable("COPYRIGHT_HTML", copyright); context.setVariable("COPYRIGHT_HTML", copyright);
} }
@ -411,6 +414,11 @@ public class OpenApiInterceptor {
theResponse.getWriter().close(); theResponse.getWriter().close();
} }
@Nonnull
private String renderMarkdown(String copyright) {
return myFlexmarkRenderer.render(myFlexmarkParser.parse(copyright));
}
protected void populateOIDCVariables(ServletRequestDetails theRequestDetails, WebContext theContext) { protected void populateOIDCVariables(ServletRequestDetails theRequestDetails, WebContext theContext) {
theContext.setVariable("OAUTH2_REDIRECT_URL_PROPERTY", ""); theContext.setVariable("OAUTH2_REDIRECT_URL_PROPERTY", "");
} }
@ -515,7 +523,7 @@ public class OpenApiInterceptor {
Tag resourceTag = new Tag(); Tag resourceTag = new Tag();
resourceTag.setName(resourceType); resourceTag.setName(resourceType);
resourceTag.setDescription("The " + resourceType + " FHIR resource type"); resourceTag.setDescription(createResourceDescription(nextResource));
openApi.addTagsItem(resourceTag); openApi.addTagsItem(resourceTag);
// Instance Read // Instance Read
@ -624,6 +632,36 @@ public class OpenApiInterceptor {
return openApi; return openApi;
} }
@Nonnull
protected String createResourceDescription(
CapabilityStatement.CapabilityStatementRestResourceComponent theResource) {
StringBuilder b = new StringBuilder();
b.append("The ").append(theResource.getType()).append(" FHIR resource type");
String documentation = theResource.getDocumentation();
if (isNotBlank(documentation)) {
b.append("<br/>");
b.append(sanitizeHtmlFragment(renderMarkdown(documentation)));
}
if (isNotBlank(theResource.getProfile())) {
b.append("<br/>");
b.append("Base profile: ");
b.append(sanitizeHtmlFragment(theResource.getProfile()));
}
for (CanonicalType next : theResource.getSupportedProfile()) {
String nextSupportedProfile = next.getValueAsString();
if (isNotBlank(nextSupportedProfile)) {
b.append("<br/>");
b.append("Supported profile: ");
b.append(sanitizeHtmlFragment(nextSupportedProfile));
}
}
return b.toString();
}
protected void addSearchOperation( protected void addSearchOperation(
final OpenAPI openApi, final OpenAPI openApi,
final Operation operation, final Operation operation,

View File

@ -2,6 +2,7 @@ package ca.uhn.fhir.rest.openapi;
import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.interceptor.api.Hook; 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.interceptor.api.Pointcut;
import ca.uhn.fhir.model.api.annotation.Description; import ca.uhn.fhir.model.api.annotation.Description;
import ca.uhn.fhir.rest.annotation.*; import ca.uhn.fhir.rest.annotation.*;
@ -32,16 +33,19 @@ import org.apache.http.client.methods.HttpGet;
import org.hamcrest.Matchers; import org.hamcrest.Matchers;
import org.hl7.fhir.instance.model.api.*; import org.hl7.fhir.instance.model.api.*;
import org.hl7.fhir.r5.model.ActorDefinition; import org.hl7.fhir.r5.model.ActorDefinition;
import org.hl7.fhir.r5.model.CapabilityStatement;
import org.junit.jupiter.api.*; import org.junit.jupiter.api.*;
import org.junit.jupiter.api.extension.RegisterExtension; import org.junit.jupiter.api.extension.RegisterExtension;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.io.IOException; import java.io.IOException;
import java.net.URL; import java.net.URL;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.util.*; import java.util.*;
import java.util.function.Consumer;
import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.not;
import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.MatcherAssert.assertThat;
@ -83,6 +87,28 @@ public class OpenApiInterceptorTest {
assertThat(buttonTexts.toString(), buttonTexts, Matchers.contains("All", "System Level Operations", "OperationDefinition 1", "Observation", "Patient")); assertThat(buttonTexts.toString(), buttonTexts, Matchers.contains("All", "System Level Operations", "OperationDefinition 1", "Observation", "Patient"));
} }
@Test
public void testResourceDocsCopied() throws IOException {
myServer.getRestfulServer().registerInterceptor(new AddResourceCountsInterceptor("OperationDefinition"));
myServer.getRestfulServer().registerInterceptor(new OpenApiInterceptor());
myServer.registerInterceptor(new CapabilityStatementEnhancingInterceptor(cs->{
org.hl7.fhir.r4.model.CapabilityStatement.CapabilityStatementRestResourceComponent patientResource = findPatientResource(cs);
patientResource.setProfile("http://baseProfile");
patientResource.addSupportedProfile("http://foo");
patientResource.addSupportedProfile("http://bar");
patientResource.setDocumentation("This is **bolded** documentation");
}));
org.hl7.fhir.r4.model.CapabilityStatement cs = myServer.getFhirClient().capabilities().ofType(org.hl7.fhir.r4.model.CapabilityStatement.class).execute();
org.hl7.fhir.r4.model.CapabilityStatement.CapabilityStatementRestResourceComponent patientResource = findPatientResource(cs);
assertEquals("This is **bolded** documentation", patientResource.getDocumentation());
String url = "http://localhost:" + myServer.getPort() + "/fhir/swagger-ui/";
String resp = fetchSwaggerUi(url);
}
} }
@Nested @Nested
@ -111,6 +137,35 @@ public class OpenApiInterceptorTest {
} }
} }
@Interceptor
private static class CapabilityStatementEnhancingInterceptor {
private final Consumer<org.hl7.fhir.r4.model.CapabilityStatement> myConsumer;
public CapabilityStatementEnhancingInterceptor(Consumer<org.hl7.fhir.r4.model.CapabilityStatement> theConsumer) {
myConsumer = theConsumer;
}
@Hook(Pointcut.SERVER_CAPABILITY_STATEMENT_GENERATED)
public void massageCapabilityStatement(IBaseConformance theCs) {
myConsumer.accept((org.hl7.fhir.r4.model.CapabilityStatement) theCs);
}
}
@Nonnull
private static org.hl7.fhir.r4.model.CapabilityStatement.CapabilityStatementRestResourceComponent findPatientResource(org.hl7.fhir.r4.model.CapabilityStatement theCs) {
org.hl7.fhir.r4.model.CapabilityStatement.CapabilityStatementRestResourceComponent patientResource = theCs
.getRest()
.get(0)
.getResource()
.stream()
.filter(t -> "Patient".equals(t.getType()))
.findFirst()
.orElseThrow();
return patientResource;
}
@SuppressWarnings("JUnitMalformedDeclaration") @SuppressWarnings("JUnitMalformedDeclaration")
abstract static class BaseOpenApiInterceptorTest { abstract static class BaseOpenApiInterceptorTest {

View File

@ -47,10 +47,16 @@ public class NarrativeUtil {
* <li>All other elements and attributes are removed</li> * <li>All other elements and attributes are removed</li>
* </ul> * </ul>
*/ */
public static String sanitize(String theHtml) { public static String sanitizeHtmlFragment(String theHtml) {
XhtmlNode node = new XhtmlNode(); PolicyFactory idPolicy =
node.setValueAsString(theHtml); new HtmlPolicyBuilder().allowAttributes("id").globally().toFactory();
return sanitize(node).getValueAsString();
PolicyFactory policy = Sanitizers.FORMATTING
.and(Sanitizers.BLOCKS)
.and(Sanitizers.TABLES)
.and(Sanitizers.STYLES)
.and(idPolicy);
return policy.sanitize(theHtml);
} }
/** /**
@ -70,15 +76,7 @@ public class NarrativeUtil {
public static XhtmlNode sanitize(XhtmlNode theNode) { public static XhtmlNode sanitize(XhtmlNode theNode) {
String html = theNode.getValueAsString(); String html = theNode.getValueAsString();
PolicyFactory idPolicy = String safeHTML = sanitizeHtmlFragment(html);
new HtmlPolicyBuilder().allowAttributes("id").globally().toFactory();
PolicyFactory policy = Sanitizers.FORMATTING
.and(Sanitizers.BLOCKS)
.and(Sanitizers.TABLES)
.and(Sanitizers.STYLES)
.and(idPolicy);
String safeHTML = policy.sanitize(html);
XhtmlNode retVal = new XhtmlNode(); XhtmlNode retVal = new XhtmlNode();
retVal.setValueAsString(safeHTML); retVal.setValueAsString(safeHTML);

View File

@ -9,21 +9,21 @@ public class NarrativeUtilTest {
@ParameterizedTest @ParameterizedTest
@CsvSource({ @CsvSource({
"<div><SPAN ID=\"foo\">hello</SPAN></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\"><span id=\"foo\">hello</span></div>", "<div><SPAN ID=\"foo\">hello</SPAN></div> , <div><span id=\"foo\">hello</span></div>",
"<div><span id=\"foo\">hello</span></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\"><span id=\"foo\">hello</span></div>", "<div><span id=\"foo\">hello</span></div> , <div><span id=\"foo\">hello</span></div>",
"<div><SPAN ONCLICK=\"hello()\">hello</SPAN></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\">hello</div>", "<div><SPAN ONCLICK=\"hello()\">hello</SPAN></div> , <div>hello</div>",
"<div><span onclick=\"hello()\">hello</span></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\">hello</div>", "<div><span onclick=\"hello()\">hello</span></div> , <div>hello</div>",
"<div><a href=\"http://goodbye\">hello</a></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\">hello</div>", "<div><a href=\"http://goodbye\">hello</a></div> , <div>hello</div>",
"<div><table><tr><td>hello</td></tr></table></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\"><table><tbody><tr><td>hello</td></tr></tbody></table></div>", "<div><table><tr><td>hello</td></tr></table></div> , <div><table><tbody><tr><td>hello</td></tr></tbody></table></div>",
"<div><span style=\"font-size: 100px;\">hello</span></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\"><span style=\"font-size:100px\">hello</span></div>", "<div><span style=\"font-size: 100px;\">hello</span></div> , <div><span style=\"font-size:100px\">hello</span></div>",
"<div><span style=\"background: url('test.jpg')\">hello</span></div> , <div xmlns=\"http://www.w3.org/1999/xhtml\">hello</div>", "<div><span style=\"background: url('test.jpg')\">hello</span></div> , <div>hello</div>",
"hello , <div xmlns=\"http://www.w3.org/1999/xhtml\">hello</div>", "hello , hello",
"empty , null", "empty , empty",
"null , null" "null , empty"
}) })
public void testValidateIsCaseInsensitive(String theHtml, String theExpected) { public void testValidateIsCaseInsensitive(String theHtml, String theExpected) {
String output = NarrativeUtil.sanitize(fixNull(theHtml)); String output = NarrativeUtil.sanitizeHtmlFragment(fixNull(theHtml));
assertEquals(fixNull(theExpected), output); assertEquals(fixNull(theExpected), fixNull(output));
} }
private String fixNull(String theExpected) { private String fixNull(String theExpected) {

View File

@ -583,7 +583,7 @@ public class BaseController {
theModelMap.put("resultBodyIsLong", resultBodyText.length() > 1000); theModelMap.put("resultBodyIsLong", resultBodyText.length() > 1000);
theModelMap.put("requestHeaders", requestHeaders); theModelMap.put("requestHeaders", requestHeaders);
theModelMap.put("responseHeaders", responseHeaders); theModelMap.put("responseHeaders", responseHeaders);
theModelMap.put("narrative", NarrativeUtil.sanitize(narrativeString)); theModelMap.put("narrative", NarrativeUtil.sanitizeHtmlFragment(narrativeString));
theModelMap.put("latencyMs", theLatency); theModelMap.put("latencyMs", theLatency);
theModelMap.put("config", myConfig); theModelMap.put("config", myConfig);