From ee286277b522d0242d0870db8f5f89b9b96677fb Mon Sep 17 00:00:00 2001 From: Cody Ebberson Date: Thu, 27 Apr 2023 22:26:29 -0700 Subject: [PATCH 1/4] Fixes #1933 - GraphQL Connection API --- packages/fhir-router/src/graphql.test.ts | 34 ++++++++ packages/fhir-router/src/graphql.ts | 101 ++++++++++++++++++++++- 2 files changed, 134 insertions(+), 1 deletion(-) diff --git a/packages/fhir-router/src/graphql.test.ts b/packages/fhir-router/src/graphql.test.ts index 6be6b0d7a6..6487edcfdb 100644 --- a/packages/fhir-router/src/graphql.test.ts +++ b/packages/fhir-router/src/graphql.test.ts @@ -942,4 +942,38 @@ describe('GraphQL', () => { expect(check3.extension).toHaveLength(1); expect(check3.extension[0]).toMatchObject(p3.extension?.[1] as Extension); }); + + test('Connection API', async () => { + const request: FhirRequest = { + method: 'POST', + pathname: '/fhir/R4/$graphql', + query: {}, + params: {}, + body: { + query: ` + { + PatientConnection(name: "Smith") { + count offset pageSize + edges { + mode, score, resource { id name { given } } + } + first previous next last + } + } + `, + }, + }; + + const res = await graphqlHandler(request, repo); + expect(res[0]).toMatchObject(allOk); + + const data = (res?.[1] as any).data; + expect(data.PatientConnection).toBeDefined(); + expect(data.PatientConnection).toMatchObject({ + count: 1, + offset: 0, + pageSize: 20, + edges: [{ resource: { name: [{ given: ['Alice'] }] } }], + }); + }); }); diff --git a/packages/fhir-router/src/graphql.ts b/packages/fhir-router/src/graphql.ts index a91fd391a6..13138e21d2 100644 --- a/packages/fhir-router/src/graphql.ts +++ b/packages/fhir-router/src/graphql.ts @@ -3,6 +3,7 @@ import { badRequest, buildTypeName, capitalize, + DEFAULT_SEARCH_COUNT, evalFhirPathTyped, Filter, forbidden, @@ -111,6 +112,19 @@ interface GraphQLContext { dataLoader: DataLoader; } +interface ConnectionResponse { + count?: number; + offset?: number; + pageSize?: number; + edges?: ConnectionEdge[]; +} + +interface ConnectionEdge { + mode?: string; + score?: number; + resource?: Resource; +} + /** * Handles FHIR GraphQL requests. * @@ -208,6 +222,14 @@ function buildRootSchema(): GraphQLSchema { args: buildSearchArgs(resourceType), resolve: resolveBySearch, }; + + // FHIR GraphQL Connection API + fields[resourceType + 'Connection'] = { + // type: new GraphQLList(graphQLType), + type: buildConnectionType(resourceType, graphQLType), + args: buildSearchArgs(resourceType), + resolve: resolveByConnectionApi, + }; } return new GraphQLSchema({ @@ -495,6 +517,33 @@ function getPropertyType(elementDefinition: ElementDefinition, typeName: string) return graphqlType; } +function buildConnectionType(resourceType: ResourceType, resourceGraphQLType: GraphQLOutputType): GraphQLOutputType { + return new GraphQLObjectType({ + name: resourceType + 'Connection', + fields: { + count: { type: GraphQLInt }, + offset: { type: GraphQLInt }, + pageSize: { type: GraphQLInt }, + first: { type: GraphQLString }, + previous: { type: GraphQLString }, + next: { type: GraphQLString }, + last: { type: GraphQLString }, + edges: { + type: new GraphQLList( + new GraphQLObjectType({ + name: resourceType + 'ConnectionEdge', + fields: { + mode: { type: GraphQLString }, + score: { type: GraphQLFloat }, + resource: { type: resourceGraphQLType }, + }, + }) + ), + }, + }, + }); +} + /** * GraphQL data loader for search requests. * The field name should always end with "List" (i.e., "Patient" search uses "PatientList"). @@ -513,12 +562,48 @@ async function resolveBySearch( info: GraphQLResolveInfo ): Promise { const fieldName = info.fieldName; - const resourceType = fieldName.substring(0, fieldName.length - 4) as ResourceType; // Remove "List" + const resourceType = fieldName.substring(0, fieldName.length - 'List'.length) as ResourceType; const searchRequest = parseSearchArgs(resourceType, source, args); const bundle = await ctx.repo.search(searchRequest); return bundle.entry?.map((e) => e.resource as Resource); } +/** + * GraphQL data loader for search requests. + * The field name should always end with "List" (i.e., "Patient" search uses "PatientList"). + * The search args should be FHIR search parameters. + * @param source The source/root. This should always be null for our top level readers. + * @param args The GraphQL search arguments. + * @param ctx The GraphQL context. + * @param info The GraphQL resolve info. This includes the schema, and additional field details. + * @returns Promise to read the resoures for the query. + * @implements {GraphQLFieldResolver} + */ +async function resolveByConnectionApi( + source: any, + args: Record, + ctx: GraphQLContext, + info: GraphQLResolveInfo +): Promise { + const fieldName = info.fieldName; + const resourceType = fieldName.substring(0, fieldName.length - 'Connection'.length) as ResourceType; + const searchRequest = parseSearchArgs(resourceType, source, args); + if (isFieldRequested(info, 'count')) { + searchRequest.total = 'accurate'; + } + const bundle = await ctx.repo.search(searchRequest); + return { + count: bundle.total, + offset: searchRequest.offset || 0, + pageSize: searchRequest.count || DEFAULT_SEARCH_COUNT, + edges: bundle.entry?.map((e) => ({ + mode: e.search?.mode, + score: e.search?.score, + resource: e.resource as Resource, + })), + }; +} + /** * GraphQL data loader for ID requests. * The field name should always by the resource type. @@ -693,6 +778,20 @@ function getDepth(path: ReadonlyArray): number { return path.filter((p) => p === 'selections').length; } +/** + * Returns true if the field is requested in the GraphQL query. + * @param info The GraphQL resolve info. This includes the field name. + * @param fieldName The field name to check. + * @returns True if the field is requested in the GraphQL query. + */ +function isFieldRequested(info: GraphQLResolveInfo, fieldName: string): boolean { + return info.fieldNodes.some((fieldNode) => + fieldNode.selectionSet?.selections.some((selection) => { + return selection.kind === 'Field' && selection.name.value === fieldName; + }) + ); +} + /** * Returns an OperationOutcome for GraphQL errors. * @param errors Array of GraphQL errors. From 77b8d13a6f3f7d51c9ece00edde01f3bff4c8d48 Mon Sep 17 00:00:00 2001 From: Cody Ebberson Date: Fri, 28 Apr 2023 09:51:16 -0700 Subject: [PATCH 2/4] Docs and updated static schema --- packages/docs/docs/search/graphql.mdx | 39 +++++++++++- packages/examples/src/search/graphql.ts | 85 +++++++++++++++++++++++++ packages/graphiql/src/index.tsx | 2 +- 3 files changed, 124 insertions(+), 2 deletions(-) diff --git a/packages/docs/docs/search/graphql.mdx b/packages/docs/docs/search/graphql.mdx index fe4afb1bfe..4b8e561b73 100644 --- a/packages/docs/docs/search/graphql.mdx +++ b/packages/docs/docs/search/graphql.mdx @@ -222,9 +222,46 @@ Another common use is to filter an `extension` array by `url`: See the "[List Navigation](https://hl7.org/fhir/r4/graphql.html#list)" section of the FHIR GraphQL specification for more information. +## Connection API + +Using the normal "List" search (i.e., "PatientList") is the most common way to search for resources. However, the FHIR GraphQL specification also supports the [Connection API](https://hl7.org/fhir/graphql.html#searching), which is a more complex way to search for resources. + +The most immediate advantage to the Connection API is better support for pagination and retrieving total counts. The Conenction API also includes more features from FHIR Bundle such as `mode` and `score`. + +To use the Connection API, append "Connection" to the resource type rather than "List". For example, use "PatientConnection" instead of "PatientList". + +Here is an example of searching for a list of `Patient` resources using the Connection API: + + + + + {ExampleCode} + + + + + {ExampleCode} + + + + + {ExampleCode} + + + + +
+ Example Response + + {ExampleCode} + +
+ +See the "[Connection API](https://hl7.org/fhir/graphql.html#searching)" section of the FHIR GraphQL specification for more information. + ## Putting it all together -The FHIR GraphQL syntax is a powerful way to query for multiple related resources in a single HTTP call. The following example combines all the concepts we've covered so far. +The FHIR GraphQL syntax is a powerful way to query for multiple related resources in a single HTTP call. The following example combines previous concepts. This query searches for a list of `Patients` named `"Eve"`, living in `"Philadelphia"`, and then searches for all `DiagnosticReports` linked to each `Patient` along with their corresponding `Observations`. diff --git a/packages/examples/src/search/graphql.ts b/packages/examples/src/search/graphql.ts index 8322160158..e07714a11b 100644 --- a/packages/examples/src/search/graphql.ts +++ b/packages/examples/src/search/graphql.ts @@ -613,3 +613,88 @@ response = { }; console.log(response); + +/* + * Connection API + */ + +/* +// start-block ConnectionApiGraphQL +{ + PatientConnection { + count + edges { + resource { + resourceType + id + name { given family } + } + } + } +} +// end-block ConnectionApiGraphQL +*/ + +/* +// start-block ConnectionApiCurl +curl -X POST 'https://api.medplum.com/fhir/R4/$graphql' \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $your_access_token" \ + -d '{"query":"{ PatientConnection count { edges { resource { resourceType id name { given family } } } } }"}' +// end-block ConnectionApiCurl +*/ + +// start-block ConnectionApiTS +await medplum.graphql(` +{ + PatientConnection { + count + edges { + resource { + resourceType + id + name { given family } + } + } + } +} +`); +// end-block ConnectionApiTS + +response = { + // start-block ConnectionApiResponse + data: { + PatientConnection: { + count: 2, + edges: [ + { + resource: { + resourceType: 'Patient', + id: 'example-patient-id-1', + name: [ + { + given: ['Bart'], + family: 'Simpson', + }, + ], + }, + }, + { + resource: { + resourceType: 'Patient', + id: 'example-patient-id-2', + name: [ + { + given: ['Homer'], + family: 'Simpson', + }, + ], + }, + }, + ], + }, + }, + // end-block ConnectionApiResponse +}; + +console.log(response); diff --git a/packages/graphiql/src/index.tsx b/packages/graphiql/src/index.tsx index 56f306e7e4..99f10086c1 100644 --- a/packages/graphiql/src/index.tsx +++ b/packages/graphiql/src/index.tsx @@ -62,7 +62,7 @@ const theme: MantineThemeOverride = { function fetcher(params: FetcherParams): Promise { if (params.operationName === 'IntrospectionQuery') { - return fetch('/schema/schema-v3.json').then((res) => res.json()); + return fetch('/schema/schema-v4.json').then((res) => res.json()); } return medplum.graphql(params.query, params.operationName, params.variables); } From 13d065df64a6c68ffd2d1e84a2d28185e8244c81 Mon Sep 17 00:00:00 2001 From: Cody Ebberson Date: Fri, 28 Apr 2023 10:37:02 -0700 Subject: [PATCH 3/4] Cleanup --- packages/fhir-router/src/graphql.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/fhir-router/src/graphql.ts b/packages/fhir-router/src/graphql.ts index 13138e21d2..42ce7131bd 100644 --- a/packages/fhir-router/src/graphql.ts +++ b/packages/fhir-router/src/graphql.ts @@ -225,7 +225,6 @@ function buildRootSchema(): GraphQLSchema { // FHIR GraphQL Connection API fields[resourceType + 'Connection'] = { - // type: new GraphQLList(graphQLType), type: buildConnectionType(resourceType, graphQLType), args: buildSearchArgs(resourceType), resolve: resolveByConnectionApi, From dd5ebc1a7bdf29532405828cb9917e2cddeb1437 Mon Sep 17 00:00:00 2001 From: Cody Ebberson Date: Fri, 28 Apr 2023 11:49:53 -0700 Subject: [PATCH 4/4] Doc cleanup --- packages/docs/docs/search/graphql.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/docs/search/graphql.mdx b/packages/docs/docs/search/graphql.mdx index 4b8e561b73..e560153b05 100644 --- a/packages/docs/docs/search/graphql.mdx +++ b/packages/docs/docs/search/graphql.mdx @@ -226,7 +226,7 @@ See the "[List Navigation](https://hl7.org/fhir/r4/graphql.html#list)" section o Using the normal "List" search (i.e., "PatientList") is the most common way to search for resources. However, the FHIR GraphQL specification also supports the [Connection API](https://hl7.org/fhir/graphql.html#searching), which is a more complex way to search for resources. -The most immediate advantage to the Connection API is better support for pagination and retrieving total counts. The Conenction API also includes more features from FHIR Bundle such as `mode` and `score`. +The most immediate advantage of the Connection API is support for retrieving total counts. The Connection API also includes more features from FHIR Bundle such as `mode` and `score`. To use the Connection API, append "Connection" to the resource type rather than "List". For example, use "PatientConnection" instead of "PatientList".