Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 79 additions & 1 deletion packages/client-r4/src/writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import { FhirResourceWriter } from "./writer.js";

const BASE_URL = "https://fhir.example.com";

function mockFetch(response: { status: number; body?: unknown }): typeof globalThis.fetch {
function mockFetch(response: {
status: number;
body?: unknown;
/** Raw body, for the responses that carry none. Defaults to `JSON.stringify(body)`. */
text?: string;
headers?: Record<string, string>;
}): typeof globalThis.fetch {
const text = response.text ?? (response.body === undefined ? "{}" : JSON.stringify(response.body));
return vi.fn().mockResolvedValue({
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.status === 200 ? "OK" : response.status === 201 ? "Created" : "Error",
json: async () => response.body ?? {},
text: async () => text,
headers: { get: (name: string) => response.headers?.[name] ?? null },
}) as unknown as typeof globalThis.fetch;
}

Expand Down Expand Up @@ -42,6 +51,75 @@ describe("FhirResourceWriter", () => {
});
});

describe("conditional create", () => {
const patient = { resourceType: "Patient", name: [{ family: "Smith" }] } as fhir4.Patient;
const search = "identifier=https://example.org/mrn|12345";

it("sends If-None-Exist when asked", async () => {
const fetchFn = mockFetch({ status: 201, body: { resourceType: "Patient", id: "new-1" } });
const writer = new FhirResourceWriter<fhir4.Patient>(BASE_URL, "Patient", fetchFn);

await writer.create(patient, { ifNoneExist: search });

expect(fetchFn).toHaveBeenCalledWith(
`${BASE_URL}/Patient`,
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/fhir+json",
Accept: "application/fhir+json",
// Raw, not url-encoded: encoding the `|` makes the search match nothing, so every
// retry would create another copy — the exact bug conditional create prevents.
"If-None-Exist": search,
},
}),
);
});

it("does not send the header when no search is given", async () => {
const fetchFn = mockFetch({ status: 201, body: { resourceType: "Patient", id: "new-1" } });
const writer = new FhirResourceWriter<fhir4.Patient>(BASE_URL, "Patient", fetchFn);

await writer.create(patient, {});

const [, init] = (fetchFn as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0];
expect(init.headers).not.toHaveProperty("If-None-Exist");
});

it("returns the existing resource when the search matched (200)", async () => {
const existing = { resourceType: "Patient", id: "already-there" };
const fetchFn = mockFetch({ status: 200, body: existing });
const writer = new FhirResourceWriter<fhir4.Patient>(BASE_URL, "Patient", fetchFn);

await expect(writer.create(patient, { ifNoneExist: search })).resolves.toEqual(existing);
});

it("falls back to Location when a match returns no body", async () => {
// A server that has nothing new to report may answer 200 with an empty body; the id is then
// only in Location, and parsing the body alone would fail exactly on the idempotent path.
const fetchFn = mockFetch({
status: 200,
text: "",
headers: { Location: `${BASE_URL}/Patient/already-there/_history/3` },
});
const writer = new FhirResourceWriter<fhir4.Patient>(BASE_URL, "Patient", fetchFn);

await expect(writer.create(patient, { ifNoneExist: search })).resolves.toEqual({
resourceType: "Patient",
id: "already-there",
});
});

it("throws when the response carries neither a body nor a Location", async () => {
const fetchFn = mockFetch({ status: 200, text: "" });
const writer = new FhirResourceWriter<fhir4.Patient>(BASE_URL, "Patient", fetchFn);

await expect(writer.create(patient, { ifNoneExist: search })).rejects.toThrow(
"response carried neither a body nor a Location",
);
});
});

