Implement GraphQL Connection Approach (#1987)

* implement GraphQLQueryBodyParameter and GraphQLQueryBody annotation

* GraphQLQueryBodyParameter to recognize application/graphql

* fix 500 error on method without @GraphQLQueryBody

* refactor to processGraphQlGetRequest and processGraphQlPostRequest

* add testGraphPostContentTypeJson and testGraphPostContentTypeGraphql to GraphQLR4RawTest

* fix imports

* implement GraphQL Connection

* add tests

* add cacheControlDirective and requestDetails to registerSearch

* fix tests and fix bugs

* set error code
This commit is contained in:
Ibrohim Kholilul Islam 2022-04-20 00:43:41 +07:00 committed by GitHub
parent 5dfdc91682
commit d20c6a7d25
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 208 additions and 36 deletions

View File

@ -25,7 +25,7 @@ public final class Msg {
/**
* IMPORTANT: Please update the following comment after you add a new code
* Last code value: 2075
* Last code value: 2076
*/
private Msg() {}

View File

@ -159,6 +159,7 @@ ca.uhn.fhir.jpa.term.BaseTermReadSvcImpl.cannotUpdateUrlOrVersionForValueSetReso
ca.uhn.fhir.jpa.patch.JsonPatchUtils.failedToApplyPatch=Failed to apply JSON patch to {0}: {1}
ca.uhn.fhir.jpa.graphql.DaoRegistryGraphQLStorageServices.invalidGraphqlArgument=Unknown GraphQL argument "{0}". Value GraphQL argument for this type are: {1}
ca.uhn.fhir.jpa.graphql.DaoRegistryGraphQLStorageServices.invalidGraphqlCursorArgument=GraphQL Cursor "{0}" does not exist and may have expired
ca.uhn.fhir.jpa.partition.RequestPartitionHelperSvc.nonDefaultPartitionSelectedForNonPartitionable=Resource type {0} can not be partitioned
ca.uhn.fhir.jpa.partition.RequestPartitionHelperSvc.unknownPartitionId=Unknown partition ID: {0}

View File

@ -6,6 +6,10 @@ import ca.uhn.fhir.jpa.api.config.DaoConfig;
import ca.uhn.fhir.jpa.test.config.TestR4Config;
import ca.uhn.fhir.jpa.test.BaseJpaR4Test;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.util.BundleUtil;
import org.apache.http.NameValuePair;
import org.apache.http.client.utils.URLEncodedUtils;
import org.hl7.fhir.instance.model.api.IBaseBundle;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.r4.model.Appointment;
import org.hl7.fhir.r4.model.CodeableConcept;
@ -18,22 +22,21 @@ import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.ContextConfiguration;
import org.springframework.test.context.junit.jupiter.SpringExtension;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.net.URI;
import java.net.URISyntaxException;
import java.nio.charset.StandardCharsets;
import java.util.*;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;
import static ca.uhn.fhir.jpa.graphql.DaoRegistryGraphQLStorageServices.SEARCH_ID_PARAM;
import static ca.uhn.fhir.jpa.graphql.DaoRegistryGraphQLStorageServices.SEARCH_OFFSET_PARAM;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.when;
@ -56,16 +59,16 @@ public class DaoRegistryGraphQLStorageServicesTest extends BaseJpaR4Test {
myDaoConfig.setFilterParameterEnabled(true);
}
private String createSomeAppointment() {
CodeableConcept someCodeableConcept = new CodeableConcept(new Coding("TEST_SYSTEM", "TEST_CODE", "TEST_DISPLAY"));
private void createSomeAppointmentWithType(String id, CodeableConcept type) {
Appointment someAppointment = new Appointment();
someAppointment.setAppointmentType(someCodeableConcept);
return myAppointmentDao.create(someAppointment).getId().getIdPart();
someAppointment.setId(id);
someAppointment.setAppointmentType(type);
myAppointmentDao.update(someAppointment);
}
@Test
public void testListResourcesGraphqlArgumentConversion() {
String appointmentId = createSomeAppointment();
createSomeAppointmentWithType("hapi-1", new CodeableConcept(new Coding("TEST_SYSTEM", "TEST_CODE", "TEST_DISPLAY")));
Argument argument = new Argument("appointment_type", new StringValue("TEST_CODE"));
@ -73,12 +76,12 @@ public class DaoRegistryGraphQLStorageServicesTest extends BaseJpaR4Test {
mySvc.listResources(mySrd, "Appointment", Collections.singletonList(argument), result);
assertFalse(result.isEmpty());
assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals(appointmentId)));
assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals("hapi-1")));
}
@Test
public void testListResourceGraphqlFilterArgument() {
String appointmentId = createSomeAppointment();
createSomeAppointmentWithType("hapi-1", new CodeableConcept(new Coding("TEST_SYSTEM", "TEST_CODE", "TEST_DISPLAY")));
Argument argument = new Argument("_filter", new StringValue("appointment-type eq TEST_CODE"));
@ -86,12 +89,12 @@ public class DaoRegistryGraphQLStorageServicesTest extends BaseJpaR4Test {
mySvc.listResources(mySrd, "Appointment", Collections.singletonList(argument), result);
assertFalse(result.isEmpty());
assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals(appointmentId)));
assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals("hapi-1")));
}
@Test
public void testListResourceGraphqlTokenArgumentWithSystem() {
String appointmentId = createSomeAppointment();
createSomeAppointmentWithType("hapi-1", new CodeableConcept(new Coding("TEST_SYSTEM", "TEST_CODE", "TEST_DISPLAY")));;
Argument argument = new Argument("appointment_type", new StringValue("TEST_SYSTEM|TEST_CODE"));
@ -99,7 +102,7 @@ public class DaoRegistryGraphQLStorageServicesTest extends BaseJpaR4Test {
mySvc.listResources(mySrd, "Appointment", Collections.singletonList(argument), result);
assertFalse(result.isEmpty());
assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals(appointmentId)));
assertTrue(result.stream().anyMatch((it) -> it.getIdElement().getIdPart().equals("hapi-1")));
}
@Test
@ -109,9 +112,9 @@ public class DaoRegistryGraphQLStorageServicesTest extends BaseJpaR4Test {
List<IBaseResource> result = new ArrayList<>();
try {
mySvc.listResources(mySrd, "Appointment", Collections.singletonList(argument), result);
fail();
fail("InvalidRequestException should be thrown.");
} catch (InvalidRequestException e) {
assertEquals(Msg.code(1275) + "Unknown GraphQL argument \"test\". Value GraphQL argument for this type are: [_content, _id, _lastUpdated, _profile, _security, _source, _tag, _text, actor, appointment_type, based_on, date, identifier, location, part_status, patient, practitioner, reason_code, reason_reference, service_category, service_type, slot, specialty, status, supporting_info]", e.getMessage());
assertTrue(e.getMessage().contains("Unknown GraphQL argument \"test\"."));
}
}
@ -176,4 +179,64 @@ public class DaoRegistryGraphQLStorageServicesTest extends BaseJpaR4Test {
List<String> expectedId2 = Arrays.asList("hapi-5", "hapi-6", "hapi-7", "hapi-8", "hapi-9");
assertTrue(result2.stream().allMatch((it) -> expectedId2.contains(it.getIdElement().getIdPart())));
}
@Test
public void testSearch() {
createSomePatientWithId("hapi-1");
List<Argument> arguments = Collections.emptyList();
IBaseBundle bundle = mySvc.search(mySrd, "Patient", arguments);
List<String> result = toUnqualifiedVersionlessIdValues(bundle);
assertEquals(1, result.size());
assertEquals("Patient/hapi-1", result.get(0));
}
@Test
public void testSearchNextPage() throws URISyntaxException {
createSomePatientWithId("hapi-1");
createSomePatientWithId("hapi-2");
createSomePatientWithId("hapi-3");
List<Argument> arguments = Collections.singletonList(new Argument("_count", new StringValue("1")));
IBaseBundle bundle = mySvc.search(mySrd, "Patient", arguments);
Optional<String> nextUrl = Optional.ofNullable(BundleUtil.getLinkUrlOfType(myFhirContext, bundle, "next"));
assertTrue(nextUrl.isPresent());
List<NameValuePair> params = URLEncodedUtils.parse(new URI(nextUrl.get()), StandardCharsets.UTF_8);
Optional<String> cursorId = params.stream()
.filter(it -> SEARCH_ID_PARAM.equals(it.getName()))
.map(NameValuePair::getValue)
.findAny();
Optional<String> cursorOffset = params.stream()
.filter(it -> SEARCH_OFFSET_PARAM.equals(it.getName()))
.map(NameValuePair::getValue)
.findAny();
assertTrue(cursorId.isPresent());
assertTrue(cursorOffset.isPresent());
List<Argument> nextArguments = Arrays.asList(
new Argument(SEARCH_ID_PARAM, new StringValue(cursorId.get())),
new Argument(SEARCH_OFFSET_PARAM, new StringValue(cursorOffset.get()))
);
Optional<IBaseBundle> nextBundle = Optional.ofNullable(mySvc.search(mySrd, "Patient", nextArguments));
assertTrue(nextBundle.isPresent());
}
@Test
public void testSearchInvalidCursor() {
try {
List<Argument> arguments = Arrays.asList(
new Argument(SEARCH_ID_PARAM, new StringValue("invalid-search-id")),
new Argument(SEARCH_OFFSET_PARAM, new StringValue("0"))
);
mySvc.search(mySrd, "Patient", arguments);
fail("InvalidRequestException should be thrown.");
} catch (InvalidRequestException e) {
assertTrue(e.getMessage().contains("GraphQL Cursor \"invalid-search-id\" does not exist and may have expired"));
}
}
}

