Fix #371 - STU3 server and client should use new sort parameter style

This commit is contained in:
James Agnew 2016-06-04 12:34:44 -04:00
parent f4b9c6423c
commit d966190f9e
9 changed files with 613 additions and 233 deletions

View File

@ -34,6 +34,7 @@ public class SortSpec {
* Constructor
*/
public SortSpec() {
super();
}
/**

View File

@ -70,6 +70,8 @@ import ca.uhn.fhir.parser.DataFormatException;
import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.rest.api.MethodOutcome;
import ca.uhn.fhir.rest.api.PreferReturnEnum;
import ca.uhn.fhir.rest.api.SortOrderEnum;
import ca.uhn.fhir.rest.api.SortSpec;
import ca.uhn.fhir.rest.api.SummaryEnum;
import ca.uhn.fhir.rest.client.api.IHttpClient;
import ca.uhn.fhir.rest.client.api.IHttpRequest;
@ -131,6 +133,7 @@ import ca.uhn.fhir.rest.method.OperationMethodBinding;
import ca.uhn.fhir.rest.method.ReadMethodBinding;
import ca.uhn.fhir.rest.method.SearchMethodBinding;
import ca.uhn.fhir.rest.method.SearchStyleEnum;
import ca.uhn.fhir.rest.method.SortParameter;
import ca.uhn.fhir.rest.method.TransactionMethodBinding;
import ca.uhn.fhir.rest.method.ValidateMethodBindingDstu1;
import ca.uhn.fhir.rest.method.ValidateMethodBindingDstu2;
@ -233,8 +236,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
return delete(theType, new IdDt(theId));
}
private <T extends IBaseResource> T doReadOrVRead(final Class<T> theType, IIdType theId, boolean theVRead, ICallable<T> theNotModifiedHandler, String theIfVersionMatches, Boolean thePrettyPrint,
SummaryEnum theSummary, EncodingEnum theEncoding, Set<String> theSubsetElements) {
private <T extends IBaseResource> T doReadOrVRead(final Class<T> theType, IIdType theId, boolean theVRead, ICallable<T> theNotModifiedHandler, String theIfVersionMatches, Boolean thePrettyPrint, SummaryEnum theSummary, EncodingEnum theEncoding, Set<String> theSubsetElements) {
String resName = toResourceName(theType);
IIdType id = theId;
if (!id.hasBaseUrl()) {
@ -264,7 +266,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
boolean allowHtmlResponse = (theSummary == SummaryEnum.TEXT) || (theSummary == null && getSummary() == SummaryEnum.TEXT);
ResourceResponseHandler<T> binding = new ResourceResponseHandler<T>(theType, (Class<? extends IBaseResource>)null, id, allowHtmlResponse);
ResourceResponseHandler<T> binding = new ResourceResponseHandler<T>(theType, (Class<? extends IBaseResource>) null, id, allowHtmlResponse);
if (theNotModifiedHandler == null) {
return invokeClient(myContext, binding, invocation, theEncoding, thePrettyPrint, myLogRequestAndResponse, theSummary, theSubsetElements);
@ -596,44 +598,48 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
}
private abstract class BaseClientExecutable<T extends IClientExecutable<?, ?>, Y> implements IClientExecutable<T, Y> {
private List<Class<? extends IBaseResource>> myPreferResponseTypes;
public List<Class<? extends IBaseResource>> getPreferResponseTypes() {
return myPreferResponseTypes;
}
public List<Class<? extends IBaseResource>> getPreferResponseTypes(Class<? extends IBaseResource> theDefault) {
if (myPreferResponseTypes != null) {
return myPreferResponseTypes;
private static String validateAndEscapeConditionalUrl(String theSearchUrl) {
Validate.notBlank(theSearchUrl, "Conditional URL can not be blank/null");
StringBuilder b = new StringBuilder();
boolean haveHadQuestionMark = false;
for (int i = 0; i < theSearchUrl.length(); i++) {
char nextChar = theSearchUrl.charAt(i);
if (!haveHadQuestionMark) {
if (nextChar == '?') {
haveHadQuestionMark = true;
} else if (!Character.isLetter(nextChar)) {
throw new IllegalArgumentException("Conditional URL must be in the format \"[ResourceType]?[Params]\" and must not have a base URL - Found: " + theSearchUrl);
}
b.append(nextChar);
} else {
return toTypeList(theDefault);
switch (nextChar) {
case '|':
case '?':
case '$':
case ':':
b.append(UrlUtil.escape(Character.toString(nextChar)));
break;
default:
b.append(nextChar);
break;
}
}
}
return b.toString();
}
@SuppressWarnings("unchecked")
@Override
public T preferResponseType(Class<? extends IBaseResource> theClass) {
myPreferResponseTypes = null;
if (theClass != null) {
myPreferResponseTypes = new ArrayList<Class<? extends IBaseResource>>();
myPreferResponseTypes.add(theClass);
}
return (T) this;
}
@SuppressWarnings("unchecked")
@Override
public T preferResponseTypes(List<Class<? extends IBaseResource>> theClass) {
myPreferResponseTypes = theClass;
return (T) this;
}
private abstract class BaseClientExecutable<T extends IClientExecutable<?, ?>, Y> implements IClientExecutable<T, Y> {
protected EncodingEnum myParamEncoding;
private List<Class<? extends IBaseResource>> myPreferResponseTypes;
protected Boolean myPrettyPrint;
private boolean myQueryLogRequestAndResponse;
private HashSet<String> mySubsetElements;
protected SummaryEnum mySummaryMode;
@SuppressWarnings("unchecked")
@ -643,6 +649,17 @@ public class GenericClient extends BaseClient implements IGenericClient {
return (T) this;
}
@SuppressWarnings("unchecked")
@Override
public T elementsSubset(String... theElements) {
if (theElements != null && theElements.length > 0) {
mySubsetElements = new HashSet<String>(Arrays.asList(theElements));
} else {
mySubsetElements = null;
}
return (T) this;
}
@SuppressWarnings("unchecked")
@Override
public T encodedJson() {
@ -661,6 +678,18 @@ public class GenericClient extends BaseClient implements IGenericClient {
return myParamEncoding;
}
public List<Class<? extends IBaseResource>> getPreferResponseTypes() {
return myPreferResponseTypes;
}
public List<Class<? extends IBaseResource>> getPreferResponseTypes(Class<? extends IBaseResource> theDefault) {
if (myPreferResponseTypes != null) {
return myPreferResponseTypes;
} else {
return toTypeList(theDefault);
}
}
protected HashSet<String> getSubsetElements() {
return mySubsetElements;
}
@ -692,19 +721,26 @@ public class GenericClient extends BaseClient implements IGenericClient {
@SuppressWarnings("unchecked")
@Override
public T prettyPrint() {
myPrettyPrint = true;
public T preferResponseType(Class<? extends IBaseResource> theClass) {
myPreferResponseTypes = null;
if (theClass != null) {
myPreferResponseTypes = new ArrayList<Class<? extends IBaseResource>>();
myPreferResponseTypes.add(theClass);
}
return (T) this;
}
@SuppressWarnings("unchecked")
@Override
public T elementsSubset(String... theElements) {
if (theElements != null && theElements.length > 0) {
mySubsetElements = new HashSet<String>(Arrays.asList(theElements));
} else {
mySubsetElements = null;
}
public T preferResponseTypes(List<Class<? extends IBaseResource>> theClass) {
myPreferResponseTypes = theClass;
return (T) this;
}
@SuppressWarnings("unchecked")
@Override
public T prettyPrint() {
myPrettyPrint = true;
return (T) this;
}
@ -736,7 +772,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
}
private class CreateInternal extends BaseClientExecutable<ICreateTyped, MethodOutcome>implements ICreate, ICreateTyped, ICreateWithQuery, ICreateWithQueryTyped {
private class CreateInternal extends BaseClientExecutable<ICreateTyped, MethodOutcome> implements ICreate, ICreateTyped, ICreateWithQuery, ICreateWithQueryTyped {
private CriterionList myCriterionList;
private String myId;
@ -856,7 +892,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
private class DeleteInternal extends BaseClientExecutable<IDeleteTyped, BaseOperationOutcome>implements IDelete, IDeleteTyped, IDeleteWithQuery, IDeleteWithQueryTyped {
private class DeleteInternal extends BaseClientExecutable<IDeleteTyped, BaseOperationOutcome> implements IDelete, IDeleteTyped, IDeleteWithQuery, IDeleteWithQueryTyped {
private CriterionList myCriterionList;
private IIdType myId;
@ -921,6 +957,14 @@ public class GenericClient extends BaseClient implements IGenericClient {
return this;
}
@Override
public IDeleteWithQuery resourceConditionalByType(Class<? extends IBaseResource> theResourceType) {
Validate.notNull(theResourceType, "theResourceType can not be null");
myCriterionList = new CriterionList();
myResourceType = myContext.getResourceDefinition(theResourceType).getName();
return this;
}
@Override
public IDeleteWithQuery resourceConditionalByType(String theResourceType) {
Validate.notBlank(theResourceType, "theResourceType can not be blank/null");
@ -943,14 +987,6 @@ public class GenericClient extends BaseClient implements IGenericClient {
myCriterionList.add((ICriterionInternal) theCriterion);
return this;
}
@Override
public IDeleteWithQuery resourceConditionalByType(Class<? extends IBaseResource> theResourceType) {
Validate.notNull(theResourceType, "theResourceType can not be null");
myCriterionList = new CriterionList();
myResourceType = myContext.getResourceDefinition(theResourceType).getName();
return this;
}
}
@SuppressWarnings({ "rawtypes", "unchecked" })
@ -977,7 +1013,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
@SuppressWarnings({ "unchecked", "rawtypes" })
private class GetPageInternal extends BaseClientExecutable<IGetPageTyped<Object>, Object>implements IGetPageTyped<Object> {
private class GetPageInternal extends BaseClientExecutable<IGetPageTyped<Object>, Object> implements IGetPageTyped<Object> {
private Class<? extends IBaseBundle> myBundleType;
private String myUrl;
@ -1007,7 +1043,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
private class GetTagsInternal extends BaseClientExecutable<IGetTags, TagList>implements IGetTags {
private class GetTagsInternal extends BaseClientExecutable<IGetTags, TagList> implements IGetTags {
private String myId;
private String myResourceName;
@ -1403,9 +1439,59 @@ public class GenericClient extends BaseClient implements IGenericClient {
private IIdType myId;
private String myOperationName;
private IBaseParameters myParameters;
private RuntimeResourceDefinition myParametersDef;
private Class<? extends IBaseResource> myType;
private boolean myUseHttpGet;
private RuntimeResourceDefinition myParametersDef;
@SuppressWarnings("unchecked")
private void addParam(String theName, IBase theValue) {
BaseRuntimeChildDefinition parameterChild = myParametersDef.getChildByName("parameter");
BaseRuntimeElementCompositeDefinition<?> parameterElem = (BaseRuntimeElementCompositeDefinition<?>) parameterChild.getChildByName("parameter");
IBase parameter = parameterElem.newInstance();
parameterChild.getMutator().addValue(myParameters, parameter);
IPrimitiveType<String> name = (IPrimitiveType<String>) myContext.getElementDefinition("string").newInstance();
name.setValue(theName);
parameterElem.getChildByName("name").getMutator().setValue(parameter, name);
if (theValue instanceof IBaseDatatype) {
BaseRuntimeElementDefinition<?> datatypeDef = myContext.getElementDefinition(theValue.getClass());
if (datatypeDef instanceof IRuntimeDatatypeDefinition) {
Class<? extends IBaseDatatype> profileOf = ((IRuntimeDatatypeDefinition) datatypeDef).getProfileOf();
if (profileOf != null) {
datatypeDef = myContext.getElementDefinition(profileOf);
}
}
String childElementName = "value" + StringUtils.capitalize(datatypeDef.getName());
BaseRuntimeChildDefinition childByName = parameterElem.getChildByName(childElementName);
childByName.getMutator().setValue(parameter, theValue);
} else if (theValue instanceof IBaseResource) {
parameterElem.getChildByName("resource").getMutator().setValue(parameter, theValue);
} else {
throw new IllegalArgumentException("Don't know how to handle parameter of type " + theValue.getClass());
}
}
private void addParam(String theName, IQueryParameterType theValue) {
IPrimitiveType<?> stringType = ParametersUtil.createString(myContext, theValue.getValueAsQueryToken(myContext));
addParam(theName, stringType);
}
@Override
public IOperationUntypedWithInputAndPartialOutput andParameter(String theName, IBase theValue) {
Validate.notEmpty(theName, "theName must not be null");
Validate.notNull(theValue, "theValue must not be null");
addParam(theName, theValue);
return this;
}
@Override
public IOperationUntypedWithInputAndPartialOutput andSearchParameter(String theName, IQueryParameterType theValue) {
addParam(theName, theValue);
return this;
}
@SuppressWarnings("unchecked")
@Override
@ -1427,7 +1513,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
ResourceResponseHandler handler = new ResourceResponseHandler();
handler.setPreferResponseTypes(getPreferResponseTypes(myType));
Object retVal = invoke(null, handler, invocation);
if (myContext.getResourceDefinition((IBaseResource) retVal).getName().equals("Parameters")) {
return retVal;
@ -1486,21 +1572,12 @@ public class GenericClient extends BaseClient implements IGenericClient {
throw new IllegalArgumentException("theOutputParameterType must refer to a HAPI FHIR Resource type: " + theOutputParameterType.getName());
}
if (!"Parameters".equals(def.getName())) {
throw new IllegalArgumentException("theOutputParameterType must refer to a HAPI FHIR Resource type for a resource named " + "Parameters" + " - " + theOutputParameterType.getName()
+ " is a resource named: " + def.getName());
throw new IllegalArgumentException("theOutputParameterType must refer to a HAPI FHIR Resource type for a resource named " + "Parameters" + " - " + theOutputParameterType.getName() + " is a resource named: " + def.getName());
}
myParameters = (IBaseParameters) def.newInstance();
return this;
}
@SuppressWarnings({ "unchecked" })
@Override
public IOperationUntypedWithInput withParameters(IBaseParameters theParameters) {
Validate.notNull(theParameters, "theParameters can not be null");
myParameters = theParameters;
return this;
}
@SuppressWarnings("unchecked")
@Override
public <T extends IBaseParameters> IOperationUntypedWithInputAndPartialOutput<T> withParameter(Class<T> theParameterType, String theName, IBase theValue) {
@ -1516,48 +1593,11 @@ public class GenericClient extends BaseClient implements IGenericClient {
return this;
}
@SuppressWarnings("unchecked")
private void addParam(String theName, IBase theValue) {
BaseRuntimeChildDefinition parameterChild = myParametersDef.getChildByName("parameter");
BaseRuntimeElementCompositeDefinition<?> parameterElem = (BaseRuntimeElementCompositeDefinition<?>) parameterChild.getChildByName("parameter");
IBase parameter = parameterElem.newInstance();
parameterChild.getMutator().addValue(myParameters, parameter);
IPrimitiveType<String> name = (IPrimitiveType<String>) myContext.getElementDefinition("string").newInstance();
name.setValue(theName);
parameterElem.getChildByName("name").getMutator().setValue(parameter, name);
if (theValue instanceof IBaseDatatype) {
BaseRuntimeElementDefinition<?> datatypeDef = myContext.getElementDefinition(theValue.getClass());
if (datatypeDef instanceof IRuntimeDatatypeDefinition) {
Class<? extends IBaseDatatype> profileOf = ((IRuntimeDatatypeDefinition) datatypeDef).getProfileOf();
if (profileOf != null) {
datatypeDef = myContext.getElementDefinition(profileOf);
}
}
String childElementName = "value" + StringUtils.capitalize(datatypeDef.getName());
BaseRuntimeChildDefinition childByName = parameterElem.getChildByName(childElementName);
childByName.getMutator().setValue(parameter, theValue);
} else if (theValue instanceof IBaseResource) {
parameterElem.getChildByName("resource").getMutator().setValue(parameter, theValue);
} else {
throw new IllegalArgumentException("Don't know how to handle parameter of type " + theValue.getClass());
}
}
@SuppressWarnings({ "unchecked" })
@Override
public IOperationUntypedWithInputAndPartialOutput andParameter(String theName, IBase theValue) {
Validate.notEmpty(theName, "theName must not be null");
Validate.notNull(theValue, "theValue must not be null");
addParam(theName, theValue);
return this;
}
@Override
public IOperationUntypedWithInputAndPartialOutput andSearchParameter(String theName, IQueryParameterType theValue) {
addParam(theName, theValue);
public IOperationUntypedWithInput withParameters(IBaseParameters theParameters) {
Validate.notNull(theParameters, "theParameters can not be null");
myParameters = theParameters;
return this;
}
@ -1576,18 +1616,12 @@ public class GenericClient extends BaseClient implements IGenericClient {
return this;
}
private void addParam(String theName, IQueryParameterType theValue) {
IPrimitiveType<?> stringType = ParametersUtil.createString(myContext, theValue.getValueAsQueryToken(myContext));
addParam(theName, stringType);
}
}
private final class OperationOutcomeResponseHandler implements IClientResponseHandler<BaseOperationOutcome> {
@Override
public BaseOperationOutcome invokeClient(String theResponseMimeType, Reader theResponseReader, int theResponseStatusCode, Map<String, List<String>> theHeaders)
throws BaseServerResponseException {
public BaseOperationOutcome invokeClient(String theResponseMimeType, Reader theResponseReader, int theResponseStatusCode, Map<String, List<String>> theHeaders) throws BaseServerResponseException {
EncodingEnum respType = EncodingEnum.forContentType(theResponseMimeType);
if (respType == null) {
return null;
@ -1608,25 +1642,25 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
private final class OutcomeResponseHandler implements IClientResponseHandler<MethodOutcome> {
private final String myResourceName;
private PreferReturnEnum myPrefer;
private final String myResourceName;
private OutcomeResponseHandler(String theResourceName) {
myResourceName = theResourceName;
}
private OutcomeResponseHandler(String theResourceName, PreferReturnEnum thePrefer) {
this(theResourceName);
myPrefer = thePrefer;
}
private OutcomeResponseHandler(String theResourceName) {
myResourceName = theResourceName;
}
@Override
public MethodOutcome invokeClient(String theResponseMimeType, Reader theResponseReader, int theResponseStatusCode, Map<String, List<String>> theHeaders) throws BaseServerResponseException {
MethodOutcome response = MethodUtil.process2xxResponse(myContext, myResourceName, theResponseStatusCode, theResponseMimeType, theResponseReader, theHeaders);
if (theResponseStatusCode == Constants.STATUS_HTTP_201_CREATED) {
response.setCreated(true);
}
if (myPrefer == PreferReturnEnum.REPRESENTATION) {
if (response.getResource() == null) {
if (response.getId() != null && isNotBlank(response.getId().getValue()) && response.getId().hasBaseUrl()) {
@ -1636,7 +1670,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
}
}
return response;
}
}
@ -1779,8 +1813,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
@SuppressWarnings("unchecked")
@Override
public List<IBaseResource> invokeClient(String theResponseMimeType, Reader theResponseReader, int theResponseStatusCode, Map<String, List<String>> theHeaders)
throws BaseServerResponseException {
public List<IBaseResource> invokeClient(String theResponseMimeType, Reader theResponseReader, int theResponseStatusCode, Map<String, List<String>> theHeaders) throws BaseServerResponseException {
if (myContext.getVersion().getVersion().isNewerThan(FhirVersionEnum.DSTU1)) {
Class<? extends IBaseResource> bundleType = myContext.getResourceDefinition("Bundle").getImplementingClass();
ResourceResponseHandler<IBaseResource> handler = new ResourceResponseHandler<IBaseResource>((Class<IBaseResource>) bundleType);
@ -1795,7 +1828,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
@SuppressWarnings({ "rawtypes", "unchecked" })
private class SearchInternal extends BaseClientExecutable<IQuery<Object>, Object>implements IQuery<Object>, IUntypedQuery {
private class SearchInternal extends BaseClientExecutable<IQuery<Object>, Object> implements IQuery<Object>, IUntypedQuery {
private String myCompartmentName;
private CriterionList myCriterion = new CriterionList();
@ -1809,10 +1842,10 @@ public class GenericClient extends BaseClient implements IGenericClient {
private Class<? extends IBaseBundle> myReturnBundleType;
private List<Include> myRevInclude = new ArrayList<Include>();
private SearchStyleEnum mySearchStyle;
private String mySearchUrl;
private List<TokenParam> mySecurity = new ArrayList<TokenParam>();
private List<SortInternal> mySort = new ArrayList<SortInternal>();
private List<TokenParam> myTags = new ArrayList<TokenParam>();
private String mySearchUrl;
public SearchInternal() {
myResourceType = null;
@ -1826,6 +1859,44 @@ public class GenericClient extends BaseClient implements IGenericClient {
return this;
}
@Override
public IQuery byUrl(String theSearchUrl) {
Validate.notBlank(theSearchUrl, "theSearchUrl must not be blank/null");
if (theSearchUrl.startsWith("http://") || theSearchUrl.startsWith("https://")) {
mySearchUrl = theSearchUrl;
int qIndex = mySearchUrl.indexOf('?');
if (qIndex != -1) {
mySearchUrl = mySearchUrl.substring(0, qIndex) + validateAndEscapeConditionalUrl(mySearchUrl.substring(qIndex));
}
} else {
String searchUrl = theSearchUrl;
if (searchUrl.startsWith("/")) {
searchUrl = searchUrl.substring(1);
}
if (!searchUrl.matches("[a-zA-Z]+($|\\?.*)")) {
throw new IllegalArgumentException("Search URL must be either a complete URL starting with http: or https:, or a relative FHIR URL in the form [ResourceType]?[Params]");
}
int qIndex = searchUrl.indexOf('?');
if (qIndex == -1) {
mySearchUrl = getUrlBase() + '/' + searchUrl;
} else {
mySearchUrl = getUrlBase() + '/' + validateAndEscapeConditionalUrl(searchUrl);
}
}
return this;
}
@Override
public IQuery count(int theLimitTo) {
if (theLimitTo > 0) {
myParamLimit = theLimitTo;
} else {
myParamLimit = null;
}
return this;
}
@Override
public IBase execute() {
@ -1861,8 +1932,27 @@ public class GenericClient extends BaseClient implements IGenericClient {
addParam(params, Constants.PARAM_REVINCLUDE, next.getValue());
}
for (SortInternal next : mySort) {
addParam(params, next.getParamName(), next.getParamValue());
if (myContext.getVersion().getVersion().isNewerThan(FhirVersionEnum.DSTU2)) {
SortSpec rootSs = null;
SortSpec lastSs = null;
for (SortInternal next : mySort) {
SortSpec nextSortSpec = new SortSpec();
nextSortSpec.setParamName(next.getParamValue());
nextSortSpec.setOrder(next.getDirection());
if (rootSs == null) {
rootSs = nextSortSpec;
} else {
lastSs.setChain(nextSortSpec);
}
lastSs = nextSortSpec;
}
if (rootSs != null) {
addParam(params, Constants.PARAM_SORT, SortParameter.createSortStringDstu3(rootSs));
}
} else {
for (SortInternal next : mySort) {
addParam(params, next.getParamName(), next.getParamValue());
}
}
if (myParamLimit != null) {
@ -1876,8 +1966,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
if (myReturnBundleType == null && myContext.getVersion().getVersion().isRi()) {
throw new IllegalArgumentException("When using the client with HL7.org structures, you must specify "
+ "the bundle return type for the client by adding \".returnBundle(org.hl7.fhir.instance.model.Bundle.class)\" to your search method call before the \".execute()\" method");
throw new IllegalArgumentException("When using the client with HL7.org structures, you must specify " + "the bundle return type for the client by adding \".returnBundle(org.hl7.fhir.instance.model.Bundle.class)\" to your search method call before the \".execute()\" method");
}
IClientResponseHandler<? extends IBase> binding;
@ -1934,16 +2023,6 @@ public class GenericClient extends BaseClient implements IGenericClient {
return count(theLimitTo);
}
@Override
public IQuery count(int theLimitTo) {
if (theLimitTo > 0) {
myParamLimit = theLimitTo;
} else {
myParamLimit = null;
}
return this;
}
@Override
public IQuery returnBundle(Class theClass) {
if (theClass == null) {
@ -2017,34 +2096,6 @@ public class GenericClient extends BaseClient implements IGenericClient {
return this;
}
@Override
public IQuery byUrl(String theSearchUrl) {
Validate.notBlank(theSearchUrl, "theSearchUrl must not be blank/null");
if (theSearchUrl.startsWith("http://") || theSearchUrl.startsWith("https://")) {
mySearchUrl = theSearchUrl;
int qIndex = mySearchUrl.indexOf('?');
if (qIndex != -1) {
mySearchUrl = mySearchUrl.substring(0, qIndex) + validateAndEscapeConditionalUrl(mySearchUrl.substring(qIndex));
}
} else {
String searchUrl = theSearchUrl;
if (searchUrl.startsWith("/")) {
searchUrl = searchUrl.substring(1);
}
if (!searchUrl.matches("[a-zA-Z]+($|\\?.*)")) {
throw new IllegalArgumentException("Search URL must be either a complete URL starting with http: or https:, or a relative FHIR URL in the form [ResourceType]?[Params]");
}
int qIndex = searchUrl.indexOf('?');
if (qIndex == -1) {
mySearchUrl = getUrlBase() + '/' + searchUrl;
} else {
mySearchUrl = getUrlBase() + '/' + validateAndEscapeConditionalUrl(searchUrl);
}
}
return this;
}
}
@SuppressWarnings("rawtypes")
@ -2053,6 +2104,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
private SearchInternal myFor;
private String myParamName;
private String myParamValue;
private SortOrderEnum myDirection;
public SortInternal(SearchInternal theFor) {
myFor = theFor;
@ -2061,13 +2113,23 @@ public class GenericClient extends BaseClient implements IGenericClient {
@Override
public IQuery ascending(IParam theParam) {
myParamName = Constants.PARAM_SORT_ASC;
myDirection = SortOrderEnum.ASC;
myParamValue = theParam.getParamName();
return myFor;
}
@Override
public IQuery ascending(String theParam) {
myParamName = Constants.PARAM_SORT_ASC;
myDirection = SortOrderEnum.ASC;
myParamValue = theParam;
return myFor;
}
@Override
public IQuery defaultOrder(IParam theParam) {
myParamName = Constants.PARAM_SORT;
myDirection = null;
myParamValue = theParam.getParamName();
return myFor;
}
@ -2075,10 +2137,23 @@ public class GenericClient extends BaseClient implements IGenericClient {
@Override
public IQuery descending(IParam theParam) {
myParamName = Constants.PARAM_SORT_DESC;
myDirection = SortOrderEnum.DESC;
myParamValue = theParam.getParamName();
return myFor;
}
@Override
public IQuery descending(String theParam) {
myParamName = Constants.PARAM_SORT_DESC;
myDirection = SortOrderEnum.DESC;
myParamValue = theParam;
return myFor;
}
public SortOrderEnum getDirection() {
return myDirection;
}
public String getParamName() {
return myParamName;
}
@ -2092,8 +2167,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
private final class StringResponseHandler implements IClientResponseHandler<String> {
@Override
public String invokeClient(String theResponseMimeType, Reader theResponseReader, int theResponseStatusCode, Map<String, List<String>> theHeaders)
throws IOException, BaseServerResponseException {
public String invokeClient(String theResponseMimeType, Reader theResponseReader, int theResponseStatusCode, Map<String, List<String>> theHeaders) throws IOException, BaseServerResponseException {
return IOUtils.toString(theResponseReader);
}
}
@ -2111,7 +2185,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
}
private final class TransactionExecutable<T> extends BaseClientExecutable<ITransactionTyped<T>, T>implements ITransactionTyped<T> {
private final class TransactionExecutable<T> extends BaseClientExecutable<ITransactionTyped<T>, T> implements ITransactionTyped<T> {
private IBaseBundle myBaseBundle;
private Bundle myBundle;
@ -2201,7 +2275,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
private class UpdateInternal extends BaseClientExecutable<IUpdateExecutable, MethodOutcome>implements IUpdate, IUpdateTyped, IUpdateExecutable, IUpdateWithQuery, IUpdateWithQueryTyped {
private class UpdateInternal extends BaseClientExecutable<IUpdateExecutable, MethodOutcome> implements IUpdate, IUpdateTyped, IUpdateExecutable, IUpdateWithQuery, IUpdateWithQueryTyped {
private CriterionList myCriterionList;
private IIdType myId;
@ -2319,7 +2393,7 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
private class ValidateInternal extends BaseClientExecutable<IValidateUntyped, MethodOutcome>implements IValidate, IValidateUntyped {
private class ValidateInternal extends BaseClientExecutable<IValidateUntyped, MethodOutcome> implements IValidate, IValidateUntyped {
private IBaseResource myResource;
@Override
@ -2361,34 +2435,4 @@ public class GenericClient extends BaseClient implements IGenericClient {
}
private static String validateAndEscapeConditionalUrl(String theSearchUrl) {
Validate.notBlank(theSearchUrl, "Conditional URL can not be blank/null");
StringBuilder b = new StringBuilder();
boolean haveHadQuestionMark = false;
for (int i = 0; i < theSearchUrl.length(); i++) {
char nextChar = theSearchUrl.charAt(i);
if (!haveHadQuestionMark) {
if (nextChar == '?') {
haveHadQuestionMark = true;
} else if (!Character.isLetter(nextChar)) {
throw new IllegalArgumentException("Conditional URL must be in the format \"[ResourceType]?[Params]\" and must not have a base URL - Found: " + theSearchUrl);
}
b.append(nextChar);
} else {
switch (nextChar) {
case '|':
case '?':
case '$':
case ':':
b.append(UrlUtil.escape(Character.toString(nextChar)));
break;
default:
b.append(nextChar);
break;
}
}
}
return b.toString();
}
}

View File

@ -22,10 +22,39 @@ package ca.uhn.fhir.rest.gclient;
public interface ISort<T> {
/**
* Sort ascending
*/
IQuery<T> ascending(IParam theParam);
/**
* Sort ascending
*
* @param theParam The param name, e.g. "address"
*/
IQuery<T> ascending(String theParam);
/**
* Sort by the default order. Note that as of STU3, there is no longer
* a concept of default order, only ascending and descending. This method
* technically implies "ascending" but it makes more sense to use
* {@link #ascending(IParam)}
*/
IQuery<T> defaultOrder(IParam theParam);
/**
* Sort descending
*
* @param A query param - Could be a constant such as <code>Patient.ADDRESS</code> or a custom
* param such as <code>new StringClientParam("foo")</code>
*/
IQuery<T> descending(IParam theParam);
/**
* Sort ascending
*
* @param theParam The param name, e.g. "address"
*/
IQuery<T> descending(String theParam);
}