describe("update", () => {
it("PUTs a resource by ID", async () => {
const updated = { resourceType: "Patient", id: "123", name: [{ family: "Jones" }] };
Expand Down
57 changes: 53 additions & 4 deletions packages/client-r4/src/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@

import type { FetchFn, WithId } from "./types.js";

/** Options for {@link FhirResourceWriter.create}. */
export interface CreateOptions {
/**
* FHIR conditional create — the `If-None-Exist` header, given as a search string
* (e.g. `identifier=https://example.org/mrn|12345`).
*
* The server creates the resource only when that search matches nothing; on a match it returns
* the existing resource instead of a duplicate. This is what makes a create idempotent, and so
* what makes a write safe to retry: a provisioning job that runs twice produces one resource.
*
* Pass the raw search string, NOT url-encoded — this is a header, not a query string, and
* encoding the `|` makes the search match nothing, which silently turns every retry into
* another copy.
*/
ifNoneExist?: string;
}

/**
* Generic FHIR resource writer with create, update, delete and createOrUpdate (PUT).
*/
Expand All @@ -17,16 +34,28 @@ export class FhirResourceWriter<T extends fhir4.Resource> {
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
}

/** Create a new resource (POST). */
async create(resource: T): Promise<WithId<T>> {
/**
* Create a new resource (POST).
*
* With {@link CreateOptions.ifNoneExist} this is a conditional create: `201` for a resource that
* was created, `200` for one that already matched. Both resolve to the resource; check
* `meta.versionId` or search first if the caller needs to tell them apart.
*/
async create(resource: T, options?: CreateOptions): Promise<WithId<T>> {
const url = `${this.baseUrl}/${this.resourceType}`;
const headers: Record<string, string> = {
"Content-Type": "application/fhir+json",
Accept: "application/fhir+json",
};
if (options?.ifNoneExist) headers["If-None-Exist"] = options.ifNoneExist;

const res = await this.fetchFn(url, {
method: "POST",
headers: { "Content-Type": "application/fhir+json", Accept: "application/fhir+json" },
headers,
body: JSON.stringify(resource),
});
if (!res.ok) throw new Error(`POST ${this.resourceType}: ${res.status} ${res.statusText}`);
return (await res.json()) as WithId<T>;
return (await this.resourceFromResponse(res)) as WithId<T>;
}

/** Update an existing resource (PUT with ID). */
Expand Down Expand Up @@ -55,4 +84,24 @@ export class FhirResourceWriter<T extends fhir4.Resource> {
async createOrUpdate(resource: WithId<T>): Promise<WithId<T>> {
return this.update(resource);
}

/**
* The resource a write responded with, falling back to the `Location` header.
*
* A conditional create that MATCHES is allowed to answer `200` with no body — the server has
* nothing new to report — and the id then exists only in `Location`. Parsing the body alone
* would throw there, which would make the idempotent path the one that fails.
*/
private async resourceFromResponse(res: Response): Promise<fhir4.Resource> {
const text = await res.text();
if (text.trim()) return JSON.parse(text) as fhir4.Resource;

const location = res.headers?.get("Location") ?? res.headers?.get("Content-Location") ?? "";
// `[base/]Type/<id>[/_history/<v>]` — take the segment after the resource type.
const id = new RegExp(`${this.resourceType}/([^/?]+)`).exec(location)?.[1];
if (!id) {
throw new Error(`POST ${this.resourceType}: response carried neither a body nor a Location`);
}
return { resourceType: this.resourceType, id } as fhir4.Resource;
}
}
80 changes: 79 additions & 1 deletion packages/client-r4b/src/writer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,21 @@ import { FhirResourceWriter } from "./writer.js";

const BASE_URL = "https://fhir.example.com";

function mockFetch(response: { status: number; body?: unknown }): typeof globalThis.fetch {
function mockFetch(response: {
status: number;
body?: unknown;
/** Raw body, for the responses that carry none. Defaults to `JSON.stringify(body)`. */
text?: string;
headers?: Record<string, string>;
}): typeof globalThis.fetch {
const text = response.text ?? (response.body === undefined ? "{}" : JSON.stringify(response.body));
return vi.fn().mockResolvedValue({
ok: response.status >= 200 && response.status < 300,
status: response.status,
statusText: response.status === 200 ? "OK" : response.status === 201 ? "Created" : "Error",
json: async () => response.body ?? {},
text: async () => text,
headers: { get: (name: string) => response.headers?.[name] ?? null },
}) as unknown as typeof globalThis.fetch;
}

Expand Down Expand Up @@ -42,6 +51,75 @@ describe("FhirResourceWriter", () => {
});
});

describe("conditional create", () => {
const patient = { resourceType: "Patient", name: [{ family: "Smith" }] } as fhir4b.Patient;
const search = "identifier=https://example.org/mrn|12345";

it("sends If-None-Exist when asked", async () => {
const fetchFn = mockFetch({ status: 201, body: { resourceType: "Patient", id: "new-1" } });
const writer = new FhirResourceWriter<fhir4b.Patient>(BASE_URL, "Patient", fetchFn);

await writer.create(patient, { ifNoneExist: search });

expect(fetchFn).toHaveBeenCalledWith(
`${BASE_URL}/Patient`,
expect.objectContaining({
method: "POST",
headers: {
"Content-Type": "application/fhir+json",
Accept: "application/fhir+json",
// Raw, not url-encoded: encoding the `|` makes the search match nothing, so every
// retry would create another copy — the exact bug conditional create prevents.
"If-None-Exist": search,
},
}),
);
});

it("does not send the header when no search is given", async () => {
const fetchFn = mockFetch({ status: 201, body: { resourceType: "Patient", id: "new-1" } });
const writer = new FhirResourceWriter<fhir4b.Patient>(BASE_URL, "Patient", fetchFn);

await writer.create(patient, {});

const [, init] = (fetchFn as unknown as { mock: { calls: [string, RequestInit][] } }).mock.calls[0];
expect(init.headers).not.toHaveProperty("If-None-Exist");
});

it("returns the existing resource when the search matched (200)", async () => {
const existing = { resourceType: "Patient", id: "already-there" };
const fetchFn = mockFetch({ status: 200, body: existing });
const writer = new FhirResourceWriter<fhir4b.Patient>(BASE_URL, "Patient", fetchFn);

await expect(writer.create(patient, { ifNoneExist: search })).resolves.toEqual(existing);
});

it("falls back to Location when a match returns no body", async () => {
// A server that has nothing new to report may answer 200 with an empty body; the id is then
// only in Location, and parsing the body alone would fail exactly on the idempotent path.
const fetchFn = mockFetch({
status: 200,
text: "",
headers: { Location: `${BASE_URL}/Patient/already-there/_history/3` },
});
const writer = new FhirResourceWriter<fhir4b.Patient>(BASE_URL, "Patient", fetchFn);

await expect(writer.create(patient, { ifNoneExist: search })).resolves.toEqual({
resourceType: "Patient",
id: "already-there",
});
});

it("throws when the response carries neither a body nor a Location", async () => {
const fetchFn = mockFetch({ status: 200, text: "" });
const writer = new FhirResourceWriter<fhir4b.Patient>(BASE_URL, "Patient", fetchFn);

await expect(writer.create(patient, { ifNoneExist: search })).rejects.toThrow(
"response carried neither a body nor a Location",
);
});
});

describe("update", () => {
it("PUTs a resource by ID", async () => {
const updated = { resourceType: "Patient", id: "123", name: [{ family: "Jones" }] };
Expand Down
57 changes: 53 additions & 4 deletions packages/client-r4b/src/writer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,23 @@

import type { FetchFn, WithId } from "./types.js";

/** Options for {@link FhirResourceWriter.create}. */
export interface CreateOptions {
/**
* FHIR conditional create — the `If-None-Exist` header, given as a search string
* (e.g. `identifier=https://example.org/mrn|12345`).
*
* The server creates the resource only when that search matches nothing; on a match it returns
* the existing resource instead of a duplicate. This is what makes a create idempotent, and so
* what makes a write safe to retry: a provisioning job that runs twice produces one resource.
*
* Pass the raw search string, NOT url-encoded — this is a header, not a query string, and
* encoding the `|` makes the search match nothing, which silently turns every retry into
* another copy.
*/
ifNoneExist?: string;
}

/**
* Generic FHIR resource writer with create, update, delete and createOrUpdate (PUT).
*/
Expand All @@ -17,16 +34,28 @@ export class FhirResourceWriter<T extends fhir4b.Resource> {
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
}

/** Create a new resource (POST). */
async create(resource: T): Promise<WithId<T>> {
/**
* Create a new resource (POST).
*
* With {@link CreateOptions.ifNoneExist} this is a conditional create: `201` for a resource that
* was created, `200` for one that already matched. Both resolve to the resource; check
* `meta.versionId` or search first if the caller needs to tell them apart.
*/
async create(resource: T, options?: CreateOptions): Promise<WithId<T>> {
const url = `${this.baseUrl}/${this.resourceType}`;
const headers: Record<string, string> = {
"Content-Type": "application/fhir+json",
Accept: "application/fhir+json",
};
if (options?.ifNoneExist) headers["If-None-Exist"] = options.ifNoneExist;

const res = await this.fetchFn(url, {
method: "POST",
headers: { "Content-Type": "application/fhir+json", Accept: "application/fhir+json" },
headers,
body: JSON.stringify(resource),
});
if (!res.ok) throw new Error(`POST ${this.resourceType}: ${res.status} ${res.statusText}`);
return (await res.json()) as WithId<T>;
return (await this.resourceFromResponse(res)) as WithId<T>;
}

/** Update an existing resource (PUT with ID). */
Expand Down Expand Up @@ -55,4 +84,24 @@ export class FhirResourceWriter<T extends fhir4b.Resource> {
async createOrUpdate(resource: WithId<T>): Promise<WithId<T>> {
return this.update(resource);
}

/**
* The resource a write responded with, falling back to the `Location` header.
*
* A conditional create that MATCHES is allowed to answer `200` with no body — the server has
* nothing new to report — and the id then exists only in `Location`. Parsing the body alone
* would throw there, which would make the idempotent path the one that fails.
*/
private async resourceFromResponse(res: Response): Promise<fhir4b.Resource> {
const text = await res.text();
if (text.trim()) return JSON.parse(text) as fhir4b.Resource;

const location = res.headers?.get("Location") ?? res.headers?.get("Content-Location") ?? "";
// `[base/]Type/<id>[/_history/<v>]` — take the segment after the resource type.
const id = new RegExp(`${this.resourceType}/([^/?]+)`).exec(location)?.[1];
if (!id) {
throw new Error(`POST ${this.resourceType}: response carried neither a body nor a Location`);
}
return { resourceType: this.resourceType, id } as fhir4b.Resource;
}
}
Loading
Loading