-
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathwriter.ts
More file actions
107 lines (96 loc) · 4.37 KB
/
Copy pathwriter.ts
File metadata and controls
107 lines (96 loc) · 4.37 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// AUTO-GENERATED by scripts/generate-client-packages.ts — DO NOT EDIT.
// Edit scripts/client-codegen/template/writer.ts (+ versions.json) and run: npm run generate:clients
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).
*/
export class FhirResourceWriter<T extends fhir4.Resource> {
private readonly fetchFn: FetchFn;
constructor(
public readonly baseUrl: string,
public readonly resourceType: string,
fetchFn?: FetchFn,
) {
this.fetchFn = fetchFn ?? globalThis.fetch.bind(globalThis);
}
/**
* 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,
body: JSON.stringify(resource),
});
if (!res.ok) throw new Error(`POST ${this.resourceType}: ${res.status} ${res.statusText}`);
return (await this.resourceFromResponse(res)) as WithId<T>;
}
/** Update an existing resource (PUT with ID). */
async update(resource: WithId<T>): Promise<WithId<T>> {
const url = `${this.baseUrl}/${this.resourceType}/${encodeURIComponent(resource.id)}`;
const res = await this.fetchFn(url, {
method: "PUT",
headers: { "Content-Type": "application/fhir+json", Accept: "application/fhir+json" },
body: JSON.stringify(resource),
});
if (!res.ok) throw new Error(`PUT ${this.resourceType}/${resource.id}: ${res.status} ${res.statusText}`);
return (await res.json()) as WithId<T>;
}
/** Delete a resource by ID. */
async delete(id: string): Promise<void> {
const url = `${this.baseUrl}/${this.resourceType}/${encodeURIComponent(id)}`;
const res = await this.fetchFn(url, {
method: "DELETE",
headers: { Accept: "application/fhir+json" },
});
if (!res.ok) throw new Error(`DELETE ${this.resourceType}/${id}: ${res.status} ${res.statusText}`);
}
/** Create-or-update via conditional PUT. */
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;
}
}