@medplum/fhir-router 2.0.16 → 2.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { OperationOutcomeError, badRequest, normalizeOperationOutcome, parseSearchUrl, getReferenceString, getStatus, isOk, allOk, LRUCache, forbidden, getResourceTypes, getResourceTypeSchema, isResourceTypeSchema, getElementDefinition, buildTypeName, capitalize, getSearchParameters, Operator, parseSearchRequest, notFound, created, deepClone, matchesSearchRequest, globalSchema, evalFhirPath } from '@medplum/core';
1
+ import { OperationOutcomeError, badRequest, normalizeOperationOutcome, parseSearchUrl, getReferenceString, resolveId, getStatus, isOk, allOk, LRUCache, forbidden, getResourceTypes, getResourceTypeSchema, isResourceTypeSchema, getElementDefinition, buildTypeName, capitalize, isLowerCase, globalSchema, getSearchParameters, DEFAULT_SEARCH_COUNT, toJsBoolean, evalFhirPathTyped, toTypedValue, Operator, parseSearchRequest, notFound, created, deepClone, matchesSearchRequest, evalFhirPath } from '@medplum/core';
2
2
  import DataLoader from 'dataloader';
3
3
  import { applyPatch } from 'rfc6902';
4
4
 
@@ -53,6 +53,11 @@ class BatchProcessor {
53
53
  const resultEntries = [];
54
54
  for (const entry of entries) {
55
55
  const rewritten = this.rewriteIdsInObject(entry);
56
+ // If the resource 'id' element is specified, we want to replace teh `urn:uuid:*` string and
57
+ // remove the `resourceType` prefix
58
+ if (entry?.resource?.id) {
59
+ rewritten.resource.id = this.rewriteIdsInString(entry.resource.id, true);
60
+ }
56
61
  try {
57
62
  resultEntries.push(await this.processBatchEntry(rewritten));
58
63
  }
@@ -156,13 +161,17 @@ class BatchProcessor {
156
161
  rewriteIdsInObject(input) {
157
162
  return Object.fromEntries(Object.entries(input).map(([k, v]) => [k, this.rewriteIds(v)]));
158
163
  }
159
- rewriteIdsInString(input) {
164
+ rewriteIdsInString(input, removeResourceType = false) {
160
165
  const matches = input.match(/urn:uuid:\w{8}-\w{4}-\w{4}-\w{4}-\w{12}/);
161
166
  if (matches) {
162
167
  const fullUrl = matches[0];
163
168
  const resource = this.ids[fullUrl];
164
169
  if (resource) {
165
- return input.replaceAll(fullUrl, getReferenceString(resource));
170
+ let referenceString = getReferenceString(resource);
171
+ if (removeResourceType) {
172
+ referenceString = resolveId({ reference: referenceString });
173
+ }
174
+ return referenceString ? input.replaceAll(fullUrl, referenceString) : input;
166
175
  }
167
176
  }
168
177
  return input;
@@ -13464,6 +13473,12 @@ function buildRootSchema() {
13464
13473
  args: buildSearchArgs(resourceType),
13465
13474
  resolve: resolveBySearch,
13466
13475
  };
13476
+ // FHIR GraphQL Connection API
13477
+ fields[resourceType + 'Connection'] = {
13478
+ type: buildConnectionType(resourceType, graphQLType),
13479
+ args: buildSearchArgs(resourceType),
13480
+ resolve: resolveByConnectionApi,
13481
+ };
13467
13482
  }
13468
13483
  return new GraphQLSchema({
13469
13484
  query: new GraphQLObjectType({
@@ -13522,17 +13537,95 @@ function buildPropertyFields(resourceType, fields) {
13522
13537
  for (const key of Object.keys(properties)) {
13523
13538
  const elementDefinition = getElementDefinition(resourceType, key);
13524
13539
  for (const type of elementDefinition.type) {
13525
- let typeName = type.code;
13526
- if (typeName === 'Element' || typeName === 'BackboneElement') {
13527
- typeName = buildTypeName(elementDefinition.path?.split('.'));
13540
+ buildPropertyField(fields, key, elementDefinition, type);
13541
+ }
13542
+ }
13543
+ }
13544
+ function buildPropertyField(fields, key, elementDefinition, elementDefinitionType) {
13545
+ let typeName = elementDefinitionType.code;
13546
+ if (typeName === 'Element' || typeName === 'BackboneElement') {
13547
+ typeName = buildTypeName(elementDefinition.path?.split('.'));
13548
+ }
13549
+ const fieldConfig = {
13550
+ description: elementDefinition.short,
13551
+ type: getPropertyType(elementDefinition, typeName),
13552
+ resolve: resolveField,
13553
+ };
13554
+ if (elementDefinition.max === '*') {
13555
+ fieldConfig.args = buildListPropertyFieldArgs(typeName);
13556
+ }
13557
+ const propertyName = key.replace('[x]', capitalize(elementDefinitionType.code));
13558
+ fields[propertyName] = fieldConfig;
13559
+ }
13560
+ /**
13561
+ * Builds field arguments for a list property.
13562
+ *
13563
+ * The FHIR GraphQL specification defines the following arguments for list properties:
13564
+ * 1. _count: Specify how many elements to return from a repeating list.
13565
+ * 2. _offset: Specify the offset to start at for a repeating element.
13566
+ * 3. fhirpath: A FHIRPath statement selecting which of the subnodes is to be included.
13567
+ * 4. All properties of the list element type.
13568
+ *
13569
+ * See: https://hl7.org/fhir/R4/graphql.html#list
13570
+ *
13571
+ * @param fieldTypeName The type name of the field.
13572
+ * @returns The arguments for the field.
13573
+ */
13574
+ function buildListPropertyFieldArgs(fieldTypeName) {
13575
+ const fieldArgs = {
13576
+ _count: {
13577
+ type: GraphQLInt,
13578
+ description: 'Specify how many elements to return from a repeating list.',
13579
+ },
13580
+ _offset: {
13581
+ type: GraphQLInt,
13582
+ description: 'Specify the offset to start at for a repeating element.',
13583
+ },
13584
+ };
13585
+ if (!isLowerCase(fieldTypeName.charAt(0))) {
13586
+ // If this is a backbone element, add "fhirpath" and all properties as arguments
13587
+ fieldArgs.fhirpath = {
13588
+ type: GraphQLString,
13589
+ description: 'A FHIRPath statement selecting which of the subnodes is to be included',
13590
+ };
13591
+ // Add all "string" and "code" properties as arguments
13592
+ const fieldTypeSchema = globalSchema.types[fieldTypeName];
13593
+ if (fieldTypeSchema.properties) {
13594
+ for (const fieldKey of Object.keys(fieldTypeSchema.properties)) {
13595
+ const fieldElementDefinition = getElementDefinition(fieldTypeName, fieldKey);
13596
+ for (const type of fieldElementDefinition.type) {
13597
+ buildListPropertyFieldArg(fieldArgs, fieldKey, fieldElementDefinition, type);
13598
+ }
13528
13599
  }
13529
- const fieldConfig = {
13600
+ }
13601
+ }
13602
+ return fieldArgs;
13603
+ }
13604
+ /**
13605
+ * Builds a field argument for a list property.
13606
+ * @param fieldArgs The output argument map.
13607
+ * @param fieldKey The key of the field.
13608
+ * @param elementDefinition The FHIR element definition of the field.
13609
+ * @param elementDefinitionType The FHIR element definition type of the field.
13610
+ */
13611
+ function buildListPropertyFieldArg(fieldArgs, fieldKey, elementDefinition, elementDefinitionType) {
13612
+ const baseType = elementDefinitionType.code;
13613
+ const fieldName = fieldKey.replace('[x]', capitalize(baseType));
13614
+ switch (baseType) {
13615
+ case 'canonical':
13616
+ case 'code':
13617
+ case 'id':
13618
+ case 'oid':
13619
+ case 'string':
13620
+ case 'uri':
13621
+ case 'url':
13622
+ case 'uuid':
13623
+ case 'http://hl7.org/fhirpath/System.String':
13624
+ fieldArgs[fieldName] = {
13625
+ type: GraphQLString,
13530
13626
  description: elementDefinition.short,
13531
- type: getPropertyType(elementDefinition, typeName),
13532
13627
  };
13533
- const propertyName = key.replace('[x]', capitalize(type.code));
13534
- fields[propertyName] = fieldConfig;
13535
- }
13628
+ break;
13536
13629
  }
13537
13630
  }
13538
13631
  /**
@@ -13638,6 +13731,30 @@ function getPropertyType(elementDefinition, typeName) {
13638
13731
  }
13639
13732
  return graphqlType;
13640
13733
  }
13734
+ function buildConnectionType(resourceType, resourceGraphQLType) {
13735
+ return new GraphQLObjectType({
13736
+ name: resourceType + 'Connection',
13737
+ fields: {
13738
+ count: { type: GraphQLInt },
13739
+ offset: { type: GraphQLInt },
13740
+ pageSize: { type: GraphQLInt },
13741
+ first: { type: GraphQLString },
13742
+ previous: { type: GraphQLString },
13743
+ next: { type: GraphQLString },
13744
+ last: { type: GraphQLString },
13745
+ edges: {
13746
+ type: new GraphQLList(new GraphQLObjectType({
13747
+ name: resourceType + 'ConnectionEdge',
13748
+ fields: {
13749
+ mode: { type: GraphQLString },
13750
+ score: { type: GraphQLFloat },
13751
+ resource: { type: resourceGraphQLType },
13752
+ },
13753
+ })),
13754
+ },
13755
+ },
13756
+ });
13757
+ }
13641
13758
  /**
13642
13759
  * GraphQL data loader for search requests.
13643
13760
  * The field name should always end with "List" (i.e., "Patient" search uses "PatientList").
@@ -13651,11 +13768,41 @@ function getPropertyType(elementDefinition, typeName) {
13651
13768
  */
13652
13769
  async function resolveBySearch(source, args, ctx, info) {
13653
13770
  const fieldName = info.fieldName;
13654
- const resourceType = fieldName.substring(0, fieldName.length - 4); // Remove "List"
13771
+ const resourceType = fieldName.substring(0, fieldName.length - 'List'.length);
13655
13772
  const searchRequest = parseSearchArgs(resourceType, source, args);
13656
13773
  const bundle = await ctx.repo.search(searchRequest);
13657
13774
  return bundle.entry?.map((e) => e.resource);
13658
13775
  }
13776
+ /**
13777
+ * GraphQL data loader for search requests.
13778
+ * The field name should always end with "List" (i.e., "Patient" search uses "PatientList").
13779
+ * The search args should be FHIR search parameters.
13780
+ * @param source The source/root. This should always be null for our top level readers.
13781
+ * @param args The GraphQL search arguments.
13782
+ * @param ctx The GraphQL context.
13783
+ * @param info The GraphQL resolve info. This includes the schema, and additional field details.
13784
+ * @returns Promise to read the resoures for the query.
13785
+ * @implements {GraphQLFieldResolver}
13786
+ */
13787
+ async function resolveByConnectionApi(source, args, ctx, info) {
13788
+ const fieldName = info.fieldName;
13789
+ const resourceType = fieldName.substring(0, fieldName.length - 'Connection'.length);
13790
+ const searchRequest = parseSearchArgs(resourceType, source, args);
13791
+ if (isFieldRequested(info, 'count')) {
13792
+ searchRequest.total = 'accurate';
13793
+ }
13794
+ const bundle = await ctx.repo.search(searchRequest);
13795
+ return {
13796
+ count: bundle.total,
13797
+ offset: searchRequest.offset || 0,
13798
+ pageSize: searchRequest.count || DEFAULT_SEARCH_COUNT,
13799
+ edges: bundle.entry?.map((e) => ({
13800
+ mode: e.search?.mode,
13801
+ score: e.search?.score,
13802
+ resource: e.resource,
13803
+ })),
13804
+ };
13805
+ }
13659
13806
  /**
13660
13807
  * GraphQL data loader for ID requests.
13661
13808
  * The field name should always by the resource type.
@@ -13706,6 +13853,38 @@ function resolveTypeByReference(resource) {
13706
13853
  }
13707
13854
  return getGraphQLType(resourceType).name;
13708
13855
  }
13856
+ /**
13857
+ * GraphQL resolver for fields.
13858
+ * In the common case, this is just a matter of returning the field value from the source object.
13859
+ * If the field is a list and the user specifies list arguments, then we can apply those arguments here.
13860
+ * @param source The source. This is the object that contains the field.
13861
+ * @param args The GraphQL search arguments.
13862
+ * @param _ctx The GraphQL context.
13863
+ * @param info The GraphQL resolve info. This includes the field name.
13864
+ * @returns Promise to read the resoure for the query.
13865
+ * @implements {GraphQLFieldResolver}
13866
+ */
13867
+ async function resolveField(source, args, _ctx, info) {
13868
+ const fieldValue = source?.[info.fieldName];
13869
+ if (!args || !fieldValue) {
13870
+ return fieldValue;
13871
+ }
13872
+ const { _offset, _count, fhirpath, ...rest } = args;
13873
+ let array = fieldValue;
13874
+ for (const [key, value] of Object.entries(rest)) {
13875
+ array = array.filter((item) => item[key] === value);
13876
+ }
13877
+ if (fhirpath) {
13878
+ array = array.filter((item) => toJsBoolean(evalFhirPathTyped(fhirpath, [toTypedValue(item)])));
13879
+ }
13880
+ if (_offset) {
13881
+ array = array.slice(_offset);
13882
+ }
13883
+ if (_count) {
13884
+ array = array.slice(0, _count);
13885
+ }
13886
+ return array;
13887
+ }
13709
13888
  function parseSearchArgs(resourceType, source, args) {
13710
13889
  let referenceFilter = undefined;
13711
13890
  if (source) {
@@ -13772,6 +13951,17 @@ const MaxDepthRule = (context) => ({
13772
13951
  function getDepth(path) {
13773
13952
  return path.filter((p) => p === 'selections').length;
13774
13953
  }
13954
+ /**
13955
+ * Returns true if the field is requested in the GraphQL query.
13956
+ * @param info The GraphQL resolve info. This includes the field name.
13957
+ * @param fieldName The field name to check.
13958
+ * @returns True if the field is requested in the GraphQL query.
13959
+ */
13960
+ function isFieldRequested(info, fieldName) {
13961
+ return info.fieldNodes.some((fieldNode) => fieldNode.selectionSet?.selections.some((selection) => {
13962
+ return selection.kind === 'Field' && selection.name.value === fieldName;
13963
+ }));
13964
+ }
13775
13965
  /**
13776
13966
  * Returns an OperationOutcome for GraphQL errors.
13777
13967
  * @param errors Array of GraphQL errors.