View File

@ -24,10 +24,18 @@ import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.RuntimeResourceDefinition;
import ca.uhn.fhir.context.RuntimeSearchParam;
import ca.uhn.fhir.i18n.Msg;
import ca.uhn.fhir.interceptor.model.RequestPartitionId;
import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
import ca.uhn.fhir.jpa.api.svc.ISearchCoordinatorSvc;
import ca.uhn.fhir.jpa.partition.IRequestPartitionHelperSvc;
import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
import ca.uhn.fhir.model.api.IQueryParameterOr;
import ca.uhn.fhir.model.valueset.BundleTypeEnum;
import ca.uhn.fhir.rest.api.BundleLinks;
import ca.uhn.fhir.rest.api.CacheControlDirective;
import ca.uhn.fhir.rest.api.Constants;
import ca.uhn.fhir.rest.api.IVersionSpecificBundleFactory;
import ca.uhn.fhir.rest.api.server.IBundleProvider;
import ca.uhn.fhir.rest.api.server.RequestDetails;
import ca.uhn.fhir.rest.param.DateOrListParam;
@ -44,8 +52,9 @@ import ca.uhn.fhir.rest.param.StringOrListParam;
import ca.uhn.fhir.rest.param.StringParam;
import ca.uhn.fhir.rest.param.TokenOrListParam;
import ca.uhn.fhir.rest.param.TokenParam;
import ca.uhn.fhir.rest.server.IPagingProvider;
import ca.uhn.fhir.rest.server.RestfulServerUtils;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
import ca.uhn.fhir.rest.server.exceptions.NotImplementedOperationException;
import ca.uhn.fhir.rest.server.util.ISearchParamRegistry;
import ca.uhn.fhir.rest.server.util.ResourceSearchParams;
import org.apache.commons.lang3.Validate;
@ -57,19 +66,27 @@ import org.hl7.fhir.instance.model.api.IIdType;
import org.hl7.fhir.utilities.graphql.Argument;
import org.hl7.fhir.utilities.graphql.IGraphQLStorageServices;
import org.hl7.fhir.utilities.graphql.Value;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;
import static ca.uhn.fhir.rest.api.Constants.PARAM_COUNT;
import static ca.uhn.fhir.rest.api.Constants.PARAM_FILTER;
public class DaoRegistryGraphQLStorageServices implements IGraphQLStorageServices {
// the constant hasn't already been defined in org.hl7.fhir.core so we define it here
static final String SEARCH_ID_PARAM = "search-id";
static final String SEARCH_OFFSET_PARAM = "search-offset";
private static final int MAX_SEARCH_SIZE = 500;
@Autowired
private FhirContext myContext;
@ -77,6 +94,12 @@ public class DaoRegistryGraphQLStorageServices implements IGraphQLStorageService
private DaoRegistry myDaoRegistry;
@Autowired
private ISearchParamRegistry mySearchParamRegistry;
@Autowired
protected ISearchCoordinatorSvc mySearchCoordinatorSvc;
@Autowired
private IRequestPartitionHelperSvc myPartitionHelperSvc;
@Autowired
private IPagingProvider myPagingProvider;
private IFhirResourceDao<? extends IBaseResource> getDao(String theResourceType) {
RuntimeResourceDefinition typeDef = myContext.getResourceDefinition(theResourceType);
@ -95,19 +118,18 @@ public class DaoRegistryGraphQLStorageServices implements IGraphQLStorageService
return name.replaceAll("-", "_");
}
@Transactional(propagation = Propagation.NEVER)
@Override
public void listResources(Object theAppInfo, String theType, List<Argument> theSearchParams, List<IBaseResource> theMatches) throws FHIRException {
private SearchParameterMap buildSearchParams(String theType, List<Argument> theSearchParams) {
List<Argument> resourceSearchParam = theSearchParams.stream()
.filter(it -> !PARAM_COUNT.equals(it.getName()))
.collect(Collectors.toList());
FhirContext fhirContext = myContext;
RuntimeResourceDefinition typeDef = fhirContext.getResourceDefinition(theType);
IFhirResourceDao<? extends IBaseResource> dao = myDaoRegistry.getResourceDao(typeDef.getImplementingClass());
SearchParameterMap params = new SearchParameterMap();
params.setLoadSynchronousUpTo(MAX_SEARCH_SIZE);
ResourceSearchParams searchParams = mySearchParamRegistry.getActiveSearchParams(typeDef.getName());
for (Argument nextArgument : theSearchParams) {
for (Argument nextArgument : resourceSearchParam) {
if (nextArgument.getName().equals(PARAM_FILTER)) {
String value = nextArgument.getValues().get(0).getValue();
@ -189,8 +211,17 @@ public class DaoRegistryGraphQLStorageServices implements IGraphQLStorageService
params.add(searchParamName, queryParam);
}
return params;
}
@Transactional(propagation = Propagation.NEVER)
@Override
public void listResources(Object theAppInfo, String theType, List<Argument> theSearchParams, List<IBaseResource> theMatches) throws FHIRException {
SearchParameterMap params = buildSearchParams(theType, theSearchParams);
params.setLoadSynchronousUpTo(MAX_SEARCH_SIZE);
RequestDetails requestDetails = (RequestDetails) theAppInfo;
IBundleProvider response = dao.search(params, requestDetails);
IBundleProvider response = getDao(theType).search(params, requestDetails);
Integer size = response.size();
//We set size to null in SearchCoordinatorSvcImpl.executeQuery() if matching results exceeds count
//so don't throw here
@ -201,7 +232,6 @@ public class DaoRegistryGraphQLStorageServices implements IGraphQLStorageService
Validate.notNull(size, "size is null");
theMatches.addAll(response.getResources(0, size));
}
@Transactional(propagation = Propagation.REQUIRED)
@ -228,10 +258,88 @@ public class DaoRegistryGraphQLStorageServices implements IGraphQLStorageService
return new ReferenceResolution(theContext, outcome);
}
private Optional<String> getArgument(List<Argument> params, String name) {
return params.stream()
.filter(it -> name.equals(it.getName()))
.map(it -> it.getValues().get(0).getValue())
.findAny();
}
@Transactional(propagation = Propagation.NEVER)
@Override
public IBaseBundle search(Object theAppInfo, String theType, List<Argument> theSearchParams) throws FHIRException {
throw new NotImplementedOperationException(Msg.code(1277) + "Not yet able to handle this GraphQL request");
RequestDetails requestDetails = (RequestDetails) theAppInfo;
Optional<String> searchIdArgument = getArgument(theSearchParams, SEARCH_ID_PARAM);
Optional<String> searchOffsetArgument = getArgument(theSearchParams, SEARCH_OFFSET_PARAM);
String searchId;
int searchOffset;
int pageSize;
IBundleProvider response;
if (searchIdArgument.isPresent() && searchOffsetArgument.isPresent()) {
searchId = searchIdArgument.get();
searchOffset = Integer.parseInt(searchOffsetArgument.get());
response = Optional.ofNullable(myPagingProvider.retrieveResultList(requestDetails, searchId)).orElseThrow(()->{
String msg = myContext.getLocalizer().getMessageSanitized(DaoRegistryGraphQLStorageServices.class, "invalidGraphqlCursorArgument", searchId);
return new InvalidRequestException(Msg.code(2076) + msg);
});
pageSize = Optional.ofNullable(response.preferredPageSize())
.orElseGet(myPagingProvider::getDefaultPageSize);
} else {
pageSize = getArgument(theSearchParams, "_count").map(Integer::parseInt)
.orElseGet(myPagingProvider::getDefaultPageSize);
SearchParameterMap params = buildSearchParams(theType, theSearchParams);
params.setCount(pageSize);
CacheControlDirective cacheControlDirective = new CacheControlDirective();
cacheControlDirective.parse(requestDetails.getHeaders(Constants.HEADER_CACHE_CONTROL));
RequestPartitionId requestPartitionId = myPartitionHelperSvc.determineReadPartitionForRequestForSearchType(requestDetails, theType, params, null);
response = mySearchCoordinatorSvc.registerSearch(getDao(theType), params, theType, cacheControlDirective, requestDetails, requestPartitionId);
searchOffset = 0;
searchId = myPagingProvider.storeResultList(requestDetails, response);
}
// response.size() may return {@literal null}, in that case use pageSize
String serverBase = requestDetails.getFhirServerBase();
Optional<Integer> numTotalResults = Optional.ofNullable(response.size());
int numToReturn = numTotalResults.map(integer -> Math.min(pageSize, integer - searchOffset)).orElse(pageSize);
BundleLinks links = new BundleLinks(requestDetails.getServerBaseForRequest(), null, RestfulServerUtils.prettyPrintResponse(requestDetails.getServer(), requestDetails), BundleTypeEnum.SEARCHSET);
// RestfulServerUtils.createLinkSelf not suitable here
String linkFormat = "%s/%s?_format=application/json&search-id=%s&search-offset=%d&_count=%d";
String linkSelf = String.format(linkFormat, serverBase, theType, searchId, searchOffset, pageSize);
links.setSelf(linkSelf);
boolean hasNext = numTotalResults.map(total -> (searchOffset + numToReturn) < total).orElse(true);
if (hasNext) {
String linkNext = String.format(linkFormat, serverBase, theType, searchId, searchOffset+numToReturn, pageSize);
links.setNext(linkNext);
}
if (searchOffset > 0) {
String linkPrev = String.format(linkFormat, serverBase, theType, searchId, Math.max(0, searchOffset-pageSize), pageSize);
links.setPrev(linkPrev);
}
List<IBaseResource> resourceList = response.getResources(searchOffset, numToReturn + searchOffset);
IVersionSpecificBundleFactory bundleFactory = myContext.newBundleFactory();
bundleFactory.addRootPropertiesToBundle(response.getUuid(), links, response.size(), response.getPublished());
bundleFactory.addResourcesToBundle(resourceList, BundleTypeEnum.SEARCHSET, serverBase, null, null);
IBaseResource result = bundleFactory.getResourceBundle();
return (IBaseBundle) result;
}
}