View File

@ -483,7 +483,7 @@ public class MethodUtil {
} else if (nextAnnotation instanceof Count) {
param = new CountParameter();
} else if (nextAnnotation instanceof Sort) {
param = new SortParameter();
param = new SortParameter(theContext);
} else if (nextAnnotation instanceof TransactionParam) {
param = new TransactionParameter(theContext);
} else if (nextAnnotation instanceof ConditionalUrlParam) {

View File

@ -26,40 +26,74 @@ import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;
import org.hl7.fhir.instance.model.api.IBaseResource;
import ca.uhn.fhir.context.ConfigurationException;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.rest.annotation.Sort;
import ca.uhn.fhir.rest.api.SortOrderEnum;
import ca.uhn.fhir.rest.api.SortSpec;
import ca.uhn.fhir.rest.param.ParameterUtil;
import ca.uhn.fhir.rest.server.Constants;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
public class SortParameter implements IParameter {
private FhirContext myContext;
public SortParameter(FhirContext theContext) {
myContext = theContext;
}
@Override
public void initializeTypes(Method theMethod, Class<? extends Collection<?>> theOuterCollectionType, Class<? extends Collection<?>> theInnerCollectionType, Class<?> theParameterType) {
if (theOuterCollectionType != null || theInnerCollectionType != null) {
throw new ConfigurationException("Method '" + theMethod.getName() + "' in type '" + theMethod.getDeclaringClass().getCanonicalName() + "' is annotated with @" + Sort.class.getName() + " but can not be of collection type");
}
if (!theParameterType.equals(SortSpec.class)) {
throw new ConfigurationException("Method '" + theMethod.getName() + "' in type '" + theMethod.getDeclaringClass().getCanonicalName() + "' is annotated with @" + Sort.class.getName() + " but is an invalid type, must be: " + SortSpec.class.getCanonicalName());
}
}
@Override
public void translateClientArgumentIntoQueryArgument(FhirContext theContext, Object theSourceClientArgument, Map<String, List<String>> theTargetQueryArguments, IBaseResource theTargetResource) throws InternalErrorException {
SortSpec ss = (SortSpec) theSourceClientArgument;
while (ss != null) {
String name;
if (ss.getOrder() == null) {
name = Constants.PARAM_SORT;
} else if (ss.getOrder() == SortOrderEnum.ASC) {
name = Constants.PARAM_SORT_ASC;
} else {
name = Constants.PARAM_SORT_DESC;
if (myContext.getVersion().getVersion().isNewerThan(FhirVersionEnum.DSTU2)) {
String string = createSortStringDstu3(ss);
if (string.length() > 0) {
if (!theTargetQueryArguments.containsKey(Constants.PARAM_SORT)) {
theTargetQueryArguments.put(Constants.PARAM_SORT, new ArrayList<String>());
}
theTargetQueryArguments.get(Constants.PARAM_SORT).add(string);
}
if (ss.getParamName() != null) {
if (!theTargetQueryArguments.containsKey(name)) {
theTargetQueryArguments.put(name, new ArrayList<String>());
} else {
while (ss != null) {
String name;
if (ss.getOrder() == null) {
name = Constants.PARAM_SORT;
} else if (ss.getOrder() == SortOrderEnum.ASC) {
name = Constants.PARAM_SORT_ASC;
} else {
name = Constants.PARAM_SORT_DESC;
}
theTargetQueryArguments.get(name).add(ss.getParamName());
if (ss.getParamName() != null) {
if (!theTargetQueryArguments.containsKey(name)) {
theTargetQueryArguments.put(name, new ArrayList<String>());
}
theTargetQueryArguments.get(name).add(ss.getParamName());
}
ss = ss.getChain();
}
ss = ss.getChain();
}
}
@ -89,17 +123,47 @@ public class SortParameter implements IParameter {
String[] values = theRequest.getParameters().get(nextParamName);
if (values != null) {
for (String nextValue : values) {
if (isNotBlank(nextValue)) {
SortSpec spec = new SortSpec();
spec.setOrder(order);
spec.setParamName(nextValue);
if (innerSpec == null) {
outerSpec = spec;
innerSpec = spec;
} else {
innerSpec.setChain(spec);
innerSpec = spec;
if (myContext.getVersion().getVersion().isNewerThan(FhirVersionEnum.DSTU2) && order == null) {
StringTokenizer tok = new StringTokenizer(nextValue, ",");
while (tok.hasMoreTokens()) {
String next = tok.nextToken();
if (isNotBlank(next) && !next.equals("-")) {
order = SortOrderEnum.ASC;
if (next.startsWith("-")) {
order = SortOrderEnum.DESC;
next = next.substring(1);
}
SortSpec spec = new SortSpec();
spec.setOrder(order);
spec.setParamName(next);
if (innerSpec == null) {
outerSpec = spec;
innerSpec = spec;
} else {
innerSpec.setChain(spec);
innerSpec = spec;
}
}
}
} else {
if (isNotBlank(nextValue)) {
SortSpec spec = new SortSpec();
spec.setOrder(order);
spec.setParamName(nextValue);
if (innerSpec == null) {
outerSpec = spec;
innerSpec = spec;
} else {
innerSpec.setChain(spec);
innerSpec = spec;
}
}
}
}
@ -109,15 +173,25 @@ public class SortParameter implements IParameter {
return outerSpec;
}
@Override
public void initializeTypes(Method theMethod, Class<? extends Collection<?>> theOuterCollectionType, Class<? extends Collection<?>> theInnerCollectionType, Class<?> theParameterType) {
if (theOuterCollectionType != null || theInnerCollectionType != null) {
throw new ConfigurationException("Method '" + theMethod.getName() + "' in type '" + theMethod.getDeclaringClass().getCanonicalName() + "' is annotated with @" + Sort.class.getName() + " but can not be of collection type");
}
if (!theParameterType.equals(SortSpec.class)) {
throw new ConfigurationException("Method '" + theMethod.getName() + "' in type '" + theMethod.getDeclaringClass().getCanonicalName() + "' is annotated with @" + Sort.class.getName() + " but is an invalid type, must be: " + SortSpec.class.getCanonicalName());
public static String createSortStringDstu3(SortSpec ss) {
StringBuilder val = new StringBuilder();
while (ss != null) {
if (isNotBlank(ss.getParamName())) {
if (val.length() > 0) {
val.append(',');
}
if (ss.getOrder() == SortOrderEnum.DESC) {
val.append('-');
}
val.append(ParameterUtil.escape(ss.getParamName()));
}
ss = ss.getChain();
}
String string = val.toString();
return string;
}
}

View File

@ -24,6 +24,7 @@ import org.apache.http.message.BasicHeader;
import org.apache.http.message.BasicStatusLine;
import org.hl7.fhir.dstu3.model.Binary;
import org.hl7.fhir.dstu3.model.Bundle;
import org.hl7.fhir.dstu3.model.Bundle.BundleType;
import org.hl7.fhir.dstu3.model.Conformance;
import org.hl7.fhir.dstu3.model.OperationOutcome;
import org.hl7.fhir.dstu3.model.Parameters;
@ -607,6 +608,60 @@ public class GenericClientDstu3Test {
}
/**
* See #371
*/
@Test
public void testSortDstu3Test() throws Exception {
IParser p = ourCtx.newXmlParser();
Bundle b = new Bundle();
b.setType(BundleType.SEARCHSET);
final String respString = p.encodeResourceToString(b);
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), 200, "OK"));
when(myHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML + "; charset=UTF-8"));
when(myHttpResponse.getEntity().getContent()).thenAnswer(new Answer<ReaderInputStream>() {
@Override
public ReaderInputStream answer(InvocationOnMock theInvocation) throws Throwable {
return new ReaderInputStream(new StringReader(respString), Charset.forName("UTF-8"));
}
});
IGenericClient client = ourCtx.newRestfulGenericClient("http://example.com/fhir");
int idx = 0;
//@formatter:off
client
.search()
.forResource(Patient.class)
.sort().ascending("address")
.returnBundle(Bundle.class)
.execute();
assertEquals("http://example.com/fhir/Patient?_sort=address", capt.getAllValues().get(idx++).getURI().toASCIIString());
client
.search()
.forResource(Patient.class)
.sort().descending("address")
.returnBundle(Bundle.class)
.execute();
assertEquals("http://example.com/fhir/Patient?_sort=-address", capt.getAllValues().get(idx++).getURI().toASCIIString());
client
.search()
.forResource(Patient.class)
.sort().descending("address")
.sort().ascending("name")
.sort().descending(Patient.BIRTHDATE)
.returnBundle(Bundle.class)
.execute();
assertEquals("http://example.com/fhir/Patient?_sort=-address%2Cname%2C-birthdate", capt.getAllValues().get(idx++).getURI().toASCIIString());
//@formatter:on
}
@Test
public void testUserAgentForConformance() throws Exception {
IParser p = ourCtx.newXmlParser();

View File

@ -1,6 +1,6 @@
package ca.uhn.fhir.rest.client;
import static org.junit.Assert.*;
import static org.junit.Assert.assertEquals;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@ -33,6 +33,9 @@ import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.Count;
import ca.uhn.fhir.rest.annotation.RequiredParam;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.annotation.Sort;
import ca.uhn.fhir.rest.api.SortOrderEnum;
import ca.uhn.fhir.rest.api.SortSpec;
import ca.uhn.fhir.rest.client.api.IRestfulClient;
import ca.uhn.fhir.rest.param.StringParam;
import ca.uhn.fhir.rest.server.Constants;
@ -91,6 +94,35 @@ public class SearchClientDstu3Test {
assertEquals("http://localhost:8081/hapi-fhir/fhir/Location?_query=match&name=smith&_count=100", value.getURI().toString());
}
/**
* See #371
*/
@Test
public void testSortForDstu3() throws Exception {
final String response = createBundleWithSearchExtension();
ArgumentCaptor<HttpUriRequest> capt = ArgumentCaptor.forClass(HttpUriRequest.class);
when(ourHttpClient.execute(capt.capture())).thenReturn(ourHttpResponse);
when(ourHttpResponse.getStatusLine()).thenReturn(new BasicStatusLine(new ProtocolVersion("HTTP", 1, 1), 200, "OK"));
when(ourHttpResponse.getEntity().getContentType()).thenReturn(new BasicHeader("content-type", Constants.CT_FHIR_XML + "; charset=UTF-8"));
when(ourHttpResponse.getEntity().getContent()).thenAnswer(new Answer<InputStream>() {
@Override
public InputStream answer(InvocationOnMock theInvocation) throws Throwable {
return new ReaderInputStream(new StringReader(response), Charset.forName("UTF-8"));
}
});
ILocationClient client = ourCtx.newRestfulClient(ILocationClient.class, "http://localhost/fhir");
int idx = 0;
client.search(new SortSpec("param1", SortOrderEnum.ASC));
assertEquals("http://localhost/fhir/Bundle?_sort=param1", ((HttpGet) capt.getAllValues().get(idx++)).getURI().toString());
client.search(new SortSpec("param1", SortOrderEnum.ASC).setChain(new SortSpec("param2", SortOrderEnum.DESC)));
assertEquals("http://localhost/fhir/Bundle?_sort=param1%2C-param2", ((HttpGet) capt.getAllValues().get(idx++)).getURI().toString());
}
/**
* See #299
*/
@ -159,6 +191,9 @@ public class SearchClientDstu3Test {
@Search(queryName = "match", type=Location.class)
public Bundle getMatchesReturnBundle(final @RequiredParam(name = Location.SP_NAME) StringParam name, final @Count Integer count);
@Search
public Bundle search(@Sort SortSpec theSort);
}
}

View File

@ -0,0 +1,138 @@
package ca.uhn.fhir.rest.server;
import static org.hamcrest.Matchers.stringContainsInOrder;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertThat;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;
import org.apache.commons.io.IOUtils;
import org.apache.http.client.methods.CloseableHttpResponse;
import org.apache.http.client.methods.HttpGet;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.impl.conn.PoolingHttpClientConnectionManager;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.hl7.fhir.dstu3.model.HumanName;
import org.hl7.fhir.dstu3.model.Patient;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.junit.AfterClass;
import org.junit.Before;
import org.junit.BeforeClass;
import org.junit.Test;
import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.rest.annotation.Count;
import ca.uhn.fhir.rest.annotation.OptionalParam;
import ca.uhn.fhir.rest.annotation.Search;
import ca.uhn.fhir.rest.annotation.Sort;
import ca.uhn.fhir.rest.api.SortOrderEnum;
import ca.uhn.fhir.rest.api.SortSpec;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.util.PortUtil;
import ca.uhn.fhir.util.TestUtil;
public class SearchSortDstu3Test {
private static CloseableHttpClient ourClient;
private static FhirContext ourCtx = FhirContext.forDstu3();
private static final org.slf4j.Logger ourLog = org.slf4j.LoggerFactory.getLogger(SearchSortDstu3Test.class);
private static int ourPort;
private static Server ourServer;
private static String ourLastMethod;
private static SortSpec ourLastSortSpec;
@Before
public void before() {
ourLastMethod = null;
ourLastSortSpec = null;
}
@Test
public void testSearch() throws Exception {
HttpGet httpGet = new HttpGet("http://localhost:" + ourPort + "/Patient?_sort=param1,-param2,param3,-param4");
CloseableHttpResponse status = ourClient.execute(httpGet);
try {
String responseContent = IOUtils.toString(status.getEntity().getContent());
ourLog.info(responseContent);
assertEquals(200, status.getStatusLine().getStatusCode());
assertEquals("search", ourLastMethod);
assertEquals("param1", ourLastSortSpec.getParamName());
assertEquals(SortOrderEnum.ASC, ourLastSortSpec.getOrder());
assertEquals("param2", ourLastSortSpec.getChain().getParamName());
assertEquals(SortOrderEnum.DESC, ourLastSortSpec.getChain().getOrder());
assertEquals("param3", ourLastSortSpec.getChain().getChain().getParamName());
assertEquals(SortOrderEnum.ASC, ourLastSortSpec.getChain().getChain().getOrder());
assertEquals("param4", ourLastSortSpec.getChain().getChain().getChain().getParamName());
assertEquals(SortOrderEnum.DESC, ourLastSortSpec.getChain().getChain().getChain().getOrder());
} finally {
IOUtils.closeQuietly(status.getEntity().getContent());
}
}
@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);
DummyPatientResourceProvider patientProvider = new DummyPatientResourceProvider();
ServletHandler proxyHandler = new ServletHandler();
RestfulServer servlet = new RestfulServer(ourCtx);
servlet.setPagingProvider(new FifoMemoryPagingProvider(10));
servlet.setResourceProviders(patientProvider);
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 DummyPatientResourceProvider implements IResourceProvider {
@Override
public Class<? extends IBaseResource> getResourceType() {
return Patient.class;
}
//@formatter:off
@SuppressWarnings("rawtypes")
@Search()
public List search(
@Sort SortSpec theSortSpec
) {
ourLastMethod = "search";
ourLastSortSpec = theSortSpec;
ArrayList<Patient> retVal = new ArrayList<Patient>();
for (int i = 1; i < 100; i++) {
retVal.add((Patient) new Patient().addName(new HumanName().addFamily("FAMILY")).setId("" + i));
}
return retVal;
}
//@formatter:on
}
}

View File

@ -259,6 +259,10 @@
the spec says they should. Thanks to Jim Steel for
reporting!
</action>
<action type="fix" issue="371">
Update STU3 client and server to use the new sort parameter style (param1,-param2,param). Thanks to GitHub user @euz1e4r for
reporting!
</action>
</release>
<release version="1.5" date="2016-04-20">
<action type="fix" issue="339">