Skip to content

Commit

Permalink
Expand profile operation (medplum#3875)
Browse files Browse the repository at this point in the history
* Add StructureDefinition $expandProfile operation

* Use new endpoint in medplum.requestProfileSchema

* Use new endpoint when viewing profiles

* Add tests

* Make most search tests project-scoped

Previously, all search tests used systemRepo. Since systemRepo searches all projects by default, this had the potential for resources created by other tests to alter the results of search tests

* Fix profile stories

* More tests

* Reduce complexity

* Add circuit breaker(s)

* PR feedback on client

* Remove test.only
  • Loading branch information
mattlong committed Feb 8, 2024
1 parent bc52f01 commit 1182202
Show file tree
Hide file tree
Showing 11 changed files with 3,598 additions and 2,985 deletions.
13 changes: 12 additions & 1 deletion packages/app/src/resource/ProfilesPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,26 @@ import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Suspense } from 'react';
import { MemoryRouter } from 'react-router-dom';
import { AppRoutes } from '../AppRoutes';
import { loadDataType } from '@medplum/core';

const medplum = new MockClient();

describe('ProfilesPage', () => {
const fishPatientProfile = FishPatientResources.getFishPatientProfileSD();
beforeAll(async () => {
const loadedProfileUrls: string[] = [];
for (const profile of [fishPatientProfile, FishPatientResources.getFishSpeciesExtensionSD()]) {
await medplum.createResourceIfNoneExist<StructureDefinition>(profile, `url:${profile.url}`);
const sd = await medplum.createResourceIfNoneExist<StructureDefinition>(profile, `url:${profile.url}`);
loadedProfileUrls.push(sd.url);
loadDataType(sd, sd.url);
}
medplum.requestProfileSchema = jest.fn((profileUrl) => {
if (loadedProfileUrls.includes(profileUrl)) {
return Promise.resolve([profileUrl]);
} else {
throw new Error('unexpected profileUrl');
}
});
});

async function setup(url: string): Promise<void> {
Expand Down
53 changes: 50 additions & 3 deletions packages/core/src/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ const schemaResponse = {
};

const patientProfileUrl = 'http:https://example.com/patient-profile';
const patientProfileExtensionUrl = 'http:https://example.com/patient-profile-extension';

const profileSchemaResponse = {
const profileSD = {
resourceType: 'StructureDefinition',
name: 'PatientProfile',
url: patientProfileUrl,
Expand Down Expand Up @@ -102,6 +103,31 @@ const profileSchemaResponse = {
},
],
},
{
path: 'Patient.extension',
sliceName: 'fancy',
type: [
{
code: 'Extension',
profile: [patientProfileExtensionUrl],
},
],
},
],
},
};

