Skip to content

Reference source · 0.1.0-dev.11

fhir-validation.ts

src/fhir-validation.ts · Download original file

import { requireThat } from './core.ts';
const fail = (ok: unknown, code='invalid_resource') => requireThat(ok,code);
export const object = (v:any):void => fail(v && typeof v==='object' && !Array.isArray(v));
export const text = (v:any):void => fail(typeof v==='string' && v.trim().length>0 && v.length<=65536,'invalid_resource_text');
export function dateTime(v:any, instant=false):void {
 text(v); const m=/^(\d{4})(?:-(\d{2})(?:-(\d{2})(?:T(\d{2}):(\d{2}):(\d{2})(?:\.\d{1,9})?(Z|[+-]\d{2}:\d{2}))?)?)?$/.exec(v);
 fail(m && +m[1]!>0,'invalid_resource_date'); if(!m)return;
 const year=+m[1]!,month=+(m[2]??1),day=+(m[3]??1),leap=year%4===0&&(year%100!==0||year%400===0);
 fail(month>=1&&month<=12&&day>=1&&day<=[31,leap?29:28,31,30,31,30,31,31,30,31,30,31][month-1]!,'invalid_resource_date');
 if(m[4]) {fail(+m[4]<=23&&+m[5]!<=59&&+m[6]!<=59,'invalid_resource_date');const z=m[7]!;if(z!=='Z'){const h=+z.slice(1,3),min=+z.slice(4);fail(h<=14&&min<=59&&(h<14||min===0),'invalid_resource_date');}}
 fail(!instant||!!m[4],'invalid_resource_date');
}
export function coding(v:any):void { object(v);if(v.system!==undefined){text(v.system);fail(/^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(v.system),'invalid_resource_code');}if(v.code!==undefined)text(v.code);if(v.display!==undefined)text(v.display);if(v.version!==undefined)text(v.version);if(v.userSelected!==undefined)fail(typeof v.userSelected==='boolean'); }
export function concept(v:any):void {object(v);if(v.text!==undefined)text(v.text);if(v.coding!==undefined){fail(Array.isArray(v.coding)&&v.coding.length>0,'invalid_resource_code');v.coding.forEach(coding);}fail(v.text!==undefined||v.coding?.some((c:any)=>typeof c.code==='string'&&c.code.length>0),'invalid_resource_code');}
export function reference(v:any):void {object(v);text(v.reference);fail(!/[\s\x00-\x1f]/.test(v.reference),'invalid_resource_reference');if(v.display!==undefined)text(v.display);if(v.type!==undefined)text(v.type);}
export function quantity(v:any):void {object(v);if(v.value!==undefined)fail(typeof v.value==='number'&&Number.isFinite(v.value),'invalid_resource_quantity');if(v.unit!==undefined)text(v.unit);if(v.system!==undefined)text(v.system);if(v.code!==undefined)text(v.code);if(v.comparator!==undefined)fail(['<','<=','>=','>'].includes(v.comparator),'invalid_resource_quantity');}
export function period(v:any):void {object(v);fail(v.start!==undefined||v.end!==undefined,'invalid_resource_period');if(v.start!==undefined)dateTime(v.start);if(v.end!==undefined)dateTime(v.end);if(v.start&&v.end&&v.start.includes('T')&&v.end.includes('T'))fail(Date.parse(v.start)<=Date.parse(v.end),'invalid_resource_period');}
function range(v:any):void {object(v);if(v.low!==undefined)quantity(v.low);if(v.high!==undefined)quantity(v.high);fail(v.low!==undefined||v.high!==undefined);if(v.low?.value!==undefined&&v.high?.value!==undefined&&v.low.unit===v.high.unit)fail(v.low.value<=v.high.value,'invalid_resource_quantity');}
const choices:Record<string,(v:any)=>void>={DateTime:dateTime,String:text,Period:period,Age:quantity,Range:range,Reference:reference,Boolean:v=>fail(typeof v==='boolean')};
function choice(v:any,prefix:string,allowed:string[],required=false):void {const keys=Object.keys(v).filter(k=>k.startsWith(prefix)&&k.length>prefix.length&&/^[A-Z]/.test(k.slice(prefix.length)));fail(keys.length<2&&(!required||keys.length===1),'invalid_resource_choice');for(const k of keys){const suffix=k.slice(prefix.length);fail(allowed.includes(suffix),'unsupported_resource_content');choices[suffix]!(v[k]);}}
function list(v:any,validator:(x:any)=>void):void{fail(Array.isArray(v)&&v.length>0,'invalid_resource_array');v.forEach(validator);}
function optional(v:any,key:string,validator:(x:any)=>void):void {if(v[key]!==undefined)validator(v[key]);}
function status(v:any,key:string,values:string[]):void {if(v[key]!==undefined)fail(values.includes(v[key]),'invalid_resource_status');}
function bound(v:any,key:string,system:string,values:string[]):string|undefined {if(v[key]===undefined)return;concept(v[key]);const codes=v[key].coding?.filter((c:any)=>c.system===`http://terminology.hl7.org/CodeSystem/${system}`).map((c:any)=>c.code);fail(codes?.length===1&&values.includes(codes[0]),'invalid_resource_status');return codes[0];}
export function validateFields(v:any):void {
 const concepts:Record<string,string[]>={Condition:['code','severity'],AllergyIntolerance:['code'],MedicationStatement:['medicationCodeableConcept','category'],Encounter:['priority'],Procedure:['code','statusReason','category','outcome'],Immunization:['vaccineCode','statusReason','route','site','reportOrigin']};
 for(const key of concepts[v.resourceType]??[])optional(v,key,concept);
 if(['Condition','Procedure'].includes(v.resourceType))optional(v,'bodySite',x=>list(x,concept));
 if(['MedicationStatement','Procedure','Encounter'].includes(v.resourceType))optional(v,'reasonCode',x=>list(x,concept));
 if(v.resourceType==='MedicationStatement')optional(v,'statusReason',x=>list(x,concept));
 const refs:Record<string,string[]>={Condition:['encounter','asserter','recorder'],AllergyIntolerance:['encounter','asserter','recorder'],MedicationStatement:['context','informationSource'],Encounter:['serviceProvider'],Procedure:['encounter','asserter','recorder','location'],Immunization:['manufacturer','location','encounter']};
 for(const key of refs[v.resourceType]??[])optional(v,key,reference);
 const dates:Record<string,string[]>={Condition:['recordedDate'],AllergyIntolerance:['recordedDate'],MedicationStatement:['dateAsserted'],Encounter:[],Procedure:[],Immunization:['recorded','expirationDate']};
 for(const key of dates[v.resourceType]??[])optional(v,key,dateTime);
 if(v.resourceType==='Encounter'){optional(v,'period',period);optional(v,'location',x=>list(x,l=>{object(l);reference(l.location);status(l,'status',['planned','active','reserved','completed']);optional(l,'period',period);}));}
 if(v.resourceType==='Immunization')optional(v,'doseQuantity',quantity);
 optional(v,'note',x=>list(x,n=>{object(n);text(n.text);optional(n,'time',dateTime);choice(n,'author',['String','Reference']);optional(n,'authorReference',reference);fail(!(n.authorString!==undefined&&n.authorReference!==undefined),'invalid_resource_choice');}));
 optional(v,'meta',m=>{object(m);optional(m,'versionId',text);optional(m,'lastUpdated',x=>dateTime(x,true));for(const k of ['profile','security','tag'])optional(m,k,x=>list(x,k==='profile'?text:coding));});
 optional(v,'identifier',x=>list(x,i=>{object(i);optional(i,'system',text);optional(i,'value',text);optional(i,'type',concept);optional(i,'period',period);optional(i,'assigner',reference);}));
 if(v.resourceType==='Condition'){
  const clinical=bound(v,'clinicalStatus','condition-clinical',['active','recurrence','relapse','inactive','remission','resolved']),verification=bound(v,'verificationStatus','condition-ver-status',['unconfirmed','provisional','differential','confirmed','refuted','entered-in-error']);
  optional(v,'category',x=>list(x,concept));choice(v,'onset',['DateTime','Age','Period','Range','String']);choice(v,'abatement',['DateTime','Age','Period','Range','String']);
  const abated=Object.keys(v).some(k=>/^abatement[A-Z]/.test(k));fail(!abated||['inactive','resolved','remission'].includes(clinical??''),'invalid_resource_invariant');
  fail(verification!=='entered-in-error'||clinical===undefined,'invalid_resource_invariant');
  if(v.category?.some((c:any)=>c.coding?.some((x:any)=>x.system==='http://terminology.hl7.org/CodeSystem/condition-category'&&x.code==='problem-list-item')))fail(verification==='entered-in-error'||clinical!==undefined,'invalid_resource_invariant');
 } else if(v.resourceType==='AllergyIntolerance') {
  const clinical=bound(v,'clinicalStatus','allergyintolerance-clinical',['active','inactive','resolved']),verification=bound(v,'verificationStatus','allergyintolerance-verification',['unconfirmed','confirmed','refuted','entered-in-error']);fail(verification==='entered-in-error'?clinical===undefined:clinical!==undefined,'invalid_resource_invariant');
  status(v,'type',['allergy','intolerance']);status(v,'criticality',['low','high','unable-to-assess']);optional(v,'category',x=>list(x,c=>fail(['food','medication','environment','biologic'].includes(c),'invalid_resource_status')));choice(v,'onset',['DateTime','Age','Period','Range','String']);optional(v,'lastOccurrence',dateTime);
  optional(v,'reaction',x=>list(x,r=>{object(r);list(r.manifestation,concept);optional(r,'substance',concept);optional(r,'onset',dateTime);status(r,'severity',['mild','moderate','severe']);optional(r,'exposureRoute',concept);}));
 } else if(v.resourceType==='MedicationStatement')choice(v,'effective',['DateTime','Period']);
 else if(v.resourceType==='Encounter'){coding(v.class);optional(v,'type',x=>list(x,concept));optional(v,'statusHistory',x=>list(x,h=>{object(h);fail(['planned','arrived','triaged','in-progress','onleave','finished','cancelled','entered-in-error','unknown'].includes(h.status),'invalid_resource_status');period(h.period);}));}
 else if(v.resourceType==='Procedure')choice(v,'performed',['DateTime','Period','String','Age','Range']);
 else if(v.resourceType==='Immunization'){choice(v,'occurrence',['DateTime','String'],true);optional(v,'primarySource',x=>fail(typeof x==='boolean'));optional(v,'isSubpotent',x=>fail(typeof x==='boolean'));}
}
export function validateExtensions(v:any):void {
 optional(v,'extension',x=>list(x,e=>{object(e);text(e.url);fail(/^[A-Za-z][A-Za-z0-9+.-]*:\S+$/.test(e.url),'invalid_resource_extension');const values=Object.keys(e).filter(k=>/^value[A-Z]/.test(k));fail((e.extension!==undefined?values.length===0:values.length===1),'invalid_resource_extension');for(const k of values){const value=e[k],suffix=k.slice(5);if(['String','Code','Uri','Url','Canonical','Markdown','Id'].includes(suffix))text(value);else if(['Integer','PositiveInt','UnsignedInt'].includes(suffix))fail(Number.isInteger(value)&&value>= (suffix==='PositiveInt'?1:suffix==='UnsignedInt'?0:-2147483648)&&value<=2147483647,'invalid_resource_extension');else if(suffix==='Boolean')fail(typeof value==='boolean');else if(suffix==='Decimal')fail(typeof value==='number'&&Number.isFinite(value));else if(suffix==='DateTime'||suffix==='Date')dateTime(value);else if(suffix==='Coding')coding(value);else if(suffix==='CodeableConcept')concept(value);else if(suffix==='Reference')reference(value);else if(suffix==='Quantity')quantity(value);else fail(value&&typeof value==='object','invalid_resource_extension');}}));
}