Add config options for default Prefer header and _total param on server

This commit is contained in:
James Agnew 2019-01-04 16:12:45 -05:00
parent 8c87c7c089
commit 5b8fee869e
16 changed files with 402 additions and 189 deletions

View File

@ -1305,9 +1305,6 @@ public abstract class BaseHapiFhirDao<T extends IBaseResource> implements IDao,
changed = populateResourceIntoEntity(theRequest, theResource, theEntity, true);
// FIXME: remove
ourLog.info("** Updated setting to: " + new InstantType(theUpdateTime).getValueAsString());
theEntity.setUpdated(theUpdateTime);
if (theResource instanceof IResource) {
theEntity.setLanguage(((IResource) theResource).getLanguage().getValue());
@ -1322,11 +1319,7 @@ public abstract class BaseHapiFhirDao<T extends IBaseResource> implements IDao,
changed = populateResourceIntoEntity(theRequest, theResource, theEntity, false);
// FIXME: remove
ourLog.info("** Updated setting to: " + new InstantType(theUpdateTime).getValueAsString());
theEntity.setUpdated(theUpdateTime);
// theEntity.setLanguage(theResource.getLanguage().getValue());
theEntity.setIndexStatus(null);
}

View File

@ -148,7 +148,6 @@ public abstract class BaseHapiFhirResourceDao<T extends IBaseResource> extends B
theResource.setId(UUID.randomUUID().toString());
}
// FIXME: this is where one date is created
return doCreate(theResource, theIfNoneExist, thePerformIndexing, theUpdateTimestamp, theRequestDetails);
}

View File

