Fix #379 - Server generated OperationDefinitions should generate

separate definitions for operations that are implemented against
multiple resource types
This commit is contained in:
James Agnew 2016-06-11 14:46:13 -05:00
parent 69572f7b3a
commit 8f1e45b3f3
5 changed files with 462 additions and 146 deletions

View File

@ -156,7 +156,24 @@ public class ServerConformanceProvider implements IServerConformanceProvider<Con
} }
private String createOperationName(OperationMethodBinding theMethodBinding) { private String createOperationName(OperationMethodBinding theMethodBinding) {
return theMethodBinding.getName().substring(1); StringBuilder retVal = new StringBuilder();
if (theMethodBinding.getResourceName() != null) {
retVal.append(theMethodBinding.getResourceName());
}
retVal.append('_');
if (theMethodBinding.isCanOperateAtInstanceLevel()) {
retVal.append('i');
}
if (theMethodBinding.isCanOperateAtServerLevel()) {
retVal.append('s');
}
retVal.append('_');
// Exclude the leading $
retVal.append(theMethodBinding.getName(), 1, theMethodBinding.getName().length());
return retVal.toString();
} }
/** /**
@ -265,7 +282,7 @@ public class ServerConformanceProvider implements IServerConformanceProvider<Con
String opName = myOperationBindingToName.get(methodBinding); String opName = myOperationBindingToName.get(methodBinding);
if (operationNames.add(opName)) { if (operationNames.add(opName)) {
// Only add each operation (by name) once // Only add each operation (by name) once
rest.addOperation().setName(opName).getDefinition().setReference("OperationDefinition/" + opName); rest.addOperation().setName(methodBinding.getName().substring(1)).getDefinition().setReference("OperationDefinition/" + opName);
} }
} }
@ -497,7 +514,7 @@ public class ServerConformanceProvider implements IServerConformanceProvider<Con
if (!sharedDescription.isIdempotent()) { if (!sharedDescription.isIdempotent()) {
op.setIdempotent(sharedDescription.isIdempotent()); op.setIdempotent(sharedDescription.isIdempotent());
} }
op.setCode(sharedDescription.getName()); op.setCode(sharedDescription.getName().substring(1));
if (sharedDescription.isCanOperateAtInstanceLevel()) { if (sharedDescription.isCanOperateAtInstanceLevel()) {
op.setInstance(sharedDescription.isCanOperateAtInstanceLevel()); op.setInstance(sharedDescription.isCanOperateAtInstanceLevel());
} }

View File

@ -1,5 +1,6 @@
package ca.uhn.fhir.rest.server; package ca.uhn.fhir.rest.server;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -9,7 +10,9 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
@ -21,6 +24,8 @@ import org.hl7.fhir.instance.model.api.IBaseResource;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.Test; import org.junit.Test;
import com.google.common.collect.Lists;
import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.model.api.ExtensionDt; import ca.uhn.fhir.model.api.ExtensionDt;
import ca.uhn.fhir.model.api.Include; import ca.uhn.fhir.model.api.Include;
@ -28,10 +33,12 @@ import ca.uhn.fhir.model.api.annotation.Description;
import ca.uhn.fhir.model.dstu2.composite.IdentifierDt; import ca.uhn.fhir.model.dstu2.composite.IdentifierDt;
import ca.uhn.fhir.model.dstu2.resource.Conformance; import ca.uhn.fhir.model.dstu2.resource.Conformance;
import ca.uhn.fhir.model.dstu2.resource.Conformance.Rest; import ca.uhn.fhir.model.dstu2.resource.Conformance.Rest;
import ca.uhn.fhir.model.dstu2.resource.Conformance.RestOperation;
import ca.uhn.fhir.model.dstu2.resource.Conformance.RestResource; import ca.uhn.fhir.model.dstu2.resource.Conformance.RestResource;
import ca.uhn.fhir.model.dstu2.resource.Conformance.RestResourceSearchParam; import ca.uhn.fhir.model.dstu2.resource.Conformance.RestResourceSearchParam;
import ca.uhn.fhir.model.dstu2.resource.Conformance.RestSecurity; import ca.uhn.fhir.model.dstu2.resource.Conformance.RestSecurity;
import ca.uhn.fhir.model.dstu2.resource.DiagnosticReport; import ca.uhn.fhir.model.dstu2.resource.DiagnosticReport;
import ca.uhn.fhir.model.dstu2.resource.Encounter;
import ca.uhn.fhir.model.dstu2.resource.OperationDefinition; import ca.uhn.fhir.model.dstu2.resource.OperationDefinition;
import ca.uhn.fhir.model.dstu2.resource.Patient; import ca.uhn.fhir.model.dstu2.resource.Patient;
import ca.uhn.fhir.model.dstu2.valueset.ConditionalDeleteStatusEnum; import ca.uhn.fhir.model.dstu2.valueset.ConditionalDeleteStatusEnum;
@ -39,6 +46,7 @@ import ca.uhn.fhir.model.dstu2.valueset.RestfulSecurityServiceEnum;
import ca.uhn.fhir.model.dstu2.valueset.SystemRestfulInteractionEnum; import ca.uhn.fhir.model.dstu2.valueset.SystemRestfulInteractionEnum;
import ca.uhn.fhir.model.dstu2.valueset.TypeRestfulInteractionEnum; import ca.uhn.fhir.model.dstu2.valueset.TypeRestfulInteractionEnum;
import ca.uhn.fhir.model.dstu2.valueset.UnknownContentCodeEnum; import ca.uhn.fhir.model.dstu2.valueset.UnknownContentCodeEnum;
import ca.uhn.fhir.model.primitive.CodeDt;
import ca.uhn.fhir.model.primitive.DateDt; import ca.uhn.fhir.model.primitive.DateDt;
import ca.uhn.fhir.model.primitive.IdDt; import ca.uhn.fhir.model.primitive.IdDt;
import ca.uhn.fhir.model.primitive.StringDt; import ca.uhn.fhir.model.primitive.StringDt;
@ -57,6 +65,7 @@ import ca.uhn.fhir.rest.annotation.RequiredParam;
import ca.uhn.fhir.rest.annotation.ResourceParam; import ca.uhn.fhir.rest.annotation.ResourceParam;
import ca.uhn.fhir.rest.annotation.Search; import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.annotation.Update; import ca.uhn.fhir.rest.annotation.Update;
import ca.uhn.fhir.rest.annotation.Validate;
import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.method.BaseMethodBinding; import ca.uhn.fhir.rest.method.BaseMethodBinding;
import ca.uhn.fhir.rest.method.IParameter; import ca.uhn.fhir.rest.method.IParameter;
@ -75,12 +84,6 @@ public class ServerConformanceProviderDstu2Test {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ServerConformanceProviderDstu2Test.class); private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ServerConformanceProviderDstu2Test.class);
@AfterClass
public static void afterClassClearContext() {
TestUtil.clearAllStaticFieldsForUnitTest();
}
private HttpServletRequest createHttpServletRequest() { private HttpServletRequest createHttpServletRequest() {
HttpServletRequest req = mock(HttpServletRequest.class); HttpServletRequest req = mock(HttpServletRequest.class);
when(req.getRequestURI()).thenReturn("/FhirStorm/fhir/Patient/_search"); when(req.getRequestURI()).thenReturn("/FhirStorm/fhir/Patient/_search");
@ -89,13 +92,25 @@ public class ServerConformanceProviderDstu2Test {
when(req.getContextPath()).thenReturn("/FhirStorm"); when(req.getContextPath()).thenReturn("/FhirStorm");
return req; return req;
} }
private ServletConfig createServletConfig() { private ServletConfig createServletConfig() {
ServletConfig sc = mock(ServletConfig.class); ServletConfig sc = mock(ServletConfig.class);
when(sc.getServletContext()).thenReturn(null); when(sc.getServletContext()).thenReturn(null);
return sc; return sc;
} }
private RestResource findRestResource(Conformance conformance, String wantResource) throws Exception {
RestResource resource = null;
for (RestResource next : conformance.getRest().get(0).getResource()) {
if (next.getType().equals(wantResource)) {
resource = next;
}
}
if (resource == null) {
throw new Exception("Could not find resource: " + wantResource);
}
return resource;
}
@Test @Test
public void testConditionalOperations() throws Exception { public void testConditionalOperations() throws Exception {
@ -137,7 +152,7 @@ public class ServerConformanceProviderDstu2Test {
assertEquals(1, conformance.getRest().get(0).getOperation().size()); assertEquals(1, conformance.getRest().get(0).getOperation().size());
assertEquals("everything", conformance.getRest().get(0).getOperation().get(0).getName()); assertEquals("everything", conformance.getRest().get(0).getOperation().get(0).getName());
assertEquals("OperationDefinition/everything", conformance.getRest().get(0).getOperation().get(0).getDefinition().getReference().getValue()); assertEquals("OperationDefinition/Patient_i_everything", conformance.getRest().get(0).getOperation().get(0).getDefinition().getReference().getValue());
} }
@Test @Test
@ -152,12 +167,12 @@ public class ServerConformanceProviderDstu2Test {
rs.init(createServletConfig()); rs.init(createServletConfig());
OperationDefinition opDef = sc.readOperationDefinition(new IdDt("OperationDefinition/everything")); OperationDefinition opDef = sc.readOperationDefinition(new IdDt("OperationDefinition/Patient_i_everything"));
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef); String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef);
ourLog.info(conf); ourLog.info(conf);
assertEquals("$everything", opDef.getCode()); assertEquals("everything", opDef.getCode());
assertEquals(true, opDef.getIdempotent().booleanValue()); assertEquals(true, opDef.getIdempotent().booleanValue());
} }
@ -180,6 +195,7 @@ public class ServerConformanceProviderDstu2Test {
assertThat(conf, containsString("<interaction><code value=\"" + TypeRestfulInteractionEnum.HISTORY_INSTANCE.getCode() + "\"/></interaction>")); assertThat(conf, containsString("<interaction><code value=\"" + TypeRestfulInteractionEnum.HISTORY_INSTANCE.getCode() + "\"/></interaction>"));
} }
@Test @Test
public void testMultiOptionalDocumentation() throws Exception { public void testMultiOptionalDocumentation() throws Exception {
@ -236,6 +252,71 @@ public class ServerConformanceProviderDstu2Test {
assertNull(res.getConditionalUpdate()); assertNull(res.getConditionalUpdate());
} }
/** See #379 */
@Test
public void testOperationAcrossMultipleTypes() throws Exception {
RestfulServer rs = new RestfulServer(ourCtx);
rs.setProviders(new MultiTypePatientProvider(), new MultiTypeEncounterProvider());
ServerConformanceProvider sc = new ServerConformanceProvider(rs);
rs.setServerConformanceProvider(sc);
rs.init(createServletConfig());
Conformance conformance = sc.getServerConformance(createHttpServletRequest());
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(conformance);
ourLog.info(conf);
assertEquals(4, conformance.getRest().get(0).getOperation().size());
List<String> operationNames = toOperationNames(conformance.getRest().get(0).getOperation());
assertThat(operationNames, containsInAnyOrder("someOp", "validate", "someOp", "validate"));
List<String> operationIdParts = toOperationIdParts(conformance.getRest().get(0).getOperation());
assertThat(operationIdParts, containsInAnyOrder("Patient_i_someOp","Encounter_i_someOp","Patient_i_validate","Encounter_i_validate"));
{
OperationDefinition opDef = sc.readOperationDefinition(new IdDt("OperationDefinition/Patient_i_someOp"));
ourLog.info(ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef));
Set<String> types = toStrings(opDef.getType());
assertEquals("someOp", opDef.getCode());
assertEquals(true, opDef.getInstance());
assertEquals(null, opDef.getSystem());
assertThat(types, containsInAnyOrder("Patient"));
assertEquals(2, opDef.getParameter().size());
assertEquals("someOpParam1", opDef.getParameter().get(0).getName());
assertEquals("date", opDef.getParameter().get(0).getType());
assertEquals("someOpParam2", opDef.getParameter().get(1).getName());
assertEquals("Patient", opDef.getParameter().get(1).getType());
}
{
OperationDefinition opDef = sc.readOperationDefinition(new IdDt("OperationDefinition/Encounter_i_someOp"));
ourLog.info(ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef));
Set<String> types = toStrings(opDef.getType());
assertEquals("someOp", opDef.getCode());
assertEquals(true, opDef.getInstance());
assertEquals(null, opDef.getSystem());
assertThat(types, containsInAnyOrder("Encounter"));
assertEquals(2, opDef.getParameter().size());
assertEquals("someOpParam1", opDef.getParameter().get(0).getName());
assertEquals("date", opDef.getParameter().get(0).getType());
assertEquals("someOpParam2", opDef.getParameter().get(1).getName());
assertEquals("Encounter", opDef.getParameter().get(1).getType());
}
{
OperationDefinition opDef = sc.readOperationDefinition(new IdDt("OperationDefinition/Patient_i_validate"));
ourLog.info(ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef));
Set<String> types = toStrings(opDef.getType());
assertEquals("validate", opDef.getCode());
assertEquals(true, opDef.getInstance());
assertEquals(null, opDef.getSystem());
assertThat(types, containsInAnyOrder("Patient"));
assertEquals(1, opDef.getParameter().size());
assertEquals("resource", opDef.getParameter().get(0).getName());
assertEquals("Patient", opDef.getParameter().get(0).getType());
}
}
@Test @Test
public void testOperationDocumentation() throws Exception { public void testOperationDocumentation() throws Exception {
@ -273,14 +354,14 @@ public class ServerConformanceProviderDstu2Test {
rs.init(createServletConfig()); rs.init(createServletConfig());
Conformance sconf = sc.getServerConformance(createHttpServletRequest()); Conformance sconf = sc.getServerConformance(createHttpServletRequest());
assertEquals("OperationDefinition/plain", sconf.getRest().get(0).getOperation().get(0).getDefinition().getReference().getValue()); assertEquals("OperationDefinition/_is_plain", sconf.getRest().get(0).getOperation().get(0).getDefinition().getReference().getValue());
OperationDefinition opDef = sc.readOperationDefinition(new IdDt("OperationDefinition/plain")); OperationDefinition opDef = sc.readOperationDefinition(new IdDt("OperationDefinition/_is_plain"));
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef); String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef);
ourLog.info(conf); ourLog.info(conf);
assertEquals("$plain", opDef.getCode()); assertEquals("plain", opDef.getCode());
assertEquals(true, opDef.getIdempotent().booleanValue()); assertEquals(true, opDef.getIdempotent().booleanValue());
assertEquals(3, opDef.getParameter().size()); assertEquals(3, opDef.getParameter().size());
assertEquals("start", opDef.getParameter().get(0).getName()); assertEquals("start", opDef.getParameter().get(0).getName());
@ -336,8 +417,7 @@ public class ServerConformanceProviderDstu2Test {
Conformance parsed = ourCtx.newJsonParser().parseResource(Conformance.class, conf); Conformance parsed = ourCtx.newJsonParser().parseResource(Conformance.class, conf);
} }
@Test @Test
public void testProviderWithRequiredAndOptional() throws Exception { public void testProviderWithRequiredAndOptional() throws Exception {
@ -444,6 +524,40 @@ public class ServerConformanceProviderDstu2Test {
assertThat(conf, containsString("<type value=\"token\"/>")); assertThat(conf, containsString("<type value=\"token\"/>"));
} }
/**
* See #286
*/
@Test
public void testSearchReferenceParameterDocumentation() throws Exception {
RestfulServer rs = new RestfulServer(ourCtx);
rs.setProviders(new PatientResourceProvider());
ServerConformanceProvider sc = new ServerConformanceProvider(rs);
rs.setServerConformanceProvider(sc);
rs.init(createServletConfig());
boolean found = false;
Collection<ResourceBinding> resourceBindings = rs.getResourceBindings();
for (ResourceBinding resourceBinding : resourceBindings) {
if (resourceBinding.getResourceName().equals("Patient")) {
List<BaseMethodBinding<?>> methodBindings = resourceBinding.getMethodBindings();
SearchMethodBinding binding = (SearchMethodBinding) methodBindings.get(0);
SearchParameter param = (SearchParameter) binding.getParameters().get(25);
assertEquals("The organization at which this person is a patient", param.getDescription());
found = true;
}
}
assertTrue(found);
Conformance conformance = sc.getServerConformance(createHttpServletRequest());
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(conformance);
ourLog.info(conf);
}
/** /**
* See #286 * See #286
@ -484,52 +598,6 @@ public class ServerConformanceProviderDstu2Test {
assertEquals(2, param.getChain().size()); assertEquals(2, param.getChain().size());
} }
private RestResource findRestResource(Conformance conformance, String wantResource) throws Exception {
RestResource resource = null;
for (RestResource next : conformance.getRest().get(0).getResource()) {
if (next.getType().equals(wantResource)) {
resource = next;
}
}
if (resource == null) {
throw new Exception("Could not find resource: " + wantResource);
}
return resource;
}
/**
* See #286
*/
@Test
public void testSearchReferenceParameterDocumentation() throws Exception {
RestfulServer rs = new RestfulServer(ourCtx);
rs.setProviders(new PatientResourceProvider());
ServerConformanceProvider sc = new ServerConformanceProvider(rs);
rs.setServerConformanceProvider(sc);
rs.init(createServletConfig());
boolean found = false;
Collection<ResourceBinding> resourceBindings = rs.getResourceBindings();
for (ResourceBinding resourceBinding : resourceBindings) {
if (resourceBinding.getResourceName().equals("Patient")) {
List<BaseMethodBinding<?>> methodBindings = resourceBinding.getMethodBindings();
SearchMethodBinding binding = (SearchMethodBinding) methodBindings.get(0);
SearchParameter param = (SearchParameter) binding.getParameters().get(25);
assertEquals("The organization at which this person is a patient", param.getDescription());
found = true;
}
}
assertTrue(found);
Conformance conformance = sc.getServerConformance(createHttpServletRequest());
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(conformance);
ourLog.info(conf);
}
@Test @Test
public void testSystemHistorySupported() throws Exception { public void testSystemHistorySupported() throws Exception {
@ -585,6 +653,35 @@ public class ServerConformanceProviderDstu2Test {
ValidationResult result = ourCtx.newValidator().validateWithResult(conformance); ValidationResult result = ourCtx.newValidator().validateWithResult(conformance);
assertTrue(result.getMessages().toString(), result.isSuccessful()); assertTrue(result.getMessages().toString(), result.isSuccessful());
} }
private List<String> toOperationIdParts(List<RestOperation> theOperation) {
ArrayList<String> retVal = Lists.newArrayList();
for (RestOperation next : theOperation) {
retVal.add(next.getDefinition().getReference().getIdPart());
}
return retVal;
}
private List<String> toOperationNames(List<RestOperation> theOperation) {
ArrayList<String> retVal = Lists.newArrayList();
for (RestOperation next : theOperation) {
retVal.add(next.getName());
}
return retVal;
}
private Set<String> toStrings(List<? extends CodeDt> theType) {
HashSet<String> retVal = new HashSet<String>();
for (CodeDt next : theType) {
retVal.add(next.getValueAsString());
}
return retVal;
}
@AfterClass
public static void afterClassClearContext() {
TestUtil.clearAllStaticFieldsForUnitTest();
}
public static class ConditionalProvider implements IResourceProvider { public static class ConditionalProvider implements IResourceProvider {
@ -635,6 +732,46 @@ public class ServerConformanceProviderDstu2Test {
} }
public static class MultiTypeEncounterProvider implements IResourceProvider {
@Operation(name = "someOp")
public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdDt theId,
@OperationParam(name = "someOpParam1") DateDt theStart, @OperationParam(name = "someOpParam2") Encounter theEnd) {
return null;
}
@Override
public Class<? extends IBaseResource> getResourceType() {
return Encounter.class;
}
@Validate
public ca.uhn.fhir.rest.server.IBundleProvider validate(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdDt theId, @ResourceParam Encounter thePatient) {
return null;
}
}
public static class MultiTypePatientProvider implements IResourceProvider {
@Operation(name = "someOp")
public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdDt theId,
@OperationParam(name = "someOpParam1") DateDt theStart, @OperationParam(name = "someOpParam2") Patient theEnd) {
return null;
}
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Validate
public ca.uhn.fhir.rest.server.IBundleProvider validate(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdDt theId, @ResourceParam Patient thePatient) {
return null;
}
}
public static class NonConditionalProvider implements IResourceProvider { public static class NonConditionalProvider implements IResourceProvider {
@Create @Create

View File

@ -77,6 +77,7 @@ import ca.uhn.fhir.rest.method.OperationParameter;
import ca.uhn.fhir.rest.method.RestSearchParameterTypeEnum; import ca.uhn.fhir.rest.method.RestSearchParameterTypeEnum;
import ca.uhn.fhir.rest.method.SearchMethodBinding; import ca.uhn.fhir.rest.method.SearchMethodBinding;
import ca.uhn.fhir.rest.method.SearchParameter; import ca.uhn.fhir.rest.method.SearchParameter;
import ca.uhn.fhir.rest.method.ValidateMethodBindingDstu3;
import ca.uhn.fhir.rest.server.Constants; import ca.uhn.fhir.rest.server.Constants;
import ca.uhn.fhir.rest.server.IServerConformanceProvider; import ca.uhn.fhir.rest.server.IServerConformanceProvider;
import ca.uhn.fhir.rest.server.ResourceBinding; import ca.uhn.fhir.rest.server.ResourceBinding;
@ -173,7 +174,24 @@ public class ServerConformanceProvider implements IServerConformanceProvider<Con
} }
private String createOperationName(OperationMethodBinding theMethodBinding) { private String createOperationName(OperationMethodBinding theMethodBinding) {
return theMethodBinding.getName().substring(1); StringBuilder retVal = new StringBuilder();
if (theMethodBinding.getResourceName() != null) {
retVal.append(theMethodBinding.getResourceName());
}
retVal.append('_');
if (theMethodBinding.isCanOperateAtInstanceLevel()) {
retVal.append('i');
}
if (theMethodBinding.isCanOperateAtServerLevel()) {
retVal.append('s');
}
retVal.append('_');
// Exclude the leading $
retVal.append(theMethodBinding.getName(), 1, theMethodBinding.getName().length());
return retVal.toString();
} }
/** /**
@ -289,7 +307,7 @@ public class ServerConformanceProvider implements IServerConformanceProvider<Con
String opName = myOperationBindingToName.get(methodBinding); String opName = myOperationBindingToName.get(methodBinding);
if (operationNames.add(opName)) { if (operationNames.add(opName)) {
// Only add each operation (by name) once // Only add each operation (by name) once
rest.addOperation().setName(opName).setDefinition(new Reference("OperationDefinition/" + opName)); rest.addOperation().setName(methodBinding.getName().substring(1)).setDefinition(new Reference("OperationDefinition/" + opName));
} }
} }
@ -517,7 +535,7 @@ public class ServerConformanceProvider implements IServerConformanceProvider<Con
if (!sharedDescription.isIdempotent()) { if (!sharedDescription.isIdempotent()) {
op.setIdempotent(sharedDescription.isIdempotent()); op.setIdempotent(sharedDescription.isIdempotent());
} }
op.setCode(createOperationName(sharedDescription)); op.setCode(sharedDescription.getName().substring(1));
if (sharedDescription.isCanOperateAtInstanceLevel()) { if (sharedDescription.isCanOperateAtInstanceLevel()) {
op.setInstance(sharedDescription.isCanOperateAtInstanceLevel()); op.setInstance(sharedDescription.isCanOperateAtInstanceLevel());
} }

View File

@ -1,5 +1,6 @@
package org.hl7.fhir.dstu3.hapi.rest.server; package org.hl7.fhir.dstu3.hapi.rest.server;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not; import static org.hamcrest.Matchers.not;
import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertEquals;
@ -9,31 +10,37 @@ import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import java.util.ArrayList;
import java.util.Collection; import java.util.Collection;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import javax.servlet.ServletConfig; import javax.servlet.ServletConfig;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import org.hl7.fhir.dstu3.hapi.rest.server.ServerConformanceProvider; import org.hl7.fhir.dstu3.model.CodeType;
import org.hl7.fhir.dstu3.model.Conformance; import org.hl7.fhir.dstu3.model.Conformance;
import org.hl7.fhir.dstu3.model.DateType;
import org.hl7.fhir.dstu3.model.DiagnosticReport;
import org.hl7.fhir.dstu3.model.IdType;
import org.hl7.fhir.dstu3.model.OperationDefinition;
import org.hl7.fhir.dstu3.model.Patient;
import org.hl7.fhir.dstu3.model.StringType;
import org.hl7.fhir.dstu3.model.Conformance.ConditionalDeleteStatus; import org.hl7.fhir.dstu3.model.Conformance.ConditionalDeleteStatus;
import org.hl7.fhir.dstu3.model.Conformance.ConformanceRestComponent; import org.hl7.fhir.dstu3.model.Conformance.ConformanceRestComponent;
import org.hl7.fhir.dstu3.model.Conformance.ConformanceRestOperationComponent;
import org.hl7.fhir.dstu3.model.Conformance.ConformanceRestResourceComponent; import org.hl7.fhir.dstu3.model.Conformance.ConformanceRestResourceComponent;
import org.hl7.fhir.dstu3.model.Conformance.ConformanceRestResourceSearchParamComponent; import org.hl7.fhir.dstu3.model.Conformance.ConformanceRestResourceSearchParamComponent;
import org.hl7.fhir.dstu3.model.Conformance.SystemRestfulInteraction; import org.hl7.fhir.dstu3.model.Conformance.SystemRestfulInteraction;
import org.hl7.fhir.dstu3.model.Conformance.TypeRestfulInteraction; import org.hl7.fhir.dstu3.model.Conformance.TypeRestfulInteraction;
import org.hl7.fhir.dstu3.model.DateType;
import org.hl7.fhir.dstu3.model.DiagnosticReport;
import org.hl7.fhir.dstu3.model.Encounter;
import org.hl7.fhir.dstu3.model.IdType;
import org.hl7.fhir.dstu3.model.OperationDefinition;
import org.hl7.fhir.dstu3.model.Patient;
import org.hl7.fhir.dstu3.model.StringType;
import org.hl7.fhir.instance.model.api.IBaseResource; import org.hl7.fhir.instance.model.api.IBaseResource;
import org.junit.AfterClass; import org.junit.AfterClass;
import org.junit.Test; import org.junit.Test;
import com.google.common.collect.Lists;
import ca.uhn.fhir.context.FhirContext; import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.model.api.Include; import ca.uhn.fhir.model.api.Include;
import ca.uhn.fhir.model.api.annotation.Description; import ca.uhn.fhir.model.api.annotation.Description;
@ -51,6 +58,7 @@ import ca.uhn.fhir.rest.annotation.RequiredParam;
import ca.uhn.fhir.rest.annotation.ResourceParam; import ca.uhn.fhir.rest.annotation.ResourceParam;
import ca.uhn.fhir.rest.annotation.Search; import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.annotation.Update; import ca.uhn.fhir.rest.annotation.Update;
import ca.uhn.fhir.rest.annotation.Validate;
import ca.uhn.fhir.rest.api.MethodOutcome; import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.method.BaseMethodBinding; import ca.uhn.fhir.rest.method.BaseMethodBinding;
import ca.uhn.fhir.rest.method.IParameter; import ca.uhn.fhir.rest.method.IParameter;
@ -71,10 +79,6 @@ public class ServerConformanceProviderDstu3Test {
private static FhirContext ourCtx = FhirContext.forDstu3(); private static FhirContext ourCtx = FhirContext.forDstu3();
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ServerConformanceProviderDstu3Test.class); private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(ServerConformanceProviderDstu3Test.class);
@AfterClass
public static void afterClassClearContext() {
TestUtil.clearAllStaticFieldsForUnitTest();
}
private HttpServletRequest createHttpServletRequest() { private HttpServletRequest createHttpServletRequest() {
HttpServletRequest req = mock(HttpServletRequest.class); HttpServletRequest req = mock(HttpServletRequest.class);
@ -91,6 +95,19 @@ public class ServerConformanceProviderDstu3Test {
return sc; return sc;
} }
private ConformanceRestResourceComponent findRestResource(Conformance conformance, String wantResource) throws Exception {
ConformanceRestResourceComponent resource = null;
for (ConformanceRestResourceComponent next : conformance.getRest().get(0).getResource()) {
if (next.getType().equals(wantResource)) {
resource = next;
}
}
if (resource == null) {
throw new Exception("Could not find resource: " + wantResource);
}
return resource;
}
@Test @Test
public void testConditionalOperations() throws Exception { public void testConditionalOperations() throws Exception {
@ -149,7 +166,7 @@ public class ServerConformanceProviderDstu3Test {
rs.init(createServletConfig()); rs.init(createServletConfig());
OperationDefinition opDef = sc.readOperationDefinition(new IdType("OperationDefinition/everything")); OperationDefinition opDef = sc.readOperationDefinition(new IdType("OperationDefinition/Patient_i_everything"));
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef); String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef);
ourLog.info(conf); ourLog.info(conf);
@ -233,6 +250,79 @@ public class ServerConformanceProviderDstu3Test {
assertNull(res.getConditionalUpdateElement().getValue()); assertNull(res.getConditionalUpdateElement().getValue());
} }
private List<String> toOperationIdParts(List<ConformanceRestOperationComponent> theOperation) {
ArrayList<String> retVal = Lists.newArrayList();
for (ConformanceRestOperationComponent next : theOperation) {
retVal.add(next.getDefinition().getReferenceElement().getIdPart());
}
return retVal;
}
/** See #379 */
@Test
public void testOperationAcrossMultipleTypes() throws Exception {
RestfulServer rs = new RestfulServer(ourCtx);
rs.setProviders(new MultiTypePatientProvider(), new MultiTypeEncounterProvider());
ServerConformanceProvider sc = new ServerConformanceProvider(rs);
rs.setServerConformanceProvider(sc);
rs.init(createServletConfig());
Conformance conformance = sc.getServerConformance(createHttpServletRequest());
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(conformance);
ourLog.info(conf);
assertEquals(4, conformance.getRest().get(0).getOperation().size());
List<String> operationNames = toOperationNames(conformance.getRest().get(0).getOperation());
assertThat(operationNames, containsInAnyOrder("someOp", "validate", "someOp", "validate"));
List<String> operationIdParts = toOperationIdParts(conformance.getRest().get(0).getOperation());
assertThat(operationIdParts, containsInAnyOrder("Patient_i_someOp","Encounter_i_someOp","Patient_i_validate","Encounter_i_validate"));
{
OperationDefinition opDef = sc.readOperationDefinition(new IdType("OperationDefinition/Patient_i_someOp"));
ourLog.info(ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef));
Set<String> types = toStrings(opDef.getType());
assertEquals("someOp", opDef.getCode());
assertEquals(true, opDef.getInstance());
assertEquals(false, opDef.getSystem());
assertThat(types, containsInAnyOrder("Patient"));
assertEquals(2, opDef.getParameter().size());
assertEquals("someOpParam1", opDef.getParameter().get(0).getName());
assertEquals("date", opDef.getParameter().get(0).getType());
assertEquals("someOpParam2", opDef.getParameter().get(1).getName());
assertEquals("Patient", opDef.getParameter().get(1).getType());
}
{
OperationDefinition opDef = sc.readOperationDefinition(new IdType("OperationDefinition/Encounter_i_someOp"));
ourLog.info(ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef));
Set<String> types = toStrings(opDef.getType());
assertEquals("someOp", opDef.getCode());
assertEquals(true, opDef.getInstance());
assertEquals(false, opDef.getSystem());
assertThat(types, containsInAnyOrder("Encounter"));
assertEquals(2, opDef.getParameter().size());
assertEquals("someOpParam1", opDef.getParameter().get(0).getName());
assertEquals("date", opDef.getParameter().get(0).getType());
assertEquals("someOpParam2", opDef.getParameter().get(1).getName());
assertEquals("Encounter", opDef.getParameter().get(1).getType());
}
{
OperationDefinition opDef = sc.readOperationDefinition(new IdType("OperationDefinition/Patient_i_validate"));
ourLog.info(ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef));
Set<String> types = toStrings(opDef.getType());
assertEquals("validate", opDef.getCode());
assertEquals(true, opDef.getInstance());
assertEquals(false, opDef.getSystem());
assertThat(types, containsInAnyOrder("Patient"));
assertEquals(1, opDef.getParameter().size());
assertEquals("resource", opDef.getParameter().get(0).getName());
assertEquals("Patient", opDef.getParameter().get(0).getType());
}
}
@Test @Test
public void testOperationDocumentation() throws Exception { public void testOperationDocumentation() throws Exception {
@ -268,7 +358,7 @@ public class ServerConformanceProviderDstu3Test {
rs.init(createServletConfig()); rs.init(createServletConfig());
OperationDefinition opDef = sc.readOperationDefinition(new IdType("OperationDefinition/plain")); OperationDefinition opDef = sc.readOperationDefinition(new IdType("OperationDefinition/_is_plain"));
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef); String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(opDef);
ourLog.info(conf); ourLog.info(conf);
@ -394,58 +484,6 @@ public class ServerConformanceProviderDstu3Test {
assertThat(conf, containsString("<type value=\"token\"/>")); assertThat(conf, containsString("<type value=\"token\"/>"));
} }
/**
* See #286
*/
@Test
public void testSearchReferenceParameterWithWhitelistDocumentation() throws Exception {
RestfulServer rs = new RestfulServer(ourCtx);
rs.setProviders(new SearchProviderWithWhitelist());
ServerConformanceProvider sc = new ServerConformanceProvider(rs);
rs.setServerConformanceProvider(sc);
rs.init(createServletConfig());
boolean found = false;
Collection<ResourceBinding> resourceBindings = rs.getResourceBindings();
for (ResourceBinding resourceBinding : resourceBindings) {
if (resourceBinding.getResourceName().equals("Patient")) {
List<BaseMethodBinding<?>> methodBindings = resourceBinding.getMethodBindings();
SearchMethodBinding binding = (SearchMethodBinding) methodBindings.get(0);
SearchParameter param = (SearchParameter) binding.getParameters().get(0);
assertEquals("The organization at which this person is a patient", param.getDescription());
found = true;
}
}
assertTrue(found);
Conformance conformance = sc.getServerConformance(createHttpServletRequest());
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(conformance);
ourLog.info(conf);
ConformanceRestResourceComponent resource = findRestResource(conformance, "Patient");
ConformanceRestResourceSearchParamComponent param = resource.getSearchParam().get(0);
assertEquals("bar", param.getChain().get(0).getValue());
assertEquals("foo", param.getChain().get(1).getValue());
assertEquals(2, param.getChain().size());
}
private ConformanceRestResourceComponent findRestResource(Conformance conformance, String wantResource) throws Exception {
ConformanceRestResourceComponent resource = null;
for (ConformanceRestResourceComponent next : conformance.getRest().get(0).getResource()) {
if (next.getType().equals(wantResource)) {
resource = next;
}
}
if (resource == null) {
throw new Exception("Could not find resource: " + wantResource);
}
return resource;
}
/** /**
* See #286 * See #286
@ -480,6 +518,45 @@ public class ServerConformanceProviderDstu3Test {
} }
/**
* See #286
*/
@Test
public void testSearchReferenceParameterWithWhitelistDocumentation() throws Exception {
RestfulServer rs = new RestfulServer(ourCtx);
rs.setProviders(new SearchProviderWithWhitelist());
ServerConformanceProvider sc = new ServerConformanceProvider(rs);
rs.setServerConformanceProvider(sc);
rs.init(createServletConfig());
boolean found = false;
Collection<ResourceBinding> resourceBindings = rs.getResourceBindings();
for (ResourceBinding resourceBinding : resourceBindings) {
if (resourceBinding.getResourceName().equals("Patient")) {
List<BaseMethodBinding<?>> methodBindings = resourceBinding.getMethodBindings();
SearchMethodBinding binding = (SearchMethodBinding) methodBindings.get(0);
SearchParameter param = (SearchParameter) binding.getParameters().get(0);
assertEquals("The organization at which this person is a patient", param.getDescription());
found = true;
}
}
assertTrue(found);
Conformance conformance = sc.getServerConformance(createHttpServletRequest());
String conf = ourCtx.newXmlParser().setPrettyPrint(true).encodeResourceToString(conformance);
ourLog.info(conf);
ConformanceRestResourceComponent resource = findRestResource(conformance, "Patient");
ConformanceRestResourceSearchParamComponent param = resource.getSearchParam().get(0);
assertEquals("bar", param.getChain().get(0).getValue());
assertEquals("foo", param.getChain().get(1).getValue());
assertEquals(2, param.getChain().size());
}
@Test @Test
public void testSystemHistorySupported() throws Exception { public void testSystemHistorySupported() throws Exception {
@ -536,6 +613,27 @@ public class ServerConformanceProviderDstu3Test {
assertTrue(result.getMessages().toString(), result.isSuccessful()); assertTrue(result.getMessages().toString(), result.isSuccessful());
} }
private List<String> toOperationNames(List<ConformanceRestOperationComponent> theOperation) {
ArrayList<String> retVal = Lists.newArrayList();
for (ConformanceRestOperationComponent next : theOperation) {
retVal.add(next.getName());
}
return retVal;
}
private Set<String> toStrings(List<CodeType> theType) {
HashSet<String> retVal = new HashSet<String>();
for (CodeType next : theType) {
retVal.add(next.getValueAsString());
}
return retVal;
}
@AfterClass
public static void afterClassClearContext() {
TestUtil.clearAllStaticFieldsForUnitTest();
}
public static class ConditionalProvider implements IResourceProvider { public static class ConditionalProvider implements IResourceProvider {
@Create @Create
@ -579,7 +677,48 @@ public class ServerConformanceProviderDstu3Test {
public static class MultiOptionalProvider { public static class MultiOptionalProvider {
@Search(type = Patient.class) @Search(type = Patient.class)
public Patient findPatient(@Description(shortDefinition = "The patient's identifier") @OptionalParam(name = Patient.SP_IDENTIFIER) TokenParam theIdentifier, @Description(shortDefinition = "The patient's name") @OptionalParam(name = Patient.SP_NAME) StringParam theName) { public Patient findPatient(@Description(shortDefinition = "The patient's identifier") @OptionalParam(name = Patient.SP_IDENTIFIER) TokenParam theIdentifier,
@Description(shortDefinition = "The patient's name") @OptionalParam(name = Patient.SP_NAME) StringParam theName) {
return null;
}
}
public static class MultiTypeEncounterProvider implements IResourceProvider {
@Operation(name = "someOp")
public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId,
@OperationParam(name = "someOpParam1") DateType theStart, @OperationParam(name = "someOpParam2") Encounter theEnd) {
return null;
}
@Override
public Class<? extends IBaseResource> getResourceType() {
return Encounter.class;
}
@Validate
public ca.uhn.fhir.rest.server.IBundleProvider validate(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId, @ResourceParam Encounter thePatient) {
return null;
}
}
public static class MultiTypePatientProvider implements IResourceProvider {
@Operation(name = "someOp")
public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId,
@OperationParam(name = "someOpParam1") DateType theStart, @OperationParam(name = "someOpParam2") Patient theEnd) {
return null;
}
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
@Validate
public ca.uhn.fhir.rest.server.IBundleProvider validate(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId, @ResourceParam Patient thePatient) {
return null; return null;
} }
@ -612,7 +751,8 @@ public class ServerConformanceProviderDstu3Test {
public static class PlainProviderWithExtendedOperationOnNoType { public static class PlainProviderWithExtendedOperationOnNoType {
@Operation(name = "plain", idempotent = true, returnParameters = { @OperationParam(min = 1, max = 2, name = "out1", type = StringType.class) }) @Operation(name = "plain", idempotent = true, returnParameters = { @OperationParam(min = 1, max = 2, name = "out1", type = StringType.class) })
public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId, @OperationParam(name = "start") DateType theStart, @OperationParam(name = "end") DateType theEnd) { public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId, @OperationParam(name = "start") DateType theStart,
@OperationParam(name = "end") DateType theEnd) {
return null; return null;
} }
@ -621,7 +761,8 @@ public class ServerConformanceProviderDstu3Test {
public static class ProviderWithExtendedOperationReturningBundle implements IResourceProvider { public static class ProviderWithExtendedOperationReturningBundle implements IResourceProvider {
@Operation(name = "everything", idempotent = true) @Operation(name = "everything", idempotent = true)
public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId, @OperationParam(name = "start") DateType theStart, @OperationParam(name = "end") DateType theEnd) { public ca.uhn.fhir.rest.server.IBundleProvider everything(javax.servlet.http.HttpServletRequest theServletRequest, @IdParam IdType theId, @OperationParam(name = "start") DateType theStart,
@OperationParam(name = "end") DateType theEnd) {
return null; return null;
} }
@ -636,16 +777,14 @@ public class ServerConformanceProviderDstu3Test {
@Description(shortDefinition = "This is a search for stuff!") @Description(shortDefinition = "This is a search for stuff!")
@Search @Search
public List<DiagnosticReport> findDiagnosticReportsByPatient(@RequiredParam(name = DiagnosticReport.SP_SUBJECT + '.' + Patient.SP_IDENTIFIER) TokenParam thePatientId, @OptionalParam(name = DiagnosticReport.SP_CODE) TokenOrListParam theNames, public List<DiagnosticReport> findDiagnosticReportsByPatient(@RequiredParam(name = DiagnosticReport.SP_SUBJECT + '.' + Patient.SP_IDENTIFIER) TokenParam thePatientId,
@OptionalParam(name = DiagnosticReport.SP_DATE) DateRangeParam theDateRange, @IncludeParam(allow = { "DiagnosticReport.result" }) Set<Include> theIncludes) throws Exception { @OptionalParam(name = DiagnosticReport.SP_CODE) TokenOrListParam theNames, @OptionalParam(name = DiagnosticReport.SP_DATE) DateRangeParam theDateRange,
@IncludeParam(allow = { "DiagnosticReport.result" }) Set<Include> theIncludes) throws Exception {
return null; return null;
} }
} }
/**
* Created by dsotnikov on 2/25/2014.
*/
public static class ReadProvider { public static class ReadProvider {
@Search(type = Patient.class) @Search(type = Patient.class)
@ -668,7 +807,8 @@ public class ServerConformanceProviderDstu3Test {
} }
@Search(type = Patient.class) @Search(type = Patient.class)
public Patient findPatient2(@Description(shortDefinition = "All patients linked to the given patient") @OptionalParam(name = "link", targetTypes = { Patient.class }) ReferenceAndListParam theLink) { public Patient findPatient2(
@Description(shortDefinition = "All patients linked to the given patient") @OptionalParam(name = "link", targetTypes = { Patient.class }) ReferenceAndListParam theLink) {
return null; return null;
} }
@ -677,10 +817,8 @@ public class ServerConformanceProviderDstu3Test {
public static class SearchProviderWithWhitelist { public static class SearchProviderWithWhitelist {
@Search(type = Patient.class) @Search(type = Patient.class)
public Patient findPatient1( public Patient findPatient1(@Description(shortDefinition = "The organization at which this person is a patient") @RequiredParam(name = Patient.SP_ORGANIZATION, chainWhitelist = { "foo",
@Description(shortDefinition = "The organization at which this person is a patient") "bar" }) ReferenceAndListParam theIdentifier) {
@RequiredParam(name = Patient.SP_ORGANIZATION, chainWhitelist= {"foo", "bar"})
ReferenceAndListParam theIdentifier) {
return null; return null;
} }

View File

@ -329,9 +329,15 @@
that are defined within resource/plain providers incorrectly stated that that are defined within resource/plain providers incorrectly stated that
the maximum cardinality was "*" for non-collection types with no explicit the maximum cardinality was "*" for non-collection types with no explicit
maximum stated, which is not the behaviour that the JavaDoc on the maximum stated, which is not the behaviour that the JavaDoc on the
<![CDATA[@OperationParam]] annotation describes. Thanks to Michael Lawley <![CDATA[@OperationParam]]> annotation describes. Thanks to Michael Lawley
for reporting! for reporting!
</action> </action>
<action type="fix" issue="379">
Operations that are defined on multiple resource provider types with
the same name (e.g. "$everything") are now automatically exposed by the server
as separate OperationDefinition resources per resource type. Thanks to
Michael Lawley for reporting!
</action>
</release> </release>
<release version="1.5" date="2016-04-20"> <release version="1.5" date="2016-04-20">
<action type="fix" issue="339"> <action type="fix" issue="339">