Merge branch 'master' into ja_20200206_multitenancy

This commit is contained in:
jamesagnew 2020-04-21 20:54:56 -04:00
commit 6226381595
25 changed files with 363 additions and 153 deletions

View File

@ -40,10 +40,8 @@
</p> </p>
</th:block> </th:block>
<p> <p>
<b style="color: red;"> <b class="text-danger"><span class="glyphicon glyphicon-warning-sign"/></b>
<span class="glyphicon glyphicon-warning-sign"/> <b><span class="text-danger">This is not a production server!</span></b>
This is not a production server!
</b>
Do not store any information here that contains personal health information Do not store any information here that contains personal health information
or any other confidential information. This server will be regularly purged or any other confidential information. This server will be regularly purged
and reloaded with fixed test data. and reloaded with fixed test data.

View File

@ -40,10 +40,8 @@
</p> </p>
</th:block> </th:block>
<p> <p>
<b style="color: red;"> <b class="text-danger"><span class="glyphicon glyphicon-warning-sign"/></b>
<span class="glyphicon glyphicon-warning-sign"/> <b><span class="text-danger">This is not a production server!</span></b>
This is not a production server!
</b>
Do not store any information here that contains personal health information Do not store any information here that contains personal health information
or any other confidential information. This server will be regularly purged or any other confidential information. This server will be regularly purged
and reloaded with fixed test data. and reloaded with fixed test data.

View File

@ -40,10 +40,8 @@
</p> </p>
</th:block> </th:block>
<p> <p>
<b style="color: red;"> <b class="text-danger"><span class="glyphicon glyphicon-warning-sign"/></b>
<span class="glyphicon glyphicon-warning-sign"/> <b><span class="text-danger">This is not a production server!</span></b>
This is not a production server!
</b>
Do not store any information here that contains personal health information Do not store any information here that contains personal health information
or any other confidential information. This server will be regularly purged or any other confidential information. This server will be regularly purged
and reloaded with fixed test data. and reloaded with fixed test data.

View File