@ -4,6 +4,7 @@ import ca.uhn.fhir.jpa.model.entity.ModelConfig;
import ca.uhn.fhir.jpa.model.entity.ResourceEncodingEnum;
import ca.uhn.fhir.jpa.search.warm.WarmCacheEntry;
import ca.uhn.fhir.jpa.searchparam.SearchParamConstants;
import ca.uhn.fhir.rest.api.SearchTotalModeEnum;
import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.Sets;
@ -25,9 +26,9 @@ import java.util.*;
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -98,6 +99,7 @@ public class DaoConfig {
private boolean myUniqueIndexesEnabled = true;
private boolean myUniqueIndexesCheckedBeforeSave = true;
private boolean myEnforceReferentialIntegrityOnWrite = true;
private SearchTotalModeEnum myDefaultTotalMode = null;
private int myEverythingIncludesFetchPageSize = 50;
/**
* update setter javadoc if default changes
@ -141,7 +143,6 @@ public class DaoConfig {
private boolean myDisableHashBasedSearches;
private boolean myEnableInMemorySubscriptionMatching = true;
private ClientIdStrategyEnum myResourceClientIdStrategy = ClientIdStrategyEnum.ALPHANUMERIC;
/**
* Constructor
*/
@ -159,6 +160,30 @@ public class DaoConfig {
}
}
/**
* If a non-null value is supplied (default is <code>null</code>), a default
* for the <code>_total</code> parameter may be specified here. For example,
* setting this value to {@link SearchTotalModeEnum#ACCURATE} will force a
* count to always be calculated for all searches. This can have a performance impact
* since it means that a count query will always be performed, but this is desirable
* for some solutions.
*/
public SearchTotalModeEnum getDefaultTotalMode() {
return myDefaultTotalMode;
}
/**
* If a non-null value is supplied (default is <code>null</code>), a default
* for the <code>_total</code> parameter may be specified here. For example,
* setting this value to {@link SearchTotalModeEnum#ACCURATE} will force a
* count to always be calculated for all searches. This can have a performance impact
* since it means that a count query will always be performed, but this is desirable
* for some solutions.
*/
public void setDefaultTotalMode(SearchTotalModeEnum theDefaultTotalMode) {
myDefaultTotalMode = theDefaultTotalMode;
}
/**
* Returns a set of searches that should be kept "warm", meaning that
* searches will periodically be performed in the background to
@ -492,18 +517,6 @@ public class DaoConfig {
return myInterceptors;
}
public void registerInterceptor(IServerInterceptor theInterceptor) {
Validate.notNull(theInterceptor, "Interceptor can not be null");
if (!myInterceptors.contains(theInterceptor)) {
myInterceptors.add(theInterceptor);
}
}
public void unregisterInterceptor(IServerInterceptor theInterceptor) {
Validate.notNull(theInterceptor, "Interceptor can not be null");
myInterceptors.remove(theInterceptor);
}
/**
* This may be used to optionally register server interceptors directly against the DAOs.
*/
@ -521,6 +534,18 @@ public class DaoConfig {
}
}
public void registerInterceptor(IServerInterceptor theInterceptor) {
Validate.notNull(theInterceptor, "Interceptor can not be null");
if (!myInterceptors.contains(theInterceptor)) {
myInterceptors.add(theInterceptor);
}
}
public void unregisterInterceptor(IServerInterceptor theInterceptor) {
Validate.notNull(theInterceptor, "Interceptor can not be null");
myInterceptors.remove(theInterceptor);
}
/**
* See {@link #setMaximumExpansionSize(int)}
*/
@ -1477,7 +1502,6 @@ public class DaoConfig {
/**
* This setting indicates which subscription channel types are supported by the server. Any subscriptions submitted
* to the server matching these types will be activated.
*
*/
public DaoConfig addSupportedSubscriptionType(Subscription.SubscriptionChannelType theSubscriptionChannelType) {
myModelConfig.addSupportedSubscriptionType(theSubscriptionChannelType);
@ -1487,7 +1511,6 @@ public class DaoConfig {
/**
* This setting indicates which subscription channel types are supported by the server. Any subscriptions submitted
* to the server matching these types will be activated.
*
*/
public Set<Subscription.SubscriptionChannelType> getSupportedSubscriptionTypes() {
return myModelConfig.getSupportedSubscriptionTypes();
@ -1515,7 +1538,6 @@ public class DaoConfig {
}
public enum IndexEnabledEnum {
ENABLED,
DISABLED

View File

@ -9,9 +9,9 @@ package ca.uhn.fhir.jpa.search;
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -103,9 +103,19 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
private IPagingProvider myPagingProvider;
private int mySyncSize = DEFAULT_SYNC_SIZE;
/** Set in {@link #start()} */
/**
* Set in {@link #start()}
*/
private boolean myCustomIsolationSupported;
/**
* Constructor
*/
public SearchCoordinatorSvcImpl() {
CustomizableThreadFactory threadFactory = new CustomizableThreadFactory("search_coord_");
myExecutor = Executors.newCachedThreadPool(threadFactory);
}
@PostConstruct
public void start() {
if (myManagedTxManager instanceof JpaTransactionManager) {
@ -119,14 +129,6 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
}
}
/**
* Constructor
*/
public SearchCoordinatorSvcImpl() {
CustomizableThreadFactory threadFactory = new CustomizableThreadFactory("search_coord_");
myExecutor = Executors.newCachedThreadPool(threadFactory);
}
@Override
public void cancelAllActiveSearches() {
for (BaseTask next : myIdToSearchTask.values()) {
@ -466,6 +468,7 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
private List<Long> myPreviouslyAddedResourcePids;
private Integer myMaxResultsToFetch;
private int myCountFetchedDuringThisPass;
/**
* Constructor
*/
@ -763,7 +766,10 @@ public class SearchCoordinatorSvcImpl implements ISearchCoordinatorSvc {
* before doing anything else.
*/
boolean wantOnlyCount = SummaryEnum.COUNT.equals(myParams.getSummaryMode());
boolean wantCount = wantOnlyCount || SearchTotalModeEnum.ACCURATE.equals(myParams.getSearchTotalMode());
boolean wantCount =
wantOnlyCount ||
SearchTotalModeEnum.ACCURATE.equals(myParams.getSearchTotalMode()) ||
(myParams.getSearchTotalMode() == null && SearchTotalModeEnum.ACCURATE.equals(myDaoConfig.getDefaultTotalMode()));
if (wantCount) {
ourLog.trace("Performing count");
ISearchBuilder sb = newSearchBuilder();

View File

@ -61,7 +61,7 @@ public class DaoSubscriptionMatcher implements ISubscriptionMatcher {
ourLog.debug("Subscription check found {} results for query: {}", results.size(), criteria);
return new SubscriptionMatchResult(results.size() > 0);
return new SubscriptionMatchResult(results.size() > 0, "DATABASE");
}
/**

View File

@ -8,6 +8,7 @@ import java.util.List;
import javax.servlet.http.HttpServletRequest;
import ca.uhn.fhir.jpa.dao.BaseHapiFhirDao;
import ca.uhn.fhir.jpa.dao.BaseHapiFhirResourceDao;
import org.hl7.fhir.r4.model.*;
import org.hl7.fhir.r4.model.Observation.ObservationStatus;
@ -341,7 +342,7 @@ public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test {
request = mock(HttpServletRequest.class);
StringAndListParam param;
ourLog.info("Pt1:{} Pt2:{} Obs1:{} Obs2:{} Obs3:{}", new Object[] {ptId1.getIdPart(), ptId2.getIdPart(), obsId1.getIdPart(), obsId2.getIdPart(), obsId3.getIdPart()});
ourLog.info("Pt1:{} Pt2:{} Obs1:{} Obs2:{} Obs3:{}", ptId1.getIdPart(), ptId2.getIdPart(), obsId1.getIdPart(), obsId2.getIdPart(), obsId3.getIdPart());
param = new StringAndListParam();
param.addAnd(new StringOrListParam().addOr(new StringParam("obsvalue1")));
@ -433,7 +434,7 @@ public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test {
request = mock(HttpServletRequest.class);
StringAndListParam param;
ourLog.info("Pt1:{} Pt2:{} Obs1:{} Obs2:{} Obs3:{}", new Object[] {ptId1.getIdPart(), ptId2.getIdPart(), obsId1.getIdPart(), obsId2.getIdPart(), obsId3.getIdPart()});
ourLog.info("Pt1:{} Pt2:{} Obs1:{} Obs2:{} Obs3:{}", ptId1.getIdPart(), ptId2.getIdPart(), obsId1.getIdPart(), obsId2.getIdPart(), obsId3.getIdPart());
param = new StringAndListParam();
param.addAnd(new StringOrListParam().addOr(new StringParam("obsvalue1")));
@ -485,7 +486,7 @@ public class FhirResourceDaoR4SearchFtTest extends BaseJpaR4Test {
*/
@Test
public void testSearchDontReindexForUpdateWithIndexDisabled() {
BaseHapiFhirResourceDao.setDisableIncrementOnUpdateForUnitTest(true);
BaseHapiFhirDao.setDisableIncrementOnUpdateForUnitTest(true);
Patient patient;
SearchParameterMap map;

View File

@ -33,6 +33,7 @@ public class ResourceProviderSummaryModeR4Test extends BaseResourceProviderR4Tes
mySearchCoordinatorSvcRaw.setLoadingThrottleForUnitTests(null);
mySearchCoordinatorSvcRaw.setSyncSizeForUnitTests(SearchCoordinatorSvcImpl.DEFAULT_SYNC_SIZE);
myDaoConfig.setSearchPreFetchThresholds(new DaoConfig().getSearchPreFetchThresholds());
myDaoConfig.setDefaultTotalMode(null);
}
@Override
@ -93,6 +94,24 @@ public class ResourceProviderSummaryModeR4Test extends BaseResourceProviderR4Tes
assertEquals(10, outcome.getEntry().size());
}
/**
* Count and data via config - Should include both a count and the data portions of results
*/
@Test
public void testSearchWithTotalAccurateSpecifiedAsDefault() {
myDaoConfig.setDefaultTotalMode(SearchTotalModeEnum.ACCURATE);
Bundle outcome = ourClient
.search()
.forResource(Patient.class)
.where(Patient.ACTIVE.exactly().code("true"))
.returnBundle(Bundle.class)
.execute();
assertEquals(new Integer(104), outcome.getTotalElement().getValue());
assertEquals(10, outcome.getEntry().size());
}
/**
* No summary mode - Should return the first page of results but not
* have the total available yet
@ -110,6 +129,26 @@ public class ResourceProviderSummaryModeR4Test extends BaseResourceProviderR4Tes
assertEquals(10, outcome.getEntry().size());
}
/**
* No summary mode - Should return the first page of results but not
* have the total available yet
*/
@Test
public void testSearchTotalNoneOverridingDefault() {
myDaoConfig.setDefaultTotalMode(SearchTotalModeEnum.ACCURATE);
Bundle outcome = ourClient
.search()
.forResource(Patient.class)
.where(Patient.ACTIVE.exactly().code("true"))
.totalMode(SearchTotalModeEnum.NONE)
.returnBundle(Bundle.class)
.execute();
assertEquals(null, outcome.getTotalElement().getValue());
assertEquals(10, outcome.getEntry().size());
}
@AfterClass
public static void afterClassClearContext() {
TestUtil.clearAllStaticFieldsForUnitTest();

View File

@ -176,6 +176,27 @@ public class RestHookTestR4Test extends BaseSubscriptionsR4Test {
}
@Test
public void testRepeatedDeliveries() throws Exception {
String payload = "application/fhir+json";
String code = "1000000050";
String criteria1 = "Observation?";
createSubscription(criteria1, payload);
waitForActivatedSubscriptionCount(1);
for (int i = 0; i < 100; i++) {
Observation observation = new Observation();
observation.getIdentifierFirstRep().setSystem("foo").setValue("ID" + i);
observation.getCode().addCoding().setCode(code).setSystem("SNOMED-CT");
observation.setStatus(Observation.ObservationStatus.FINAL);
myObservationDao.create(observation);
}
waitForSize(100, ourUpdatedObservations);
}
@Test
public void testActiveSubscriptionShouldntReActivate() throws Exception {

View File

@ -42,6 +42,7 @@ import java.util.function.Predicate;
@Service
public class CriteriaResourceMatcher {
public static final String CRITERIA = "CRITERIA";
@Autowired
private MatchUrlService myMatchUrlService;
@Autowired
@ -52,7 +53,7 @@ public class CriteriaResourceMatcher {
try {
searchParameterMap = myMatchUrlService.translateMatchUrl(theCriteria, theResourceDefinition);
} catch (UnsupportedOperationException e) {
return new SubscriptionMatchResult(theCriteria);
return new SubscriptionMatchResult(theCriteria, CRITERIA);
}
searchParameterMap.clean();
if (searchParameterMap.getLastUpdated() != null) {
@ -67,13 +68,13 @@ public class CriteriaResourceMatcher {
return result;
}
}
return SubscriptionMatchResult.MATCH;
return new SubscriptionMatchResult(true, CRITERIA);
}
// This method is modelled from SearchBuilder.searchForIdsWithAndOr()
private SubscriptionMatchResult matchIdsWithAndOr(String theParamName, List<List<? extends IQueryParameterType>> theAndOrParams, RuntimeResourceDefinition theResourceDefinition, ResourceIndexedSearchParams theSearchParams) {
if (theAndOrParams.isEmpty()) {
return SubscriptionMatchResult.MATCH;
return new SubscriptionMatchResult(true, CRITERIA);
}
if (hasQualifiers(theAndOrParams)) {
@ -91,19 +92,19 @@ public class CriteriaResourceMatcher {
}
if (theParamName.equals(IAnyResource.SP_RES_ID)) {
return new SubscriptionMatchResult(theParamName);
return new SubscriptionMatchResult(theParamName, CRITERIA);
} else if (theParamName.equals(IAnyResource.SP_RES_LANGUAGE)) {
return new SubscriptionMatchResult(theParamName);
return new SubscriptionMatchResult(theParamName, CRITERIA);
} else if (theParamName.equals(Constants.PARAM_HAS)) {
return new SubscriptionMatchResult(theParamName);
return new SubscriptionMatchResult(theParamName, CRITERIA);
} else if (theParamName.equals(Constants.PARAM_TAG) || theParamName.equals(Constants.PARAM_PROFILE) || theParamName.equals(Constants.PARAM_SECURITY)) {
return new SubscriptionMatchResult(theParamName);
return new SubscriptionMatchResult(theParamName, CRITERIA);
} else {
@ -123,16 +124,16 @@ public class CriteriaResourceMatcher {
case URI:
case DATE:
case REFERENCE:
return new SubscriptionMatchResult(theAndOrParams.stream().anyMatch(nextAnd -> matchParams(theResourceName, theParamName, theParamDef, nextAnd, theSearchParams)));
return new SubscriptionMatchResult(theAndOrParams.stream().anyMatch(nextAnd -> matchParams(theResourceName, theParamName, theParamDef, nextAnd, theSearchParams)), CRITERIA);
case COMPOSITE:
case HAS:
case SPECIAL:
default:
return new SubscriptionMatchResult(theParamName);
return new SubscriptionMatchResult(theParamName, CRITERIA);
}
} else {
if (Constants.PARAM_CONTENT.equals(theParamName) || Constants.PARAM_TEXT.equals(theParamName)) {
return new SubscriptionMatchResult(theParamName);
return new SubscriptionMatchResult(theParamName, CRITERIA);
} else {
throw new InvalidRequestException("Unknown search parameter " + theParamName + " for resource type " + theResourceName);
}

View File

@ -9,9 +9,9 @@ package ca.uhn.fhir.jpa.subscription.module.matcher;
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -21,34 +21,34 @@ package ca.uhn.fhir.jpa.subscription.module.matcher;
*/
public class SubscriptionMatchResult {
// This could be an enum, but we may want to include details about unsupported matches in the future
public static final SubscriptionMatchResult MATCH = new SubscriptionMatchResult(true);
public static final SubscriptionMatchResult NO_MATCH = new SubscriptionMatchResult(false);
private final boolean myMatch;
private final boolean mySupported;
private final String myUnsupportedParameter;
private final String myUnsupportedReason;
private final String myMatcherShortName;
public SubscriptionMatchResult(boolean theMatch) {
public SubscriptionMatchResult(boolean theMatch, String theMatcherShortName) {
this.myMatch = theMatch;
this.mySupported = true;
this.myUnsupportedParameter = null;
this.myUnsupportedReason = null;
this.myMatcherShortName = theMatcherShortName;
}
public SubscriptionMatchResult(String theUnsupportedParameter) {
public SubscriptionMatchResult(String theUnsupportedParameter, String theMatcherShortName) {
this.myMatch = false;
this.mySupported = false;
this.myUnsupportedParameter = theUnsupportedParameter;
this.myUnsupportedReason = "Parameter not supported";
this.myMatcherShortName = theMatcherShortName;
}
public SubscriptionMatchResult(String theUnsupportedParameter, String theUnsupportedReason) {
public SubscriptionMatchResult(String theUnsupportedParameter, String theUnsupportedReason, String theMatcherShortName) {
this.myMatch = false;
this.mySupported = false;
this.myUnsupportedParameter = theUnsupportedParameter;
this.myUnsupportedReason = theUnsupportedReason;
this.myMatcherShortName = theMatcherShortName;
}
public boolean supported() {
@ -62,4 +62,12 @@ public class SubscriptionMatchResult {
public String getUnsupportedReason() {
return "Parameter: <" + myUnsupportedParameter + "> Reason: " + myUnsupportedReason;
}
/**
* Returns a short name of the matcher that generated this
* response, for use in logging
*/
public String matcherShortName() {
return myMatcherShortName;
}
}

View File

@ -5,6 +5,7 @@ import ca.uhn.fhir.jpa.subscription.module.ResourceModifiedMessage;
import ca.uhn.fhir.jpa.subscription.module.cache.ActiveSubscription;
import ca.uhn.fhir.jpa.subscription.module.cache.SubscriptionRegistry;
import ca.uhn.fhir.jpa.subscription.module.matcher.ISubscriptionMatcher;
import ca.uhn.fhir.jpa.subscription.module.matcher.SubscriptionMatchResult;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IIdType;
import org.slf4j.Logger;
@ -113,11 +114,12 @@ public class SubscriptionMatchingSubscriber implements MessageHandler {
continue;
}
if (!mySubscriptionMatcher.match(nextCriteriaString, theMsg).matched()) {
SubscriptionMatchResult matchResult = mySubscriptionMatcher.match(nextCriteriaString, theMsg);
if (!matchResult.matched()) {
continue;
}
ourLog.debug("Found match: queueing rest-hook notification for resource: {}", id.toUnqualifiedVersionless().getValue());
ourLog.info("Subscription {} was matched by resource {} using matcher {}", nextActiveSubscription.getSubscription().getIdElement(myFhirContext).getValue(), id.toUnqualifiedVersionless().getValue(), matchResult.matcherShortName());
ResourceDeliveryMessage deliveryMsg = new ResourceDeliveryMessage();
deliveryMsg.setPayload(myFhirContext, theMsg.getNewPayload(myFhirContext));
@ -130,7 +132,7 @@ public class SubscriptionMatchingSubscriber implements MessageHandler {
if (deliveryChannel != null) {
deliveryChannel.send(wrappedMsg);
} else {
ourLog.warn("Do not have deliovery channel for subscription {}", nextActiveSubscription.getIdElement(myFhirContext));
ourLog.warn("Do not have delivery channel for subscription {}", nextActiveSubscription.getIdElement(myFhirContext));
}
}
}

View File

@ -1,6 +1,7 @@
package ca.uhn.fhir.rest.api.server;
import ca.uhn.fhir.context.api.BundleInclusionRule;
import ca.uhn.fhir.rest.api.PreferReturnEnum;
import ca.uhn.fhir.rest.server.IPagingProvider;
import ca.uhn.fhir.rest.server.IRestfulServerDefaults;
@ -30,4 +31,7 @@ public interface IRestfulServer<T extends RequestDetails> extends IRestfulServer
BundleInclusionRule getBundleInclusionRule();
void setDefaultPreferReturn(PreferReturnEnum theDefaultPreferReturn);
PreferReturnEnum getDefaultPreferReturn();
}

View File

@ -9,9 +9,9 @@ package ca.uhn.fhir.rest.server;
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -31,10 +31,7 @@ import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.rest.annotation.Destroy;
import ca.uhn.fhir.rest.annotation.IdParam;
import ca.uhn.fhir.rest.annotation.Initialize;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.EncodingEnum;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.RequestTypeEnum;
import ca.uhn.fhir.rest.api.*;
import ca.uhn.fhir.rest.api.server.IFhirVersionServer;
import ca.uhn.fhir.rest.api.server.IRestfulServer;
import ca.uhn.fhir.rest.api.server.ParseAction;
@ -55,6 +52,8 @@ import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Validate;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ServletException;
import javax.servlet.UnavailableException;
@ -96,8 +95,12 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
*/
public static final String SERVLET_CONTEXT_ATTRIBUTE = "ca.uhn.fhir.rest.server.RestfulServer.servlet_context";
private static final ExceptionHandlingInterceptor DEFAULT_EXCEPTION_HANDLER = new ExceptionHandlingInterceptor();
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(RestfulServer.class);
private static final Logger ourLog = LoggerFactory.getLogger(RestfulServer.class);
private static final long serialVersionUID = 1L;
/**
* Default value for {@link #setDefaultPreferReturn(PreferReturnEnum)}
*/
public static final PreferReturnEnum DEFAULT_PREFER_RETURN = PreferReturnEnum.REPRESENTATION;
private final List<IServerInterceptor> myInterceptors = new ArrayList<>();
private final List<Object> myPlainProviders = new ArrayList<>();
private final List<IResourceProvider> myResourceProviders = new ArrayList<>();
@ -126,6 +129,7 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
private boolean myUseBrowserFriendlyContentTypes;
private ITenantIdentificationStrategy myTenantIdentificationStrategy;
private Date myConformanceDate;
private PreferReturnEnum myDefaultPreferReturn = DEFAULT_PREFER_RETURN;
/**
* Constructor. Note that if no {@link FhirContext} is passed in to the server (either through the constructor, or
@ -823,7 +827,7 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
String completeUrl;
Map<String, String[]> params = null;
if (StringUtils.isNotBlank(theRequest.getQueryString())) {
if (isNotBlank(theRequest.getQueryString())) {
completeUrl = requestUrl + "?" + theRequest.getQueryString();
/*
* By default, we manually parse the request params (the URL params, or the body for
@ -1629,6 +1633,39 @@ public class RestfulServer extends HttpServlet implements IRestfulServer<Servlet
theResponse.getWriter().write(theException.getMessage());
}
/**
* By default, server create/update/patch/transaction methods return a copy of the resource
* as it was stored. This may be overridden by the client using the
* <code>Prefer</code> header.
* <p>
* This setting changes the default behaviour if no Prefer header is supplied by the client.
* The default is {@link PreferReturnEnum#REPRESENTATION}
* </p>
*
* @see <a href="http://hl7.org/fhir/http.html#ops">HL7 FHIR Specification</a> section on the Prefer header
*/
@Override
public PreferReturnEnum getDefaultPreferReturn() {
return myDefaultPreferReturn;
}
/**
* By default, server create/update/patch/transaction methods return a copy of the resource
* as it was stored. This may be overridden by the client using the
* <code>Prefer</code> header.
* <p>
* This setting changes the default behaviour if no Prefer header is supplied by the client.
* The default is {@link PreferReturnEnum#REPRESENTATION}
* </p>
*
* @see <a href="http://hl7.org/fhir/http.html#ops">HL7 FHIR Specification</a> section on the Prefer header
*/
@Override
public void setDefaultPreferReturn(PreferReturnEnum theDefaultPreferReturn) {
Validate.notNull(theDefaultPreferReturn, "theDefaultPreferReturn must not be null");
myDefaultPreferReturn = theDefaultPreferReturn;
}
/**
* Count length of URL string, but treating unescaped sequences (e.g. ' ') as their unescaped equivalent (%20)
*/

View File

@ -9,9 +9,9 @@ package ca.uhn.fhir.rest.server.method;
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*
* http://www.apache.org/licenses/LICENSE-2.0
*
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@ -21,28 +21,9 @@ package ca.uhn.fhir.rest.server.method;
*/
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import ca.uhn.fhir.context.ConfigurationException;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.PreferReturnEnum;
import ca.uhn.fhir.rest.api.RequestTypeEnum;
import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
import ca.uhn.fhir.rest.api.SummaryEnum;
import ca.uhn.fhir.rest.api.*;
import ca.uhn.fhir.rest.api.server.IRestfulResponse;
import ca.uhn.fhir.rest.api.server.IRestfulServer;
import ca.uhn.fhir.rest.api.server.RequestDetails;
@ -52,6 +33,18 @@ import ca.uhn.fhir.rest.server.exceptions.BaseServerResponseException;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.interceptor.IServerInterceptor;
import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
import org.apache.commons.lang3.StringUtils;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IIdType;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.lang.reflect.Method;
import java.util.Collections;
import java.util.EnumSet;
import java.util.Set;
abstract class BaseOutcomeReturningMethodBinding extends BaseMethodBinding<MethodOutcome> {
static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(BaseOutcomeReturningMethodBinding.class);
@ -198,19 +191,24 @@ abstract class BaseOutcomeReturningMethodBinding extends BaseMethodBinding<Metho
}
if (allowPrefer) {
outcome = resource;
String prefer = theRequest.getHeader(Constants.HEADER_PREFER);
PreferReturnEnum preferReturn = RestfulServerUtils.parsePreferHeader(prefer);
if (preferReturn != null) {
if (preferReturn == PreferReturnEnum.MINIMAL) {
if (preferReturn == null) {
preferReturn = theServer.getDefaultPreferReturn();
}
switch (preferReturn) {
case REPRESENTATION:
outcome = resource;
break;
case MINIMAL:
outcome = null;
}
else {
if (preferReturn == PreferReturnEnum.OPERATION_OUTCOME) {
outcome = originalOutcome;
}
}
}
break;
case OPERATION_OUTCOME:
outcome = originalOutcome;
break;
}
}
ResponseDetails responseDetails = new ResponseDetails();

View File

@ -1,19 +1,16 @@
package ca.uhn.fhir.rest.server;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.stringContainsInOrder;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertThat;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.*;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.PreferReturnEnum;
import ca.uhn.fhir.rest.client.MyPatientWithExtensions;
import ca.uhn.fhir.util.PortUtil;
import ca.uhn.fhir.util.TestUtil;
import org.apache.commons.io.IOUtils;
import org.apache.http.HttpResponse;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.entity.ContentType;
@ -24,37 +21,35 @@ import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.hl7.fhir.instance.model.api.IBaseOperationOutcome;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.DateType;
import org.hl7.fhir.r4.model.IdType;
import org.hl7.fhir.r4.model.OperationOutcome;
import org.hl7.fhir.r4.model.OperationOutcome.OperationOutcomeIssueComponent;
import org.hl7.fhir.r4.model.Patient;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import org.junit.*;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.Create;
import ca.uhn.fhir.rest.annotation.IdParam;
import ca.uhn.fhir.rest.annotation.Read;
import ca.uhn.fhir.rest.annotation.ResourceParam;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.client.MyPatientWithExtensions;
import ca.uhn.fhir.util.PortUtil;
import ca.uhn.fhir.util.TestUtil;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import static org.hamcrest.Matchers.*;
import static org.junit.Assert.*;
public class CreateR4Test {
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(CreateR4Test.class);
public static IBaseOperationOutcome ourReturnOo;
public static OperationOutcome ourReturnOo;
private static CloseableHttpClient ourClient;
private static FhirContext ourCtx = FhirContext.forR4();
private static int ourPort;
private static Server ourServer;
private static RestfulServer ourServlet;
@After
public void after() {
ourServlet.setDefaultPreferReturn(RestfulServer.DEFAULT_PREFER_RETURN);
}
@Before
public void before() {
@ -122,12 +117,12 @@ public class CreateR4Test {
assertThat(responseContent, containsString("DIAG"));
}
@Test
public void testCreateReturnsRepresentation() throws Exception {
ourReturnOo = new OperationOutcome().addIssue(new OperationOutcomeIssueComponent().setDiagnostics("DIAG"));
String expectedResponseContent = "{\"resourceType\":\"Patient\",\"id\":\"1\",\"meta\":{\"versionId\":\"1\"},\"gender\":\"male\"}";
HttpPost httpPost = new HttpPost("http://localhost:" + ourPort + "/Patient");
httpPost.setEntity(new StringEntity("{\"resourceType\":\"Patient\", \"gender\":\"male\"}", ContentType.parse("application/fhir+json; charset=utf-8")));
HttpResponse status = ourClient.execute(httpPost);
@ -219,6 +214,79 @@ public class CreateR4Test {
}
@Test
public void testCreatePreferDefaultRepresentation() throws Exception {
ourReturnOo = new OperationOutcome();
ourReturnOo.addIssue().setDiagnostics("FOO");
Patient p = new Patient();
p.setActive(true);
String body = ourCtx.newJsonParser().encodeResourceToString(p);
HttpPost httpPost;
httpPost = new HttpPost("http://localhost:" + ourPort + "/Patient");
httpPost.setEntity(new StringEntity(body, ContentType.parse("application/fhir+json; charset=utf-8")));
try (CloseableHttpResponse status = ourClient.execute(httpPost)) {
assertEquals(201, status.getStatusLine().getStatusCode());
assertEquals("application/fhir+json;charset=utf-8", status.getFirstHeader(Constants.HEADER_CONTENT_TYPE).getValue());
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info("Response was:\n{}", responseContent);
assertThat(responseContent, containsString("\"resourceType\":\"Patient\""));
}
}
@Test
public void testCreatePreferDefaultOperationOutcome() throws Exception {
ourReturnOo = new OperationOutcome();
ourReturnOo.addIssue().setDiagnostics("FOO");
Patient p = new Patient();
p.setActive(true);
String body = ourCtx.newJsonParser().encodeResourceToString(p);
HttpPost httpPost = new HttpPost("http://localhost:" + ourPort + "/Patient");
httpPost.setEntity(new StringEntity(body, ContentType.parse("application/fhir+json; charset=utf-8")));
ourServlet.setDefaultPreferReturn(PreferReturnEnum.OPERATION_OUTCOME);
try (CloseableHttpResponse status = ourClient.execute(httpPost)) {
assertEquals(201, status.getStatusLine().getStatusCode());
assertEquals("application/fhir+json;charset=utf-8", status.getFirstHeader(Constants.HEADER_CONTENT_TYPE).getValue());
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
ourLog.info("Response was:\n{}", responseContent);
assertThat(responseContent, containsString("\"resourceType\":\"OperationOutcome\""));
}
}
@Test
public void testCreatePreferDefaultMinimal() throws Exception {
ourReturnOo = new OperationOutcome();
ourReturnOo.addIssue().setDiagnostics("FOO");
Patient p = new Patient();
p.setActive(true);
String body = ourCtx.newJsonParser().encodeResourceToString(p);
HttpPost httpPost = new HttpPost("http://localhost:" + ourPort + "/Patient");
httpPost.setEntity(new StringEntity(body, ContentType.parse("application/fhir+json; charset=utf-8")));
ourServlet.setDefaultPreferReturn(PreferReturnEnum.MINIMAL);
try (CloseableHttpResponse status = ourClient.execute(httpPost)) {
assertEquals(201, status.getStatusLine().getStatusCode());
assertNull(status.getFirstHeader(Constants.HEADER_CONTENT_TYPE));
String responseContent = IOUtils.toString(status.getEntity().getContent(), StandardCharsets.UTF_8);
assertThat(responseContent, emptyOrNullString());
}
}
@Test
public void testSearch() throws Exception {
@ -248,36 +316,6 @@ public class CreateR4Test {
assertThat(responseContent, not(containsString("http://hl7.org/fhir/")));
}
@AfterClass
public static void afterClassClearContext() throws Exception {
ourServer.stop();
TestUtil.clearAllStaticFieldsForUnitTest();
}
@BeforeClass
public static void beforeClass() throws Exception {
ourPort = PortUtil.findFreePort();
ourServer = new Server(ourPort);
PatientProviderCreate patientProviderCreate = new PatientProviderCreate();
PatientProviderRead patientProviderRead = new PatientProviderRead();
PatientProviderSearch patientProviderSearch = new PatientProviderSearch();
ServletHandler proxyHandler = new ServletHandler();
RestfulServer servlet = new RestfulServer(ourCtx);
servlet.setResourceProviders(patientProviderCreate, patientProviderRead, patientProviderSearch);
ServletHolder servletHolder = new ServletHolder(servlet);
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
ourServer.start();
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS);
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setConnectionManager(connectionManager);
ourClient = builder.build();
}
public static class PatientProviderRead implements IResourceProvider {
@Read()
@ -299,12 +337,13 @@ public class CreateR4Test {
public Class<Patient> getResourceType() {
return Patient.class;
}
@Create()
public MethodOutcome create(@ResourceParam Patient theIdParam) {
assertNull(theIdParam.getIdElement().getIdPart());
theIdParam.setId("1");
theIdParam.getMeta().setVersionId("1");
return new MethodOutcome(new IdType("Patient", "1"), true).setOperationOutcome(ourReturnOo).setResource(theIdParam);
public MethodOutcome create(@ResourceParam Patient thePatient) {
assertNull(thePatient.getIdElement().getIdPart());
thePatient.setId("1");
thePatient.getMeta().setVersionId("1");
return new MethodOutcome(new IdType("Patient", "1"), true).setOperationOutcome(ourReturnOo).setResource(thePatient);
}
}
@ -336,4 +375,35 @@ public class CreateR4Test {
}
@AfterClass
public static void afterClassClearContext() throws Exception {
ourServer.stop();
TestUtil.clearAllStaticFieldsForUnitTest();
}
@BeforeClass
public static void beforeClass() throws Exception {
ourPort = PortUtil.findFreePort();
ourServer = new Server(ourPort);
PatientProviderCreate patientProviderCreate = new PatientProviderCreate();
PatientProviderRead patientProviderRead = new PatientProviderRead();
PatientProviderSearch patientProviderSearch = new PatientProviderSearch();
ServletHandler proxyHandler = new ServletHandler();
ourServlet = new RestfulServer(ourCtx);
ourServlet.setResourceProviders(patientProviderCreate, patientProviderRead, patientProviderSearch);
ServletHolder servletHolder = new ServletHolder(ourServlet);
proxyHandler.addServletWithMapping(servletHolder, "/*");
ourServer.setHandler(proxyHandler);
ourServer.start();
PoolingHttpClientConnectionManager connectionManager = new PoolingHttpClientConnectionManager(5000, TimeUnit.MILLISECONDS);
HttpClientBuilder builder = HttpClientBuilder.create();
builder.setConnectionManager(connectionManager);
ourClient = builder.build();
}
}

View File

@ -34,21 +34,21 @@
subscription matching will query the database as before.
</action>
<action type="change">
Removed BaseSubscriptionInterceptor and all its subclasses (RestHook, EMail, WebSocket). These are replaced
Removed BaseSubscriptionInterceptor and all its subclasses (RestHook, EMail, WebSocket). These are replaced
by two new interceptors: SubscriptionActivatingInterceptor that is responsible for activating subscriptions
and SubscriptionMatchingInterceptor that is responsible for matching incoming resources against activated
subscriptions. Call DaoConfig.addSupportedSubscriptionType(type) to configure which subscription types
are supported in your environment. The helper method SubscriptionInterceptorLoader.registerInterceptors()
subscriptions. Call DaoConfig.addSupportedSubscriptionType(type) to configure which subscription types
are supported in your environment. The helper method SubscriptionInterceptorLoader.registerInterceptors()
will check if any subscription types are supported, and if so then load active subscriptions into the
SubscriptionRegistry and then register both the activating and matching interceptors.
See https://github.com/jamesagnew/hapi-fhir/wiki/Proposed-Subscription-Design-Change for more
details.
</action>
<action type="change">
Added support for matching subscriptions in a separate server from the REST Server. To do this, run the
Added support for matching subscriptions in a separate server from the REST Server. To do this, run the
SubscriptionActivatingInterceptor on the REST server and the SubscriptionMatchingInterceptor in the
standalone server. Classes required to support running a standalone subscription server are in the
ca.uhn.fhir.jpa.subscription.module.standalone package. These classes are excluded by default from
standalone server. Classes required to support running a standalone subscription server are in the
ca.uhn.fhir.jpa.subscription.module.standalone package. These classes are excluded by default from
the JPA ApplicationContext (that package is explicitly filtered out in the BaseConfig.java @ComponentScan).
</action>
<action type="add">
@ -125,7 +125,7 @@
</action>
<action type="add">
JPA Migrator tool enhancements:
An invalid SQL syntax issue has been fixed when running the CLI JPA Migrator tool against
An invalid SQL syntax issue has been fixed when running the CLI JPA Migrator tool against
Oracle or SQL Server. In addition, when using the "Dry Run" option, all generated SQL
statements will be logged at the end of the run. Also, a case sensitivity issue when running against
some Postgres databases has been corrected.
@ -146,7 +146,7 @@
</action>
<action type="add">
The resource reindexer can now detect when a resource's current version no longer
exists in the database (e.g. because it was manually expunged), and can automatically
exists in the database (e.g. because it was manually expunged), and can automatically
adjust the most recent version to
account for this.
</action>
@ -171,20 +171,21 @@
the correct content type cia the Accept header.
</action>
<action type="add" issue="917">
A new configuration item has been added to the FhirInstanceValidator that
A new configuration item has been added to the FhirInstanceValidator that
allows you to specify additional "known extension domains", meaning
domains in which the validator will not complain about when it
encounters new extensions. Thanks to Heinz-Dieter Conradi for the
pull request!
</action>
<action type="fix">
Under some circumstances, when a custom search parameter was added to the JPA server
Under some circumstances, when a custom search parameter was added to the JPA server
resources could start reindexing before the new search parameter had been saved, meaning that
it was not applied to all resources. This has been corrected.
</action>
<action type="change">
In example-projects/README.md and hapi-fhir-jpaserver-example/README.md, incidate that these examples projects
are no longer maintained. The README.md points users to a starter project they should use for examples.
In example-projects/README.md and hapi-fhir-jpaserver-example/README.md, incidate that these examples
projects
are no longer maintained. The README.md points users to a starter project they should use for examples.
</action>
<action type="change">
Replaced use of BeanFactory with custom factory classes that Spring @Lookup the @Scope("prototype") beans
@ -194,27 +195,27 @@
Moved e-mail from address configuration from EmailInterceptor (which doesn't exist any more) to DaoConfig.
</action>
<action type="add">
Added 3 interfaces for services required by the standalone subscription server. The standalone subscription
server doesn't have access to a database and so needs to get its resources using a FhirClient. Thus
Added 3 interfaces for services required by the standalone subscription server. The standalone subscription
server doesn't have access to a database and so needs to get its resources using a FhirClient. Thus
for each of these interfaces, there are two implementations: a Dao implementaiton and a FhirClient
implementation. The interfaces thus introduced are ISubscriptionProvider (used to load subscriptions
implementation. The interfaces thus introduced are ISubscriptionProvider (used to load subscriptions
into the SubscriptionRegistry), the IResourceProvider (used to get the latest version of a resource
if the "get latest version" flag is set on the subscription) and ISearchParamProvider used to load
custom search parameters.
</action>
<action type="change">
Separated active subscription cache from the interceptors into a new Spring component called the
SubscriptionRegistry. This component maintains a cache of ActiveSubscriptions. An ActiveSubscription
SubscriptionRegistry. This component maintains a cache of ActiveSubscriptions. An ActiveSubscription
contains the subscription, it's delivery channel, and a list of delivery handlers.
</action>
<action type="change">
Introduced a new Spring factory interface ISubscribableChannelFactory that is used to create delivery
channels and handlers. By default, HAPI FHIR ships with a LinkedBlockingQueue implementation of the
delivery channel factory. If a different type of channel factory is required (e.g. JMS or Kafka), add it
channels and handlers. By default, HAPI FHIR ships with a LinkedBlockingQueue implementation of the
delivery channel factory. If a different type of channel factory is required (e.g. JMS or Kafka), add it
to your application context and mark it as @Primary.
</action>
<action type="fix" issue="980">
When using the HL7.org DSTU2 structures, a QuestionnaireResponse with a
When using the HL7.org DSTU2 structures, a QuestionnaireResponse with a
value of type reference would fail to parse. Thanks to David Gileadi for
the pull request!
</action>
@ -231,6 +232,17 @@
JPA Subscription deliveries did not always include the accurate versionId if the Subscription
module was configured to use an external queuing engine. This has been corrected.
</action>
<action type="add">
It is now possible in a plain or JPA server to specify the default return
type for create/update operations when no Prefer header has been provided
by the client.
</action>
<action type="add">
It is now possible in a JPA server to specify the _total calculation
behaviour if no parameter is supplied by the client. This is done using a
new setting on the DaoConfig. This can be used to force a total to
always be calculated for searches, including large ones.
</action>
</release>
<release version="3.6.0" date="2018-11-12" description="Food">
<action type="add">