Add multitenancy support for server

This commit is contained in:
James Agnew 2018-01-11 11:14:54 -05:00
parent f58a03dacf
commit f6c9e3d0fe
12 changed files with 977 additions and 648 deletions

View File

@ -0,0 +1,50 @@
package example;
import ca.uhn.fhir.rest.annotation.IdParam;
import ca.uhn.fhir.rest.annotation.Read;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.IResourceProvider;
import ca.uhn.fhir.rest.server.RestfulServer;
import ca.uhn.fhir.rest.server.tenant.UrlBaseTenantIdentificationStrategy;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.Patient;
public class Multitenancy {
//START SNIPPET: enableUrlBaseTenantIdentificationStrategy
public class MyServer extends RestfulServer {
@Override
protected void initialize() {
setTenantIdentificationStrategy(new UrlBaseTenantIdentificationStrategy());
// ... do other initialization ...
}
}
//END SNIPPET: enableUrlBaseTenantIdentificationStrategy
//START SNIPPET: resourceProvider
public class MyPatientResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Read
public Patient read(RequestDetails theRequestDetails, @IdParam IdType theId) {
String tenantId = theRequestDetails.getTenantId();
String resourceId = theId.getIdPart();
// Use these two values to fetch the patient
return new Patient();
}
}
//END SNIPPET: resourceProvider
}

View File