const profileExtensionSD = {
resourceType: 'StructureDefinition',
type: 'Extension',
derivation: 'constraint',
name: 'PatientProfile',
url: patientProfileExtensionUrl,
snapshot: {
element: [
{
path: 'Extension',
},
],
},
};
Expand Down Expand Up @@ -1709,7 +1735,7 @@ describe('Client', () => {
test('requestProfileSchema', async () => {
const fetch = mockFetch(200, {
resourceType: 'Bundle',
entry: [{ resource: profileSchemaResponse }],
entry: [{ resource: profileSD }],
});

const client = new MedplumClient({ fetch });
Expand All @@ -1721,7 +1747,28 @@ describe('Client', () => {

await request1;
expect(isProfileLoaded(patientProfileUrl)).toBe(true);
expect(getDataType(profileSchemaResponse.name, patientProfileUrl)).toBeDefined();
expect(getDataType(profileSD.name, patientProfileUrl)).toBeDefined();
});

test('requestProfileSchema expandProfile', async () => {
const fetch = mockFetch(200, {
resourceType: 'Bundle',
entry: [{ resource: profileSD }, { resource: profileExtensionSD }],
});

const client = new MedplumClient({ fetch });

// Issue two requests simultaneously
const request1 = client.requestProfileSchema(patientProfileUrl, { expandProfile: true });
const request2 = client.requestProfileSchema(patientProfileUrl, { expandProfile: true });
expect(request2).toBe(request1);

await request1;
await request2;
expect(isProfileLoaded(patientProfileUrl)).toBe(true);
expect(isProfileLoaded(patientProfileExtensionUrl)).toBe(true);
expect(getDataType(profileSD.name, patientProfileUrl)).toBeDefined();
expect(getDataType(profileExtensionSD.name, patientProfileExtensionUrl)).toBeDefined();
});

test('Search', async () => {
Expand Down
50 changes: 34 additions & 16 deletions packages/core/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import {
import { ReadablePromise } from './readablepromise';
import { ClientStorage, IClientStorage } from './storage';
import { indexSearchParameter } from './types';
import { indexStructureDefinitionBundle, isDataTypeLoaded, isProfileLoaded } from './typeschema/types';
import { indexStructureDefinitionBundle, isDataTypeLoaded, isProfileLoaded, loadDataType } from './typeschema/types';
import {
CodeChallengeMethod,
ProfileResource,
Expand Down Expand Up @@ -590,6 +590,11 @@ export interface ValueSetExpandParams {
count?: number;
}

export interface RequestProfileSchemaOptions {
/** (optional) Whether to include nested profiles, e.g. from extensions. Defaults to false. */
expandProfile?: boolean;
}

/**
* The MedplumClient class provides a client for the Medplum FHIR server.
*
Expand Down Expand Up @@ -1658,32 +1663,45 @@ export class MedplumClient extends EventTarget {
* If the schema is already cached, the promise is resolved immediately.
* @category Schema
* @param profileUrl - The FHIR URL of the profile
* @returns Promise to a schema with the requested profile.
* @param options - (optional) Additional options
* @returns Promise with an array of URLs of the profile(s) loaded.
*/
requestProfileSchema(profileUrl: string): Promise<void> {
if (isProfileLoaded(profileUrl)) {
return Promise.resolve();
requestProfileSchema(profileUrl: string, options?: RequestProfileSchemaOptions): Promise<string[]> {
if (!options?.expandProfile && isProfileLoaded(profileUrl)) {
return Promise.resolve([profileUrl]);
}

const cacheKey = profileUrl + '-requestSchema';
const cacheKey = profileUrl + '-requestSchema' + (options?.expandProfile ? '-nested' : '');
const cached = this.getCacheEntry(cacheKey, undefined);
if (cached) {
return cached.value;
}

const promise = new ReadablePromise<void>(
const promise = new ReadablePromise<string[]>(
(async () => {
// Just sort by lastUpdated. Ideally, it would also be based on a logical sort of version
// See https://hl7.org/fhir/references.html#canonical-matching for more discussion
const sd = await this.searchOne('StructureDefinition', {
url: profileUrl,
_sort: '-_lastUpdated',
});

if (!sd) {
console.warn(`No StructureDefinition found for ${profileUrl}!`);
if (options?.expandProfile) {
const url = this.fhirUrl('StructureDefinition', '$expand-profile');
url.search = new URLSearchParams({ url: profileUrl }).toString();
const sdBundle = await this.get<Bundle<StructureDefinition>>(url.toString());
return bundleToResourceArray(sdBundle).map((sd) => {
loadDataType(sd, sd.url);
return sd.url;
});
} else {
// Just sort by lastUpdated. Ideally, it would also be based on a logical sort of version
// See https://hl7.org/fhir/references.html#canonical-matching for more discussion
const sd = await this.searchOne('StructureDefinition', {
url: profileUrl,
_sort: '-_lastUpdated',
});

if (!sd) {
console.warn(`No StructureDefinition found for ${profileUrl}!`);
return [];
}

indexStructureDefinitionBundle([sd], profileUrl);
return [profileUrl];
}
})()
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ export async function prepareSlices({

const supportedSlices: SupportedSliceDefinition[] = [];
const profileUrls: (string | undefined)[] = [];
const promises: Promise<void>[] = [];
const promises: Promise<string[]>[] = [];
for (const slice of property.slicing.slices) {
if (!isSupportedSliceDefinition(slice)) {
console.debug('Unsupported slice definition', slice);
Expand All @@ -132,7 +132,7 @@ export async function prepareSlices({
if (profileUrl) {
promises.push(medplum.requestProfileSchema(profileUrl));
} else {
promises.push(Promise.resolve());
promises.push(Promise.resolve([]));
}
}

Expand Down
35 changes: 30 additions & 5 deletions packages/react/src/ResourceForm/ResourceForm.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import { Meta } from '@storybook/react';
import { Document } from '../Document/Document';
import { ResourceForm } from './ResourceForm';
import { useMedplum } from '@medplum/react-hooks';
import { useEffect, useMemo, useState } from 'react';
import { MedplumClient, deepClone, loadDataType } from '@medplum/core';
import { useEffect, useLayoutEffect, useMemo, useState } from 'react';
import { MedplumClient, RequestProfileSchemaOptions, deepClone, loadDataType } from '@medplum/core';
import { StructureDefinition } from '@medplum/fhirtypes';

export default {
Expand Down Expand Up @@ -167,7 +167,30 @@ function useUSCoreDataTypes({ medplum }: { medplum: MedplumClient }): { loaded:
return result;
}

function useProfile(profileName: string): StructureDefinition {
function useFakeRequestProfileSchema(medplum: MedplumClient): void {
useLayoutEffect(() => {
const realRequestProfileSchema = medplum.requestProfileSchema;
async function fakeRequestProfileSchema(
profileUrl: string,
options?: RequestProfileSchemaOptions
): Promise<string[]> {
console.log(
'Fake medplum.requestProfileSchema invoked but not doing anything; ensure expected profiles are already loaded',
profileUrl,
options
);
return [profileUrl];
}

medplum.requestProfileSchema = fakeRequestProfileSchema;

return () => {
medplum.requestProfileSchema = realRequestProfileSchema;
};
}, [medplum]);
}

function useUSCoreProfile(profileName: string): StructureDefinition {
const profileSD = useMemo<StructureDefinition>(() => {
const result = (USCoreStructureDefinitionList as StructureDefinition[]).find((sd) => sd.name === profileName);
if (!result) {
Expand All @@ -181,8 +204,9 @@ function useProfile(profileName: string): StructureDefinition {

export const USCorePatient = (): JSX.Element => {
const medplum = useMedplum();
useFakeRequestProfileSchema(medplum);
const { loaded } = useUSCoreDataTypes({ medplum });
const profileSD = useProfile('USCorePatientProfile');
const profileSD = useUSCoreProfile('USCorePatientProfile');

const homerSimpsonUSCorePatient = useMemo(() => {
return deepClone(HomerSimpsonUSCorePatient);
Expand All @@ -207,8 +231,9 @@ export const USCorePatient = (): JSX.Element => {

export const USCoreImplantableDevice = (): JSX.Element => {
const medplum = useMedplum();
useFakeRequestProfileSchema(medplum);
const { loaded } = useUSCoreDataTypes({ medplum });
const profileSD = useProfile('USCoreImplantableDeviceProfile');
const profileSD = useUSCoreProfile('USCoreImplantableDeviceProfile');

const implantedKnee = useMemo(() => {
return deepClone(ImplantableDeviceKnee);
Expand Down
31 changes: 27 additions & 4 deletions packages/react/src/ResourceForm/ResourceForm.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { createReference } from '@medplum/core';
import { HTTP_HL7_ORG, createReference, loadDataType } from '@medplum/core';
import { Observation, Patient, Specimen } from '@medplum/fhirtypes';
import { HomerObservation1, MockClient } from '@medplum/mock';
import { HomerObservation1, MockClient, USCoreStructureDefinitionList } from '@medplum/mock';
import { MedplumProvider } from '@medplum/react-hooks';
import { convertIsoToLocal, convertLocalToIso } from '../DateTimeInput/DateTimeInput.utils';
import { act, fireEvent, render, screen, waitFor } from '../test-utils/render';
Expand All @@ -9,10 +9,10 @@ import { ResourceForm, ResourceFormProps } from './ResourceForm';
const medplum = new MockClient();

describe('ResourceForm', () => {
async function setup(props: ResourceFormProps): Promise<void> {
async function setup(props: ResourceFormProps, medplumClient?: MockClient): Promise<void> {
await act(async () => {
render(
<MedplumProvider medplum={medplum}>
<MedplumProvider medplum={medplumClient ?? medplum}>
<ResourceForm {...props} />
</MedplumProvider>
);
Expand Down Expand Up @@ -223,4 +223,27 @@ describe('ResourceForm', () => {
expect(patient.resourceType).toBe('Patient');
expect(patient.active).toBe(true);
});

test('With profileUrl specified', async () => {
const profileUrl = `${HTTP_HL7_ORG}/fhir/us/core/StructureDefinition/us-core-implantable-device`;
const profilesToLoad = [profileUrl, `${HTTP_HL7_ORG}/fhir/us/core/StructureDefinition/us-core-patient`];
for (const url of profilesToLoad) {
const sd = USCoreStructureDefinitionList.find((sd) => sd.url === url);
if (!sd) {
fail(`could not find structure definition for ${url}`);
}
loadDataType(sd, sd.url);
}

const onSubmit = jest.fn();

const mockedMedplum = new MockClient();
const fakeRequestProfileSchema = jest.fn(async (profileUrl: string) => {
return [profileUrl];
});
mockedMedplum.requestProfileSchema = fakeRequestProfileSchema;
await setup({ defaultValue: { resourceType: 'Device' }, profileUrl, onSubmit }, mockedMedplum);

expect(fakeRequestProfileSchema).toHaveBeenCalledTimes(1);
});
});
8 changes: 5 additions & 3 deletions packages/react/src/ResourceForm/ResourceForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,16 +29,18 @@ export function ResourceForm(props: ResourceFormProps): JSX.Element {
if (props.profileUrl) {
const profileUrl: string = props.profileUrl;
medplum
.requestProfileSchema(props.profileUrl)
.requestProfileSchema(props.profileUrl, { expandProfile: true })
.then(() => {
const profile = tryGetProfile(profileUrl);
if (profile) {
setSchemaLoaded(profile.name);
} else {
console.log(`Schema not found for ${profileUrl}`);
console.error(`Schema not found for ${profileUrl}`);
}
})
.catch(console.log);
.catch((reason) => {
console.error('Error in requestProfileSchema', reason);
});
} else {
const schemaName = props.schemaName ?? defaultValue?.resourceType;
medplum
Expand Down
Loading

0 comments on commit 1182202

Please sign in to comment.