@ -21,9 +21,10 @@ package ca.uhn.fhir.rest.gclient;
*/ */
import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum; import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum;
import ca.uhn.fhir.rest.api.MethodOutcome;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome; import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
public interface IDeleteTyped extends IClientExecutable<IDeleteTyped, IBaseOperationOutcome> { public interface IDeleteTyped extends IClientExecutable<IDeleteTyped, MethodOutcome> {
/** /**
* Delete cascade mode - Note that this is a HAPI FHIR specific feature and is not supported on all servers. * Delete cascade mode - Note that this is a HAPI FHIR specific feature and is not supported on all servers.

View File

@ -72,6 +72,7 @@ public interface IQuery<Y> extends IBaseQuery<IQuery<Y>>, IClientExecutable<IQue
* on a single page. * on a single page.
* *
* @deprecated This parameter is badly named, since FHIR calls this parameter "_count" and not "_limit". Use {@link #count(int)} instead (it also sets the _count parameter) * @deprecated This parameter is badly named, since FHIR calls this parameter "_count" and not "_limit". Use {@link #count(int)} instead (it also sets the _count parameter)
* @see #count(int)
*/ */
@Deprecated @Deprecated
IQuery<Y> limitTo(int theLimitTo); IQuery<Y> limitTo(int theLimitTo);

View File

@ -139,6 +139,8 @@ ca.uhn.fhir.jpa.term.BaseTermReadSvcImpl.expansionTooLarge=Expansion of ValueSet
ca.uhn.fhir.jpa.util.jsonpatch.JsonPatchUtils.failedToApplyPatch=Failed to apply JSON patch to {0}: {1} ca.uhn.fhir.jpa.util.jsonpatch.JsonPatchUtils.failedToApplyPatch=Failed to apply JSON patch to {0}: {1}
ca.uhn.fhir.jpa.graphql.JpaStorageServices.invalidGraphqlArgument=Unknown GraphQL argument "{0}". Value GraphQL argument for this type are: {1}
ca.uhn.fhir.jpa.partition.RequestPartitionHelperService.blacklistedResourceTypeForPartitioning=Resource type {0} can not be partitioned ca.uhn.fhir.jpa.partition.RequestPartitionHelperService.blacklistedResourceTypeForPartitioning=Resource type {0} can not be partitioned
ca.uhn.fhir.jpa.partition.RequestPartitionHelperService.unknownPartitionId=Unknown partition ID: {0} ca.uhn.fhir.jpa.partition.RequestPartitionHelperService.unknownPartitionId=Unknown partition ID: {0}
ca.uhn.fhir.jpa.partition.RequestPartitionHelperService.unknownPartitionName=Unknown partition name: {0} ca.uhn.fhir.jpa.partition.RequestPartitionHelperService.unknownPartitionName=Unknown partition name: {0}

View File

@ -604,7 +604,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
} }
private class DeleteInternal extends BaseSearch<IDeleteTyped, IDeleteWithQueryTyped, IBaseOperationOutcome> implements IDelete, IDeleteTyped, IDeleteWithQuery, IDeleteWithQueryTyped { private class DeleteInternal extends BaseSearch<IDeleteTyped, IDeleteWithQueryTyped, MethodOutcome> implements IDelete, IDeleteTyped, IDeleteWithQuery, IDeleteWithQueryTyped {
private boolean myConditional; private boolean myConditional;
private IIdType myId; private IIdType myId;
@ -613,7 +613,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
private DeleteCascadeModeEnum myCascadeMode; private DeleteCascadeModeEnum myCascadeMode;
@Override @Override
public IBaseOperationOutcome execute() { public MethodOutcome execute() {
Map<String, List<String>> additionalParams = new HashMap<>(); Map<String, List<String>> additionalParams = new HashMap<>();
if (myCascadeMode != null) { if (myCascadeMode != null) {
@ -635,7 +635,8 @@ public class GenericClient extends BaseClient implements IGenericClient {
} else { } else {
invocation = DeleteMethodBinding.createDeleteInvocation(getFhirContext(), mySearchUrl, getParamMap()); invocation = DeleteMethodBinding.createDeleteInvocation(getFhirContext(), mySearchUrl, getParamMap());
} }
OperationOutcomeResponseHandler binding = new OperationOutcomeResponseHandler();
OutcomeResponseHandler binding = new OutcomeResponseHandler();
return invoke(additionalParams, binding, invocation); return invoke(additionalParams, binding, invocation);
} }
@ -1389,30 +1390,6 @@ public class GenericClient extends BaseClient implements IGenericClient {
} }
} }
private final class OperationOutcomeResponseHandler implements IClientResponseHandler<IBaseOperationOutcome> {
@Override
public IBaseOperationOutcome invokeClient(String theResponseMimeType, InputStream theResponseInputStream, int theResponseStatusCode, Map<String, List<String>> theHeaders)
throws BaseServerResponseException {
EncodingEnum respType = EncodingEnum.forContentType(theResponseMimeType);
if (respType == null) {
return null;
}
IParser parser = respType.newParser(myContext);
IBaseOperationOutcome retVal;
try {
// TODO: handle if something else than OO comes back
retVal = (IBaseOperationOutcome) parser.parseResource(theResponseInputStream);
} catch (DataFormatException e) {
ourLog.warn("Failed to parse OperationOutcome response", e);
return null;
}
MethodUtil.parseClientRequestResourceHeaders(null, theHeaders, retVal);
return retVal;
}
}
private final class OutcomeResponseHandler implements IClientResponseHandler<MethodOutcome> { private final class OutcomeResponseHandler implements IClientResponseHandler<MethodOutcome> {
private PreferReturnEnum myPrefer; private PreferReturnEnum myPrefer;

View File

@ -236,21 +236,27 @@ public class GenericClientExample {
// START SNIPPET: conformance // START SNIPPET: conformance
// Retrieve the server's conformance statement and print its // Retrieve the server's conformance statement and print its
// description // description
CapabilityStatement conf = client.capabilities().ofType(CapabilityStatement.class).execute(); CapabilityStatement conf = client
.capabilities()
.ofType(CapabilityStatement.class)
.execute();
System.out.println(conf.getDescriptionElement().getValue()); System.out.println(conf.getDescriptionElement().getValue());
// END SNIPPET: conformance // END SNIPPET: conformance
} }
{ {
// START SNIPPET: delete // START SNIPPET: delete
IBaseOperationOutcome resp = client.delete().resourceById(new IdType("Patient", "1234")).execute(); MethodOutcome response = client
.delete()
.resourceById(new IdType("Patient", "1234"))
.execute();
// outcome may be null if the server didn't return one // outcome may be null if the server didn't return one
if (resp != null) { OperationOutcome outcome = (OperationOutcome) response.getOperationOutcome();
OperationOutcome outcome = (OperationOutcome) resp; if (outcome != null) {
System.out.println(outcome.getIssueFirstRep().getDetails().getCodingFirstRep().getCode()); System.out.println(outcome.getIssueFirstRep().getDetails().getCodingFirstRep().getCode());
} }
// END SNIPPET: delete // END SNIPPET: delete
} }
{ {
// START SNIPPET: deleteConditional // START SNIPPET: deleteConditional
client.delete() client.delete()
@ -356,7 +362,8 @@ public class GenericClientExample {
.revInclude(Provenance.INCLUDE_TARGET) .revInclude(Provenance.INCLUDE_TARGET)
.lastUpdated(new DateRangeParam("2011-01-01", null)) .lastUpdated(new DateRangeParam("2011-01-01", null))
.sort().ascending(Patient.BIRTHDATE) .sort().ascending(Patient.BIRTHDATE)
.sort().descending(Patient.NAME).limitTo(123) .sort().descending(Patient.NAME)
.count(123)
.returnBundle(Bundle.class) .returnBundle(Bundle.class)
.execute(); .execute();
// END SNIPPET: searchAdv // END SNIPPET: searchAdv

View File

@ -0,0 +1,5 @@
---
type: fix
issue: 1791
title: The GraphQL Expression parser sometimes fails and reports unhelpful error messages when using search arguments.
Thanks to Ibrohim Kholilul Islam for the pull request!

View File

@ -0,0 +1,6 @@
---
type: fix
issue: 1806
title: The JPA server ElasticSearch provider failed to initialize if username/password credentials were not
explicitly provided, meaning it could not run on AWS-supplied ElasticSearch. Thanks to Maciej Kucharek for
the pull request!

View File

@ -0,0 +1,5 @@
---
type: fix
issue: 1810
title: The text styling on the Testpage Overlay homepage has been improved to use native
Bootstrap warning colours. Thanks to Joel Schneider for the pull request!

View File

@ -36,6 +36,23 @@
These classes have not changed in terms of functionality, but existing projects may need to adjust some These classes have not changed in terms of functionality, but existing projects may need to adjust some
package import statements. package import statements.
" "
- item:
issue: "1804"
type: "change"
title: "**Breaking Change**:
The Generic/Fluent **delete()** operation now returns a [MethodOutcome](/apidocs/hapi-fhir-base/ca/uhn/fhir/rest/api/MethodOutcome.html)
object instead of an OperationOutcome. The OperationOutcomoe is still available direcly by querying
the MethodOutcome object, but this change makes the delete() method more consistent with
other similar methods in the API.
"
- item:
type: "change"
title: "**Breaking Change**:
Some R4 and R5 structure fields containing a `code` value with a **Required (closed) binding**
did not use the java Enum type that was generated for the given field. These have been changed
to use the Enum values where possible. This change does not remove any functionality from the model
but may require a small amount of re-coding to deal with new setter/getter types on a few fields.
"
- item: - item:
issue: "1807" issue: "1807"
type: "change" type: "change"

View File

@ -168,7 +168,7 @@ public class AbstractJaxRsResourceProviderDstu3Test {
@Test @Test
public void testDeletePatient() { public void testDeletePatient() {
when(mock.delete(idCaptor.capture(), conditionalCaptor.capture())).thenReturn(new MethodOutcome()); when(mock.delete(idCaptor.capture(), conditionalCaptor.capture())).thenReturn(new MethodOutcome());
final IBaseOperationOutcome results = client.delete().resourceById("Patient", "1").execute(); final IBaseOperationOutcome results = client.delete().resourceById("Patient", "1").execute().getOperationOutcome();
assertEquals("1", idCaptor.getValue().getIdPart()); assertEquals("1", idCaptor.getValue().getIdPart());
} }

View File

@ -164,7 +164,7 @@ public class AbstractJaxRsResourceProviderTest {
@Test @Test
public void testDeletePatient() { public void testDeletePatient() {
when(mock.delete(idCaptor.capture(), conditionalCaptor.capture())).thenReturn(new MethodOutcome()); when(mock.delete(idCaptor.capture(), conditionalCaptor.capture())).thenReturn(new MethodOutcome());
final IBaseOperationOutcome results = client.delete().resourceById("Patient", "1").execute(); final IBaseOperationOutcome results = client.delete().resourceById("Patient", "1").execute().getOperationOutcome();
assertEquals("1", idCaptor.getValue().getIdPart()); assertEquals("1", idCaptor.getValue().getIdPart());
} }

View File

@ -45,21 +45,42 @@ import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.utilities.graphql.Argument; import org.hl7.fhir.utilities.graphql.Argument;
import org.hl7.fhir.utilities.graphql.IGraphQLStorageServices; import org.hl7.fhir.utilities.graphql.IGraphQLStorageServices;
import org.hl7.fhir.utilities.graphql.Value; import org.hl7.fhir.utilities.graphql.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Nullable; import javax.annotation.Nullable;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import static ca.uhn.fhir.rest.api.Constants.PARAM_FILTER;
public class JpaStorageServices extends BaseHapiFhirDao<IBaseResource> implements IGraphQLStorageServices { public class JpaStorageServices extends BaseHapiFhirDao<IBaseResource> implements IGraphQLStorageServices {
private static final int MAX_SEARCH_SIZE = 500; private static final int MAX_SEARCH_SIZE = 500;
private static final Logger ourLog = LoggerFactory.getLogger(JpaStorageServices.class);
private IFhirResourceDao<? extends IBaseResource> getDao(String theResourceType) { private IFhirResourceDao<? extends IBaseResource> getDao(String theResourceType) {
RuntimeResourceDefinition typeDef = getContext().getResourceDefinition(theResourceType); RuntimeResourceDefinition typeDef = getContext().getResourceDefinition(theResourceType);
return myDaoRegistry.getResourceDaoOrNull(typeDef.getImplementingClass()); return myDaoRegistry.getResourceDaoOrNull(typeDef.getImplementingClass());
} }
private String graphqlArgumentToSearchParam(String name) {
if (name.startsWith("_")) {
return name;
} else {
return name.replaceAll("_", "-");
}
}
private String searchParamToGraphqlArgument(String name) {
return name.replaceAll("-", "_");
}
@Transactional(propagation = Propagation.NEVER) @Transactional(propagation = Propagation.NEVER)
@Override @Override
public void listResources(Object theAppInfo, String theType, List<Argument> theSearchParams, List<IBaseResource> theMatches) throws FHIRException { public void listResources(Object theAppInfo, String theType, List<Argument> theSearchParams, List<IBaseResource> theMatches) throws FHIRException {
@ -70,9 +91,25 @@ public class JpaStorageServices extends BaseHapiFhirDao<IBaseResource> implement
SearchParameterMap params = new SearchParameterMap(); SearchParameterMap params = new SearchParameterMap();
params.setLoadSynchronousUpTo(MAX_SEARCH_SIZE); params.setLoadSynchronousUpTo(MAX_SEARCH_SIZE);
Map<String, RuntimeSearchParam> searchParams = mySearchParamRegistry.getActiveSearchParams(typeDef.getName());
for (Argument nextArgument : theSearchParams) { for (Argument nextArgument : theSearchParams) {
RuntimeSearchParam searchParam = mySearchParamRegistry.getSearchParamByName(typeDef, nextArgument.getName()); if (nextArgument.getName().equals(PARAM_FILTER)) {
String value = nextArgument.getValues().get(0).getValue();
params.add(PARAM_FILTER, new StringParam(value));
continue;
}
String searchParamName = graphqlArgumentToSearchParam(nextArgument.getName());
RuntimeSearchParam searchParam = searchParams.get(searchParamName);
if (searchParam == null) {
Set<String> graphqlArguments = searchParams.keySet().stream()
.map(this::searchParamToGraphqlArgument)
.collect(Collectors.toSet());
String msg = getContext().getLocalizer().getMessageSanitized(JpaStorageServices.class, "invalidGraphqlArgument", nextArgument.getName(), new TreeSet<>(graphqlArguments));
throw new InvalidRequestException(msg);
}
for (Value nextValue : nextArgument.getValues()) { for (Value nextValue : nextArgument.getValues()) {
String value = nextValue.getValue(); String value = nextValue.getValue();
@ -108,7 +145,7 @@ public class JpaStorageServices extends BaseHapiFhirDao<IBaseResource> implement
break; break;
} }
params.add(nextArgument.getName(), param); params.add(searchParamName, param);
} }
} }

View File

@ -34,8 +34,10 @@ import ca.uhn.fhir.jpa.delete.DeleteConflictOutcome;
import ca.uhn.fhir.jpa.util.JpaInterceptorBroadcaster; import ca.uhn.fhir.jpa.util.JpaInterceptorBroadcaster;
import ca.uhn.fhir.model.primitive.IdDt; import ca.uhn.fhir.model.primitive.IdDt;
import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum;
import ca.uhn.fhir.rest.api.server.RequestDetails; import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.api.server.ResponseDetails; import ca.uhn.fhir.rest.api.server.ResponseDetails;
import ca.uhn.fhir.rest.server.RestfulServerUtils;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails; import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import ca.uhn.fhir.util.OperationOutcomeUtil; import ca.uhn.fhir.util.OperationOutcomeUtil;
import org.apache.commons.lang3.Validate; import org.apache.commons.lang3.Validate;
@ -45,6 +47,9 @@ import org.hl7.fhir.r4.model.OperationOutcome;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.validation.constraints.Null;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Iterator; import java.util.Iterator;
@ -92,7 +97,12 @@ public class CascadingDeleteInterceptor {
public DeleteConflictOutcome handleDeleteConflicts(DeleteConflictList theConflictList, RequestDetails theRequest) { public DeleteConflictOutcome handleDeleteConflicts(DeleteConflictList theConflictList, RequestDetails theRequest) {
ourLog.debug("Have delete conflicts: {}", theConflictList); ourLog.debug("Have delete conflicts: {}", theConflictList);
if (!shouldCascade(theRequest)) { if (shouldCascade(theRequest) == DeleteCascadeModeEnum.NONE) {
// Add a message to the response
String message = theRequest.getFhirContext().getLocalizer().getMessage(CascadingDeleteInterceptor.class, "noParam");
theRequest.getUserData().put(CASCADED_DELETES_FAILED_KEY, message);
return null; return null;
} }
@ -180,28 +190,12 @@ public class CascadingDeleteInterceptor {
/** /**
* Subclasses may override * Subclasses may override
* *
* @param theRequest The REST request * @param theRequest The REST request (may be null)
* @return Returns true if cascading delete should be allowed * @return Returns true if cascading delete should be allowed
*/ */
@SuppressWarnings("WeakerAccess") @Nonnull
protected boolean shouldCascade(RequestDetails theRequest) { protected DeleteCascadeModeEnum shouldCascade(@Nullable RequestDetails theRequest) {
if (theRequest != null) { return RestfulServerUtils.extractDeleteCascadeParameter(theRequest);
String[] cascadeParameters = theRequest.getParameters().get(Constants.PARAMETER_CASCADE_DELETE);
if (cascadeParameters != null && Arrays.asList(cascadeParameters).contains(Constants.CASCADE_DELETE)) {
return true;
}
String cascadeHeader = theRequest.getHeader(Constants.HEADER_CASCADE);
if (Constants.CASCADE_DELETE.equals(cascadeHeader)) {
return true;
}
// Add a message to the response
String message = theRequest.getFhirContext().getLocalizer().getMessage(CascadingDeleteInterceptor.class, "noParam");
theRequest.getUserData().put(CASCADED_DELETES_FAILED_KEY, message);
}
return false;
} }

View File

@ -20,6 +20,7 @@ package ca.uhn.fhir.jpa.search.elastic;
* #L% * #L%
*/ */
import org.apache.commons.lang3.StringUtils;
import org.hibernate.search.cfg.Environment; import org.hibernate.search.cfg.Environment;
import org.hibernate.search.elasticsearch.cfg.ElasticsearchEnvironment; import org.hibernate.search.elasticsearch.cfg.ElasticsearchEnvironment;
import org.hibernate.search.elasticsearch.cfg.ElasticsearchIndexStatus; import org.hibernate.search.elasticsearch.cfg.ElasticsearchIndexStatus;
@ -63,8 +64,12 @@ public class ElasticsearchHibernatePropertiesBuilder {
theProperties.put("hibernate.search." + ElasticsearchEnvironment.ANALYSIS_DEFINITION_PROVIDER, ElasticsearchMappingProvider.class.getName()); theProperties.put("hibernate.search." + ElasticsearchEnvironment.ANALYSIS_DEFINITION_PROVIDER, ElasticsearchMappingProvider.class.getName());
theProperties.put("hibernate.search.default.elasticsearch.host", myRestUrl); theProperties.put("hibernate.search.default.elasticsearch.host", myRestUrl);
theProperties.put("hibernate.search.default.elasticsearch.username", myUsername); if (StringUtils.isNotBlank(myUsername)) {
theProperties.put("hibernate.search.default.elasticsearch.password", myPassword); theProperties.put("hibernate.search.default.elasticsearch.username", myUsername);
}
if (StringUtils.isNotBlank(myPassword)) {
theProperties.put("hibernate.search.default.elasticsearch.password", myPassword);
}
theProperties.put("hibernate.search.default." + ElasticsearchEnvironment.INDEX_SCHEMA_MANAGEMENT_STRATEGY, myIndexSchemaManagementStrategy.getExternalName()); theProperties.put("hibernate.search.default." + ElasticsearchEnvironment.INDEX_SCHEMA_MANAGEMENT_STRATEGY, myIndexSchemaManagementStrategy.getExternalName());
theProperties.put("hibernate.search.default." + ElasticsearchEnvironment.INDEX_MANAGEMENT_WAIT_TIMEOUT, Long.toString(myIndexManagementWaitTimeoutMillis)); theProperties.put("hibernate.search.default." + ElasticsearchEnvironment.INDEX_MANAGEMENT_WAIT_TIMEOUT, Long.toString(myIndexManagementWaitTimeoutMillis));

View File

@ -0,0 +1,88 @@
package ca.uhn.fhir.jpa.graphql;
import ca.uhn.fhir.jpa.config.TestR4Config;
import ca.uhn.fhir.jpa.dao.DaoConfig;
import ca.uhn.fhir.jpa.dao.r4.BaseJpaR4Test;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.r4.model.Appointment;
import org.hl7.fhir.r4.model.CodeableConcept;
import org.hl7.fhir.r4.model.Coding;
import org.hl7.fhir.utilities.graphql.Argument;
import org.hl7.fhir.utilities.graphql.IGraphQLStorageServices;
import org.hl7.fhir.utilities.graphql.StringValue;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
@ContextConfiguration(classes = {TestR4Config.class})
@RunWith(SpringJUnit4ClassRunner.class)
@DirtiesContext
public class JpaStorageServicesTest extends BaseJpaR4Test {
@After
public void after() {
myDaoConfig.setFilterParameterEnabled(new DaoConfig().isFilterParameterEnabled());
}
@Before
public void before() {
myDaoConfig.setFilterParameterEnabled(true);
}
@Autowired
private IGraphQLStorageServices mySvc;
private String createSomeAppointment() {
CodeableConcept someCodeableConcept = new CodeableConcept(new Coding("TEST_SYSTEM", "TEST_CODE", "TEST_DISPLAY"));
Appointment someAppointment = new Appointment();
someAppointment.setAppointmentType(someCodeableConcept);
return myAppointmentDao.create(someAppointment).getId().getIdPart();
}
@Test
public void testListResourcesGraphqlArgumentConversion() {
String appointmentId = createSomeAppointment();
Argument argument = new Argument("appointment_type", new StringValue("TEST_CODE"));
List<IBaseResource> result = new ArrayList<>();
mySvc.listResources(mySrd, "Appointment", Collections.singletonList(argument), result);
Assert.assertFalse(result.isEmpty());
Assert.assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals(appointmentId)));
}
@Test
public void testListResourceGraphqlFilterArgument() {
String appointmentId = createSomeAppointment();
Argument argument = new Argument("_filter", new StringValue("appointment-type eq TEST_CODE"));
List<IBaseResource> result = new ArrayList<>();
mySvc.listResources(mySrd, "Appointment", Collections.singletonList(argument), result);
Assert.assertFalse(result.isEmpty());
Assert.assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals(appointmentId)));
}
@Test(expected = InvalidRequestException.class)
public void testListResourceGraphqlInvalidException() {
Argument argument = new Argument("test", new StringValue("some test value"));
List<IBaseResource> result = new ArrayList<>();
mySvc.listResources(mySrd, "Appointment", Collections.singletonList(argument), result);
}
}

View File

@ -814,15 +814,13 @@ public class ResourceProviderDstu3Test extends BaseResourceProviderDstu3Test {
myDaoConfig.setAllowMultipleDelete(true); myDaoConfig.setAllowMultipleDelete(true);
//@formatter:off MethodOutcome response = ourClient
IBaseOperationOutcome response = ourClient
.delete() .delete()
.resourceConditionalByType(Patient.class) .resourceConditionalByType(Patient.class)
.where(Patient.IDENTIFIER.exactly().code(methodName)) .where(Patient.IDENTIFIER.exactly().code(methodName))
.execute(); .execute();
//@formatter:on
String encoded = myFhirCtx.newXmlParser().encodeResourceToString(response); String encoded = myFhirCtx.newXmlParser().encodeResourceToString(response.getOperationOutcome());
ourLog.info(encoded); ourLog.info(encoded);
assertThat(encoded, containsString( assertThat(encoded, containsString(
"<issue><severity value=\"information\"/><code value=\"informational\"/><diagnostics value=\"Successfully deleted 2 resource(s) in ")); "<issue><severity value=\"information\"/><code value=\"informational\"/><diagnostics value=\"Successfully deleted 2 resource(s) in "));
@ -1028,8 +1026,8 @@ public class ResourceProviderDstu3Test extends BaseResourceProviderDstu3Test {
p.addName().setFamily("FAM"); p.addName().setFamily("FAM");
IIdType id = ourClient.create().resource(p).execute().getId().toUnqualifiedVersionless(); IIdType id = ourClient.create().resource(p).execute().getId().toUnqualifiedVersionless();
IBaseOperationOutcome resp = ourClient.delete().resourceById(id).execute(); MethodOutcome resp = ourClient.delete().resourceById(id).execute();
OperationOutcome oo = (OperationOutcome) resp; OperationOutcome oo = (OperationOutcome) resp.getOperationOutcome();
assertThat(oo.getIssueFirstRep().getDiagnostics(), startsWith("Successfully deleted 1 resource(s) in ")); assertThat(oo.getIssueFirstRep().getDiagnostics(), startsWith("Successfully deleted 1 resource(s) in "));
} }

View File

@ -1257,15 +1257,13 @@ public class ResourceProviderR4Test extends BaseResourceProviderR4Test {
myDaoConfig.setAllowMultipleDelete(true); myDaoConfig.setAllowMultipleDelete(true);
//@formatter:off MethodOutcome response = ourClient
IBaseOperationOutcome response = ourClient
.delete() .delete()
.resourceConditionalByType(Patient.class) .resourceConditionalByType(Patient.class)
.where(Patient.IDENTIFIER.exactly().code(methodName)) .where(Patient.IDENTIFIER.exactly().code(methodName))
.execute(); .execute();
//@formatter:on
String encoded = myFhirCtx.newXmlParser().encodeResourceToString(response); String encoded = myFhirCtx.newXmlParser().encodeResourceToString(response.getOperationOutcome());
ourLog.info(encoded); ourLog.info(encoded);
assertThat(encoded, containsString( assertThat(encoded, containsString(
"<issue><severity value=\"information\"/><code value=\"informational\"/><diagnostics value=\"Successfully deleted 2 resource(s) in ")); "<issue><severity value=\"information\"/><code value=\"informational\"/><diagnostics value=\"Successfully deleted 2 resource(s) in "));
@ -1478,8 +1476,8 @@ public class ResourceProviderR4Test extends BaseResourceProviderR4Test {
p.addName().setFamily("FAM"); p.addName().setFamily("FAM");
IIdType id = ourClient.create().resource(p).execute().getId().toUnqualifiedVersionless(); IIdType id = ourClient.create().resource(p).execute().getId().toUnqualifiedVersionless();
IBaseOperationOutcome resp = ourClient.delete().resourceById(id).execute(); MethodOutcome resp = ourClient.delete().resourceById(id).execute();
OperationOutcome oo = (OperationOutcome) resp; OperationOutcome oo = (OperationOutcome) resp.getOperationOutcome();
assertThat(oo.getIssueFirstRep().getDiagnostics(), startsWith("Successfully deleted 1 resource(s) in ")); assertThat(oo.getIssueFirstRep().getDiagnostics(), startsWith("Successfully deleted 1 resource(s) in "));
} }

View File

@ -40,10 +40,8 @@
</p> </p>
</th:block> </th:block>
<p> <p>
<b style="color: red;"> <b class="text-danger"><span class="glyphicon glyphicon-warning-sign"/></b>
<span class="glyphicon glyphicon-warning-sign"/> <b><span class="text-danger">This is not a production server!</span></b>
This is not a production server!
</b>
Do not store any information here that contains personal health information Do not store any information here that contains personal health information
or any other confidential information. This server will be regularly purged or any other confidential information. This server will be regularly purged
and reloaded with fixed test data. and reloaded with fixed test data.

View File

@ -31,6 +31,7 @@ import ca.uhn.fhir.model.primitive.InstantDt;
import ca.uhn.fhir.model.valueset.BundleTypeEnum; import ca.uhn.fhir.model.valueset.BundleTypeEnum;
import ca.uhn.fhir.parser.IParser; import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.rest.api.Constants; import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum;
import ca.uhn.fhir.rest.api.EncodingEnum; import ca.uhn.fhir.rest.api.EncodingEnum;
import ca.uhn.fhir.rest.api.PreferHeader; import ca.uhn.fhir.rest.api.PreferHeader;
import ca.uhn.fhir.rest.api.PreferReturnEnum; import ca.uhn.fhir.rest.api.PreferReturnEnum;
@ -78,6 +79,7 @@ public class RestfulServerUtils {
private static final HashSet<String> TEXT_ENCODE_ELEMENTS = new HashSet<>(Arrays.asList("*.text", "*.id", "*.meta", "*.(mandatory)")); private static final HashSet<String> TEXT_ENCODE_ELEMENTS = new HashSet<>(Arrays.asList("*.text", "*.id", "*.meta", "*.(mandatory)"));
private static Map<FhirVersionEnum, FhirContext> myFhirContextMap = Collections.synchronizedMap(new HashMap<>()); private static Map<FhirVersionEnum, FhirContext> myFhirContextMap = Collections.synchronizedMap(new HashMap<>());
private static EnumSet<RestOperationTypeEnum> ourOperationsWhichAllowPreferHeader = EnumSet.of(RestOperationTypeEnum.CREATE, RestOperationTypeEnum.UPDATE, RestOperationTypeEnum.PATCH);
private enum NarrativeModeEnum { private enum NarrativeModeEnum {
NORMAL, ONLY, SUPPRESS; NORMAL, ONLY, SUPPRESS;
@ -696,59 +698,55 @@ public class RestfulServerUtils {
return retVal; return retVal;
} }
private static EnumSet<RestOperationTypeEnum> ourOperationsWhichAllowPreferHeader = EnumSet.of(RestOperationTypeEnum.CREATE, RestOperationTypeEnum.UPDATE, RestOperationTypeEnum.PATCH);
public static boolean respectPreferHeader(RestOperationTypeEnum theRestOperationType) { public static boolean respectPreferHeader(RestOperationTypeEnum theRestOperationType) {
return ourOperationsWhichAllowPreferHeader.contains(theRestOperationType); return ourOperationsWhichAllowPreferHeader.contains(theRestOperationType);
} }
@Nonnull @Nonnull
public static PreferHeader parsePreferHeader(IRestfulServer<?> theServer, String theValue) { public static PreferHeader parsePreferHeader(IRestfulServer<?> theServer, String theValue) {
PreferHeader retVal = new PreferHeader(); PreferHeader retVal = new PreferHeader();
if (isNotBlank(theValue)) { if (isNotBlank(theValue)) {
StringTokenizer tok = new StringTokenizer(theValue, ";"); StringTokenizer tok = new StringTokenizer(theValue, ";");
while (tok.hasMoreTokens()) { while (tok.hasMoreTokens()) {
String next = trim(tok.nextToken()); String next = trim(tok.nextToken());
int eqIndex = next.indexOf('='); int eqIndex = next.indexOf('=');
String key; String key;
String value; String value;
if (eqIndex == -1 || eqIndex >= next.length() - 2) { if (eqIndex == -1 || eqIndex >= next.length() - 2) {
key = next; key = next;
value = ""; value = "";
} else { } else {
key = next.substring(0, eqIndex).trim(); key = next.substring(0, eqIndex).trim();
value = next.substring(eqIndex + 1).trim(); value = next.substring(eqIndex + 1).trim();
} }
if (key.equals(Constants.HEADER_PREFER_RETURN)) { if (key.equals(Constants.HEADER_PREFER_RETURN)) {
if (value.length() < 2) { if (value.length() < 2) {
continue; continue;
} }
if ('"' == value.charAt(0) && '"' == value.charAt(value.length() - 1)) { if ('"' == value.charAt(0) && '"' == value.charAt(value.length() - 1)) {
value = value.substring(1, value.length() - 1); value = value.substring(1, value.length() - 1);
} }
retVal.setReturn(PreferReturnEnum.fromHeaderValue(value)); retVal.setReturn(PreferReturnEnum.fromHeaderValue(value));
} else if (key.equals(Constants.HEADER_PREFER_RESPOND_ASYNC)) { } else if (key.equals(Constants.HEADER_PREFER_RESPOND_ASYNC)) {
retVal.setRespondAsync(true); retVal.setRespondAsync(true);
} }
} }
} }
if (retVal.getReturn() == null && theServer != null && theServer.getDefaultPreferReturn() != null) { if (retVal.getReturn() == null && theServer != null && theServer.getDefaultPreferReturn() != null) {
retVal.setReturn(theServer.getDefaultPreferReturn()); retVal.setReturn(theServer.getDefaultPreferReturn());
} }
return retVal; return retVal;
} }
public static boolean prettyPrintResponse(IRestfulServerDefaults theServer, RequestDetails theRequest) { public static boolean prettyPrintResponse(IRestfulServerDefaults theServer, RequestDetails theRequest) {
@ -772,12 +770,12 @@ public class RestfulServerUtils {
} }
public static Object streamResponseAsResource(IRestfulServerDefaults theServer, IBaseResource theResource, Set<SummaryEnum> theSummaryMode, int stausCode, boolean theAddContentLocationHeader, public static Object streamResponseAsResource(IRestfulServerDefaults theServer, IBaseResource theResource, Set<SummaryEnum> theSummaryMode, int stausCode, boolean theAddContentLocationHeader,
boolean respondGzip, RequestDetails theRequestDetails) throws IOException { boolean respondGzip, RequestDetails theRequestDetails) throws IOException {
return streamResponseAsResource(theServer, theResource, theSummaryMode, stausCode, null, theAddContentLocationHeader, respondGzip, theRequestDetails, null, null); return streamResponseAsResource(theServer, theResource, theSummaryMode, stausCode, null, theAddContentLocationHeader, respondGzip, theRequestDetails, null, null);
} }
public static Object streamResponseAsResource(IRestfulServerDefaults theServer, IBaseResource theResource, Set<SummaryEnum> theSummaryMode, int theStatusCode, String theStatusMessage, public static Object streamResponseAsResource(IRestfulServerDefaults theServer, IBaseResource theResource, Set<SummaryEnum> theSummaryMode, int theStatusCode, String theStatusMessage,
boolean theAddContentLocationHeader, boolean respondGzip, RequestDetails theRequestDetails, IIdType theOperationResourceId, IPrimitiveType<Date> theOperationResourceLastUpdated) boolean theAddContentLocationHeader, boolean respondGzip, RequestDetails theRequestDetails, IIdType theOperationResourceId, IPrimitiveType<Date> theOperationResourceLastUpdated)
throws IOException { throws IOException {
IRestfulResponse response = theRequestDetails.getResponse(); IRestfulResponse response = theRequestDetails.getResponse();
@ -954,5 +952,22 @@ public class RestfulServerUtils {
} }
/**
* @since 5.0.0
*/
public static DeleteCascadeModeEnum extractDeleteCascadeParameter(RequestDetails theRequest) {
if (theRequest != null) {
String[] cascadeParameters = theRequest.getParameters().get(Constants.PARAMETER_CASCADE_DELETE);
if (cascadeParameters != null && Arrays.asList(cascadeParameters).contains(Constants.CASCADE_DELETE)) {
return DeleteCascadeModeEnum.DELETE;
}
String cascadeHeader = theRequest.getHeader(Constants.HEADER_CASCADE);
if (Constants.CASCADE_DELETE.equals(cascadeHeader)) {
return DeleteCascadeModeEnum.DELETE;
}
}
return DeleteCascadeModeEnum.NONE;
}
} }

View File

@ -377,7 +377,7 @@ public class GenericClientR4Test extends BaseGenericClientR4Test {
}); });
IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir"); IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir");
IBaseOperationOutcome outcome; MethodOutcome outcome;
// Regular delete // Regular delete
outcome = client outcome = client
@ -1629,7 +1629,6 @@ public class GenericClientR4Test extends BaseGenericClientR4Test {
idx++; idx++;
} }
@SuppressWarnings("deprecation")
@Test @Test
public void testSearchByQuantity() throws Exception { public void testSearchByQuantity() throws Exception {
ArgumentCaptor<HttpUriRequest> capt = prepareClientForSearchResponse(); ArgumentCaptor<HttpUriRequest> capt = prepareClientForSearchResponse();

View File

@ -5,6 +5,7 @@ import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.*; import ca.uhn.fhir.rest.api.*;
import ca.uhn.fhir.rest.client.api.IGenericClient; import ca.uhn.fhir.rest.client.api.IGenericClient;
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
import ca.uhn.fhir.rest.client.exceptions.FhirClientConnectionException;
import ca.uhn.fhir.rest.client.exceptions.NonFhirResponseException; import ca.uhn.fhir.rest.client.exceptions.NonFhirResponseException;
import ca.uhn.fhir.rest.client.impl.BaseClient; import ca.uhn.fhir.rest.client.impl.BaseClient;
import ca.uhn.fhir.rest.client.impl.GenericClient; import ca.uhn.fhir.rest.client.impl.GenericClient;
@ -386,23 +387,81 @@ public class GenericClientTest {
IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir"); IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir");
OperationOutcome outcome = (OperationOutcome) client.delete().resourceById("Patient", "123") MethodOutcome outcome = client
.withAdditionalHeader("myHeaderName", "myHeaderValue").execute(); .delete()
.resourceById("Patient", "123")
.withAdditionalHeader("myHeaderName", "myHeaderValue")
.execute();
oo = (OperationOutcome) outcome.getOperationOutcome();
assertEquals("http://example.com/fhir/Patient/123", capt.getValue().getURI().toString()); assertEquals("http://example.com/fhir/Patient/123", capt.getValue().getURI().toString());
assertEquals("DELETE", capt.getValue().getMethod()); assertEquals("DELETE", capt.getValue().getMethod());
Assert.assertEquals("testDelete01", outcome.getIssueFirstRep().getLocation().get(0).getValue()); Assert.assertEquals("testDelete01", oo.getIssueFirstRep().getLocation().get(0).getValue());
assertEquals("myHeaderValue", capt.getValue().getFirstHeader("myHeaderName").getValue()); assertEquals("myHeaderValue", capt.getValue().getFirstHeader("myHeaderName").getValue());
}
@Test
public void testDeleteInvalidResponse() throws Exception {
OperationOutcome oo = new OperationOutcome();
oo.addIssue().addLocation("testDelete01");
String ooStr = ourCtx.newXmlParser().encodeResourceToString(oo);
ArgumentCaptor<HttpUriRequest> capt = ArgumentCaptor.forClass(HttpUriRequest.class);
when(myHttpClient.execute(capt.capture())).thenReturn(myHttpResponse);
when(myHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 201, "OK"));
when(myHttpResponse.getAllHeaders()).thenReturn(new Header[]{new BasicHeader(Constants.HEADER_LOCATION, "/Patient/44/_history/22")});
when(myHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML + "; charset=UTF-8"));
when(myHttpResponse.getEntity().getContent()).thenReturn(new ReaderInputStream(new StringReader("LKJHLKJGLKJKLL"), StandardCharsets.UTF_8)); when(myHttpResponse.getEntity().getContent()).thenReturn(new ReaderInputStream(new StringReader("LKJHLKJGLKJKLL"), StandardCharsets.UTF_8));
outcome = (OperationOutcome) client.delete().resourceById(new IdType("Location", "123", "456")).prettyPrint().encodedJson().execute();
assertEquals("http://example.com/fhir/Location/123?_pretty=true", capt.getAllValues().get(1).getURI().toString()); IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir");
assertEquals("DELETE", capt.getValue().getMethod());
Assert.assertEquals(null, outcome); // Try with invalid response
try {
client
.delete()
.resourceById(new IdType("Location", "123", "456"))
.prettyPrint()
.encodedJson()
.execute();
} catch (FhirClientConnectionException e) {
assertEquals(0, e.getStatusCode());
assertThat(e.getMessage(), containsString("Failed to parse response from server when performing DELETE to URL"));
}
} }
@Test
public void testDeleteNoResponse() throws Exception {
OperationOutcome oo = new OperationOutcome();
oo.addIssue().addLocation("testDelete01");
String ooStr = ourCtx.newXmlParser().encodeResourceToString(oo);
ArgumentCaptor<HttpUriRequest> capt = ArgumentCaptor.forClass(HttpUriRequest.class);
when(myHttpClient.execute(capt.capture())).thenReturn(myHttpResponse);
when(myHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 201, "OK"));
when(myHttpResponse.getAllHeaders()).thenReturn(new Header[]{new BasicHeader(Constants.HEADER_LOCATION, "/Patient/44/_history/22")});
when(myHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML + "; charset=UTF-8"));
when(myHttpResponse.getEntity().getContent()).thenReturn(new ReaderInputStream(new StringReader(ooStr), StandardCharsets.UTF_8));
IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir");
MethodOutcome outcome = client
.delete()
.resourceById("Patient", "123")
.withAdditionalHeader("myHeaderName", "myHeaderValue")
.execute();
oo = (OperationOutcome) outcome.getOperationOutcome();
assertEquals("http://example.com/fhir/Patient/123", capt.getValue().getURI().toString());
assertEquals("DELETE", capt.getValue().getMethod());
Assert.assertEquals("testDelete01", oo.getIssueFirstRep().getLocation().get(0).getValue());
assertEquals("myHeaderValue", capt.getValue().getFirstHeader("myHeaderName").getValue());
}
@Test @Test
public void testHistory() throws Exception { public void testHistory() throws Exception {
@ -413,12 +472,8 @@ public class GenericClientTest {
when(myHttpClient.execute(capt.capture())).thenReturn(myHttpResponse); when(myHttpClient.execute(capt.capture())).thenReturn(myHttpResponse);
when(myHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK")); when(myHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
when(myHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML + "; charset=UTF-8")); when(myHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML + "; charset=UTF-8"));
when(myHttpResponse.getEntity().getContent()).thenAnswer(new Answer<InputStream>() { when(myHttpResponse.getEntity().getContent()).thenAnswer(t ->
@Override new ReaderInputStream(new StringReader(msg), StandardCharsets.UTF_8));
public InputStream answer(InvocationOnMock theInvocation) throws Throwable {
return new ReaderInputStream(new StringReader(msg), StandardCharsets.UTF_8);
}
});
IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir"); IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir");
@ -428,7 +483,7 @@ public class GenericClientTest {
response = client response = client
.history() .history()
.onServer() .onServer()
.andReturnBundle(Bundle.class) .returnBundle(Bundle.class)
.withAdditionalHeader("myHeaderName", "myHeaderValue") .withAdditionalHeader("myHeaderName", "myHeaderValue")
.execute(); .execute();
assertEquals("http://example.com/fhir/_history", capt.getAllValues().get(idx).getURI().toString()); assertEquals("http://example.com/fhir/_history", capt.getAllValues().get(idx).getURI().toString());
@ -439,7 +494,7 @@ public class GenericClientTest {
response = client response = client
.history() .history()
.onType(Patient.class) .onType(Patient.class)
.andReturnBundle(Bundle.class) .returnBundle(Bundle.class)
.withAdditionalHeader("myHeaderName", "myHeaderValue1") .withAdditionalHeader("myHeaderName", "myHeaderValue1")
.withAdditionalHeader("myHeaderName", "myHeaderValue2") .withAdditionalHeader("myHeaderName", "myHeaderValue2")
.execute(); .execute();

View File

@ -603,6 +603,14 @@
<id>dgileadi</id> <id>dgileadi</id>
<name>David Gileadi</name> <name>David Gileadi</name>
</developer> </developer>
<developer>
<id>ibrohimislam</id>
<name>Ibrohim Kholilul Islam</name>
</developer>
<developer>
<id>mkucharek</id>
<name>Maciej Kucharek</name>
</developer>
</developers> </developers>
<licenses> <licenses>