@ -22,27 +22,20 @@ package ca.uhn.fhir.util;
import java.util.StringTokenizer;
public class UrlPathTokenizer extends StringTokenizer {
public class UrlPathTokenizer {
private final StringTokenizer myTok;
public UrlPathTokenizer(String theRequestPath) {
super(theRequestPath, "/");
myTok = new StringTokenizer(theRequestPath, "/");
}
public boolean hasMoreTokens() {
return myTok.hasMoreTokens();
}
@Override
public String nextToken() {
return UrlUtil.unescape(super.nextToken());
}
@CoverageIgnore
@Override
public String nextToken(String theDelim) {
throw new UnsupportedOperationException();
}
@CoverageIgnore
@Override
public Object nextElement() {
return super.nextElement();
return UrlUtil.unescape(myTok.nextToken());
}
}

View File

@ -43,6 +43,7 @@ import static org.apache.commons.lang3.StringUtils.isBlank;
public abstract class RequestDetails {
private String myTenantId;
private String myCompartmentName;
private String myCompleteUrl;
private String myFhirServerBase;
@ -286,6 +287,14 @@ public abstract class RequestDetails {
*/
public abstract String getServerBaseForRequest();
public String getTenantId() {
return myTenantId;
}
public void setTenantId(String theTenantId) {
myTenantId = theTenantId;
}
public Map<String, List<String>> getUnqualifiedToQualifiedNames() {
return myUnqualifiedToQualifiedNames;
}

View File

@ -35,6 +35,7 @@ import javax.servlet.ServletException;
import javax.servlet.UnavailableException;
import javax.servlet.http.*;
import ca.uhn.fhir.rest.server.tenant.ITenantIdentificationStrategy;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
@ -101,6 +102,7 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
private Map<String, IResourceProvider> myTypeToProvider = new HashMap<>();
private boolean myUncompressIncomingContents = true;
private boolean myUseBrowserFriendlyContentTypes;
private ITenantIdentificationStrategy myTenantIdentificationStrategy;
/**
* Constructor. Note that if no {@link FhirContext} is passed in to the server (either through the constructor, or
@ -603,14 +605,20 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
/**
* Returns the server base URL (with no trailing '/') for a given request
* @param theRequest
*/
public String getServerBaseForRequest(HttpServletRequest theRequest) {
public String getServerBaseForRequest(ServletRequestDetails theRequest) {
String fhirServerBase;
fhirServerBase = myServerAddressStrategy.determineServerBase(getServletContext(), theRequest);
fhirServerBase = myServerAddressStrategy.determineServerBase(getServletContext(), theRequest.getServletRequest());
if (fhirServerBase.endsWith("/")) {
fhirServerBase = fhirServerBase.substring(0, fhirServerBase.length() - 1);
}
if (myTenantIdentificationStrategy != null) {
fhirServerBase = myTenantIdentificationStrategy.massageServerBaseUrl(fhirServerBase, theRequest);
}
return fhirServerBase;
}
@ -669,6 +677,15 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
myServerConformanceProvider = theServerConformanceProvider;
}
/**
* If provided (default is <code>null</code>), the tenant identification
* strategy provides a mechanism for a multitenant server to identify which tenant
* a given request corresponds to.
*/
public void setTenantIdentificationStrategy(ITenantIdentificationStrategy theTenantIdentificationStrategy) {
myTenantIdentificationStrategy = theTenantIdentificationStrategy;
}
/**
* Gets the server's name, as exported in conformance profiles exported by the server. This is informational only,
* but can be helpful to set with something appropriate.
@ -788,12 +805,11 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
requestPath = requestPath.substring(1);
}
fhirServerBase = getServerBaseForRequest(theRequest);
IIdType id;
populateRequestDetailsFromRequestPath(requestDetails, requestPath);
fhirServerBase = getServerBaseForRequest(requestDetails);
if (theRequestType == RequestTypeEnum.PUT) {
String contentLocation = theRequest.getHeader(Constants.HEADER_CONTENT_LOCATION);
if (contentLocation != null) {
@ -1173,9 +1189,13 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
}
public void populateRequestDetailsFromRequestPath(RequestDetails theRequestDetails, String theRequestPath) {
StringTokenizer tok = new UrlPathTokenizer(theRequestPath);
UrlPathTokenizer tok = new UrlPathTokenizer(theRequestPath);
String resourceName = null;
if (myTenantIdentificationStrategy != null) {
myTenantIdentificationStrategy.extractTenant(tok, theRequestDetails);
}
IIdType id = null;
String operation = null;
String compartment = null;

View File

@ -122,7 +122,7 @@ public class ServletRequestDetails extends RequestDetails {
@Override
public String getServerBaseForRequest() {
return getServer().getServerBaseForRequest(getServletRequest());
return getServer().getServerBaseForRequest(this);
}
public HttpServletRequest getServletRequest() {

View File

@ -0,0 +1,23 @@
package ca.uhn.fhir.rest.server.tenant;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.util.UrlPathTokenizer;
public interface ITenantIdentificationStrategy {
/**
* Implementations should use this method to determine the tenant ID
* based on the incoming request andand populate it in the
* {@link RequestDetails#setTenantId(String)}.
*
* @param theUrlPathTokenizer The tokenizer which is used to parse the request path
* @param theRequestDetails The request details object which can be used to access headers and to populate the tenant ID to
*/
void extractTenant(UrlPathTokenizer theUrlPathTokenizer, RequestDetails theRequestDetails);
/**
* Implementations may use this method to tweak the server base URL
* if neccesary based on the tenant ID
*/
String massageServerBaseUrl(String theFhirServerBase, RequestDetails theRequestDetails);
}

View File

@ -0,0 +1,32 @@
package ca.uhn.fhir.rest.server.tenant;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.util.UrlPathTokenizer;
import org.apache.commons.lang3.Validate;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This class is a tenant identification strategy which assumes that a single path
* element will be present between the server base URL and the beginning
*/
public class UrlBaseTenantIdentificationStrategy implements ITenantIdentificationStrategy {
private static final Logger ourLog = LoggerFactory.getLogger(UrlBaseTenantIdentificationStrategy.class);
@Override
public void extractTenant(UrlPathTokenizer theUrlPathTokenizer, RequestDetails theRequestDetails) {
if (theUrlPathTokenizer.hasMoreTokens()) {
String tenantId = theUrlPathTokenizer.nextToken();
ourLog.trace("Found tenant ID {} in request string", tenantId);
theRequestDetails.setTenantId(tenantId);
}
}
@Override
public String massageServerBaseUrl(String theFhirServerBase, RequestDetails theRequestDetails) {
Validate.notNull(theRequestDetails.getTenantId(), "theTenantId is not populated on this request");
return theFhirServerBase + '/' + theRequestDetails.getTenantId();
}
}

View File

@ -74,7 +74,7 @@ public class R4BundleFactory implements IVersionSpecificBundleFactory {
List<IBaseReference> references = myContext.newTerser().getAllPopulatedChildElementsOfType(next, IBaseReference.class);
do {
List<IAnyResource> addedResourcesThisPass = new ArrayList<IAnyResource>();
List<IAnyResource> addedResourcesThisPass = new ArrayList<>();
for (IBaseReference nextRef : references) {
IAnyResource nextRes = (IAnyResource) nextRef.getResource();
@ -101,7 +101,7 @@ public class R4BundleFactory implements IVersionSpecificBundleFactory {
}
// Linked resources may themselves have linked resources
references = new ArrayList<IBaseReference>();
references = new ArrayList<>();
for (IAnyResource iResource : addedResourcesThisPass) {
List<IBaseReference> newReferences = myContext.newTerser().getAllPopulatedChildElementsOfType(iResource, IBaseReference.class);
references.addAll(newReferences);

View File

@ -23,6 +23,8 @@ import java.util.*;
import javax.servlet.http.HttpServletRequest;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.StructureDefinition;
@ -49,7 +51,7 @@ public class ServerProfileProvider implements IResourceProvider {
}
@Read()
public StructureDefinition getProfileById(HttpServletRequest theRequest, @IdParam IdType theId) {
public StructureDefinition getProfileById(ServletRequestDetails theRequest, @IdParam IdType theId) {
RuntimeResourceDefinition retVal = myContext.getResourceDefinitionById(theId.getIdPart());
if (retVal==null) {
return null;
@ -59,7 +61,7 @@ public class ServerProfileProvider implements IResourceProvider {
}
@Search()
public List<StructureDefinition> getAllProfiles(HttpServletRequest theRequest) {
public List<StructureDefinition> getAllProfiles(ServletRequestDetails theRequest) {
final String serverBase = getServerBase(theRequest);
List<RuntimeResourceDefinition> defs = new ArrayList<RuntimeResourceDefinition>(myContext.getResourceDefinitionsWithExplicitId());
Collections.sort(defs, new Comparator<RuntimeResourceDefinition>() {
@ -78,7 +80,7 @@ public class ServerProfileProvider implements IResourceProvider {
return retVal;
}
private String getServerBase(HttpServletRequest theHttpRequest) {
private String getServerBase(ServletRequestDetails theHttpRequest) {
return myRestfulServer.getServerBaseForRequest(theHttpRequest);
}
}

View File

@ -0,0 +1,143 @@
package ca.uhn.fhir.rest.server;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.RequiredParam;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.api.EncodingEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.param.TokenAndListParam;
import ca.uhn.fhir.rest.server.tenant.UrlBaseTenantIdentificationStrategy;
import ca.uhn.fhir.util.PortUtil;
import ca.uhn.fhir.util.TestUtil;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.HumanName;
import org.hl7.fhir.r4.model.Patient;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.Matchers.startsWith;
import static org.junit.Assert.*;
public class MultitenancyR4Test {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(MultitenancyR4Test.class);
private static CloseableHttpClient ourClient;
private static FhirContext ourCtx = FhirContext.forR4();
private static TokenAndListParam ourIdentifiers;
private static String ourLastMethod;
private static int ourPort;
private static Server ourServer;
private static String ourLastTenantId;
@Before
public void before() {
ourLastMethod = null;
ourIdentifiers = null;
ourLastTenantId = null;
}
@Test
public void testUrlBaseStrategy() throws Exception {
ourPort = PortUtil.findFreePort();
ourServer = new Server(ourPort);
DummyPatientResourceProvider patientProvider = new DummyPatientResourceProvider();
ServletHandler proxyHandler = new ServletHandler();
RestfulServer servlet = new RestfulServer(ourCtx);
servlet.setDefaultResponseEncoding(EncodingEnum.JSON);
servlet.setPagingProvider(new FifoMemoryPagingProvider(10));
servlet.setTenantIdentificationStrategy(new UrlBaseTenantIdentificationStrategy());
servlet.setResourceProviders(patientProvider);
ServletHolder servletHolder = new ServletHolder(servlet);
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
ourServer.start();
HttpGet httpGet = new HttpGet("http://localhost:" + ourPort + "/TENANT2/Patient?identifier=foo%7Cbar");
CloseableHttpResponse status = ourClient.execute(httpGet);
try {
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("search", ourLastMethod);
assertEquals("TENANT2", ourLastTenantId);
assertEquals("foo", ourIdentifiers.getValuesAsQueryTokens().get(0).getValuesAsQueryTokens().get(0).getSystem());
assertEquals("bar", ourIdentifiers.getValuesAsQueryTokens().get(0).getValuesAsQueryTokens().get(0).getValue());
Bundle resp = ourCtx.newJsonParser().parseResource(Bundle.class, responseContent);
ourLog.info(ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(resp));
assertEquals("http://localhost:" + ourPort + "/TENANT2/Patient/0", resp.getEntry().get(0).getFullUrl());
assertEquals("http://localhost:"+ourPort+"/TENANT2/Patient/0", resp.getEntry().get(0).getResource().getId());
assertThat(resp.getLink("next").getUrl(), startsWith("http://localhost:"+ourPort+"/TENANT2?_getpages="));
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
}
@AfterClass
public static void afterClassClearContext() throws Exception {
ourServer.stop();
TestUtil.clearAllStaticFieldsForUnitTest();
}
@BeforeClass
public static void beforeClass() {
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS);
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setConnectionManager(connectionManager);
ourClient = builder.build();
}
public static class DummyPatientResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@SuppressWarnings("rawtypes")
@Search()
public List search(
RequestDetails theRequestDetails,
@RequiredParam(name = Patient.SP_IDENTIFIER) TokenAndListParam theIdentifiers) {
ourLastMethod = "search";
ourIdentifiers = theIdentifiers;
ourLastTenantId = theRequestDetails.getTenantId();
ArrayList<Patient> retVal = new ArrayList<>();
for (int i = 0; i < 200; i++) {
Patient patient = new Patient();
patient.addName(new HumanName().setFamily("FAMILY"));
patient.setActive(true);
patient.getIdElement().setValue("Patient/" + i);
retVal.add(patient);
}
return retVal;
}
}
}

View File

@ -73,6 +73,15 @@
DSTU3/R4 structure in the getMeta() version field instead of in the
getIdElement() ID. Thanks to GitHub user @Chrisjobling for reporting!
</action>
<action type="add">
The HAPI FHIR Server framework now has initial support for
multitenancy. At this time the support is limited to the server
framework (not the client, JPA, or JAX-RS frameworks). See
<![CDATA[
<a href="http://hapifhir.io/doc_rest_server.html">Server Documentation</a>
]]>
for more information.
</action>
</release>
<release version="3.1.0" date="2017-11-23">
<action type="add">

View File

@ -531,7 +531,7 @@
</p>
<p>
<b>Pretty Printing:</b> The HAPI RESTful server supports a non-standard parameter called
<b>Pretty Printing:</b> The HAPI RESTful server supports a called
<code>_pretty</code>, which can be used to request that responses be pretty-printed (indented for
easy reading by humans) by setting the value to <code>true</code>. This can be useful in testing. An example URL for this might be:<br/>
<code>http://example.com/fhir/Patient/_search?name=TESTING&amp;_pretty=true</code>
@ -612,6 +612,54 @@
</section>
-->
<section id="Multitenancy">
<p>
If you wish to allow a single endpoint to support multiple
tenants, you may supply the server with a multitenancy provider.
</p>
<p>
This means that additional logic will be performed during request
parsing to determine a tenant ID, which will be supplied to
resource providers. This can be useful in servers that have
multiple distinct logical pools of resources hosted on the
same infrastructure.
</p>
<subsection name="URL Base Multitenancy">
<p>
Using URL Base Multitenancy means that an additional element
is added to the path of each resource between the server base
URL and the resource name. For example, if your restful server
is deployed to <code>http://acme.org:8080/baseDstu3</code>
and user wished to access Patient 123 for Tenant "FOO", the
resource ID (and URL to fetch that resource) would be
<code>http://acme.org:8080/FOO/Patient/123</code>.
</p>
<p>
To enable this mode on your server, simply provide the
<code>UrlBaseTenantIdentificationStrategy</code> to the
server as shown below:
</p>
<macro name="snippet">
<param name="id" value="enableUrlBaseTenantIdentificationStrategy" />
<param name="file" value="examples/src/main/java/example/Multitenancy.java" />
</macro>
<p>
Your resource providers can then use a RequestDetails parameter to
determine the tenant ID:
</p>
<macro name="snippet">
<param name="id" value="resourceProvider" />
<param name="file" value="examples/src/main/java/example/Multitenancy.java" />
</macro>
</subsection>
</section>
</body>