FHIR client: typed German FHIR over any transport.
@polaris/sdk/fhir is the typed FHIR client for German practice systems. It wraps any FhirTransport implementation with profile-scoped CRUD, optional PII anonymization, and first-class error types — without bundling a fetch transport itself.
Overview
Architecture in one diagram.
The client wraps a FhirTransport (a plain object with a request() method you provide). Profile-scoped resource clients are obtained via forProfile() — they carry the TypeScript types of the matching IG profile. An optional AnonymizingTransport layer strips PII before data reaches the caller.
createFhirDeClient({ baseUrl, transport, piiMode? })
|
| wraps (optionally via AnonymizingTransport)
v
┌──────────────────────┐
│ FhirDeClient │ .forProfile(Profile, 'Patient')
│ │ |
│ .forProfile() → │ v ResourceClient<TResource, TProfile, TArgs>
└──────────────────────┘ .read() / .search() / .create() / .update() / .delete()
|
v
FhirTransport.request()
|
v
Aidbox (or mock) createFhirDeClient
The factory function.
createFhirDeClient is the recommended entry point. It accepts a configuration object and returns a configured FhirDeClient. When piiMode: 'anonymized' is set, the transport is automatically wrapped in an AnonymizingTransport — requiring POLARIS_ANON_SALT to be set in the environment.
// TypeScript signature
function createFhirDeClient(config: FhirDeClientConfig): FhirDeClient
interface FhirDeClientConfig {
baseUrl: string // Aidbox FHIR base, e.g. 'https://aidbox.example.com/fhir'
transport: FhirTransport // Your fetch/axios/mock implementation
piiMode?: 'real' | 'anonymized' // default: 'real'
} Real mode (default)
import { createFhirDeClient } from '@polaris/sdk/fhir'
import { FPDEPatientProfile } from '@polaris/fhir-de'
// Provide your own fetch-based transport
const myTransport = {
async request(method, path, init) {
const url = new URL(path, 'https://aidbox.example.com/fhir')
if (init?.query) {
for (const [k, v] of Object.entries(init.query)) {
[v].flat().forEach(val => url.searchParams.append(k, val))
}
}
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: 'Basic ' + btoa('admin:secret'),
...init?.headers,
},
body: init?.body ? JSON.stringify(init.body) : undefined,
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
return res.json()
},
}
const client = createFhirDeClient({
baseUrl: 'https://aidbox.example.com/fhir',
transport: myTransport,
})
const patients = client.forProfile(FPDEPatientProfile, 'Patient')
const patient = await patients.read('pat-12345')
// patient is typed as FPDEPatientProfile | null Anonymized mode
// POLARIS_ANON_SALT must be set before this call:
// process.env.POLARIS_ANON_SALT = 'demo-salt-replace-in-production-use-32-plus-random-bytes'
// In production, generate with: crypto.randomBytes(32).toString('hex')
import { createFhirDeClient } from '@polaris/sdk/fhir'
import { FPDEPatientProfile } from '@polaris/fhir-de'
const client = createFhirDeClient({
baseUrl: 'https://aidbox.example.com/fhir',
transport: myTransport,
piiMode: 'anonymized', // wraps transport in AnonymizingTransport
})
// All responses have PII stripped:
// name, address, telecom, identifier, photo removed
// birthDate is NOT stripped (use purpose-limitation controls if needed)
// Resource IDs replaced with SHA-256 hashed versions
// Free text scanned for KV numbers, phone numbers, dates
const patients = client.forProfile(FPDEPatientProfile, 'Patient')
const patient = await patients.read('pat-12345')
// patient.name === undefined (stripped)
// patient.id === 'anon-3f8a2b1c94d7' (hashed) FhirDeClient & ResourceClient
Profile-scoped typed CRUD.
FhirDeClient holds the configuration and exposes a single method: forProfile(). This returns a ResourceClient typed to the specific IG profile class. All CRUD operations are available on the ResourceClient.
// FhirDeClient signature
class FhirDeClient {
constructor(config: FhirDeClientConfig)
forProfile<TResource, TProfile, TArgs>(
profile: ProfileClass<TResource, TProfile, TArgs>,
resourceType: string
): ResourceClient<TResource, TProfile, TArgs>
}
// ResourceClient — all methods return typed profile instances
class ResourceClient<TResource, TProfile, TArgs> {
read(id: string): Promise<TProfile | null>
search(params?: Record<string, string | string[]>): Promise<TProfile[]>
create(...args: TArgs extends void ? [] : [TArgs]): Promise<TProfile>
createResource(resource: TResource): Promise<TProfile>
update(id: string, resource: TResource): Promise<TProfile>
delete(id: string): Promise<void>
} | Method | Returns | Behaviour |
|---|---|---|
| read(id) | Promise<TProfile | null> | Returns null on 404. Throws FhirDeHttpError on other non-2xx. Throws FhirDeProfileMismatchError if Profile.from() fails. |
| search(params?) | Promise<TProfile[]> | Empty array for zero results. 404 is an error here (not an empty result). Params are forwarded as query string. Entries that fail Profile.from() validation are silently filtered — a single malformed entry does not fail the whole search. |
| create(...args) | Promise<TProfile> | Calls profile.createResource() to build the resource, then POSTs. TArgs drives whether arguments are required. |
| createResource(resource) | Promise<TProfile> | POST a pre-built resource directly. Bypasses profile.createResource(). |
| update(id, resource) | Promise<TProfile> | HTTP PUT. Replaces the resource at the given ID. Note: the implementation silently overwrites resource.id in the body with the path id — pass the same id in both arguments. |
| delete(id) | Promise<void> | Idempotent. 404 counts as success. |
Read & search — Patient
FPDEPatientProfile from @polaris/fhir-de maps to the fpde-patient profile from the fhir-praxis-de IG.
import { createFhirDeClient } from '@polaris/sdk/fhir'
import { FPDEPatientProfile } from '@polaris/fhir-de'
const client = createFhirDeClient({ baseUrl, transport })
const patients = client.forProfile(
FPDEPatientProfile,
'Patient'
)
// Read by ID — null on 404
const patient = await patients.read('pat-12345')
if (patient) {
console.log(patient.name?.[0]?.family)
}
// Search with FHIR search parameters
// Note: entries that fail Profile.from() validation are
// silently filtered — a single malformed entry does not
// fail the whole search.
const results = await patients.search({
family: 'Mustermann',
birthdate: 'ge1970-01-01',
})
// results: FPDEPatientProfile[] Create, update & delete
EncounterPraxisProfile maps to the encounter-praxis profile from the fhir-praxis-de IG.
import { createFhirDeClient } from '@polaris/sdk/fhir'
import { EncounterPraxisProfile } from '@polaris/fhir-de'
const encounters = client.forProfile(
EncounterPraxisProfile,
'Encounter'
)
// POST a pre-built resource directly
const enc = await encounters.createResource(myEncounterResource)
// Update (PUT)
// Note: the implementation silently overwrites resource.id
// in the body with the path id — pass the same id in both.
const updated = await encounters.update(enc.id!, {
...myEncounterResource,
status: 'finished',
})
// Delete — idempotent, 404 = success
await encounters.delete(enc.id!) FhirTransport
Transport SPI — bring your own fetch.
FhirTransport is a service-provider interface — a plain object with a single request() method. The SDK ships no built-in fetch transport in v1. You implement the transport once and pass it into createFhirDeClient. On 2xx: resolve with parsed body. On non-2xx: throw FhirDeHttpError.
// Full interface definition
import type { FhirTransport } from '@polaris/sdk/fhir'
interface FhirTransport {
request(
method: 'GET' | 'POST' | 'PUT' | 'DELETE',
path: string,
init?: {
body?: unknown
headers?: Record<string, string>
query?: Record<string, string | string[]>
}
): Promise<unknown>
} Minimal fetch transport
import type { FhirTransport } from '@polaris/sdk/fhir'
import { FhirDeHttpError } from '@polaris/sdk/fhir'
function createFetchTransport(
baseUrl: string,
token: string
): FhirTransport {
return {
async request(method, path, init) {
const url = new URL(path, baseUrl)
if (init?.query) {
for (const [k, vs] of Object.entries(init.query)) {
;[vs].flat().forEach(v => url.searchParams.append(k, v))
}
}
const res = await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
...init?.headers,
},
body: init?.body ? JSON.stringify(init.body) : undefined,
})
if (!res.ok) {
const body = await res.json().catch(() => null)
throw new FhirDeHttpError(res.status, body)
}
return res.json()
},
}
} Axios transport (Node.js)
import axios from 'axios'
import type { FhirTransport } from '@polaris/sdk/fhir'
import { FhirDeHttpError } from '@polaris/sdk/fhir'
function createAxiosTransport(
baseUrl: string,
apiKey: string
): FhirTransport {
const http = axios.create({
baseURL: baseUrl,
headers: { Authorization: `Basic ${apiKey}` },
})
return {
async request(method, path, init) {
try {
const res = await http.request({
method,
url: path,
params: init?.query,
data: init?.body,
headers: init?.headers,
})
return res.data
} catch (err: any) {
if (axios.isAxiosError(err) && err.response) {
throw new FhirDeHttpError(
err.response.status,
err.response.data
)
}
throw err
}
},
}
} AnonymizingTransport
PII stripping at the transport layer.
AnonymizingTransport wraps any FhirTransport and strips PII from FHIR responses before they reach the caller. It is typically activated via piiMode: 'anonymized' in FhirDeClientConfig, but can also be used directly when you need fine-grained control.
What gets stripped
Removed fields
- name, address, telecom
- identifier, photo
- birthDate is NOT stripped — use purpose-limitation controls if needed
- Resource IDs hashed: SHA-256(salt:id) → 'anon-' + first 12 hex chars
Scrubbed free-text fields
- DocumentReference.description
- AllergyIntolerance.note[].text
- Titled names, dates, phone numbers, KV numbers detected via regex
Direct use
import { AnonymizingTransport } from '@polaris/sdk/fhir'
// POLARIS_ANON_SALT must be set — thrown synchronously if missing
// process.env.POLARIS_ANON_SALT = 'demo-salt-replace-in-production-use-32-plus-random-bytes'
// In production, generate with: crypto.randomBytes(32).toString('hex')
const anonTransport = new AnonymizingTransport(myFetchTransport)
// Use directly or pass to createFhirDeClient
const client = createFhirDeClient({
baseUrl: 'https://aidbox.example.com/fhir',
transport: anonTransport, // already wrapped — use piiMode: 'real'
})
// The piiMode shorthand is equivalent:
// createFhirDeClient({ baseUrl, transport: myFetchTransport, piiMode: 'anonymized' })
// (internally does: new AnonymizingTransport(transport)) Warning: avoid double-wrapping
Never pass an already-wrapped AnonymizingTransport to createFhirDeClient with piiMode: 'anonymized' — that would double-wrap and re-hash already-hashed IDs. When using AnonymizingTransport directly, always use piiMode: 'real' (or omit it).
POLARIS_ANON_SALT
A stable 64-character hex string. Must be the same across restarts so that hashed IDs are consistent. Generate once and store as a secret:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" extractNextPageUrl
FHIR Bundle pagination helper.
extractNextPageUrl extracts the link[relation=next] URL from a FHIR Bundle. Absolute http(s) URLs have the /fhir/ prefix stripped and are returned as relative path + query string, which is what FhirTransport.request() expects as its path argument. Returns null when there is no next page.
// Signature
function extractNextPageUrl(
bundle: { link?: Array<{ relation?: string; url?: string }> } | null | undefined
): string | null
// URL transformation rules:
// 'https://aidbox.example.com/fhir/Patient?_page=2' → 'Patient?_page=2'
// '/Patient?_page=2' (relative) → '/Patient?_page=2' (unchanged)
// no next link → null Paginating search results
import {
createFhirDeClient,
extractNextPageUrl,
} from '@polaris/sdk/fhir'
import { FPDEPatientProfile } from '@polaris/fhir-de'
const client = createFhirDeClient({ baseUrl, transport })
const patients = client.forProfile(FPDEPatientProfile, 'Patient')
// Collect all pages
const allPatients: FPDEPatientProfile[] = []
// First page via search()
let page = await patients.search({ _count: '50' })
allPatients.push(...page)
// Subsequent pages using the raw bundle link
let bundle = await transport.request('GET', 'Patient?_count=50') as any
let nextUrl = extractNextPageUrl(bundle)
while (nextUrl) {
bundle = await transport.request('GET', nextUrl) as any
// extract entries from bundle.entry
nextUrl = extractNextPageUrl(bundle)
} Null safety
import { extractNextPageUrl } from '@polaris/sdk/fhir'
// Safe on malformed or missing bundles:
extractNextPageUrl(null) // → null
extractNextPageUrl(undefined) // → null
extractNextPageUrl({}) // → null (no link field)
extractNextPageUrl({ link: [] }) // → null (no next relation)
// Only returns a value when relation === 'next' exists:
extractNextPageUrl({
link: [
{ relation: 'self', url: 'https://aidbox.example.com/fhir/Patient' },
{ relation: 'next', url: 'https://aidbox.example.com/fhir/Patient?_page=2' },
],
})
// → 'Patient?_page=2' Error classes
Two typed errors.
The SDK throws only two error types. Both extend Error with additional typed fields for structured error handling. FhirDeHttpError carries the HTTP status code and raw body. FhirDeProfileMismatchError indicates that the response passed the transport but failed profile validation.
FhirDeHttpError
// Definition
class FhirDeHttpError extends Error {
readonly status: number
readonly body: unknown
constructor(status: number, body: unknown, message?: string)
// error.name === 'FhirDeHttpError'
}
// Import
import { FhirDeHttpError } from '@polaris/sdk/fhir'
// Catching
try {
await patients.read('missing-id')
} catch (err) {
if (err instanceof FhirDeHttpError) {
console.error(err.status) // e.g. 422
console.error(err.body) // Aidbox OperationOutcome
}
}
// Note: .read() returns null on 404 — no throw
// .search() throws FhirDeHttpError on 404 FhirDeProfileMismatchError
// Definition
class FhirDeProfileMismatchError extends Error {
readonly expectedProfile: string
readonly resourceId?: string
constructor(expectedProfile: string, resourceId?: string)
// error.name === 'FhirDeProfileMismatchError'
}
// Import
import { FhirDeProfileMismatchError } from '@polaris/sdk/fhir'
// When it is thrown:
// The transport returned 200, but Profile.from() rejected
// the resource (e.g. missing required fields, wrong profile URL)
try {
const patient = await patients.read('bad-patient')
} catch (err) {
if (err instanceof FhirDeProfileMismatchError) {
console.error(err.expectedProfile)
// e.g. 'KBV_PR_FOR_Patient'
console.error(err.resourceId)
// e.g. 'bad-patient'
}
} Complete error-handling pattern
import {
createFhirDeClient,
FhirDeHttpError,
FhirDeProfileMismatchError,
} from '@polaris/sdk/fhir'
import { FPDEPatientProfile } from '@polaris/fhir-de'
const client = createFhirDeClient({ baseUrl, transport })
const patients = client.forProfile(FPDEPatientProfile, 'Patient')
async function safeRead(id: string) {
try {
const patient = await patients.read(id)
if (!patient) return { type: 'not-found' as const }
return { type: 'ok' as const, patient }
} catch (err) {
if (err instanceof FhirDeProfileMismatchError) {
return {
type: 'profile-mismatch' as const,
expected: err.expectedProfile,
id: err.resourceId,
}
}
if (err instanceof FhirDeHttpError) {
return { type: 'http-error' as const, status: err.status, body: err.body }
}
throw err // re-throw unexpected errors
}
} createMockFhirTransport
In-memory transport for local dev and tests.
createMockFhirTransport creates an in-memory FhirTransport implementation for testing and local development — no Aidbox required. Pre-load fixture resources, inspect the store directly, or let it start empty and build resources via create(). The default (strict: true) throws FhirDeHttpError(404) for GET on unknown resources.
// Signature
function createMockFhirTransport(options?: MockFhirTransportOptions): MockFhirTransport
interface MockFhirTransportOptions {
fixtures?: FhirResource[] // pre-loaded resources
strict?: boolean // default: true — throws 404 for unknown GET
}
interface MockFhirTransport extends FhirTransport {
store: Map<string, FhirResource> // inspect or mutate the in-memory store
}
// Supports: POST (create), GET/:id (read), GET (search + naive filtering),
// PUT (upsert), DELETE (idempotent) Unit test example
import {
createFhirDeClient,
createMockFhirTransport,
} from '@polaris/sdk/fhir'
import { FPDEPatientProfile } from '@polaris/fhir-de'
// Pre-load a fixture patient
const mock = createMockFhirTransport({
fixtures: [{
resourceType: 'Patient',
id: 'test-patient-1',
meta: { profile: ['https://fhir.cognovis.de/praxis/StructureDefinition/fpde-patient'] },
name: [{ family: 'Mustermann', given: ['Max'] }],
birthDate: '1980-05-12',
}],
})
const client = createFhirDeClient({
baseUrl: 'http://mock',
transport: mock,
})
const patients = client.forProfile(FPDEPatientProfile, 'Patient')
// In your test:
const patient = await patients.read('test-patient-1')
// → the fixture, fully typed
// Inspect the store
console.log(mock.store.size) // 1 Create & verify in tests
import {
createFhirDeClient,
createMockFhirTransport,
} from '@polaris/sdk/fhir'
import { FPDECoverageGKVProfile } from '@polaris/fhir-de'
// Start with empty store (no fixtures)
const mock = createMockFhirTransport()
const client = createFhirDeClient({
baseUrl: 'http://mock',
transport: mock,
})
const coverages = client.forProfile(FPDECoverageGKVProfile, 'Coverage')
// POST a pre-built resource
const cov = await coverages.createResource(myCoverageResource)
// Verify the store was updated
console.log(mock.store.has(cov.id!)) // true
// Delete and verify
await coverages.delete(cov.id!)
console.log(mock.store.has(cov.id!)) // false Public exports
Complete export surface.
All public exports from @polaris/sdk/fhir are listed below. Import only what you need — the package is tree-shakeable.
Values (runtime)
import {
createFhirDeClient, // factory function
FhirDeClient, // class (rarely used directly)
AnonymizingTransport, // PII-stripping transport wrapper
extractNextPageUrl, // Bundle pagination helper
FhirDeHttpError, // non-2xx HTTP error
FhirDeProfileMismatchError, // profile validation error
createMockFhirTransport, // in-memory test transport
} from '@polaris/sdk/fhir' Types (compile-time only)
import type {
FhirTransport, // transport SPI interface
FhirDeClientConfig, // { baseUrl, transport, piiMode? }
MockFhirTransportOptions, // { fixtures?, strict? }
MockFhirTransport, // FhirTransport + .store
} from '@polaris/sdk/fhir' IG profile types
Profile classes (FPDEPatientProfile, EncounterPraxisProfile, FPDECoverageGKVProfile, etc.) are exported from @polaris/fhir-de — not from this package. They come from the fhir-praxis-de IG.
IG profiles
IG-only policy — no own profile definitions.
Polaris defines no FHIR profiles. All profile classes come from the official IGs published on npm.cognovis.de. The table below maps the resource types used with forProfile() to their canonical IG definitions.
| Profile class | Resource type | Canonical URL |
|---|---|---|
| FPDEPatientProfile | Patient | fhir.cognovis.de/praxis/…/fpde-patient |
| EncounterPraxisProfile | Encounter | fhir.cognovis.de/praxis/…/encounter-praxis |
| FPDECoverageGKVProfile | Coverage | fhir.cognovis.de/praxis/…/fpde-coverage-gkv |
IG package versions
Check the latest published IG version before pinning:
curl -s https://npm.cognovis.de/de.cognovis.fhir.praxis | jq '.["dist-tags"].latest'
curl -s https://npm.cognovis.de/de.cognovis.fhir.dental | jq '.["dist-tags"].latest' De-identification SDK
PII anonymization — full reference.
The de-identification layer is built on the io.cognovis.de-identification.de IG. Use AnonymizingTransport to strip PII before data crosses the Zone-A→Zone-C boundary into the LLM gateway.
anonymizing-transport
Five PII transformations
ID hashing, PII field removal, display replacement, reference rewriting, free-text scrubbing. Typed errors with docUrl. IG version pinning via requireIgVersion.
de-identification ig catalogue
All three Library manifests
PII_FIELDS, FREE_TEXT_FIELDS + FREE_TEXT_PATTERNS, QUASI_ID_K_FLOORS — rendered as tables. IG version tracing and regeneration guide.
See also
getting started
Install & first steps
Registry setup, install, and combined FHIR + LLM walkthrough.
resource builders
All core builders
Patient, Practitioner, Organization, Encounter, Condition, Claim.
llm gateway
LLM routing API
Capability-based model routing, intent catalog, PII types. Use AnonymizingTransport at the Zone-A→Zone-C boundary.
troubleshooting
Common errors
Transport SPI errors, profile mismatches, registry auth.