Skip to main content
Glama
client.ts25.9 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import type { BinarySource, CreateBinaryOptions, LoginState, MedplumClientOptions, MedplumRequestOptions, ProfileResource, SubscriptionEmitter, WithId, } from '@medplum/core'; import { ContentType, MedplumClient, OperationOutcomeError, allOk, badRequest, generateId, getReferenceString, getStatus, indexSearchParameter, loadDataType, normalizeCreateBinaryOptions, } from '@medplum/core'; import type { FhirRequest, HttpMethod } from '@medplum/fhir-router'; import { FhirRouter, MemoryRepository } from '@medplum/fhir-router'; import type { Agent, Binary, Bot, Device, Reference, Resource, SearchParameter, StructureDefinition, Subscription, UserConfiguration, } from '@medplum/fhirtypes'; // eslint-disable-next-line @typescript-eslint/ban-ts-comment /** @ts-ignore */ import type { CustomTableLayout, TDocumentDefinitions, TFontDictionary } from 'pdfmake/interfaces'; import { BartSimpson, DifferentOrganization, DrAliceSmith, DrAliceSmithPreviousVersion, DrAliceSmithSchedule, ExampleBot, ExampleClient, ExampleQuestionnaire, ExampleQuestionnaireResponse, ExampleSubscription, ExampleThreadHeader, ExampleThreadMessages, HomerCommunication, HomerDiagnosticReport, HomerEncounter, HomerMedia, HomerObservation1, HomerObservation2, HomerObservation3, HomerObservation4, HomerObservation5, HomerObservation6, HomerObservation7, HomerObservation8, HomerServiceRequest, HomerSimpson, HomerSimpsonPreviousVersion, HomerSimpsonSpecimen, TestOrganization, exampleValueSet, makeDrAliceSmithSlots, } from './mocks'; import { ExampleAccessPolicy, ExampleStatusValueSet, ExampleUserConfiguration } from './mocks/accesspolicy'; import { TestProject, TestProjectMembership } from './mocks/project'; import SearchParameterList from './mocks/searchparameters.json'; import { ExampleSmartClientApplication } from './mocks/smart'; import StructureDefinitionList from './mocks/structuredefinitions.json'; import { ExampleWorkflowPlanDefinition, ExampleWorkflowQuestionnaire1, ExampleWorkflowQuestionnaire2, ExampleWorkflowQuestionnaire3, ExampleWorkflowQuestionnaireResponse1, ExampleWorkflowRequestGroup, ExampleWorkflowTask1, ExampleWorkflowTask2, ExampleWorkflowTask3, } from './mocks/workflow'; import { MockSubscriptionManager } from './subscription-manager'; export interface MockClientOptions extends Pick< MedplumClientOptions, 'baseUrl' | 'clientId' | 'storage' | 'cacheTime' | 'fetch' > { readonly debug?: boolean; /** * Override currently logged in user. Specifying null results in * MedplumContext.profile returning undefined as if no one were logged in. */ readonly profile?: ReturnType<MedplumClient['getProfile']> | null; /** * Override the `MockFetchClient` used by this `MockClient`. */ readonly mockFetchOverride?: MockFetchOverrideOptions; } /** * Override must contain all of `router`, `repo`, and `client`. */ export type MockFetchOverrideOptions = { client: MockFetchClient; router: FhirRouter; repo: MemoryRepository; }; export class MockClient extends MedplumClient { readonly router: FhirRouter; readonly repo: MemoryRepository; readonly client: MockFetchClient; readonly debug: boolean; activeLoginOverride?: LoginState; private agentAvailable = true; private profile: ReturnType<MedplumClient['getProfile']>; subManager: MockSubscriptionManager | undefined; constructor(clientOptions?: MockClientOptions) { const baseUrl = clientOptions?.baseUrl ?? 'https://example.com/'; let router: FhirRouter; let repo: MemoryRepository; let client: MockFetchClient; if (clientOptions?.mockFetchOverride) { if ( !( clientOptions.mockFetchOverride?.router && clientOptions.mockFetchOverride?.repo && clientOptions.mockFetchOverride?.client ) ) { throw new Error('mockFetchOverride must specify all fields: client, repo, router'); } router = clientOptions.mockFetchOverride.router; repo = clientOptions.mockFetchOverride.repo; client = clientOptions.mockFetchOverride.client; } else { router = new FhirRouter(); repo = new MemoryRepository(); client = new MockFetchClient(router, repo, baseUrl, clientOptions?.debug); } super({ baseUrl, clientId: clientOptions?.clientId, storage: clientOptions?.storage, cacheTime: clientOptions?.cacheTime, createPdf: ( docDefinition: TDocumentDefinitions, tableLayouts?: { [name: string]: CustomTableLayout }, fonts?: TFontDictionary ) => client.mockCreatePdf(docDefinition, tableLayouts, fonts), fetch: clientOptions?.fetch ? clientOptions.fetch : (url: string, options: any) => { return client.mockFetch(url, options); }, }); this.router = router; this.repo = repo; this.client = client; // if null is specified, treat it as if no one is logged in this.profile = clientOptions?.profile === null ? undefined : (clientOptions?.profile ?? DrAliceSmith); this.debug = !!clientOptions?.debug; } clear(): void { super.clear(); this.activeLoginOverride = undefined; } getProfile(): ProfileResource | undefined { return this.profile; } getUserConfiguration(): WithId<UserConfiguration> | undefined { return { resourceType: 'UserConfiguration', id: 'mock-user-config', menu: [ { title: 'Favorites', link: [ { name: 'Patients', target: '/Patient' }, { name: 'Active Orders', target: '/ServiceRequest?status=active' }, { name: 'Completed Orders', target: '/ServiceRequest?status=completed' }, ], }, { title: 'Admin', link: [ { name: 'Project', target: '/admin/project' }, { name: 'Batch', target: '/batch' }, ], }, ], }; } setActiveLoginOverride(activeLoginOverride: LoginState): void { this.activeLoginOverride = activeLoginOverride; } setProfile(profile: ProfileResource | undefined): void { this.profile = profile; this.dispatchEvent({ type: 'change' }); } getActiveLogin(): LoginState | undefined { if (this.activeLoginOverride !== undefined) { return this.activeLoginOverride; } return super.getActiveLogin(); } getLogins(): LoginState[] { if (this.activeLoginOverride !== undefined) { return [this.activeLoginOverride]; } return super.getLogins(); } /** * Creates a FHIR `Binary` resource with the provided data content. * * The return value is the newly created resource, including the ID and meta. * * The `data` parameter can be a string or a `File` object. * * A `File` object often comes from a `<input type="file">` element. * * @example * Example: * * ```typescript * const result = await medplum.createBinary(myFile, 'test.jpg', 'image/jpeg'); * console.log(result.id); * ``` * * See the FHIR "create" operation for full details: https://www.hl7.org/fhir/http.html#create * * @category Create * @param createBinaryOptions -The binary options. See `CreateBinaryOptions` for full details. * @param requestOptions - Optional fetch options. **NOTE:** only `options.signal` is respected when `onProgress` is also provided. * @returns The result of the create operation. */ createBinary( createBinaryOptions: CreateBinaryOptions, requestOptions?: MedplumRequestOptions ): Promise<WithId<Binary>>; /** * @category Create * @param data - The binary data to upload. * @param filename - Optional filename for the binary. * @param contentType - Content type for the binary. * @param onProgress - Optional callback for progress events. **NOTE:** only `options.signal` is respected when `onProgress` is also provided. * @param options - Optional fetch options. **NOTE:** only `options.signal` is respected when `onProgress` is also provided. * @returns The result of the create operation. * @deprecated Use `createBinary` with `CreateBinaryOptions` instead. To be removed in a future version. */ createBinary( data: BinarySource, filename: string | undefined, contentType: string, onProgress?: (e: ProgressEvent) => void, options?: MedplumRequestOptions ): Promise<WithId<Binary>>; async createBinary( arg1: BinarySource | CreateBinaryOptions, arg2: string | undefined | MedplumRequestOptions, arg3?: string, arg4?: (e: ProgressEvent) => void ): Promise<WithId<Binary>> { const createBinaryOptions = normalizeCreateBinaryOptions(arg1, arg2, arg3, arg4); const { filename, contentType, onProgress, securityContext } = createBinaryOptions; if (filename?.endsWith('.exe')) { throw new OperationOutcomeError(badRequest('Invalid file type')); } if (onProgress) { onProgress({ loaded: 0, lengthComputable: false } as ProgressEvent); onProgress({ loaded: 0, total: 100, lengthComputable: true } as ProgressEvent); onProgress({ loaded: 100, total: 100, lengthComputable: true } as ProgressEvent); } let data: string | undefined; if (typeof createBinaryOptions.data === 'string') { data = base64Encode(createBinaryOptions.data); } const binary = await this.repo.createResource<Binary>({ resourceType: 'Binary', contentType, data, securityContext, }); return { ...binary, url: `https://example.com/binary/${binary.id}`, }; } async pushToAgent( agent: Agent | Reference<Agent>, destination: Device | Reference<Device> | string, body: any, contentType?: string, _waitForResponse?: boolean, _options?: MedplumRequestOptions ): Promise<any> { if (contentType === ContentType.PING) { if (!this.agentAvailable) { throw new OperationOutcomeError(badRequest('Timeout')); } if (typeof destination !== 'string' || (destination !== '8.8.8.8' && destination !== 'localhost')) { // Exception for test case if (destination !== 'abc123') { console.warn( 'IPs other than 8.8.8.8 and hostnames other than `localhost` will always throw an error in MockClient' ); } throw new OperationOutcomeError(badRequest('Destination device not found')); } const ip = destination === 'localhost' ? '127.0.0.1' : destination; return `PING ${destination} (${ip}): 56 data bytes 64 bytes from ${ip}: icmp_seq=0 ttl=115 time=10.977 ms 64 bytes from ${ip}: icmp_seq=1 ttl=115 time=13.037 ms 64 bytes from ${ip}: icmp_seq=2 ttl=115 time=23.159 ms 64 bytes from ${ip}: icmp_seq=3 ttl=115 time=12.725 ms --- ${destination} ping statistics --- 4 packets transmitted, 4 packets received, 0.0% packet loss round-trip min/avg/max/stddev = 10.977/14.975/23.159/4.790 ms `; } return undefined; } setAgentAvailable(value: boolean): void { this.agentAvailable = value; } getSubscriptionManager(): MockSubscriptionManager { if (!this.subManager) { this.subManager = new MockSubscriptionManager(this, 'wss://example.com/ws/subscriptions-r4', { mockReconnectingWebSocket: true, }); } return this.subManager; } setSubscriptionManager(subManager: MockSubscriptionManager): void { if (this.subManager) { this.subManager.closeWebSocket(); } this.subManager = subManager; } subscribeToCriteria(criteria: string, subscriptionProps?: Partial<Subscription>): SubscriptionEmitter { return this.getSubscriptionManager().addCriteria(criteria, subscriptionProps); } unsubscribeFromCriteria(criteria: string, subscriptionProps?: Partial<Subscription>): void { this.getSubscriptionManager().removeCriteria(criteria, subscriptionProps); } getMasterSubscriptionEmitter(): SubscriptionEmitter { return this.getSubscriptionManager().getMasterEmitter(); } } export class MockFetchClient { readonly router: FhirRouter; readonly repo: MemoryRepository; readonly baseUrl: string; readonly debug: boolean; initialized = false; initPromise?: Promise<void>; constructor(router: FhirRouter, repo: MemoryRepository, baseUrl: string, debug = false) { this.router = router; this.repo = repo; this.baseUrl = baseUrl; this.debug = debug; } async mockFetch(url: string, options: any): Promise<Partial<Response>> { if (!this.initialized) { if (!this.initPromise) { this.initPromise = this.initMockRepo(); } await this.initPromise; this.initialized = true; } const method = options.method ?? 'GET'; const path = url.replace(this.baseUrl, ''); if (this.debug) { console.log('MockClient', method, path); } const response = await this.mockHandler(method, path, options); if (this.debug && !response) { console.log('MockClient: not found', method, path); } if (this.debug) { console.log('MockClient', JSON.stringify(response, null, 2)); } return { ok: true, status: response?.resourceType === 'OperationOutcome' ? getStatus(response) : 200, headers: new Headers({ 'content-type': ContentType.FHIR_JSON, }), blob: () => Promise.resolve(response), json: () => Promise.resolve(response), text: () => Promise.resolve(response), }; } mockCreatePdf( docDefinition: TDocumentDefinitions, tableLayouts?: { [name: string]: CustomTableLayout }, fonts?: TFontDictionary ): Promise<any> { if (this.debug) { console.log(`Mock Client: createPdf(`); console.log(` ${JSON.stringify(docDefinition, null, 2)},`); console.log(` ${JSON.stringify(tableLayouts, null, 2)},`); console.log(` ${JSON.stringify(fonts, null, 2)});`); } return Promise.resolve({}); } private async mockHandler(method: HttpMethod, path: string, options: any): Promise<any> { if (path.startsWith('admin/')) { return this.mockAdminHandler(method, path, options); } else if (path.startsWith('auth/')) { return this.mockAuthHandler(method, path, options); } else if (path.startsWith('oauth2/')) { return this.mockOAuthHandler(method, path, options); } else if (path.startsWith('fhir/R4')) { return this.mockFhirHandler(method, path, options); } else { return null; } } private async mockAdminHandler(method: string, path: string, options: RequestInit): Promise<any> { if (path === 'admin/projects/setpassword' && method.toUpperCase() === 'POST') { return { ok: true }; } // Create new bot const botCreateMatch = /^admin\/projects\/([\w(-)?]+)\/bot$/.exec(path); if (botCreateMatch && method.toUpperCase() === 'POST') { const body = options.body; let jsonBody: Record<string, any> | undefined; if (body) { jsonBody = JSON.parse(body as string); } const binary = await this.repo.createResource<Binary>({ id: generateId(), resourceType: 'Binary', contentType: ContentType.TYPESCRIPT, }); const projectId = botCreateMatch[1]; return this.repo.createResource<Bot>({ meta: { project: projectId, }, id: generateId(), resourceType: 'Bot', name: jsonBody?.name, description: jsonBody?.description, runtimeVersion: jsonBody?.runtimeVersion ?? 'awslambda', sourceCode: { contentType: ContentType.TYPESCRIPT, title: 'index.ts', url: getReferenceString(binary), }, }); } const projectMatch = /^admin\/projects\/([\w(-)?]+)$/.exec(path); if (projectMatch) { return { project: await this.repo.readResource('Project', projectMatch[1]), }; } const membershipMatch = /^admin\/projects\/([\w(-)?]+)\/members\/([\w(-)?]+)$/.exec(path); if (membershipMatch) { return this.repo.readResource('ProjectMembership', membershipMatch[2]); } return { ok: true }; } private mockAuthHandler(method: HttpMethod, path: string, options: any): any { if (path.startsWith('auth/method')) { return {}; } if (path.startsWith('auth/changepassword')) { return this.mockChangePasswordHandler(method, path, options); } if (path.startsWith('auth/login')) { return this.mockLoginHandler(method, path, options); } if (path.startsWith('auth/setpassword')) { return this.mockSetPasswordHandler(method, path, options); } if (path.startsWith('auth/newuser')) { return this.mockNewUserHandler(method, path, options); } if (path.startsWith('auth/newproject') || path.startsWith('auth/newpatient') || path.startsWith('auth/scope')) { return { login: '123', code: 'xyz', }; } if (path.startsWith('auth/resetpassword')) { return this.mockResetPasswordHandler(method, path, options); } if (path.startsWith('auth/me')) { return { profile: DrAliceSmith, security: { mfaEnrolled: false, sessions: [ { id: '123', lastUpdated: new Date().toISOString(), authMethod: 'password', remoteAddress: '5.5.5.5', browser: 'Chrome', os: 'Linux', }, { id: '456', lastUpdated: new Date().toISOString(), authMethod: 'password', remoteAddress: '6.6.6.6', browser: 'Chrome', os: 'Android', }, ], }, }; } if (path.startsWith('auth/mfa/status')) { return { enrolled: false, enrollUri: 'otpauth://totp/medplum.com:alice.smith%40example', }; } if (path.startsWith('auth/mfa/enroll')) { return allOk; } if (path.startsWith('auth/mfa/verify')) { return { login: '123', code: 'xyz', }; } if (path.startsWith('auth/mfa/disable')) { if (options.body && JSON.parse(options.body)?.token !== 'INVALID_TOKEN') { return allOk; } return badRequest('Invalid token'); } return null; } private mockChangePasswordHandler(_method: string, _path: string, options: any): any { const { body } = options; const { oldPassword } = JSON.parse(body); if (oldPassword === 'orange') { return allOk; } else { return { resourceType: 'OperationOutcome', issue: [ { expression: ['oldPassword'], details: { text: 'Incorrect password', }, }, ], }; } } private mockLoginHandler(_method: string, _path: string, options: any): any { const { body } = options; const { password } = JSON.parse(body); if (password === 'password') { return { login: '123', code: 'xyz', }; } else { return { resourceType: 'OperationOutcome', issue: [ { expression: ['password'], details: { text: 'Invalid password', }, }, ], }; } } private mockSetPasswordHandler(_method: string, _path: string, options: any): any { const { body } = options; const { password } = JSON.parse(body); if (password === 'orange') { return allOk; } else { return { resourceType: 'OperationOutcome', issue: [ { expression: ['password'], details: { text: 'Invalid password', }, }, ], }; } } private mockNewUserHandler(_method: string, _path: string, options: any): any { const { body } = options; const { email, password } = JSON.parse(body); if (email === 'george@example.com' && password === 'password') { return allOk; } else { return { resourceType: 'OperationOutcome', issue: [ { details: { text: 'Invalid', }, }, ], }; } } private mockResetPasswordHandler(_method: string, _path: string, options: any): any { const { body } = options; const { email } = JSON.parse(body); if (email === 'admin@example.com') { return allOk; } else { return { resourceType: 'OperationOutcome', issue: [ { expression: ['email'], details: { text: 'Email not found', }, }, ], }; } } private mockOAuthHandler(_method: string, path: string, options: any): any { if (path.startsWith('oauth2/token')) { const formBody = new URLSearchParams(options.body); const clientId = formBody.get('client_id') ?? 'my-client-id'; return { access_token: createFakeJwt({ sub: '1234567890', iat: Math.ceil(Date.now() / 1000), exp: Math.ceil(Date.now() / 1000) + 60 * 60, // adding one hour in seconds client_id: clientId, login_id: '123', }), refresh_token: createFakeJwt({ client_id: 123 }), profile: { reference: 'Practitioner/123' }, }; } return null; } private async initMockRepo(): Promise<void> { const defaultResources = [ HomerSimpsonPreviousVersion, HomerSimpson, ExampleAccessPolicy, ExampleStatusValueSet, ExampleUserConfiguration, ExampleBot, ExampleClient, HomerDiagnosticReport, HomerEncounter, HomerCommunication, HomerMedia, HomerObservation1, HomerObservation2, HomerObservation3, HomerObservation4, HomerObservation5, HomerObservation6, HomerObservation7, HomerObservation8, HomerSimpsonSpecimen, TestOrganization, DifferentOrganization, ExampleQuestionnaire, ExampleQuestionnaireResponse, HomerServiceRequest, ExampleSubscription, BartSimpson, DrAliceSmithPreviousVersion, DrAliceSmith, DrAliceSmithSchedule, ExampleWorkflowQuestionnaire1, ExampleWorkflowQuestionnaire2, ExampleWorkflowQuestionnaire3, ExampleWorkflowPlanDefinition, ExampleWorkflowQuestionnaireResponse1, ExampleWorkflowTask1, ExampleWorkflowTask2, ExampleWorkflowTask3, ExampleWorkflowRequestGroup, ExampleSmartClientApplication, TestProject, TestProjectMembership, ExampleThreadHeader, ...ExampleThreadMessages, ] satisfies Resource[]; for (const resource of defaultResources) { await this.repo.createResource(resource); } for (const structureDefinition of StructureDefinitionList as StructureDefinition[]) { structureDefinition.kind = 'resource'; structureDefinition.url = 'http://hl7.org/fhir/StructureDefinition/' + structureDefinition.name; loadDataType(structureDefinition); await this.repo.createResource(structureDefinition); } for (const searchParameter of SearchParameterList) { indexSearchParameter(searchParameter as SearchParameter); await this.repo.createResource(searchParameter as SearchParameter); } makeDrAliceSmithSlots().forEach((slot) => this.repo.createResource(slot)); } private async mockFhirHandler(method: HttpMethod, url: string, options: any): Promise<Resource> { if (url.startsWith('fhir/R4/ValueSet/$expand')) { return exampleValueSet; } if (url.includes('fhir/R4')) { url = url.substring(url.indexOf('fhir/R4') + 7); } let body = undefined; if (options.body) { try { body = JSON.parse(options.body); } catch (_err) { body = options.body; } } const request: FhirRequest = { method, url, pathname: '', body, params: Object.create(null), query: Object.create(null), headers: toIncomingHttpHeaders(options.headers), }; const result = await this.router.handleRequest(request, this.repo); if (result.length === 1) { return result[0]; } else { return result[1]; } } } /** * Creates a fake JWT token with the provided claims for testing. * * **NOTE: This function does not create a real signed JWT. Attempting to read the header or signature will fail.** * * @param claims - The claims to encode in the body of the fake JWT. * @returns A stringified fake JWT token. */ export function createFakeJwt(claims: Record<string, string | number>): string { return 'header.' + base64Encode(JSON.stringify(claims)) + '.signature'; } function base64Encode(str: string): string { return typeof window !== 'undefined' ? window.btoa(str) : Buffer.from(str).toString('base64'); } // even though it's just a type, avoid importing IncomingHttpHeaders from node:http // since MockClient needs to work in the browser. Use a reasonable approximation instead interface PseudoIncomingHttpHeaders { [key: string]: string | undefined; } function toIncomingHttpHeaders(headers: HeadersInit | undefined): PseudoIncomingHttpHeaders { const result: PseudoIncomingHttpHeaders = {}; if (headers) { for (const [key, value] of Object.entries(headers)) { const lowerKey = key.toLowerCase(); if (typeof value === 'string') { result[lowerKey] = value; } else { console.warn(`Ignoring non-string value ${value} for header ${lowerKey}`); } } } return result; }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server