Skip to main content
Glama
csv.ts4.79 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { badRequest, evalFhirPath, formatAddress, formatHumanName, getSearchParameter, isResourceType, OperationOutcomeError, parseSearchRequest, } from '@medplum/core'; import type { Address, CodeableConcept, ContactPoint, HumanName, Reference, Resource, ResourceType, } from '@medplum/fhirtypes'; import type { Request, Response } from 'express'; import { getAuthenticatedContext } from '../../context'; import { sendOutcome } from '../outcomes'; /** * Handles a CSV export request. * @param req - The HTTP request. * @param res - The HTTP response. */ export async function csvHandler(req: Request, res: Response): Promise<void> { const ctx = getAuthenticatedContext(); const { resourceType } = req.params as { resourceType: ResourceType }; const query = req.query as Record<string, string[] | string | undefined>; const fields = query['_fields'] as string; delete query['_fields']; if (!fields) { sendOutcome(res, badRequest('Missing _fields parameter')); return; } if (!isResourceType(resourceType)) { sendOutcome(res, badRequest('Unsupported resource type')); return; } const columnNames: string[] = []; const expressions: string[] = []; for (const field of fields.split(',')) { columnNames.push(field); const searchParam = getSearchParameter(resourceType, field); if (searchParam) { expressions.push(searchParam.expression as string); } else { expressions.push(field); } } const searchRequest = parseSearchRequest(resourceType, query); searchRequest.count = 1000; const resources = await ctx.repo.searchResources(searchRequest); const output: string[][] = []; // Header row output.push(columnNames); // For each resource... for (const resource of resources) { const row: string[] = []; // For each column... for (const expression of expressions) { const values = tryEvalFhirPath(expression, resource); if (values.length > 0) { row.push(tryCsvEscape(values[0])); } else { row.push(''); } } output.push(row); } // Build the final CSV content // Add the BOM (byte order marker) to identify the file as UTF-8. const content = '\uFEFF' + output.map((row) => row.join(',')).join('\n'); // Respond with the CSV content // Use Content-Disposition to force file download res.type('text/csv').set('Content-Disposition', 'attachment; filename=export.csv').send(content); } function tryEvalFhirPath(expression: string, resource: Resource): unknown[] { try { return evalFhirPath(expression, resource); } catch (err) { throw new OperationOutcomeError(badRequest('Invalid FHIRPath expression'), { cause: err }); } } function tryCsvEscape(input: unknown): string { try { return csvEscape(input); } catch (_err) { // Silently ignore malformed data in projects that do not use "strict" mode return ''; } } function csvEscape(input: unknown): string { if (!input) { // Null, undefined, and empty string return ''; } if (typeof input === 'string') { return csvEscapeString(input); } if (typeof input === 'number' || typeof input === 'boolean') { return input.toString(); } if (typeof input === 'object') { if ('city' in input) { // Address return csvEscapeString(formatAddress(input as Address)); } if ('family' in input) { // HumanName return csvEscapeString(formatHumanName(input as HumanName)); } if ('value' in input) { // ContactPoint return csvEscapeString((input as ContactPoint).value as string); } if ('display' in input) { // Reference return csvEscapeString((input as Reference).display as string); } if ('coding' in input) { // CodeableConcept const coding = (input as CodeableConcept).coding; if (coding?.[0]?.display) { return csvEscapeString(coding[0].display); } if (coding?.[0]?.code) { return csvEscapeString(coding[0].code); } } if ('text' in input) { // CodeableConcept return csvEscapeString((input as CodeableConcept).text as string); } } // ??? return ''; } // CSV Injection, also known as Formula Injection, occurs when websites embed untrusted input inside CSV files. // See: https://owasp.org/www-community/attacks/CSV_Injection const CSV_INJECTION_CHARS = ['=', '+', '-', '@']; function csvEscapeString(input: string): string { let result = input.trim().replaceAll('"', '""'); if (result.length > 0 && CSV_INJECTION_CHARS.includes(result[0])) { result = "'" + result; } 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