F POLARIS · FHIR
sdk · fhir

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'