Web Admin Console Improvements (#4456)

* Testpage improvements

* Work on tests

* Split fragment out

* Reformat command buttons

* Work on web admin console

* Work on testpage

* Fixes

* Fix

* Test fixes

* Improve examples

* Test fixes

* Address review comment
This commit is contained in:
James Agnew 2023-01-25 09:27:12 -05:00 committed by GitHub
parent 456cc81b32
commit d46f6d635e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1299 additions and 603 deletions

View File

@ -167,7 +167,7 @@ public class FhirValidator {
*/
public synchronized FhirValidator registerValidatorModule(IValidatorModule theValidator) {
Validate.notNull(theValidator, "theValidator must not be null");
ArrayList<IValidatorModule> newValidators = new ArrayList<IValidatorModule>(myValidators.size() + 1);
ArrayList<IValidatorModule> newValidators = new ArrayList<>(myValidators.size() + 1);
newValidators.addAll(myValidators);
newValidators.add(theValidator);

View File

@ -11,7 +11,7 @@
<tr th:each="issue : ${resource.issue}">
<td th:text="${issue.severityElement.value}" style="font-weight: bold;"></td>
<td th:text="${issue.location}"></td>
<td><pre th:text="${issue.diagnostics}"/></td>
<td th:text="${issue.diagnostics}"></td>
</tr>
</table>
</div>

View File

@ -21,6 +21,7 @@ package ca.uhn.hapi.fhir.docs;
*/
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.to.FhirTesterMvcConfig;
import ca.uhn.fhir.to.TesterConfig;
import org.springframework.context.annotation.Bean;
@ -60,14 +61,20 @@ public class FhirTesterConfig {
retVal
.addServer()
.withId("home")
.withFhirVersion(FhirVersionEnum.DSTU2)
.withFhirVersion(FhirVersionEnum.R4)
.withBaseUrl("${serverBase}/fhir")
.withName("Local Tester")
// Add a $diff button on search result rows where version > 1
.withSearchResultRowOperation("$diff", id -> id.isVersionIdPartValidLong() && id.getVersionIdPartAsLong() > 1)
.addServer()
.withId("hapi")
.withFhirVersion(FhirVersionEnum.DSTU2)
.withBaseUrl("http://fhirtest.uhn.ca/baseDstu2")
.withName("Public HAPI Test Server");
.withFhirVersion(FhirVersionEnum.R4)
.withBaseUrl("http://hapi.fhir.org/baseR4")
.withName("Public HAPI Test Server")
// Disable the read and update buttons on search result rows for this server
.withSearchResultRowInteraction(RestOperationTypeEnum.READ, id -> false)
.withSearchResultRowInteraction(RestOperationTypeEnum.UPDATE, id -> false);
/*
* Use the method below to supply a client "factory" which can be used

View File

@ -23,8 +23,10 @@ package ca.uhn.hapi.fhir.docs;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.model.api.Include;
import ca.uhn.fhir.model.api.ResourceMetadataKeyEnum;
import ca.uhn.fhir.model.api.TemporalPrecisionEnum;
import ca.uhn.fhir.model.api.annotation.Description;
import ca.uhn.fhir.model.valueset.BundleEntryTransactionMethodEnum;
import ca.uhn.fhir.parser.DataFormatException;
import ca.uhn.fhir.rest.annotation.Count;
import ca.uhn.fhir.rest.annotation.*;
@ -392,17 +394,33 @@ public List<Patient> getPatientHistory(
List<Patient> retVal = new ArrayList<Patient>();
Patient patient = new Patient();
patient.addName().setFamily("Smith");
// Set the ID and version
patient.setId(theId.withVersion("1"));
// ...populate the rest...
if (isDeleted(patient)) {
// If the resource is deleted, it just needs to have an ID and some metadata
ResourceMetadataKeyEnum.DELETED_AT.put(patient, InstantType.withCurrentTime());
ResourceMetadataKeyEnum.ENTRY_TRANSACTION_METHOD.put(patient, BundleEntryTransactionMethodEnum.DELETE);
} else {
// If the resource is not deleted, it should have normal resource content
patient.addName().setFamily("Smith"); // ..populate the rest
}
return retVal;
}
//END SNIPPET: history
private boolean isDeleted(Patient thePatient) {
return false;
}
//START SNIPPET: vread
@Read(version=true)
public Patient readOrVread(@IdParam IdType theId) {

View File

@ -0,0 +1,8 @@
---
type: add
issue: 4456
title: "The Testpage Overlay module now supports customizable operation buttons
on search results. For example, you can configure each row of the search
results page to include a `$validate` button and a `$diff` button, or
choose other operations relevant to your use case. Currently only
operations which do not require any parameters are possible."

View File

@ -0,0 +1,5 @@
---
type: fix
issue: 4456
title: "The HashMapResourceProvider failed to display history results if the history
included deleted resources. This has been corrected."

View File

@ -0,0 +1,6 @@
---
type: change
issue: 4456
title: "The built-in narrative template for the OperationOutcome resource no longer renders
the _OperationOutcome.issue.diagnostic_ inside a &lt;pre&gt; tag, which made long
messages hard to read."

View File

@ -4749,7 +4749,7 @@ public class ResourceProviderDstu3Test extends BaseResourceProviderDstu3Test {
ourLog.info(resp);
assertEquals(200, response.getStatusLine().getStatusCode());
assertThat(resp, not(containsString("Resource has no id")));
assertThat(resp, containsString("<pre>No issues detected during validation</pre>"));
assertThat(resp, containsString("<td>No issues detected during validation</td>"));
assertThat(resp,
stringContainsInOrder("<issue>", "<severity value=\"information\"/>", "<code value=\"informational\"/>", "<diagnostics value=\"No issues detected during validation\"/>",
"</issue>"));
@ -4776,7 +4776,7 @@ public class ResourceProviderDstu3Test extends BaseResourceProviderDstu3Test {
ourLog.info(resp);
assertEquals(200, response.getStatusLine().getStatusCode());
assertThat(resp, not(containsString("Resource has no id")));
assertThat(resp, containsString("<pre>No issues detected during validation</pre>"));
assertThat(resp, containsString("<td>No issues detected during validation</td>"));
assertThat(resp,
stringContainsInOrder("<issue>", "<severity value=\"information\"/>", "<code value=\"informational\"/>", "<diagnostics value=\"No issues detected during validation\"/>",
"</issue>"));

View File

@ -7011,7 +7011,7 @@ public class ResourceProviderR4Test extends BaseResourceProviderR4Test {
ourLog.info(resp);
assertEquals(200, response.getStatusLine().getStatusCode());
assertThat(resp, not(containsString("Resource has no id")));
assertThat(resp, containsString("<pre>No issues detected during validation</pre>"));
assertThat(resp, containsString("<td>No issues detected during validation</td>"));
assertThat(resp,
stringContainsInOrder("<issue>", "<severity value=\"information\"/>", "<code value=\"informational\"/>", "<diagnostics value=\"No issues detected during validation\"/>",
"</issue>"));

View File

@ -0,0 +1,77 @@
package ca.uhn.fhirtest;
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.SystemRequestDetails;
import org.apache.commons.lang3.time.DateUtils;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.event.ContextClosedEvent;
import org.springframework.context.event.ContextRefreshedEvent;
import org.springframework.context.event.EventListener;
import java.util.Date;
import java.util.Timer;
import java.util.TimerTask;
/**
* This is just a quick and dirty utility class to purge subscriptions on the
* public test server after 1 day. It uses a Timer to automatically check for old
* subscriptions periodically, and if it finds any with a lastUpdated date more than
* 24 hours ago it deletes them. This is to prevent people's subscription testing
* from hanging around and gumming up the server.
*/
public class ScheduledSubscriptionDeleter {
private static final Logger ourLog = LoggerFactory.getLogger(ScheduledSubscriptionDeleter.class);
@Autowired
private DaoRegistry myDaoRegistry;
private Timer myTimer;
@EventListener(ContextRefreshedEvent.class)
public void start() {
if (myTimer == null) {
myTimer = new Timer();
myTimer.scheduleAtFixedRate(new MyTimerTask(), 0, DateUtils.MILLIS_PER_HOUR);
}
}
@EventListener(ContextClosedEvent.class)
public void stop() {
myTimer.cancel();
myTimer = null;
}
private class MyTimerTask extends TimerTask {
@Override
public void run() {
deleteOldSubscriptions();
}
private void deleteOldSubscriptions() {
if (myDaoRegistry.isResourceTypeSupported("Subscription")) {
int count = 10;
Date cutoff = DateUtils.addDays(new Date(), -1);
IFhirResourceDao<?> subscriptionDao = myDaoRegistry.getResourceDao("Subscription");
SearchParameterMap params = SearchParameterMap
.newSynchronous()
.setCount(count);
IBundleProvider subscriptions = subscriptionDao.search(params, new SystemRequestDetails());
for (IBaseResource next : subscriptions.getResources(0, count)) {
if (next.getMeta().getLastUpdated().before(cutoff)) {
ourLog.info("Auto deleting old subscription: {}", next.getIdElement());
subscriptionDao.delete(next.getIdElement().toUnqualifiedVersionless(), new SystemRequestDetails());
}
}
}
}
}
}

View File

@ -12,6 +12,7 @@ import ca.uhn.fhir.jpa.subscription.match.deliver.email.IEmailSender;
import ca.uhn.fhir.jpa.subscription.submit.config.SubscriptionSubmitterConfig;
import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor;
import ca.uhn.fhir.rest.server.interceptor.LoggingInterceptor;
import ca.uhn.fhirtest.ScheduledSubscriptionDeleter;
import ca.uhn.fhirtest.interceptor.AnalyticsInterceptor;
import ca.uhn.fhirtest.joke.HolyFooCowInterceptor;
import org.springframework.context.annotation.Bean;
@ -99,4 +100,9 @@ public class CommonConfig {
return "true".equalsIgnoreCase(System.getProperty("testmode.local"));
}
@Bean
public ScheduledSubscriptionDeleter scheduledSubscriptionDeleter() {
return new ScheduledSubscriptionDeleter();
}
}

View File

@ -1,25 +1,24 @@
package ca.uhn.fhirtest.config;
import org.springframework.beans.factory.annotation.Autowire;
import org.springframework.context.annotation.*;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.to.FhirTesterMvcConfig;
import ca.uhn.fhir.to.TesterConfig;
import ca.uhn.fhirtest.mvc.SubscriptionPlaygroundController;
import org.springframework.beans.factory.annotation.Autowire;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import static ca.uhn.fhir.rest.api.Constants.EXTOP_VALIDATE;
//@formatter:off
/**
* This spring config file configures the web testing module. It serves two
* purposes:
* 1. It imports FhirTesterMvcConfig, which is the spring config for the
* tester itself
* tester itself
* 2. It tells the tester which server(s) to talk to, via the testerConfig()
* method below
* method below
*/
//@Configuration
//@Import(FhirTesterMvcConfig.class)
//@ComponentScan(basePackages = "ca.uhn.fhirtest.mvc")
@Configuration
@Import(FhirTesterMvcConfig.class)
public class FhirTesterConfig {
@ -30,7 +29,7 @@ public class FhirTesterConfig {
* server, as well as one public server. If you are creating a project to
* deploy somewhere else, you might choose to only put your own server's
* address here.
*
* <p>
* Note the use of the ${serverBase} variable below. This will be replaced with
* the base URL as reported by the server itself. Often for a simple Tomcat
* (or other container) installation, this will end up being something
@ -43,70 +42,81 @@ public class FhirTesterConfig {
TesterConfig retVal = new TesterConfig();
retVal
.addServer()
.withId("home_r4")
.withFhirVersion(FhirVersionEnum.R4)
.withBaseUrl("http://hapi.fhir.org/baseR4")
.withName("HAPI Test Server (R4 FHIR)")
.withId("home_r4")
.withFhirVersion(FhirVersionEnum.R4)
.withBaseUrl("http://hapi.fhir.org/baseR4")
.withName("HAPI Test Server (R4 FHIR)")
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$diff", id -> id.isVersionIdPartValidLong() && id.getVersionIdPartAsLong() > 1)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
.addServer()
.withId("home_r4b")
.withFhirVersion(FhirVersionEnum.R4B)
.withBaseUrl("http://hapi.fhir.org/baseR4B")
.withName("HAPI Test Server (R4B FHIR)")
.withId("home_r4b")
.withFhirVersion(FhirVersionEnum.R4B)
.withBaseUrl("http://hapi.fhir.org/baseR4B")
.withName("HAPI Test Server (R4B FHIR)")
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$diff", id -> id.isVersionIdPartValidLong() && id.getVersionIdPartAsLong() > 1)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
.addServer()
.withId("home_21")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://hapi.fhir.org/baseDstu3")
.withName("HAPI Test Server (STU3 FHIR)")
.withId("home_21")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://hapi.fhir.org/baseDstu3")
.withName("HAPI Test Server (STU3 FHIR)")
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$diff", id -> id.isVersionIdPartValidLong() && id.getVersionIdPartAsLong() > 1)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
.addServer()
.withId("hapi_dev")
.withFhirVersion(FhirVersionEnum.DSTU2)
.withBaseUrl("http://hapi.fhir.org/baseDstu2")
.withName("HAPI Test Server (DSTU2 FHIR)")
.withId("hapi_dev")
.withFhirVersion(FhirVersionEnum.DSTU2)
.withBaseUrl("http://hapi.fhir.org/baseDstu2")
.withName("HAPI Test Server (DSTU2 FHIR)")
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
.addServer()
.withId("home_r5")
.withFhirVersion(FhirVersionEnum.R5)
.withBaseUrl("http://hapi.fhir.org/baseR5")
.withName("HAPI Test Server (R5 FHIR)")
// .addServer()
// .withId("tdl_d2")
// .withFhirVersion(FhirVersionEnum.DSTU2)
// .withBaseUrl("http://hapi.fhir.org/testDataLibraryDstu2")
// .withName("Test Data Library (DSTU2 FHIR)")
// .allowsApiKey()
// .addServer()
// .withId("tdl_d3")
// .withFhirVersion(FhirVersionEnum.DSTU3)
// .withBaseUrl("http://hapi.fhir.org/testDataLibraryStu3")
// .withName("Test Data Library (DSTU3 FHIR)")
// .allowsApiKey()
.withId("home_r5")
.withFhirVersion(FhirVersionEnum.R5)
.withBaseUrl("http://hapi.fhir.org/baseR5")
.withName("HAPI Test Server (R5 FHIR)")
.withSearchResultRowOperation(EXTOP_VALIDATE, id -> true)
.withSearchResultRowOperation("$diff", id -> id.isVersionIdPartValidLong() && id.getVersionIdPartAsLong() > 1)
.withSearchResultRowOperation("$everything", id -> "Patient".equals(id.getResourceType()))
// Non-HAPI servers follow
.addServer()
.withId("hi4")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://test.fhir.org/r4")
.withName("Health Intersections (R4 FHIR)")
.withId("hi4")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://test.fhir.org/r4")
.withName("Health Intersections (R4 FHIR)")
.addServer()
.withId("hi3")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://test.fhir.org/r3")
.withName("Health Intersections (STU3 FHIR)")
.withId("hi3")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://test.fhir.org/r3")
.withName("Health Intersections (STU3 FHIR)")
.addServer()
.withId("hi2")
.withFhirVersion(FhirVersionEnum.DSTU2)
.withBaseUrl("http://test.fhir.org/r2")
.withName("Health Intersections (DSTU2 FHIR)")
.withId("hi2")
.withFhirVersion(FhirVersionEnum.DSTU2)
.withBaseUrl("http://test.fhir.org/r2")
.withName("Health Intersections (DSTU2 FHIR)")
.addServer()
.withId("spark2")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://vonk.fire.ly/")
.withName("Vonk - Firely (STU3 FHIR)");
.withId("spark2")
.withFhirVersion(FhirVersionEnum.DSTU3)
.withBaseUrl("http://vonk.fire.ly/")
.withName("Vonk - Firely (STU3 FHIR)");
return retVal;
}
@Bean(autowire=Autowire.BY_TYPE)
@Bean(autowire = Autowire.BY_TYPE)
public SubscriptionPlaygroundController subscriptionPlaygroundController() {
return new SubscriptionPlaygroundController();
}
}
//@formatter:on

View File

@ -191,7 +191,7 @@ public class HistoryMethodBinding extends BaseResourceReturningMethodBinding {
}
if (isBlank(nextResource.getIdElement().getVersionIdPart()) && nextResource instanceof IResource) {
//TODO: Use of a deprecated method should be resolved.
IdDt versionId = ResourceMetadataKeyEnum.VERSION_ID.get((IResource) nextResource);
IdDt versionId = ResourceMetadataKeyEnum.VERSION_ID.get(nextResource);
if (versionId == null || versionId.isEmpty()) {
throw new InternalErrorException(Msg.code(411) + "Server provided resource at index " + index + " with no Version ID set (using IResource#setId(IdDt))");
}

View File

@ -20,17 +20,16 @@ package ca.uhn.fhir.rest.server.provider;
* #L%
*/
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.context.BaseRuntimeChildDefinition;
import ca.uhn.fhir.context.BaseRuntimeElementCompositeDefinition;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.api.HookParams;
import ca.uhn.fhir.interceptor.api.IInterceptorBroadcaster;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.model.api.IResource;
import ca.uhn.fhir.model.api.ResourceMetadataKeyEnum;
import ca.uhn.fhir.model.valueset.BundleEntryTransactionMethodEnum;
import ca.uhn.fhir.rest.annotation.ConditionalUrlParam;
import ca.uhn.fhir.rest.annotation.Create;
import ca.uhn.fhir.rest.annotation.Delete;
@ -68,6 +67,7 @@ import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Date;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
@ -103,10 +103,10 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
protected LinkedList<T> myTypeHistory = new LinkedList<>();
protected AtomicLong mySearchCount = new AtomicLong(0);
private long myNextId;
private AtomicLong myDeleteCount = new AtomicLong(0);
private AtomicLong myUpdateCount = new AtomicLong(0);
private AtomicLong myCreateCount = new AtomicLong(0);
private AtomicLong myReadCount = new AtomicLong(0);
private final AtomicLong myDeleteCount = new AtomicLong(0);
private final AtomicLong myUpdateCount = new AtomicLong(0);
private final AtomicLong myCreateCount = new AtomicLong(0);
private final AtomicLong myReadCount = new AtomicLong(0);
/**
* Constructor
@ -134,7 +134,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
/**
* Clear the counts used by {@link #getCountRead()} and other count methods
*/
public synchronized void clearCounts() {
public synchronized void clearCounts() {
myReadCount.set(0L);
myUpdateCount.set(0L);
myCreateCount.set(0L);
@ -163,12 +163,12 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
assert !myIdToVersionToResourceMap.containsKey(idPartAsString);
IIdType id = store(theResource, idPartAsString, versionIdPart, theRequestDetails, theTransactionDetails);
theResource.setId(id);
store(theResource, idPartAsString, versionIdPart, theRequestDetails, theTransactionDetails, false);
}
@SuppressWarnings({"unchecked"})
@Delete
public synchronized MethodOutcome delete(@IdParam IIdType theId, RequestDetails theRequestDetails) {
public synchronized MethodOutcome delete(@IdParam IIdType theId, RequestDetails theRequestDetails) {
TransactionDetails transactionDetails = new TransactionDetails();
TreeMap<Long, T> versions = myIdToVersionToResourceMap.get(theId.getIdPart());
@ -176,9 +176,9 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
throw new ResourceNotFoundException(Msg.code(1979) + theId);
}
T deletedInstance = (T) myFhirContext.getResourceDefinition(myResourceType).newInstance();
long nextVersion = versions.lastEntry().getKey() + 1L;
IIdType id = store(null, theId.getIdPart(), nextVersion, theRequestDetails, transactionDetails);
IIdType id = store(deletedInstance, theId.getIdPart(), nextVersion, theRequestDetails, transactionDetails, true);
myDeleteCount.incrementAndGet();
@ -190,7 +190,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* This method returns a simple operation count. This is mostly
* useful for testing purposes.
*/
public synchronized long getCountCreate() {
public synchronized long getCountCreate() {
return myCreateCount.get();
}
@ -198,7 +198,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* This method returns a simple operation count. This is mostly
* useful for testing purposes.
*/
public synchronized long getCountDelete() {
public synchronized long getCountDelete() {
return myDeleteCount.get();
}
@ -206,7 +206,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* This method returns a simple operation count. This is mostly
* useful for testing purposes.
*/
public synchronized long getCountRead() {
public synchronized long getCountRead() {
return myReadCount.get();
}
@ -214,7 +214,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* This method returns a simple operation count. This is mostly
* useful for testing purposes.
*/
public synchronized long getCountSearch() {
public synchronized long getCountSearch() {
return mySearchCount.get();
}
@ -222,7 +222,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* This method returns a simple operation count. This is mostly
* useful for testing purposes.
*/
public synchronized long getCountUpdate() {
public synchronized long getCountUpdate() {
return myUpdateCount.get();
}
@ -237,7 +237,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
}
@History
public synchronized List<IBaseResource> historyInstance(@IdParam IIdType theId, RequestDetails theRequestDetails) {
public synchronized List<IBaseResource> historyInstance(@IdParam IIdType theId, RequestDetails theRequestDetails) {
LinkedList<T> retVal = myIdToHistory.get(theId.getIdPart());
if (retVal == null) {
throw new ResourceNotFoundException(Msg.code(1980) + theId);
@ -252,7 +252,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
}
@Read(version = true)
public synchronized T read(@IdParam IIdType theId, RequestDetails theRequestDetails) {
public synchronized T read(@IdParam IIdType theId, RequestDetails theRequestDetails) {
TreeMap<Long, T> versions = myIdToVersionToResourceMap.get(theId.getIdPart());
if (versions == null || versions.isEmpty()) {
throw new ResourceNotFoundException(Msg.code(1981) + theId);
@ -265,7 +265,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
throw new ResourceNotFoundException(Msg.code(1982) + theId);
} else {
T resource = versions.get(versionId);
if (resource == null) {
if (resource == null || ResourceMetadataKeyEnum.DELETED_AT.get(resource) != null) {
throw new ResourceGoneException(Msg.code(1983) + theId);
}
retVal = resource;
@ -285,21 +285,26 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
}
@Search
public synchronized List<IBaseResource> searchAll(RequestDetails theRequestDetails) {
public synchronized List<IBaseResource> searchAll(RequestDetails theRequestDetails) {
mySearchCount.incrementAndGet();
List<T> retVal = getAllResources();
return fireInterceptorsAndFilterAsNeeded(retVal, theRequestDetails);
}
@Nonnull
protected synchronized List<T> getAllResources() {
protected synchronized List<T> getAllResources() {
List<T> retVal = new ArrayList<>();
for (TreeMap<Long, T> next : myIdToVersionToResourceMap.values()) {
if (next.isEmpty() == false) {
T nextResource = next.lastEntry().getValue();
if (nextResource != null) {
retVal.add(nextResource);
if (ResourceMetadataKeyEnum.DELETED_AT.get(nextResource) == null) {
// Clone the resource for search results so that the
// stored metadata doesn't appear in the results
T nextResourceClone = myFhirContext.newTerser().clone(nextResource);
retVal.add(nextResourceClone);
}
}
}
}
@ -308,7 +313,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
}
@Search
public synchronized List<IBaseResource> searchById(
public synchronized List<IBaseResource> searchById(
@RequiredParam(name = "_id") TokenAndListParam theIds, RequestDetails theRequestDetails) {
List<T> retVal = new ArrayList<>();
@ -345,12 +350,25 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
return fireInterceptorsAndFilterAsNeeded(retVal, theRequestDetails);
}
private IIdType store(@ResourceParam T theResource, String theIdPart, Long theVersionIdPart, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails) {
@SuppressWarnings({"unchecked", "DataFlowIssue"})
private IIdType store(@Nonnull T theResource, String theIdPart, Long theVersionIdPart, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails, boolean theDeleted) {
IIdType id = myFhirContext.getVersion().newIdType();
String versionIdPart = Long.toString(theVersionIdPart);
id.setParts(null, myResourceName, theIdPart, versionIdPart);
if (theResource != null) {
theResource.setId(id);
theResource.setId(id);
if (theDeleted) {
IPrimitiveType<Date> deletedAt = (IPrimitiveType<Date>) myFhirContext.getElementDefinition("instant").newInstance();
deletedAt.setValue(new Date());
ResourceMetadataKeyEnum.DELETED_AT.put(theResource, deletedAt);
ResourceMetadataKeyEnum.ENTRY_TRANSACTION_METHOD.put(theResource, BundleEntryTransactionMethodEnum.DELETE);
} else {
ResourceMetadataKeyEnum.DELETED_AT.put(theResource, null);
if (theVersionIdPart > 1) {
ResourceMetadataKeyEnum.ENTRY_TRANSACTION_METHOD.put(theResource, BundleEntryTransactionMethodEnum.PUT);
} else {
ResourceMetadataKeyEnum.ENTRY_TRANSACTION_METHOD.put(theResource, BundleEntryTransactionMethodEnum.POST);
}
}
/*
@ -358,22 +376,13 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* in the resource being stored accurately represents the version
* that was assigned by this provider
*/
if (theResource != null) {
if (myFhirContext.getVersion().getVersion() == FhirVersionEnum.DSTU2) {
ResourceMetadataKeyEnum.VERSION.put((IResource) theResource, versionIdPart);
} else {
BaseRuntimeChildDefinition metaChild = myFhirContext.getResourceDefinition(myResourceType).getChildByName("meta");
List<IBase> metaValues = metaChild.getAccessor().getValues(theResource);
if (metaValues.size() > 0) {
IBase meta = metaValues.get(0);
BaseRuntimeElementCompositeDefinition<?> metaDef = (BaseRuntimeElementCompositeDefinition<?>) myFhirContext.getElementDefinition(meta.getClass());
BaseRuntimeChildDefinition versionIdDef = metaDef.getChildByName("versionId");
List<IBase> versionIdValues = versionIdDef.getAccessor().getValues(meta);
if (versionIdValues.size() > 0) {
IPrimitiveType<?> versionId = (IPrimitiveType<?>) versionIdValues.get(0);
versionId.setValueAsString(versionIdPart);
}
}
if (myFhirContext.getVersion().getVersion() == FhirVersionEnum.DSTU2) {
ResourceMetadataKeyEnum.VERSION.put(theResource, versionIdPart);
} else {
BaseRuntimeChildDefinition metaChild = myFhirContext.getResourceDefinition(myResourceType).getChildByName("meta");
List<IBase> metaValues = metaChild.getAccessor().getValues(theResource);
if (metaValues.size() > 0) {
theResource.getMeta().setVersionId(versionIdPart);
}
}
@ -383,52 +392,70 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
TreeMap<Long, T> versionToResource = getVersionToResource(theIdPart);
versionToResource.put(theVersionIdPart, theResource);
if (theRequestDetails != null) {
if (theRequestDetails != null && theRequestDetails.getInterceptorBroadcaster() != null) {
IInterceptorBroadcaster interceptorBroadcaster = theRequestDetails.getInterceptorBroadcaster();
if (theResource != null) {
if (!myIdToHistory.containsKey(theIdPart)) {
if (theDeleted) {
// Interceptor call: STORAGE_PRESTORAGE_RESOURCE_CREATED
HookParams preStorageParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, theResource)
.add(RequestPartitionId.class, null) // we should add this if we want - but this is test usage
.add(TransactionDetails.class, theTransactionDetails);
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED, preStorageParams);
// Interceptor call: STORAGE_PRESTORAGE_RESOURCE_DELETED
HookParams preStorageParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, myIdToHistory.get(theIdPart).getFirst())
.add(TransactionDetails.class, theTransactionDetails);
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRESTORAGE_RESOURCE_DELETED, preStorageParams);
// Interceptor call: STORAGE_PRECOMMIT_RESOURCE_CREATED
HookParams preCommitParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, theResource)
.add(TransactionDetails.class, theTransactionDetails)
.add(InterceptorInvocationTimingEnum.class, theTransactionDetails.getInvocationTiming(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED));
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED, preCommitParams);
// Interceptor call: STORAGE_PRECOMMIT_RESOURCE_DELETED
HookParams preCommitParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, myIdToHistory.get(theIdPart).getFirst())
.add(TransactionDetails.class, theTransactionDetails)
.add(InterceptorInvocationTimingEnum.class, theTransactionDetails.getInvocationTiming(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED));
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRECOMMIT_RESOURCE_DELETED, preCommitParams);
} else {
// Interceptor call: STORAGE_PRESTORAGE_RESOURCE_UPDATED
HookParams preStorageParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, myIdToHistory.get(theIdPart).getFirst())
.add(IBaseResource.class, theResource)
.add(TransactionDetails.class, theTransactionDetails);
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED, preStorageParams);
} else if (!myIdToHistory.containsKey(theIdPart)) {
// Interceptor call: STORAGE_PRECOMMIT_RESOURCE_UPDATED
HookParams preCommitParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, myIdToHistory.get(theIdPart).getFirst())
.add(IBaseResource.class, theResource)
.add(TransactionDetails.class, theTransactionDetails)
.add(InterceptorInvocationTimingEnum.class, theTransactionDetails.getInvocationTiming(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED));
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRECOMMIT_RESOURCE_UPDATED, preCommitParams);
// Interceptor call: STORAGE_PRESTORAGE_RESOURCE_CREATED
HookParams preStorageParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, theResource)
.add(RequestPartitionId.class, null) // we should add this if we want - but this is test usage
.add(TransactionDetails.class, theTransactionDetails);
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED, preStorageParams);
// Interceptor call: STORAGE_PRECOMMIT_RESOURCE_CREATED
HookParams preCommitParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, theResource)
.add(TransactionDetails.class, theTransactionDetails)
.add(InterceptorInvocationTimingEnum.class, theTransactionDetails.getInvocationTiming(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED));
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED, preCommitParams);
} else {
// Interceptor call: STORAGE_PRESTORAGE_RESOURCE_UPDATED
HookParams preStorageParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, myIdToHistory.get(theIdPart).getFirst())
.add(IBaseResource.class, theResource)
.add(TransactionDetails.class, theTransactionDetails);
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED, preStorageParams);
// Interceptor call: STORAGE_PRECOMMIT_RESOURCE_UPDATED
HookParams preCommitParams = new HookParams()
.add(RequestDetails.class, theRequestDetails)
.addIfMatchesType(ServletRequestDetails.class, theRequestDetails)
.add(IBaseResource.class, myIdToHistory.get(theIdPart).getFirst())
.add(IBaseResource.class, theResource)
.add(TransactionDetails.class, theTransactionDetails)
.add(InterceptorInvocationTimingEnum.class, theTransactionDetails.getInvocationTiming(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED));
interceptorBroadcaster.callHooks(Pointcut.STORAGE_PRECOMMIT_RESOURCE_UPDATED, preCommitParams);
}
}
}
@ -447,7 +474,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* @param theConditional This is provided only so that subclasses can implement if they want
*/
@Update
public synchronized MethodOutcome update(
public synchronized MethodOutcome update(
@ResourceParam T theResource,
@ConditionalUrlParam String theConditional,
RequestDetails theRequestDetails) {
@ -478,7 +505,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
created = false;
}
IIdType id = store(theResource, idPartAsString, versionIdPart, theRequestDetails, theTransactionDetails);
IIdType id = store(theResource, idPartAsString, versionIdPart, theRequestDetails, theTransactionDetails, false);
theResource.setId(id);
return created;
}
@ -495,7 +522,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
* @param theResource The resource to store. If the resource has an ID, that ID is updated.
* @return Return the ID assigned to the stored resource
*/
public synchronized IIdType store(T theResource) {
public synchronized IIdType store(T theResource) {
if (theResource.getIdElement().hasIdPart()) {
updateInternal(theResource, null, new TransactionDetails());
} else {
@ -509,7 +536,7 @@ public class HashMapResourceProvider<T extends IBaseResource> implements IResour
*
* @since 4.1.0
*/
public synchronized List<T> getStoredResources() {
public synchronized List<T> getStoredResources() {
List<T> retVal = new ArrayList<>();
for (TreeMap<Long, T> next : myIdToVersionToResourceMap.values()) {
retVal.add(next.lastEntry().getValue());

View File

@ -103,7 +103,7 @@ public class DefaultThymeleafNarrativeGeneratorDstu2Test {
ourLog.info(output);
assertThat(output, containsString("<td><pre>YThis is a warning</pre></td>"));
assertThat(output, containsString("<td>YThis is a warning</td>"));
}
@Test

View File

@ -163,7 +163,7 @@ public class DefaultThymeleafNarrativeGeneratorDstu3Test {
ourLog.info(output);
assertThat(output, containsString("<td><pre>YThis is a warning</pre></td>"));
assertThat(output, containsString("<td>YThis is a warning</td>"));
}
@Test

View File

@ -111,7 +111,7 @@ public class DefaultThymeleafNarrativeGeneratorR4Test {
ourLog.info(output);
assertThat(output, containsString("<td><pre>YThis is a warning</pre></td>"));
assertThat(output, containsString("<td>YThis is a warning</td>"));
}
@Test

View File

@ -3,35 +3,31 @@ package ca.uhn.fhir.rest.server.provider;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.interceptor.api.IAnonymousInterceptor;
import ca.uhn.fhir.interceptor.api.Pointcut;
import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;
import ca.uhn.fhir.rest.gclient.IDeleteTyped;
import ca.uhn.fhir.rest.server.IResourceProvider;
import ca.uhn.fhir.rest.server.RestfulServer;
import ca.uhn.fhir.rest.server.exceptions.ResourceGoneException;
import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
import ca.uhn.fhir.test.utilities.JettyUtil;
import ca.uhn.fhir.rest.server.interceptor.ResponseValidatingInterceptor;
import ca.uhn.fhir.test.utilities.server.HashMapResourceProviderExtension;
import ca.uhn.fhir.test.utilities.server.RestfulServerExtension;
import ca.uhn.fhir.util.TestUtil;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import ca.uhn.fhir.validation.FhirValidator;
import ca.uhn.fhir.validation.ResultSeverityEnum;
import org.hl7.fhir.instance.model.api.IAnyResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Observation;
import org.hl7.fhir.r4.model.Patient;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ServletException;
import java.util.List;
import java.util.stream.Collectors;
@ -40,6 +36,8 @@ import static org.hamcrest.Matchers.contains;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.matchesPattern;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@ -48,24 +46,22 @@ import static org.mockito.Mockito.verify;
@ExtendWith(MockitoExtension.class)
public class HashMapResourceProviderTest {
private static final FhirContext ourCtx = FhirContext.forR4Cached();
@RegisterExtension
@Order(0)
private static final RestfulServerExtension ourRestServer = new RestfulServerExtension(ourCtx);
@RegisterExtension
@Order(1)
private static final HashMapResourceProviderExtension<Patient> myPatientResourceProvider = new HashMapResourceProviderExtension<>(ourRestServer, Patient.class);
@RegisterExtension
@Order(2)
private static final HashMapResourceProviderExtension<Observation> myObservationResourceProvider = new HashMapResourceProviderExtension<>(ourRestServer, Observation.class);
private static final Logger ourLog = LoggerFactory.getLogger(HashMapResourceProviderTest.class);
private static MyRestfulServer ourRestServer;
private static Server ourListenerServer;
private static IGenericClient ourClient;
private static FhirContext ourCtx = FhirContext.forR4();
private static HashMapResourceProvider<Patient> myPatientResourceProvider;
private static HashMapResourceProvider<Observation> myObservationResourceProvider;
@Mock
private IAnonymousInterceptor myAnonymousInterceptor;
@BeforeEach
public void before() {
ourRestServer.clearData();
myPatientResourceProvider.clearCounts();
myObservationResourceProvider.clearCounts();
}
@Test
public void testCreateAndRead() {
ourRestServer.getInterceptorService().registerAnonymousInterceptor(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED, myAnonymousInterceptor);
@ -74,7 +70,7 @@ public class HashMapResourceProviderTest {
// Create
Patient p = new Patient();
p.setActive(true);
IIdType id = ourClient.create().resource(p).execute().getId();
IIdType id = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id.getVersionIdPart());
@ -82,8 +78,8 @@ public class HashMapResourceProviderTest {
verify(myAnonymousInterceptor, Mockito.times(1)).invoke(eq(Pointcut.STORAGE_PRECOMMIT_RESOURCE_CREATED), any());
// Read
p = (Patient) ourClient.read().resource("Patient").withId(id).execute();
assertEquals(true, p.getActive());
p = (Patient) ourRestServer.getFhirClient().read().resource("Patient").withId(id).execute();
assertTrue(p.getActive());
assertEquals(1, myPatientResourceProvider.getCountRead());
}
@ -94,13 +90,13 @@ public class HashMapResourceProviderTest {
Patient p = new Patient();
p.setId("ABC");
p.setActive(true);
IIdType id = ourClient.update().resource(p).execute().getId();
IIdType id = ourRestServer.getFhirClient().update().resource(p).execute().getId();
assertEquals("ABC", id.getIdPart());
assertEquals("1", id.getVersionIdPart());
// Read
p = (Patient) ourClient.read().resource("Patient").withId(id).execute();
assertEquals(true, p.getActive());
p = (Patient) ourRestServer.getFhirClient().read().resource("Patient").withId(id).execute();
assertTrue(p.getActive());
}
@Test
@ -108,31 +104,39 @@ public class HashMapResourceProviderTest {
// Create
Patient p = new Patient();
p.setActive(true);
IIdType id = ourClient.create().resource(p).execute().getId();
IIdType id = ourRestServer.getFhirClient().create().resource(p).execute().getId().toUnqualified();
assertThat(id.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id.getVersionIdPart());
assertEquals(0, myPatientResourceProvider.getCountDelete());
IDeleteTyped iDeleteTyped = ourClient.delete().resourceById(id.toUnqualifiedVersionless());
ourRestServer.getFhirClient().delete().resourceById(id.toUnqualifiedVersionless()).execute();
ourLog.info("About to execute");
try {
iDeleteTyped.execute();
} catch (NullPointerException e) {
ourLog.error("NPE", e);
fail(e.toString());
}
assertEquals(1, myPatientResourceProvider.getCountDelete());
// Read
ourClient.read().resource("Patient").withId(id.withVersion("1")).execute();
ourRestServer.getFhirClient().read().resource("Patient").withId(id.withVersion("1")).execute();
try {
ourClient.read().resource("Patient").withId(id.withVersion("2")).execute();
ourRestServer.getFhirClient().read().resource("Patient").withId(id.withVersion("2")).execute();
fail();
} catch (ResourceGoneException e) {
// good
}
// History should include deleted entry
Bundle history = ourRestServer.getFhirClient().history().onType(Patient.class).returnBundle(Bundle.class).execute();
ourLog.info("History:\n{}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(history));
assertEquals(id.withVersion("2").getValue(), history.getEntry().get(0).getRequest().getUrl());
assertEquals("DELETE", history.getEntry().get(0).getRequest().getMethod().toCode());
assertEquals(id.withVersion("1").getValue(), history.getEntry().get(1).getRequest().getUrl());
assertEquals("POST", history.getEntry().get(1).getRequest().getMethod().toCode());
// Search should not include deleted entry
Bundle search = ourRestServer.getFhirClient().search().forResource("Patient").returnBundle(Bundle.class).execute();
ourLog.info("Search:\n{}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(search));
assertEquals(0, search.getEntry().size());
}
@Test
@ -140,14 +144,14 @@ public class HashMapResourceProviderTest {
// Create Res 1
Patient p = new Patient();
p.setActive(true);
IIdType id1 = ourClient.create().resource(p).execute().getId();
IIdType id1 = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id1.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id1.getVersionIdPart());
// Create Res 2
p = new Patient();
p.setActive(true);
IIdType id2 = ourClient.create().resource(p).execute().getId();
IIdType id2 = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id2.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id2.getVersionIdPart());
@ -155,11 +159,11 @@ public class HashMapResourceProviderTest {
p = new Patient();
p.setId(id2);
p.setActive(false);
id2 = ourClient.update().resource(p).execute().getId();
id2 = ourRestServer.getFhirClient().update().resource(p).execute().getId();
assertThat(id2.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("2", id2.getVersionIdPart());
Bundle history = ourClient
Bundle history = ourRestServer.getFhirClient()
.history()
.onInstance(id2.toUnqualifiedVersionless())
.andReturnBundle(Bundle.class)
@ -184,14 +188,14 @@ public class HashMapResourceProviderTest {
// Create Res 1
Patient p = new Patient();
p.setActive(true);
IIdType id1 = ourClient.create().resource(p).execute().getId();
IIdType id1 = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id1.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id1.getVersionIdPart());
// Create Res 2
p = new Patient();
p.setActive(true);
IIdType id2 = ourClient.create().resource(p).execute().getId();
IIdType id2 = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id2.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id2.getVersionIdPart());
@ -199,11 +203,11 @@ public class HashMapResourceProviderTest {
p = new Patient();
p.setId(id2);
p.setActive(false);
id2 = ourClient.update().resource(p).execute().getId();
id2 = ourRestServer.getFhirClient().update().resource(p).execute().getId();
assertThat(id2.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("2", id2.getVersionIdPart());
Bundle history = ourClient
Bundle history = ourRestServer.getFhirClient()
.history()
.onType(Patient.class)
.andReturnBundle(Bundle.class)
@ -228,20 +232,23 @@ public class HashMapResourceProviderTest {
for (int i = 0; i < 100; i++) {
Patient p = new Patient();
p.addName().setFamily("FAM" + i);
ourClient.registerInterceptor(new LoggingInterceptor(true));
IIdType id = ourClient.create().resource(p).execute().getId();
ourRestServer.getFhirClient().registerInterceptor(new LoggingInterceptor(true));
IIdType id = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id.getVersionIdPart());
}
// Search
Bundle resp = ourClient
Bundle resp = ourRestServer.getFhirClient()
.search()
.forResource("Patient")
.returnBundle(Bundle.class)
.execute();
ourLog.info("Search:\n{}", ourCtx.newJsonParser().setPrettyPrint(true).encodeResourceToString(resp));
assertEquals(100, resp.getTotal());
assertEquals(100, resp.getEntry().size());
assertFalse(resp.getEntry().get(0).hasRequest());
assertFalse(resp.getEntry().get(1).hasRequest());
assertEquals(1, myPatientResourceProvider.getCountSearch());
@ -253,13 +260,13 @@ public class HashMapResourceProviderTest {
for (int i = 0; i < 100; i++) {
Patient p = new Patient();
p.addName().setFamily("FAM" + i);
IIdType id = ourClient.create().resource(p).execute().getId();
IIdType id = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id.getVersionIdPart());
}
// Search
Bundle resp = ourClient
Bundle resp = ourRestServer.getFhirClient()
.search()
.forResource("Patient")
.where(IAnyResource.RES_ID.exactly().codes("2", "3"))
@ -270,7 +277,7 @@ public class HashMapResourceProviderTest {
assertThat(respIds, containsInAnyOrder("Patient/2", "Patient/3"));
// Search
resp = ourClient
resp = ourRestServer.getFhirClient()
.search()
.forResource("Patient")
.where(IAnyResource.RES_ID.exactly().codes("2", "3"))
@ -281,7 +288,7 @@ public class HashMapResourceProviderTest {
respIds = resp.getEntry().stream().map(t -> t.getResource().getIdElement().toUnqualifiedVersionless().getValue()).collect(Collectors.toList());
assertThat(respIds, containsInAnyOrder("Patient/2", "Patient/3"));
resp = ourClient
resp = ourRestServer.getFhirClient()
.search()
.forResource("Patient")
.where(IAnyResource.RES_ID.exactly().codes("2", "3"))
@ -299,7 +306,7 @@ public class HashMapResourceProviderTest {
// Create
Patient p = new Patient();
p.setActive(true);
IIdType id = ourClient.create().resource(p).execute().getId();
IIdType id = ourRestServer.getFhirClient().create().resource(p).execute().getId();
assertThat(id.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("1", id.getVersionIdPart());
@ -310,7 +317,7 @@ public class HashMapResourceProviderTest {
p = new Patient();
p.setId(id);
p.setActive(false);
id = ourClient.update().resource(p).execute().getId();
id = ourRestServer.getFhirClient().update().resource(p).execute().getId();
assertThat(id.getIdPart(), matchesPattern("[0-9]+"));
assertEquals("2", id.getVersionIdPart());
@ -321,71 +328,21 @@ public class HashMapResourceProviderTest {
assertEquals(1, myPatientResourceProvider.getCountUpdate());
// Read
p = (Patient) ourClient.read().resource("Patient").withId(id.withVersion("1")).execute();
assertEquals(true, p.getActive());
p = (Patient) ourClient.read().resource("Patient").withId(id.withVersion("2")).execute();
assertEquals(false, p.getActive());
p = (Patient) ourRestServer.getFhirClient().read().resource("Patient").withId(id.withVersion("1")).execute();
assertTrue(p.getActive());
p = (Patient) ourRestServer.getFhirClient().read().resource("Patient").withId(id.withVersion("2")).execute();
assertFalse(p.getActive());
try {
ourClient.read().resource("Patient").withId(id.withVersion("3")).execute();
ourRestServer.getFhirClient().read().resource("Patient").withId(id.withVersion("3")).execute();
fail();
} catch (ResourceNotFoundException e) {
// good
}
}
private static class MyRestfulServer extends RestfulServer {
MyRestfulServer() {
super(ourCtx);
}
void clearData() {
for (IResourceProvider next : getResourceProviders()) {
if (next instanceof HashMapResourceProvider) {
((HashMapResourceProvider) next).clear();
}
}
}
@Override
protected void initialize() throws ServletException {
super.initialize();
myPatientResourceProvider = new HashMapResourceProvider<>(ourCtx, Patient.class);
myObservationResourceProvider = new HashMapResourceProvider<>(ourCtx, Observation.class);
registerProvider(myPatientResourceProvider);
registerProvider(myObservationResourceProvider);
}
}
@AfterAll
public static void afterClassClearContext() throws Exception {
JettyUtil.closeServer(ourListenerServer);
TestUtil.randomizeLocaleAndTimezone();
}
@BeforeAll
public static void startListenerServer() throws Exception {
ourRestServer = new MyRestfulServer();
ourListenerServer = new Server(0);
ServletContextHandler proxyHandler = new ServletContextHandler();
proxyHandler.setContextPath("/");
ServletHolder servletHolder = new ServletHolder();
servletHolder.setServlet(ourRestServer);
proxyHandler.addServlet(servletHolder, "/*");
ourListenerServer.setHandler(proxyHandler);
JettyUtil.startServer(ourListenerServer);
int ourListenerPort = JettyUtil.getPortForStartedServer(ourListenerServer);
String ourBase = "http://localhost:" + ourListenerPort + "/";
ourCtx.getRestfulClientFactory().setSocketTimeout(120000);
ourClient = ourCtx.newRestfulGenericClient(ourBase);
}
}

View File

@ -76,6 +76,7 @@ public class HashMapResourceProviderExtension<T extends IBaseResource> extends H
myRestfulServerExtension.getRestfulServer().registerProvider(HashMapResourceProviderExtension.this);
}
@Override
public synchronized MethodOutcome update(T theResource, String theConditional, RequestDetails theRequestDetails) {
T resourceClone = getFhirContext().newTerser().clone(theResource);
myUpdates.add(resourceClone);

View File

@ -30,6 +30,9 @@ import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
import ca.uhn.fhir.rest.server.IPagingProvider;
import ca.uhn.fhir.rest.server.RestfulServer;
import ca.uhn.fhir.rest.server.interceptor.ResponseValidatingInterceptor;
import ca.uhn.fhir.validation.FhirValidator;
import ca.uhn.fhir.validation.ResultSeverityEnum;
import org.apache.commons.lang3.Validate;
import org.apache.commons.lang3.time.DateUtils;
import org.junit.jupiter.api.extension.ExtensionContext;

View File

@ -1,4 +1,5 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
@ -98,19 +99,6 @@
<artifactId>jakarta.annotation-api</artifactId>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<scope>test</scope>
</dependency>
<!-- Test Database -->
<dependency>
<groupId>org.apache.derby</groupId>
<artifactId>derby</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring -->
<dependency>
<groupId>org.springframework</groupId>
@ -189,7 +177,12 @@
<artifactId>popper.js</artifactId>
</dependency>
<!-- Test Dependencies -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.eclipse.jetty</groupId>
<artifactId>jetty-servlets</artifactId>
@ -220,6 +213,22 @@
<artifactId>commons-dbcp2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>net.sourceforge.htmlunit</groupId>
<artifactId>htmlunit</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>ca.uhn.hapi.fhir</groupId>
<artifactId>hapi-fhir-test-utilities</artifactId>
<version>${project.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
@ -235,9 +244,11 @@
</goals>
<configuration>
<target>
<copy todir="${project.build.directory}/${project.build.finalName}/css" flatten="true" failonerror="true">
<copy todir="${project.build.directory}/${project.build.finalName}/css" flatten="true"
failonerror="true">
<resources>
<file file="${basedir}/../hapi-fhir-base/src/main/resources/ca/uhn/fhir/narrative/hapi-narrative.css" />
<file
file="${basedir}/../hapi-fhir-base/src/main/resources/ca/uhn/fhir/narrative/hapi-narrative.css"/>
</resources>
</copy>
</target>
@ -262,17 +273,17 @@
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-source-plugin</artifactId>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>jar-no-fork</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

View File

@ -12,6 +12,7 @@ import ca.uhn.fhir.rest.client.api.IHttpRequest;
import ca.uhn.fhir.rest.client.api.IHttpResponse;
import ca.uhn.fhir.rest.client.impl.GenericClient;
import ca.uhn.fhir.to.model.HomeRequest;
import ca.uhn.fhir.util.BundleUtil;
import ca.uhn.fhir.util.ExtensionConstants;
import ca.uhn.hapi.converters.canonical.VersionCanonicalizer;
import org.apache.commons.io.IOUtils;
@ -21,8 +22,10 @@ import org.apache.http.Header;
import org.apache.http.entity.ContentType;
import org.apache.http.message.BasicHeader;
import org.hl7.fhir.instance.model.api.IAnyResource;
import org.hl7.fhir.instance.model.api.IBaseBundle;
import org.hl7.fhir.instance.model.api.IBaseConformance;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IBaseXhtml;
import org.hl7.fhir.instance.model.api.IDomainResource;
import org.hl7.fhir.r5.model.CapabilityStatement;
import org.springframework.beans.factory.annotation.Autowired;
@ -373,23 +376,38 @@ public class BaseController {
private String parseNarrative(HomeRequest theRequest, EncodingEnum theCtEnum, String theResultBody) {
try {
IBaseResource par = theCtEnum.newParser(getContext(theRequest)).parseResource(theResultBody);
String retVal;
if (par instanceof IResource) {
IResource resource = (IResource) par;
retVal = resource.getText().getDiv().getValueAsString();
} else if (par instanceof IDomainResource) {
retVal = ((IDomainResource) par).getText().getDivAsString();
} else {
retVal = null;
}
return StringUtils.defaultString(retVal);
FhirContext context = getContext(theRequest);
IBaseResource result = theCtEnum.newParser(context).parseResource(theResultBody);
return parseNarrative(context, result);
} catch (Exception e) {
ourLog.error("Failed to parse resource", e);
return "";
}
}
private String parseNarrative(FhirContext theContext, IBaseResource theResult) throws Exception {
String retVal = null;
if (theResult instanceof IResource) {
IResource resource = (IResource) theResult;
retVal = resource.getText().getDiv().getValueAsString();
} else if (theResult instanceof IDomainResource) {
retVal = ((IDomainResource) theResult).getText().getDivAsString();
} else if (theResult instanceof IBaseBundle) {
// If this is a document, we'll pull the narrative from the Composition
IBaseBundle bundle = (IBaseBundle) theResult;
if ("document".equals(BundleUtil.getBundleType(theContext, bundle))) {
IBaseResource firstResource = theContext.newTerser().getSingleValueOrNull(bundle, "Bundle.entry.resource", IBaseResource.class);
if (firstResource != null && "Composition".equals(theContext.getResourceType(firstResource))) {
IBaseXhtml html = theContext.newTerser().getSingleValueOrNull(firstResource, "text.div", IBaseXhtml.class);
if (html != null) {
retVal = html.getValueAsString();
}
}
}
}
return StringUtils.defaultString(retVal);
}
protected String preProcessMessageBody(String theBody) {
if (theBody == null) {
return "";
@ -462,7 +480,6 @@ public class BaseController {
switch (ctEnum) {
case JSON:
if (theResultType == ResultType.RESOURCE) {
narrativeString = parseNarrative(theRequest, ctEnum, resultBody);
resultDescription.append("JSON resource");
} else if (theResultType == ResultType.BUNDLE) {
resultDescription.append("JSON bundle");
@ -472,7 +489,6 @@ public class BaseController {
case XML:
default:
if (theResultType == ResultType.RESOURCE) {
narrativeString = parseNarrative(theRequest, ctEnum, resultBody);
resultDescription.append("XML resource");
} else if (theResultType == ResultType.BUNDLE) {
resultDescription.append("XML bundle");
@ -480,6 +496,7 @@ public class BaseController {
}
break;
}
narrativeString = parseNarrative(theRequest, ctEnum, resultBody);
}
resultDescription.append(" (").append(defaultString(resultBody).length() + " bytes)");
@ -508,6 +525,9 @@ public class BaseController {
theModelMap.put("narrative", narrativeString);
theModelMap.put("latencyMs", theLatency);
theModelMap.put("config", myConfig);
theModelMap.put("serverId", theRequest.getServerId());
} catch (Exception e) {
ourLog.error("Failure during processing", e);
theModelMap.put("errorMsg", toDisplayError("Error during processing: " + e.getMessage(), e));

View File

@ -25,10 +25,11 @@ import ca.uhn.fhir.rest.gclient.QuantityClientParam;
import ca.uhn.fhir.rest.gclient.QuantityClientParam.IAndUnits;
import ca.uhn.fhir.rest.gclient.StringClientParam;
import ca.uhn.fhir.rest.gclient.TokenClientParam;
import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import ca.uhn.fhir.to.model.HomeRequest;
import ca.uhn.fhir.to.model.ResourceRequest;
import ca.uhn.fhir.to.model.TransactionRequest;
import ca.uhn.fhir.util.UrlUtil;
import ca.uhn.fhir.util.StopWatch;
import com.google.gson.stream.JsonWriter;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.dstu3.model.CapabilityStatement;
@ -38,6 +39,7 @@ import org.hl7.fhir.dstu3.model.CapabilityStatement.CapabilityStatementRestResou
import org.hl7.fhir.dstu3.model.StringType;
import org.hl7.fhir.instance.model.api.IBaseBundle;
import org.hl7.fhir.instance.model.api.IBaseConformance;
import org.hl7.fhir.instance.model.api.IBaseParameters;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.springframework.ui.ModelMap;
import org.springframework.validation.BindingResult;
@ -599,6 +601,53 @@ public class Controller extends BaseController {
}
@SuppressWarnings("unchecked")
@RequestMapping(value = { "/operation" })
public String actionOperation(final HttpServletRequest theReq, final HomeRequest theRequest, final BindingResult theBindingResult, final ModelMap theModel) {
String instanceType = theReq.getParameter("instanceType");
String instanceId = theReq.getParameter("instanceId");
String operationName = theReq.getParameter("operationName");
boolean finished = false;
addCommonParams(theReq, theRequest, theModel);
CaptureInterceptor interceptor = new CaptureInterceptor();
GenericClient client = theRequest.newClient(theReq, getContext(theRequest), myConfig, interceptor);
client.setPrettyPrint(true);
Class<? extends IBaseResource> type = getContext(theRequest).getResourceDefinition(instanceType).getImplementingClass();
Class<? extends IBaseParameters> parametersType = (Class<? extends IBaseParameters>) getContext(theRequest).getResourceDefinition("Parameters").getImplementingClass();
StopWatch sw = new StopWatch();
ResultType returnsResource = ResultType.BUNDLE;
try {
client
.operation()
.onInstance(instanceType + "/" + instanceId)
.named(operationName)
.withNoParameters(parametersType)
.useHttpGet()
.execute();
} catch (DataFormatException e) {
ourLog.warn("Failed to parse resource", e);
theModel.put("errorMsg", toDisplayError("Failed to parse message body. Error was: " + e.getMessage(), e));
finished = true;
} catch (BaseServerResponseException e) {
theModel.put("errorMsg", e.getMessage());
returnsResource = ResultType.RESOURCE;
}
String outcomeDescription = "Execute " + operationName + " Operation";
processAndAddLastClientInvocation(client, returnsResource, theModel, sw.getMillis(), outcomeDescription, interceptor, theRequest);
return "result";
}
private void doActionHistory(HttpServletRequest theReq, HomeRequest theRequest, BindingResult theBindingResult, ModelMap theModel, String theMethod, String theMethodDescription) {
addCommonParams(theReq, theRequest, theModel);

View File

@ -8,20 +8,22 @@ import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurerAdapter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerAdapter;
import org.thymeleaf.spring5.SpringTemplateEngine;
import org.thymeleaf.spring5.templateresolver.SpringResourceTemplateResolver;
import org.thymeleaf.spring5.view.ThymeleafViewResolver;
import org.thymeleaf.templatemode.TemplateMode;
import javax.annotation.Nonnull;
@Configuration
@EnableWebMvc
@ComponentScan(basePackages = "ca.uhn.fhir.to")
public class FhirTesterMvcConfig extends WebMvcConfigurerAdapter {
public class FhirTesterMvcConfig implements WebMvcConfigurer {
@Override
public void addResourceHandlers(ResourceHandlerRegistry theRegistry) {
public void addResourceHandlers(@Nonnull ResourceHandlerRegistry theRegistry) {
WebUtil.webJarAddBoostrap(theRegistry);
WebUtil.webJarAddJQuery(theRegistry);
WebUtil.webJarAddFontAwesome(theRegistry);
@ -40,13 +42,17 @@ public class FhirTesterMvcConfig extends WebMvcConfigurerAdapter {
}
@Bean
public SpringResourceTemplateResolver templateResolver() {
public SpringResourceTemplateResolver templateResolver(TesterConfig theTesterConfig) {
SpringResourceTemplateResolver resolver = new SpringResourceTemplateResolver();
resolver.setPrefix("/WEB-INF/templates/");
resolver.setSuffix(".html");
resolver.setTemplateMode(TemplateMode.HTML);
resolver.setCharacterEncoding("UTF-8");
if (theTesterConfig.getDebugTemplatesMode()) {
resolver.setCacheable(false);
}
return resolver;
}
@ -56,17 +62,17 @@ public class FhirTesterMvcConfig extends WebMvcConfigurerAdapter {
}
@Bean
public ThymeleafViewResolver viewResolver() {
public ThymeleafViewResolver viewResolver(SpringTemplateEngine theTemplateEngine) {
ThymeleafViewResolver viewResolver = new ThymeleafViewResolver();
viewResolver.setTemplateEngine(templateEngine());
viewResolver.setTemplateEngine(theTemplateEngine);
viewResolver.setCharacterEncoding("UTF-8");
return viewResolver;
}
@Bean
public SpringTemplateEngine templateEngine() {
public SpringTemplateEngine templateEngine(SpringResourceTemplateResolver theTemplateResolver) {
SpringTemplateEngine templateEngine = new SpringTemplateEngine();
templateEngine.setTemplateResolver(templateResolver());
templateEngine.setTemplateResolver(theTemplateResolver);
return templateEngine;
}

View File

@ -1,29 +1,35 @@
package ca.uhn.fhir.to;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.i18n.Msg;
import java.util.*;
import javax.annotation.PostConstruct;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.server.util.ITestingUiClientFactory;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IIdType;
import org.springframework.beans.factory.annotation.Required;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.rest.server.util.ITestingUiClientFactory;
import javax.annotation.PostConstruct;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public class TesterConfig {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(TesterConfig.class);
public static final String SYSPROP_FORCE_SERVERS = "ca.uhn.fhir.to.TesterConfig_SYSPROP_FORCE_SERVERS";
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(TesterConfig.class);
private final LinkedHashMap<String, Boolean> myIdToAllowsApiKey = new LinkedHashMap<>();
private final LinkedHashMap<String, FhirVersionEnum> myIdToFhirVersion = new LinkedHashMap<>();
private final LinkedHashMap<String, String> myIdToServerBase = new LinkedHashMap<>();
private final LinkedHashMap<String, String> myIdToServerName = new LinkedHashMap<>();
private final List<ServerBuilder> myServerBuilders = new ArrayList<>();
private final LinkedHashMap<String, Map<String, IInclusionChecker>> myServerIdToTypeToOperationNameToInclusionChecker = new LinkedHashMap<>();
private final LinkedHashMap<String, Map<RestOperationTypeEnum, IInclusionChecker>> myServerIdToTypeToInteractionNameToInclusionChecker = new LinkedHashMap<>();
private ITestingUiClientFactory myClientFactory;
private LinkedHashMap<String, Boolean> myIdToAllowsApiKey = new LinkedHashMap<String, Boolean>();
private LinkedHashMap<String, FhirVersionEnum> myIdToFhirVersion = new LinkedHashMap<String, FhirVersionEnum>();
private LinkedHashMap<String, String> myIdToServerBase = new LinkedHashMap<String, String>();
private LinkedHashMap<String, String> myIdToServerName = new LinkedHashMap<String, String>();
private boolean myRefuseToFetchThirdPartyUrls = true;
private List<ServerBuilder> myServerBuilders = new ArrayList<TesterConfig.ServerBuilder>();
private boolean myDebugTemplatesMode;
public IServerBuilderStep1 addServer() {
ServerBuilder retVal = new ServerBuilder();
@ -42,6 +48,11 @@ public class TesterConfig {
myIdToServerBase.put(next.myId, next.myBaseUrl);
myIdToServerName.put(next.myId, next.myName);
myIdToAllowsApiKey.put(next.myId, next.myAllowsApiKey);
myServerIdToTypeToOperationNameToInclusionChecker.put(next.myId, next.myOperationNameToInclusionChecker);
myServerIdToTypeToInteractionNameToInclusionChecker.put(next.myId, next.mySearchResultRowInteractionEnabled);
if (next.myEnableDebugTemplates) {
myDebugTemplatesMode = true;
}
}
myServerBuilders.clear();
}
@ -50,8 +61,12 @@ public class TesterConfig {
return myClientFactory;
}
public void setClientFactory(ITestingUiClientFactory theClientFactory) {
myClientFactory = theClientFactory;
}
public boolean getDebugTemplatesMode() {
return true;
return myDebugTemplatesMode;
}
public LinkedHashMap<String, Boolean> getIdToAllowsApiKey() {
@ -72,24 +87,52 @@ public class TesterConfig {
/**
* If set to {@literal true} (default is true) the server will refuse to load URLs in
* response payloads the refer to third party servers (e.g. paging URLs etc)
* response payloads that refer to third party servers (e.g. paging URLs etc)
*/
public boolean isRefuseToFetchThirdPartyUrls() {
return myRefuseToFetchThirdPartyUrls;
}
public void setClientFactory(ITestingUiClientFactory theClientFactory) {
myClientFactory = theClientFactory;
}
/**
* If set to {@literal true} (default is true) the server will refuse to load URLs in
* response payloads the refer to third party servers (e.g. paging URLs etc)
* response payloads that refer to third party servers (e.g. paging URLs etc)
*/
public void setRefuseToFetchThirdPartyUrls(boolean theRefuseToFetchThirdPartyUrls) {
myRefuseToFetchThirdPartyUrls = theRefuseToFetchThirdPartyUrls;
}
/**
* Called from Thymeleaf
*/
@SuppressWarnings("unused")
public List<String> getSearchResultRowOperations(String theId, IIdType theResourceId) {
List<String> retVal = new ArrayList<>();
Map<String, IInclusionChecker> operationNamesToInclusionCheckers = myServerIdToTypeToOperationNameToInclusionChecker.get(theId);
for (String operationName : operationNamesToInclusionCheckers.keySet()) {
IInclusionChecker checker = operationNamesToInclusionCheckers.get(operationName);
if (checker.shouldInclude(theResourceId)) {
retVal.add(operationName);
}
}
return retVal;
}
/**
* Called from Thymeleaf
*/
@SuppressWarnings("unused")
public boolean isSearchResultRowInteractionEnabled(String theServerId, String theInteractionName, IIdType theResourceId) {
List<String> retVal = new ArrayList<>();
Map<RestOperationTypeEnum, IInclusionChecker> interactionNamesToInclusionCheckers = myServerIdToTypeToInteractionNameToInclusionChecker.get(theServerId);
RestOperationTypeEnum interaction = RestOperationTypeEnum.forCode(theInteractionName);
Validate.isTrue(interaction != null, "Unknown interaction: %s", theInteractionName);
IInclusionChecker inclusionChecker = interactionNamesToInclusionCheckers.getOrDefault(interaction, id -> false);
return inclusionChecker.shouldInclude(theResourceId);
}
@Required
public void setServers(List<String> theServers) {
List<String> servers = theServers;
@ -97,7 +140,7 @@ public class TesterConfig {
// This is mostly for unit tests
String force = System.getProperty(SYSPROP_FORCE_SERVERS);
if (StringUtils.isNotBlank(force)) {
ourLog.warn("Forcing server confirguration because of system property: {}", force);
ourLog.warn("Forcing server configuration because of system property: {}", force);
servers = Collections.singletonList(force);
}
@ -148,15 +191,49 @@ public class TesterConfig {
IServerBuilderStep5 allowsApiKey();
/**
* If this is set, Thymeleaf UI templates will be run in debug mode, meaning
* no caching between executions. This is helpful if you want to make live changes
* to the template.
*
* @since 6.4.0
*/
IServerBuilderStep5 enableDebugTemplates();
/**
* Use this method to add buttons to invoke operations on the search result table.
*/
ServerBuilder withSearchResultRowOperation(String theOperationName, IInclusionChecker theInclusionChecker);
/**
* Use this method to enable/disable the interaction buttons on the search result rows table.
* By default {@link RestOperationTypeEnum#READ} and {@link RestOperationTypeEnum#UPDATE} are
* already enabled, and they are currently the only interactions supported.
*/
ServerBuilder withSearchResultRowInteraction(RestOperationTypeEnum theInteraction, IInclusionChecker theEnabled);
}
public interface IInclusionChecker {
boolean shouldInclude(IIdType theResourceId);
}
public class ServerBuilder implements IServerBuilderStep1, IServerBuilderStep2, IServerBuilderStep3, IServerBuilderStep4, IServerBuilderStep5 {
private final Map<String, IInclusionChecker> myOperationNameToInclusionChecker = new LinkedHashMap<>();
private final Map<RestOperationTypeEnum, IInclusionChecker> mySearchResultRowInteractionEnabled = new LinkedHashMap<>();
private boolean myAllowsApiKey;
private String myBaseUrl;
private String myId;
private String myName;
private FhirVersionEnum myVersion;
private boolean myEnableDebugTemplates;
public ServerBuilder() {
mySearchResultRowInteractionEnabled.put(RestOperationTypeEnum.READ, id -> true);
mySearchResultRowInteractionEnabled.put(RestOperationTypeEnum.UPDATE, id -> true);
}
@Override
public IServerBuilderStep1 addServer() {
@ -171,6 +248,24 @@ public class TesterConfig {
return this;
}
@Override
public IServerBuilderStep5 enableDebugTemplates() {
myEnableDebugTemplates = true;
return this;
}
@Override
public ServerBuilder withSearchResultRowOperation(String theOperationName, IInclusionChecker theResourceType) {
myOperationNameToInclusionChecker.put(theOperationName, theResourceType);
return this;
}
@Override
public ServerBuilder withSearchResultRowInteraction(RestOperationTypeEnum theInteraction, IInclusionChecker theEnabled) {
mySearchResultRowInteractionEnabled.put(theInteraction, theEnabled);
return this;
}
@Override
public IServerBuilderStep4 withBaseUrl(String theBaseUrl) {
Validate.notBlank(theBaseUrl, "theBaseUrl can not be blank");
@ -200,5 +295,4 @@ public class TesterConfig {
}
}
}

View File

@ -117,6 +117,10 @@
</div>
</td>
</tr>
<tr th:if="${!#strings.isEmpty(narrative)}">
<td class="propertyKeyCell">Result Narrative</td>
<td th:utext="${narrative}"></td>
</tr>
<tr th:if="${!#strings.isEmpty(resultBody)}">
<td rowspan="2">
Result Body
@ -124,239 +128,18 @@
</td>
<td style="border-width: 0px; padding: 0px;">
<!--
If the response is a bundle, this block will contain a collapsible
table with a summary of each entry as well as paging buttons and
controls for viewing/editing/etc results
<th:block th:if="${bundle} != null">
<th:block th:replace="tmpl-result-controltable-hapi :: controltable"></th:block>
</th:block>
<th:block th:if="${riBundle} != null AND ( ${riBundle.type.name()} == 'SEARCHSET' OR ${riBundle.type.name()} == 'HISTORY' )">
<th:block th:replace="tmpl-result-controltable-ri :: controltable"></th:block>
</th:block>
NON-RI Bundle
-->
<div th:if="${bundle} != null" class="panel-group" id="accordion" style="margin-bottom: 0px;">
<div class="panel panel-default" style="border: none; border-bottom: 1px solid #ddd; border-radius: 0px;">
<div class="panel-heading">
<div class="panel-title">
<th:block th:if="${#lists.isEmpty(bundle.entries)}">Bundle contains no entries</th:block>
<a th:unless="${#lists.isEmpty(bundle.entries)}" data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
<i id="collapseOneIcon" class="far fa-minus-square"></i>
<span th:if="${bundle.totalResults.empty}" th:text="'Bundle contains ' + ${#lists.size(bundle.entries)} + ' entries'"/>
<span th:unless="${bundle.totalResults.empty}" th:text="'Bundle contains ' + ${#lists.size(bundle.entries)} + ' / ' + ${bundle.totalResults.value} + ' entries'"/>
</a>
<th:block th:if="${!bundle.linkNext.empty} or ${!bundle.linkPrevious.empty}">
<!-- Prev/Next Page Buttons -->
<button class="btn btn-success btn-xs" type="button" id="page-prev-btn"
style="margin-left: 15px;">
<i class="fas fa-angle-double-left"></i>
Prev Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${bundle.linkPrevious.empty}]]) {
$('#page-prev-btn').prop('disabled', true);
}
$('#page-prev-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: [[${bundle.linkPrevious.value}]] }));
$("#outerForm").attr("action", "page").submit();
});
</script>
<button class="btn btn-success btn-xs" type="button" id="page-next-btn">
<i class="fas fa-angle-double-right"></i>
Next Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${bundle.linkNext.empty}]]) {
$('#page-next-btn').prop('disabled', true);
}
$('#page-next-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: [[${bundle.linkNext.value}]] }));
$("#outerForm").attr("action", "page").submit();
});
</script>
</th:block>
</div>
</div>
<div id="collapseOne" class="panel-collapse in" th:unless="${#lists.isEmpty(bundle.entries)}">
<div class="panel-body" style="padding-bottom: 0px;">
<table class="table table-condensed" style="padding-bottom: 0px; margin-bottom: 0px;">
<colgroup>
<col style="width: 100px;"/>
<col/>
<col/>
<col style="width: 100px;"/>
</colgroup>
<thead>
<tr>
<th></th>
<th>ID</th>
<th>Title</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr th:each="entry : ${bundle.entries}">
<td style="white-space: nowrap;">
<th:block th:if="${entry.resource} != null">
<button class="btn btn-primary btn-xs" th:data1="${entry.resource.id.resourceType}" th:data2="${entry.resource.id.idPart}" th:data3="${#strings.defaultString(entry.resource.id.versionIdPart,'')}" onclick="readFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="read"><i class="fas fa-book"></i> Read</button>
<button class="btn btn-primary btn-xs" th:data1="${entry.resource.id.resourceType}" th:data2="${entry.resource.id.idPart}" th:data3="${#strings.defaultString(entry.resource.id.versionIdPart,'')}" onclick="updateFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="home"><i class="far fa-edit"></i> Update</button>
</th:block>
</td>
<td>
<a th:if="${entry.resource} != null" th:href="${entry.resource.id}" th:text="${entry.resource.id.toUnqualified()}" style="font-size: 0.8em"/>
</td>
<td>
<!-- Title used to go here -->
</td>
<td th:if="${entry.updated.value} == null"></td>
<td th:if="${entry.updated.value} != null and ${entry.updated.today} == true" th:text="${#dates.format(entry.updated.value, 'HH:mm:ss')}"></td>
<td th:if="${entry.updated.value} != null and ${entry.updated.today} == false" th:text="${#dates.format(entry.updated.value, 'yyyy-MM-dd')}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<script type="text/javascript">
/*
$('#collapseOne').on('hidden.bs.collapse', function () {
$("#collapseOneIcon").removeClass("fa-minus-square").addClass("fa-plus-square");
});
$('#collapseOne').on('shown.bs.collapse', function () {
$("#collapseOneIcon").removeClass("fa-plus-square").addClass("fa-minus-square");
});
*/
</script>
<div class="panel-heading">
<div class="panel-title-text">
Payload
</div>
</div>
<!-- END Non-RI Bundle -->
<!--
If the response is a bundle, this block will contain a collapsible
table with a summary of each entry as well as paging buttons and
controls for viewing/editing/etc results
RI Bundle
-->
<div th:if="${riBundle} != null" class="panel-group" id="accordion" style="margin-bottom: 0px;">
<div class="panel panel-default" style="border: none; border-bottom: 1px solid #ddd; border-radius: 0px;">
<div class="panel-heading">
<div class="panel-title">
<th:block th:if="${#lists.isEmpty(riBundle.entry)}">Bundle contains no entries</th:block>
<a th:unless="${#lists.isEmpty(riBundle.entry)}" data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
<i id="collapseOneIcon" class="far fa-minus-square"></i>
<span th:if="${riBundle.totalElement.empty}" th:text="'Bundle contains ' + ${#lists.size(riBundle.entry)} + ' entries'"/>
<span th:unless="${riBundle.totalElement.empty}" th:text="'Bundle contains ' + ${#lists.size(riBundle.entry)} + ' / ' + ${riBundle.totalElement.value} + ' entries'"/>
</a>
<th:block th:if="${riBundle.getLink('next') != null} or ${riBundle.getLink('prev') != null} or ${riBundle.getLink('previous') != null}">
<!-- Prev/Next Page Buttons -->
<button class="btn btn-success btn-xs" type="button" id="page-prev-btn"
style="margin-left: 15px;">
<i class="fas fa-angle-double-left"></i>
Prev Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${riBundle.getLink('prev') == null && riBundle.getLink('previous') == null}]]) {
$('#page-prev-btn').prop('disabled', true);
}
$('#page-prev-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
var prev = [[${riBundle.getLinkOrCreate('prev').url}]];
var previous = [[${riBundle.getLinkOrCreate('previous').url}]];
var url = prev != null ? prev : previous;
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: url }));
$("#outerForm").attr("action", "page").submit();
});
</script>
<button class="btn btn-success btn-xs" type="button" id="page-next-btn">
<i class="fas fa-angle-double-right"></i>
Next Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${riBundle.getLink('next') == null}]]) {
$('#page-next-btn').prop('disabled', true);
}
$('#page-next-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: [[${riBundle.getLinkOrCreate('next').url}]] }));
$("#outerForm").attr("action", "page").submit();
});
</script>
</th:block>
</div>
</div>
<div id="collapseOne" class="panel-collapse in" th:unless="${#lists.isEmpty(riBundle.entry)}">
<div class="panel-body" style="padding-bottom: 0px;">
<table class="table table-condensed" style="padding-bottom: 0px; margin-bottom: 0px;">
<colgroup>
<col style="width: 100px;"/>
<col/>
<col/>
<col style="width: 100px;"/>
</colgroup>
<thead>
<tr>
<th></th>
<th>ID</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr th:each="entry : ${riBundle.entry}">
<td style="white-space: nowrap;">
<th:block th:if="${entry.resource} != null">
<button class="btn btn-primary btn-xs" th:data1="${entry.resource.idElement.resourceType}" th:data2="${entry.resource.idElement.idPart}" th:data3="${#strings.defaultString(entry.resource.idElement.versionIdPart,'')}" onclick="readFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="read"><i class="fas fa-book"></i> Read</button>
<button class="btn btn-primary btn-xs" th:data1="${entry.resource.idElement.resourceType}" th:data2="${entry.resource.idElement.idPart}" th:data3="${#strings.defaultString(entry.resource.idElement.versionIdPart,'')}" onclick="updateFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="home"><i class="far fa-edit"></i> Update</button>
</th:block>
</td>
<td>
<a th:if="${entry.resource} != null" th:href="${entry.resource.id}" th:text="${entry.resource.idElement.toUnqualified()}" style="font-size: 0.8em"/>
</td>
<th:block th:if="${ri}">
<td th:if="${entry.resource} == null or ${entry.resource.meta.lastUpdatedElement.value} == null"></td>
<td th:if="${entry.resource} != null and ${entry.resource.meta.lastUpdatedElement.value} != null and ${entry.resource.meta.lastUpdatedElement.today} == true" th:text="${#dates.format(entry.resource.meta.lastUpdated, 'HH:mm:ss')}"></td>
<td th:if="${entry.resource} != null and ${entry.resource.meta.lastUpdatedElement.value} != null and ${entry.resource.meta.lastUpdatedElement.today} == false" th:text="${#dates.format(entry.resource.meta.lastUpdated, 'yyyy-MM-dd HH:mm:ss')}"></td>
</th:block>
<th:block th:unless="${ri}">
<td></td>
</th:block>
</tr>
</tbody>
</table>
</div>
</div>
<script type="text/javascript">
/*
$('#collapseOne').on('hidden.bs.collapse', function () {
$("#collapseOneIcon").removeClass("fa-minus-square").addClass("fa-plus-square");
});
$('#collapseOne').on('shown.bs.collapse', function () {
$("#collapseOneIcon").removeClass("fa-plus-square").addClass("fa-minus-square");
});
*/
</script>
</div>
</div>
<!-- END RI Bundle -->
<div class="panel-heading" sstyle="margin: 5px;">
<h4 class="panel-title">
Raw Message
</h4>
</div>
</td>
</tr>
<tr th:if="${!#strings.isEmpty(resultBody)}">
@ -367,10 +150,6 @@
</div>
</td>
</tr>
<tr th:if="${!#strings.isEmpty(narrative)}">
<td class="propertyKeyCell">Result Narrative</td>
<td th:utext="${narrative}"></td>
</tr>
</tbody>
</table>

View File

@ -0,0 +1,112 @@
<!--/*
If the response is a bundle, this block will contain a collapsible
table with a summary of each entry as well as paging buttons and
controls for viewing/editing/etc results
ca.uhn.hapi Bundle
*/-->
<th:block th:fragment="controltable">
<div class="panel-group" id="accordion" style="margin-bottom: 0px;">
<div class="panel panel-default" style="border: none; border-bottom: 1px solid #ddd; border-radius: 0px;">
<div class="panel-heading">
<div class="panel-title">
<th:block th:if="${#lists.isEmpty(bundle.entries)}">Bundle contains no entries</th:block>
<a th:unless="${#lists.isEmpty(bundle.entries)}" data-toggle="collapse" data-parent="#accordion" href="#collapseOne">
<i id="collapseOneIcon" class="far fa-minus-square"></i>
<span th:if="${bundle.totalResults.empty}" th:text="'Bundle contains ' + ${#lists.size(bundle.entries)} + ' entries'"/>
<span th:unless="${bundle.totalResults.empty}" th:text="'Bundle contains ' + ${#lists.size(bundle.entries)} + ' / ' + ${bundle.totalResults.value} + ' entries'"/>
</a>
<th:block th:if="${!bundle.linkNext.empty} or ${!bundle.linkPrevious.empty}">
<!-- Prev/Next Page Buttons -->
<button class="btn btn-success btn-xs" type="button" id="page-prev-btn"
style="margin-left: 15px;">
<i class="fas fa-angle-double-left"></i>
Prev Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${bundle.linkPrevious.empty}]]) {
$('#page-prev-btn').prop('disabled', true);
}
$('#page-prev-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: [[${bundle.linkPrevious.value}]] }));
$("#outerForm").attr("action", "page").submit();
});
</script>
<button class="btn btn-success btn-xs" type="button" id="page-next-btn">
<i class="fas fa-angle-double-right"></i>
Next Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${bundle.linkNext.empty}]]) {
$('#page-next-btn').prop('disabled', true);
}
$('#page-next-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: [[${bundle.linkNext.value}]] }));
$("#outerForm").attr("action", "page").submit();
});
</script>
</th:block>
</div>
</div>
<div id="collapseOne" class="panel-collapse in" th:unless="${#lists.isEmpty(bundle.entries)}">
<div class="panel-body" style="padding-bottom: 0px;">
<table class="table table-condensed" style="padding-bottom: 0px; margin-bottom: 0px;">
<colgroup>
<col style="width: 100px;"/>
<col/>
<col/>
<col style="width: 100px;"/>
</colgroup>
<thead>
<tr>
<th></th>
<th>ID</th>
<th>Title</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr th:each="entry : ${bundle.entries}">
<td style="white-space: nowrap;">
<th:block th:if="${entry.resource} != null">
<button class="btn btn-primary btn-xs" th:data1="${entry.resource.id.resourceType}" th:data2="${entry.resource.id.idPart}" th:data3="${#strings.defaultString(entry.resource.id.versionIdPart,'')}" onclick="readFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="read"><i class="fas fa-book"></i> Read</button>
<button class="btn btn-primary btn-xs" th:data1="${entry.resource.id.resourceType}" th:data2="${entry.resource.id.idPart}" th:data3="${#strings.defaultString(entry.resource.id.versionIdPart,'')}" onclick="updateFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="home"><i class="far fa-edit"></i> Update</button>
</th:block>
</td>
<td>
<a th:if="${entry.resource} != null" th:href="${entry.resource.id}" th:text="${entry.resource.id.toUnqualified()}" style="font-size: 0.8em"/>
</td>
<td>
<!-- Title used to go here -->
</td>
<td th:if="${entry.updated.value} == null"></td>
<td th:if="${entry.updated.value} != null and ${entry.updated.today} == true" th:text="${#dates.format(entry.updated.value, 'HH:mm:ss')}"></td>
<td th:if="${entry.updated.value} != null and ${entry.updated.today} == false" th:text="${#dates.format(entry.updated.value, 'yyyy-MM-dd')}"></td>
</tr>
</tbody>
</table>
</div>
</div>
<script type="text/javascript">
/*
$('#collapseOne').on('hidden.bs.collapse', function () {
$("#collapseOneIcon").removeClass("fa-minus-square").addClass("fa-plus-square");
});
$('#collapseOne').on('shown.bs.collapse', function () {
$("#collapseOneIcon").removeClass("fa-plus-square").addClass("fa-minus-square");
});
*/
</script>
</div>
</div>
<!-- END Non-RI Bundle -->
</th:block>

View File

@ -0,0 +1,105 @@
<!--/*
If the response is a bundle, this block will contain a collapsible
table with a summary of each entry as well as paging buttons and
controls for viewing/editing/etc results
RI Bundle
*/-->
<th:block th:fragment="controltable">
<div class="panel-group" id="accordion" style="margin-bottom: 0px;">
<div class="panel panel-default" style="border: none; border-bottom: 1px solid #ddd; border-radius: 0px;">
<div class="panel-heading">
<div class="panel-title">
<div class="panel-title-text">
<th:block th:if="${#lists.isEmpty(riBundle.entry)}">Bundle contains no entries</th:block>
<th:block th:unless="${#lists.isEmpty(riBundle.entry)}">
<th:block th:if="${riBundle.totalElement.empty}" th:text="'Bundle contains ' + ${#lists.size(riBundle.entry)} + ' entries'"/>
<th:block th:unless="${riBundle.totalElement.empty}" th:text="'Bundle contains ' + ${#lists.size(riBundle.entry)} + ' / ' + ${riBundle.totalElement.value} + ' entries'"/>
</th:block>
</div>
<th:block th:if="${riBundle.getLink('next') != null} or ${riBundle.getLink('prev') != null} or ${riBundle.getLink('previous') != null}">
<!-- Prev/Next Page Buttons -->
<button class="btn btn-success btn-xs" type="button" id="page-prev-btn"
style="margin-left: 15px;">
<i class="fas fa-angle-double-left"></i>
Prev Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${riBundle.getLink('prev') == null && riBundle.getLink('previous') == null}]]) {
$('#page-prev-btn').prop('disabled', true);
}
$('#page-prev-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
var prev = [[${riBundle.getLinkOrCreate('prev').url}]];
var previous = [[${riBundle.getLinkOrCreate('previous').url}]];
var url = prev != null ? prev : previous;
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: url }));
$("#outerForm").attr("action", "page").submit();
});
</script>
<button class="btn btn-success btn-xs" type="button" id="page-next-btn">
<i class="fas fa-angle-double-right"></i>
Next Page
</button>
<script type="text/javascript" th:inline="javascript">
if ([[${riBundle.getLink('next') == null}]]) {
$('#page-next-btn').prop('disabled', true);
}
$('#page-next-btn').click(function() {
var btn = $(this);
handleActionButtonClick($(this));
btn.append($('<input />', { type: 'hidden', name: 'page-url', value: [[${riBundle.getLinkOrCreate('next').url}]] }));
$("#outerForm").attr("action", "page").submit();
});
</script>
</th:block>
</div>
</div>
<div id="collapseOne" class="panel-collapse in" th:unless="${#lists.isEmpty(riBundle.entry)}">
<div class="panel-body" style="padding-bottom: 0px;">
<table class="table table-condensed" id="resultControlsTable" style="padding-bottom: 0px; margin-bottom: 0px; width: 100%;">
<thead>
<tr>
<th></th>
<th>ID</th>
<th>Updated</th>
</tr>
</thead>
<tbody>
<tr th:each="entry : ${riBundle.entry}">
<td class="resultControlButtons">
<th:block th:if="${entry.resource} != null">
<!-- Individual Search Result Button Row -->
<button th:if="${config.isSearchResultRowInteractionEnabled(serverId, 'read', entry.resource.idElement)}" class="btn btn-primary btn-xs" th:data1="${entry.resource.idElement.resourceType}" th:data2="${entry.resource.idElement.idPart}" th:data3="${#strings.defaultString(entry.resource.idElement.versionIdPart,'')}" onclick="readFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="read"><i class="fas fa-book"></i> Read</button>
<button th:if="${config.isSearchResultRowInteractionEnabled(serverId, 'update', entry.resource.idElement)}" class="btn btn-primary btn-xs" th:data1="${entry.resource.idElement.resourceType}" th:data2="${entry.resource.idElement.idPart}" th:data3="${#strings.defaultString(entry.resource.idElement.versionIdPart,'')}" onclick="updateFromEntriesTable(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="home"><i class="far fa-edit"></i> Update</button>
<th:block th:each="operation : ${config.getSearchResultRowOperations(serverId, entry.resource.idElement)}">
<button class="btn btn-primary btn-xs" th:data1="${entry.resource.idElement.resourceType}" th:data2="${entry.resource.idElement.idPart}" th:data3="${operation}" onclick="executeOperation(this, this.getAttribute('data1'), this.getAttribute('data2'), this.getAttribute('data3'));" type="submit" name="action" value="home">[[${operation}]]</button>
</th:block>
</th:block>
</td>
<td>
<a th:if="${entry.resource} != null" th:href="${entry.resource.id}" th:text="${entry.resource.idElement.toUnqualified()}" style="font-size: 0.8em"/>
</td>
<th:block th:if="${ri}">
<td th:if="${entry.resource} == null or ${entry.resource.meta.lastUpdatedElement.value} == null"></td>
<td th:if="${entry.resource} != null and ${entry.resource.meta.lastUpdatedElement.value} != null and ${entry.resource.meta.lastUpdatedElement.today} == true" th:text="${#dates.format(entry.resource.meta.lastUpdated, 'HH:mm:ss')}"></td>
<td th:if="${entry.resource} != null and ${entry.resource.meta.lastUpdatedElement.value} != null and ${entry.resource.meta.lastUpdatedElement.today} == false" th:text="${#dates.format(entry.resource.meta.lastUpdated, 'yyyy-MM-dd HH:mm:ss')}"></td>
</th:block>
<th:block th:unless="${ri}">
<td></td>
</th:block>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<!-- END RI Bundle -->
</th:block>

View File

@ -26,6 +26,19 @@ body {
font-family: sans-serif;
}
H1 {
font-size: 1.3em;
}
H2 {
font-size: 1.2em;
}
H3 {
font-size: 1.1em;
}
H4 {
font-size: 1.1em;
}
.clientCodeBox {
font-family: monospace;
font-size: 0.8em;
@ -53,6 +66,14 @@ label {
line-height: 0.8em;
}
TD.resultControlButtons {
font-size: 0;
}
TD.resultControlButtons BUTTON {
margin: 2px;
}
TD.headerBox {
line-height: 0.8em !important;
}
@ -153,10 +174,12 @@ DIV.navbarBreadcrumb:HOVER, A.navbarBreadcrumb:HOVER {
}
DIV.resultBodyActual {
/*
max-height: 400px;
overflow: scroll;
*/
padding: 5px;
}
DIV.panel-title-text {
font-weight: bold;
margin: 12px;
}
PRE.resultBodyPre {

View File

@ -509,6 +509,18 @@ function updateFromEntriesTable(source, type, id, vid) {
$("#outerForm").attr("action", "resource").submit();
}
/*
* Handler for Operation button which appears on each entry in the
* summary at the top of a Bundle response view
*/
function executeOperation(source, type, id, operationName) {
var btn = $(source);
btn.button('loading');
btn.append($('<input />', { type: 'hidden', name: 'instanceType', value: type }));
btn.append($('<input />', { type: 'hidden', name: 'instanceId', value: id }));
btn.append($('<input />', { type: 'hidden', name: 'operationName', value: operationName }));
$("#outerForm").attr("action", "operation").submit();
}
/**
* http://stackoverflow.com/a/10997390/11236

View File

@ -8,7 +8,6 @@ import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.to.FhirTesterMvcConfig;
import ca.uhn.fhir.to.TesterConfig;
//@formatter:off
/**
* This spring config file configures the web testing module. It serves two
* purposes:
@ -41,7 +40,7 @@ public class FhirTesterConfig {
retVal
.addServer()
.withId("internal")
.withFhirVersion(FhirVersionEnum.DSTU2)
.withFhirVersion(FhirVersionEnum.R4)
.withBaseUrl("http://localhost:8888/fhir")
.withName("Localhost Server")
.allowsApiKey()
@ -65,4 +64,3 @@ public class FhirTesterConfig {
}
}
//@formatter:on

View File

@ -0,0 +1,271 @@
package ca.uhn.fhir.jpa.test;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.IdParam;
import ca.uhn.fhir.rest.annotation.Operation;
import ca.uhn.fhir.rest.annotation.Validate;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.test.utilities.JettyUtil;
import ca.uhn.fhir.test.utilities.server.HashMapResourceProviderExtension;
import ca.uhn.fhir.test.utilities.server.RestfulServerExtension;
import com.gargoylesoftware.css.parser.CSSErrorHandler;
import com.gargoylesoftware.htmlunit.Page;
import com.gargoylesoftware.htmlunit.SilentCssErrorHandler;
import com.gargoylesoftware.htmlunit.WebClient;
import com.gargoylesoftware.htmlunit.html.HtmlAnchor;
import com.gargoylesoftware.htmlunit.html.HtmlButton;
import com.gargoylesoftware.htmlunit.html.HtmlPage;
import com.gargoylesoftware.htmlunit.html.HtmlTable;
import com.gargoylesoftware.htmlunit.html.HtmlTableCell;
import com.gargoylesoftware.htmlunit.html.HtmlTableRow;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ContextHandler;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Composition;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.InstantType;
import org.hl7.fhir.r4.model.OperationOutcome;
import org.hl7.fhir.r4.model.Patient;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Order;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.htmlunit.MockMvcWebConnection;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
import org.springframework.web.servlet.DispatcherServlet;
import java.io.File;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.util.List;
import static org.hamcrest.MatcherAssert.assertThat;
import static org.hamcrest.Matchers.containsString;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class WebTest {
private static final Logger ourLog = LoggerFactory.getLogger(WebTest.class);
private static final FhirContext ourCtx = FhirContext.forR4Cached();
@RegisterExtension
@Order(0)
private static final RestfulServerExtension ourFhirServer = new RestfulServerExtension(ourCtx)
.registerProvider(new MyPatientFakeDocumentController());
@RegisterExtension
@Order(1)
private static final HashMapResourceProviderExtension<Patient> ourPatientProvider = new HashMapResourceProviderExtension<>(ourFhirServer, Patient.class);
protected static MockMvc ourMockMvc;
private static Server ourOverlayServer;
private WebClient myWebClient;
@BeforeEach
public void before() throws Exception {
if (ourOverlayServer == null) {
AnnotationConfigWebApplicationContext appCtx = new AnnotationConfigWebApplicationContext();
appCtx.register(WebTestFhirTesterConfig.class);
DispatcherServlet dispatcherServlet = new DispatcherServlet(appCtx);
ServletHolder holder = new ServletHolder(dispatcherServlet);
holder.setName("servlet");
ServletHandler servletHandler = new ServletHandler();
servletHandler.addServletWithMapping(holder, "/*");
ServletContextHandler contextHandler = new MyServletContextHandler();
contextHandler.setAllowNullPathInfo(true);
contextHandler.setServletHandler(servletHandler);
contextHandler.setResourceBase("hapi-fhir-testpage-overlay/src/main/webapp");
ourOverlayServer = new Server(0);
ourOverlayServer.setHandler(contextHandler);
ourOverlayServer.start();
ourMockMvc = MockMvcBuilders.webAppContextSetup(appCtx).build();
}
myWebClient = new WebClient();
myWebClient.setWebConnection(new MockMvcWebConnection(ourMockMvc, myWebClient));
myWebClient.getOptions().setJavaScriptEnabled(true);
myWebClient.getOptions().setCssEnabled(false);
CSSErrorHandler errorHandler = new SilentCssErrorHandler();
myWebClient.setCssErrorHandler(errorHandler);
ourLog.info("Started FHIR endpoint at " + ourFhirServer.getBaseUrl());
WebTestFhirTesterConfig.setBaseUrl(ourFhirServer.getBaseUrl());
String baseUrl = "http://localhost:" + JettyUtil.getPortForStartedServer(ourOverlayServer) + "/";
ourLog.info("Started test overlay at " + baseUrl);
}
@Test
public void testSearchForPatients() throws IOException {
register5Patients();
// Load home page
HtmlPage page = myWebClient.getPage("http://localhost/");
// Navigate to Patient resource page
HtmlAnchor patientLink = page.getHtmlElementById("leftResourcePatient");
HtmlPage patientPage = patientLink.click();
// Click search button
HtmlButton searchButton = patientPage.getHtmlElementById("search-btn");
HtmlPage searchResultPage = searchButton.click();
HtmlTable controlsTable = searchResultPage.getHtmlElementById("resultControlsTable");
List<HtmlTableRow> controlRows = controlsTable.getBodies().get(0).getRows();
assertEquals(5, controlRows.size());
assertEquals("Read Update $summary $validate", controlRows.get(0).getCell(0).asNormalizedText());
assertEquals("Patient/A0/_history/1", controlRows.get(0).getCell(1).asNormalizedText());
assertEquals("Patient/A4/_history/1", controlRows.get(4).getCell(1).asNormalizedText());
}
@Test
public void testHistoryWithDeleted() throws IOException {
register5Patients();
for (int i = 0; i < 5; i++) {
ourFhirServer.getFhirClient().delete().resourceById(new IdType("Patient/A" + i));
}
// Load home page
HtmlPage page = myWebClient.getPage("http://localhost/");
// Navigate to Patient resource page
HtmlAnchor patientLink = page.getHtmlElementById("leftResourcePatient");
HtmlPage patientPage = patientLink.click();
// Click search button
HtmlButton historyButton = patientPage.getElementByName("action-history-type");
HtmlPage searchResultPage = historyButton.click();
HtmlTable controlsTable = searchResultPage.getHtmlElementById("resultControlsTable");
List<HtmlTableRow> controlRows = controlsTable.getBodies().get(0).getRows();
assertEquals(5, controlRows.size());
ourLog.info(controlRows.get(0).asXml());
assertEquals("Patient/A4/_history/1", controlRows.get(0).getCell(1).asNormalizedText());
assertEquals("Patient/A0/_history/1", controlRows.get(4).getCell(1).asNormalizedText());
}
@Test
public void testInvokeCustomOperation() throws IOException {
register5Patients();
HtmlPage searchResultPage = searchForPatients();
HtmlTable controlsTable = searchResultPage.getHtmlElementById("resultControlsTable");
List<HtmlTableRow> controlRows = controlsTable.getBodies().get(0).getRows();
HtmlTableCell controlsCell = controlRows.get(0).getCell(0);
// Find the $summary button and click it
HtmlPage summaryPage = controlsCell
.getElementsByTagName("button")
.stream()
.filter(t -> t.asNormalizedText().equals("$summary"))
.findFirst()
.orElseThrow()
.click();
assertThat(summaryPage.asNormalizedText(), containsString("Result Narrative\t\nHELLO WORLD DOCUMENT"));
}
@Test
public void testInvokeCustomOperation_Validate() throws IOException {
register5Patients();
HtmlPage searchResultPage = searchForPatients();
HtmlTable controlsTable = searchResultPage.getHtmlElementById("resultControlsTable");
List<HtmlTableRow> controlRows = controlsTable.getBodies().get(0).getRows();
HtmlTableCell controlsCell = controlRows.get(0).getCell(0);
// Find the $summary button and click it
HtmlPage summaryPage = controlsCell
.getElementsByTagName("button")
.stream()
.filter(t -> t.asNormalizedText().equals("$validate"))
.findFirst()
.orElseThrow()
.click();
assertThat(summaryPage.asNormalizedText(), containsString("\"diagnostics\": \"VALIDATION FAILURE\""));
}
private HtmlPage searchForPatients() throws IOException {
// Load home page
HtmlPage page = myWebClient.getPage("http://localhost/");
// Navigate to Patient resource page
HtmlPage patientPage = page.<HtmlAnchor>getHtmlElementById("leftResourcePatient").click();
// Click search button
HtmlPage searchResultPage = patientPage.<HtmlButton>getHtmlElementById("search-btn").click();
return searchResultPage;
}
private void register5Patients() {
for (int i = 0; i < 5; i++) {
Patient p = new Patient();
p.setId("Patient/A" + i);
p.getMeta().setLastUpdatedElement(new InstantType("2022-01-01T12:12:12.000Z"));
p.setActive(true);
ourPatientProvider.store(p);
}
}
private static class MyPatientFakeDocumentController {
@Operation(name = "summary", typeName = "Patient", idempotent = true)
public Bundle summary(@IdParam IIdType theId) {
Composition composition = new Composition();
composition.getText().setDivAsString("<div>HELLO WORLD DOCUMENT</div>");
Bundle retVal = new Bundle();
retVal.setType(Bundle.BundleType.DOCUMENT);
retVal.addEntry().setResource(composition);
return retVal;
}
@Operation(name = "validate", typeName = "Patient", idempotent = true)
public OperationOutcome validate(@IdParam IIdType theId) {
OperationOutcome oo = new OperationOutcome();
oo.addIssue()
.setDiagnostics("VALIDATION FAILURE");
throw new PreconditionFailedException("failure", oo);
}
}
private static class MyServletContextHandler extends ServletContextHandler {
public MyServletContextHandler() {
super();
_scontext = new ContextHandler.Context() {
@Override
public URL getResource(String thePath) {
File parent = new File("hapi-fhir-testpage-overlay/src/main/webapp").getAbsoluteFile();
if (!parent.exists()) {
parent = new File("src/main/webapp").getAbsoluteFile();
}
File file = new File(parent, thePath);
try {
return file.toURI().toURL();
} catch (MalformedURLException e) {
throw new InternalErrorException(e);
}
}
};
}
}
@AfterAll
public static void afterAll() throws Exception {
JettyUtil.closeServer(ourOverlayServer);
}
}

View File

@ -0,0 +1,43 @@
package ca.uhn.fhir.jpa.test;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.rest.server.util.ITestingUiClientFactory;
import ca.uhn.fhir.to.FhirTesterMvcConfig;
import ca.uhn.fhir.to.TesterConfig;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
@Configuration
@Import(FhirTesterMvcConfig.class)
public class WebTestFhirTesterConfig {
private static String ourBaseUrl;
@Bean
public ITestingUiClientFactory clientFactory() {
// Replace the base URL
return (theFhirContext, theRequest, theServerBaseUrl) -> theFhirContext.newRestfulGenericClient(ourBaseUrl);
}
@Bean
public TesterConfig testerConfig(ITestingUiClientFactory theClientFactory) {
TesterConfig retVal = new TesterConfig();
retVal.setClientFactory(theClientFactory);
retVal
.addServer()
.withId("internal")
.withFhirVersion(FhirVersionEnum.R4)
.withBaseUrl("http://localhost:8000")
.withName("Localhost Server")
.withSearchResultRowOperation("$summary", id -> "Patient".equals(id.getResourceType()))
.withSearchResultRowOperation("$validate", id -> true)
.enableDebugTemplates();
return retVal;
}
public static void setBaseUrl(String theBaseUrl) {
ourBaseUrl = theBaseUrl;
}
}

View File

@ -0,0 +1,48 @@
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
<!-- Creates the Spring Container shared by all Servlets and Filters -->
<listener>
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>
<context-param>
<param-name>contextClass</param-name>
<param-value>
org.springframework.web.context.support.AnnotationConfigWebApplicationContext
</param-value>
</context-param>
<context-param>
<param-name>contextConfigLocation</param-name>
<param-value>
ca.uhn.fhir.jpa.test.FhirServerConfig
</param-value>
</context-param>
<servlet>
<servlet-name>spring</servlet-name>
<servlet-class>org.springframework.web.servlet.DispatcherServlet</servlet-class>
<init-param>
<param-name>contextClass</param-name>
<param-value>org.springframework.web.context.support.AnnotationConfigWebApplicationContext</param-value>
</init-param>
<init-param>
<param-name>contextConfigLocation</param-name>
<param-value>ca.uhn.fhir.jpa.test.FhirTesterConfig</param-value>
</init-param>
<load-on-startup>2</load-on-startup>
</servlet>
<servlet-mapping>
<servlet-name>spring</servlet-name>
<url-pattern>/</url-pattern>
</servlet-mapping>
<jsp-config>
<jsp-property-group>
<url-pattern>*.jsp</url-pattern>
<trim-directive-whitespaces>true</trim-directive-whitespaces>
</jsp-property-group>
</jsp-config>
</web-app>