From 6d5016083c8a000497a21ff8edcc7bfcd71971bc Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 19 May 2025 16:57:53 +0500 Subject: [PATCH 1/6] Clean FHIR resources before saving according to specification --- src/services/fhir.ts | 82 +++++++++++++++++++++++++++++----------- src/utils/fhir.ts | 54 ++++++++++++++++++++++++++ tests/utils/fhir.spec.ts | 39 +++++++++++++++++++ 3 files changed, 152 insertions(+), 23 deletions(-) create mode 100644 src/utils/fhir.ts create mode 100644 tests/utils/fhir.spec.ts diff --git a/src/services/fhir.ts b/src/services/fhir.ts index a0a149c..df09829 100644 --- a/src/services/fhir.ts +++ b/src/services/fhir.ts @@ -1,5 +1,6 @@ import { AxiosRequestConfig } from 'axios'; import { AidboxReference, AidboxResource, ValueSet, Bundle, BundleEntry, id } from 'shared/src/contrib/aidbox'; +import { cleanEmptyValues, removeNullsFromDicts } from 'utils/fhir'; import { isFailure, RemoteDataResult, success, failure } from '../libs/remoteData'; import { buildQueryParams } from './instance'; @@ -93,17 +94,28 @@ function getInactiveSearchParam(resourceType: string) { export async function createFHIRResource( resource: R, - searchParams?: SearchParams + searchParams?: SearchParams, + dropNullsFromDicts = true ): Promise>> { - return service(create(resource, searchParams)); + return service(create(resource, searchParams, dropNullsFromDicts)); } -export function create(resource: R, searchParams?: SearchParams): AxiosRequestConfig { +export function create( + resource: R, + searchParams?: SearchParams, + dropNullsFromDicts = true +): AxiosRequestConfig { + let cleanedResource = resource; + if (dropNullsFromDicts) { + cleanedResource = removeNullsFromDicts(cleanedResource); + } + cleanedResource = cleanEmptyValues(cleanedResource); + return { method: 'POST', - url: `/${resource.resourceType}`, + url: `/${cleanedResource.resourceType}`, params: searchParams, - data: resource, + data: cleanedResource, }; } @@ -114,23 +126,33 @@ export async function updateFHIRResource( return service(update(resource, searchParams)); } -export function update(resource: R, searchParams?: SearchParams): AxiosRequestConfig { +export function update( + resource: R, + searchParams?: SearchParams, + dropNullsFromDicts = true +): AxiosRequestConfig { + let cleanedResource = resource; + if (dropNullsFromDicts) { + cleanedResource = removeNullsFromDicts(cleanedResource); + } + cleanedResource = cleanEmptyValues(cleanedResource); + if (searchParams) { return { method: 'PUT', - url: `/${resource.resourceType}`, - data: resource, + url: `/${cleanedResource.resourceType}`, + data: cleanedResource, params: searchParams, }; } - if (resource.id) { - const versionId = resource.meta && resource.meta.versionId; + if (cleanedResource.id) { + const versionId = cleanedResource.meta && cleanedResource.meta.versionId; return { method: 'PUT', - url: `/${resource.resourceType}/${resource.id}`, - data: resource, + url: `/${cleanedResource.resourceType}/${cleanedResource.id}`, + data: cleanedResource, ...(versionId ? { headers: { 'If-Match': versionId } } : {}), }; } @@ -236,16 +258,24 @@ export async function findFHIRResource( } } -export async function saveFHIRResource(resource: R): Promise>> { - return service(save(resource)); +export async function saveFHIRResource( + resource: R, + dropNullsFromDicts: boolean = true +): Promise>> { + return service(save(resource, dropNullsFromDicts)); } -export function save(resource: R): AxiosRequestConfig { +export function save(resource: R, dropNullsFromDicts: boolean = true): AxiosRequestConfig { const versionId = resource.meta && resource.meta.versionId; + let cleanedResource = resource; + if (dropNullsFromDicts) { + cleanedResource = removeNullsFromDicts(cleanedResource); + } + cleanedResource = cleanEmptyValues(cleanedResource); return { method: resource.id ? 'PUT' : 'POST', - data: resource, + data: cleanedResource, url: `/${resource.resourceType}${resource.id ? '/' + resource.id : ''}`, ...(resource.id && versionId ? { headers: { 'If-Match': versionId } } : {}), }; @@ -253,7 +283,8 @@ export function save(resource: R): AxiosRequestConfig export async function saveFHIRResources( resources: R[], - bundleType: 'transaction' | 'batch' + bundleType: 'transaction' | 'batch', + dropNullsFromDicts: boolean = true ): Promise>>> { return service({ method: 'POST', @@ -261,14 +292,19 @@ export async function saveFHIRResources( data: { type: bundleType, entry: resources.map((resource) => { - const versionId = resource.meta && resource.meta.versionId; + let cleanedResource = resource; + if (dropNullsFromDicts) { + cleanedResource = removeNullsFromDicts(cleanedResource); + } + cleanedResource = cleanEmptyValues(cleanedResource); + const versionId = cleanedResource.meta && cleanedResource.meta.versionId; return { - resource, + cleanedResource, request: { - method: resource.id ? 'PUT' : 'POST', - url: `/${resource.resourceType}${resource.id ? '/' + resource.id : ''}`, - ...(resource.id && versionId ? { ifMatch: versionId } : {}), + method: cleanedResource.id ? 'PUT' : 'POST', + url: `/${cleanedResource.resourceType}${cleanedResource.id ? '/' + cleanedResource.id : ''}`, + ...(cleanedResource.id && versionId ? { ifMatch: versionId } : {}), }, }; }), @@ -395,7 +431,7 @@ export type ResourcesMap = { export function extractBundleResources(bundle: Bundle): ResourcesMap { const entriesByResourceType = {} as ResourcesMap; const entries = bundle.entry || []; - entries.forEach(function(entry) { + entries.forEach(function (entry) { const type = entry.resource!.resourceType; if (!entriesByResourceType[type]) { entriesByResourceType[type] = []; diff --git a/src/utils/fhir.ts b/src/utils/fhir.ts new file mode 100644 index 0000000..46240df --- /dev/null +++ b/src/utils/fhir.ts @@ -0,0 +1,54 @@ +function isEmpty(data: any): boolean { + if (Array.isArray(data)) { + return data.length === 0; + } + + if (typeof data === 'object' && data !== null) { + return Object.keys(data).length === 0; + } + + return false; +} + +export function cleanEmptyValues(data: any): any { + if (Array.isArray(data)) { + return data.map((item) => { + return isEmpty(item) ? null : cleanEmptyValues(item); + }); + } + + if (typeof data === 'object' && data !== null) { + const cleaned: Record = {}; + for (const [key, value] of Object.entries(data)) { + const cleanedValue = cleanEmptyValues(value); + if (!isEmpty(cleanedValue)) { + cleaned[key] = cleanedValue; + } + } + return cleaned; + } + + return data; +} + +function isNull(value: any): boolean { + return value === null; +} + +export function removeNullsFromDicts(data: any): any { + if (Array.isArray(data)) { + return data.map(removeNullsFromDicts); + } + + if (typeof data === 'object' && data !== null) { + const result: Record = {}; + for (const [key, value] of Object.entries(data)) { + if (!isNull(value)) { + result[key] = removeNullsFromDicts(value); + } + } + return result; + } + + return data; +} diff --git a/tests/utils/fhir.spec.ts b/tests/utils/fhir.spec.ts new file mode 100644 index 0000000..ec0ae04 --- /dev/null +++ b/tests/utils/fhir.spec.ts @@ -0,0 +1,39 @@ +import { cleanEmptyValues, removeNullsFromDicts } from '../../src/utils/fhir'; + +describe('cleanEmptyValues', () => { + it('cleans empty values from dictionaries and arrays recursively', () => { + expect(cleanEmptyValues({})).toEqual({}); + expect(cleanEmptyValues({ str: '' })).toEqual({ str: '' }); + + expect(cleanEmptyValues({ nested: { nested2: [{}] } })).toEqual({ + nested: { nested2: [null] }, + }); + + expect(cleanEmptyValues({ nested: { nested2: {} } })).toEqual({}); + + expect(cleanEmptyValues({ item: [] })).toEqual({}); + expect(cleanEmptyValues({ item: [null] })).toEqual({ item: [null] }); + + expect(cleanEmptyValues({ item: [null, { item: null }] })).toEqual({ + item: [null, { item: null }], + }); + + expect(cleanEmptyValues({ item: [null, { item: null }, {}] })).toEqual({ + item: [null, { item: null }, null], + }); + }); +}); + +describe('removeNullsFromDicts', () => { + it('removes nulls from nested dictionaries but not from arrays', () => { + expect(removeNullsFromDicts({})).toEqual({}); + expect(removeNullsFromDicts({ item: [] })).toEqual({ item: [] }); + expect(removeNullsFromDicts({ item: [null] })).toEqual({ item: [null] }); + expect(removeNullsFromDicts({ item: [null, { item: null }] })).toEqual({ + item: [null, {}], + }); + expect(removeNullsFromDicts({ item: [null, { item: null }, {}] })).toEqual({ + item: [null, {}, {}], + }); + }); +}); From 2491d0bbf07f5e1e01c32f4a6c21e96870c1d656 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 19 May 2025 18:01:10 +0500 Subject: [PATCH 2/6] Handle undefined values --- src/utils/fhir.ts | 10 +++++++++- tests/utils/fhir.spec.ts | 36 +++++++++++++++++++++++++++++++++++- 2 files changed, 44 insertions(+), 2 deletions(-) diff --git a/src/utils/fhir.ts b/src/utils/fhir.ts index 46240df..1587b7e 100644 --- a/src/utils/fhir.ts +++ b/src/utils/fhir.ts @@ -28,11 +28,15 @@ export function cleanEmptyValues(data: any): any { return cleaned; } + if (typeof data === 'undefined') { + return null; + } + return data; } function isNull(value: any): boolean { - return value === null; + return value === null || value === undefined; } export function removeNullsFromDicts(data: any): any { @@ -50,5 +54,9 @@ export function removeNullsFromDicts(data: any): any { return result; } + if (typeof data === 'undefined') { + return null; + } + return data; } diff --git a/tests/utils/fhir.spec.ts b/tests/utils/fhir.spec.ts index ec0ae04..11cb828 100644 --- a/tests/utils/fhir.spec.ts +++ b/tests/utils/fhir.spec.ts @@ -1,7 +1,7 @@ import { cleanEmptyValues, removeNullsFromDicts } from '../../src/utils/fhir'; describe('cleanEmptyValues', () => { - it('cleans empty values from dictionaries and arrays recursively', () => { + it('cleans null values from dictionaries and arrays recursively', () => { expect(cleanEmptyValues({})).toEqual({}); expect(cleanEmptyValues({ str: '' })).toEqual({ str: '' }); @@ -22,6 +22,28 @@ describe('cleanEmptyValues', () => { item: [null, { item: null }, null], }); }); + + it('cleans undefined values from dictionaries and arrays recursively', () => { + expect(cleanEmptyValues({})).toEqual({}); + expect(cleanEmptyValues({ str: '' })).toEqual({ str: '' }); + + expect(cleanEmptyValues({ nested: { nested2: [{}] } })).toEqual({ + nested: { nested2: [null] }, + }); + + expect(cleanEmptyValues({ nested: { nested2: {} } })).toEqual({}); + + expect(cleanEmptyValues({ item: [] })).toEqual({}); + expect(cleanEmptyValues({ item: [undefined] })).toEqual({ item: [null] }); + + expect(cleanEmptyValues({ item: [undefined, { item: undefined }] })).toEqual({ + item: [null, { item: null }], + }); + + expect(cleanEmptyValues({ item: [undefined, { item: undefined }, {}] })).toEqual({ + item: [null, { item: null }, null], + }); + }); }); describe('removeNullsFromDicts', () => { @@ -36,4 +58,16 @@ describe('removeNullsFromDicts', () => { item: [null, {}, {}], }); }); + + it('removes undefined from nested dictionaries but not from arrays', () => { + expect(removeNullsFromDicts({})).toEqual({}); + expect(removeNullsFromDicts({ item: [] })).toEqual({ item: [] }); + expect(removeNullsFromDicts({ item: [undefined] })).toEqual({ item: [null] }); + expect(removeNullsFromDicts({ item: [undefined, { item: undefined }] })).toEqual({ + item: [null, {}], + }); + expect(removeNullsFromDicts({ item: [null, { item: null }, {}] })).toEqual({ + item: [null, {}, {}], + }); + }); }); From ab24f14d81be22964adaf04af73d70c256c86c95 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 19 May 2025 18:05:07 +0500 Subject: [PATCH 3/6] Add utils/fhir module to parent module export --- src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/index.ts b/src/index.ts index 9e511a0..de24cf5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -10,6 +10,7 @@ export * from './utils/date'; export * from './utils/error'; export * from './utils/tests'; export * from './utils/uuid'; +export * from './utils/fhir'; export * from './hooks/bus'; export * from './hooks/service'; From e2364c3b61904c63f9772c848e3bf2e2e7762215 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 19 May 2025 18:23:55 +0500 Subject: [PATCH 4/6] Fix import in src/services/fhir --- src/services/fhir.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/services/fhir.ts b/src/services/fhir.ts index df09829..610ea6d 100644 --- a/src/services/fhir.ts +++ b/src/services/fhir.ts @@ -1,8 +1,8 @@ import { AxiosRequestConfig } from 'axios'; import { AidboxReference, AidboxResource, ValueSet, Bundle, BundleEntry, id } from 'shared/src/contrib/aidbox'; -import { cleanEmptyValues, removeNullsFromDicts } from 'utils/fhir'; import { isFailure, RemoteDataResult, success, failure } from '../libs/remoteData'; +import { cleanEmptyValues, removeNullsFromDicts } from '../utils/fhir'; import { buildQueryParams } from './instance'; import { SearchParams } from './search'; import { service } from './service'; @@ -300,7 +300,7 @@ export async function saveFHIRResources( const versionId = cleanedResource.meta && cleanedResource.meta.versionId; return { - cleanedResource, + resource: cleanedResource, request: { method: cleanedResource.id ? 'PUT' : 'POST', url: `/${cleanedResource.resourceType}${cleanedResource.id ? '/' + cleanedResource.id : ''}`, From cbad1fb5cf50c3c61b06c85b87aff104493d0b60 Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 19 May 2025 18:24:12 +0500 Subject: [PATCH 5/6] Add new test with both functions combination --- tests/utils/fhir.spec.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/utils/fhir.spec.ts b/tests/utils/fhir.spec.ts index 11cb828..f923d58 100644 --- a/tests/utils/fhir.spec.ts +++ b/tests/utils/fhir.spec.ts @@ -71,3 +71,8 @@ describe('removeNullsFromDicts', () => { }); }); }); + +describe('combine two cleaning functions', () => { + const data = { item: [undefined, { item: undefined }, {}] }; + expect(cleanEmptyValues(removeNullsFromDicts(data))).toEqual({ item: [null, null, null] }); +}); From f43498ee0a6868692293de16a41977fb5b4019ac Mon Sep 17 00:00:00 2001 From: Dmitry Shutov Date: Mon, 19 May 2025 18:24:58 +0500 Subject: [PATCH 6/6] 1.11.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index ecaa25b..35d8c2f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aidbox-react", - "version": "1.10.1", + "version": "1.11.0", "scripts": { "build": "tsc & rollup -c", "prebuild": "rimraf lib/* & rimraf dist/*",