@cuvo-health-us/api 0.1.0-next.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +264 -0
- package/dist/client.d.ts +31 -0
- package/dist/client.js +50 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +59 -0
- package/dist/events.d.ts +126 -0
- package/dist/events.js +100 -0
- package/dist/generated/v1.d.ts +6371 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.js +11 -0
- package/dist/pagination.d.ts +33 -0
- package/dist/pagination.js +46 -0
- package/dist/retry.d.ts +24 -0
- package/dist/retry.js +81 -0
- package/dist/webhooks.d.ts +43 -0
- package/dist/webhooks.js +78 -0
- package/package.json +48 -0
package/README.md
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
# `@cuvo-health-us/api`
|
|
2
|
+
|
|
3
|
+
The TypeScript client for the [Cuvo Integrations API](https://developers.cuvo.co): create
|
|
4
|
+
patients, record consents, file cases for a licensed clinician to decide, and follow
|
|
5
|
+
prescriptions and orders through events and webhooks.
|
|
6
|
+
|
|
7
|
+
Every type in this package is generated from `api/openapi/v1.yaml`, which is itself generated
|
|
8
|
+
from the schemas the server validates with. If it compiles, it speaks the deployed contract.
|
|
9
|
+
|
|
10
|
+
> Prerelease. Published under the `next` tag while the developer product settles; `0.1.x` may
|
|
11
|
+
> still move.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
pnpm add @cuvo-health-us/api@next
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Requires Node 20 or newer. `verifySignature` uses `node:crypto`; the client itself runs anywhere
|
|
20
|
+
there is a `fetch`.
|
|
21
|
+
|
|
22
|
+
## Authenticate
|
|
23
|
+
|
|
24
|
+
Two credentials reach the same API, and both arrive as `Authorization: Bearer …`.
|
|
25
|
+
|
|
26
|
+
**An API key** is what one integration uses for its own traffic. Mint it in the developer portal;
|
|
27
|
+
`cuvo_sk_test_…` reaches the sandbox and `cuvo_sk_live_…` reaches production. Never ship a live key
|
|
28
|
+
to a browser.
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { createCuvoClient } from "@cuvo-health-us/api";
|
|
32
|
+
|
|
33
|
+
const cuvo = createCuvoClient({
|
|
34
|
+
apiKey: process.env.CUVO_API_KEY!,
|
|
35
|
+
// organization: "org_…", // only when the credential is granted to more than one
|
|
36
|
+
});
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
**An OAuth client** is what a platform uses to act for the organizations that granted it access.
|
|
40
|
+
Exchange the client id and secret for a token at
|
|
41
|
+
`https://developers.cuvo.co/api/auth/oauth2/token` with `grant_type=client_credentials`, ask for
|
|
42
|
+
the scopes you need, and hand the access token to the same option. The client id and secret go in
|
|
43
|
+
the `Authorization` header as HTTP Basic credentials, which is the one method a client is
|
|
44
|
+
registered for:
|
|
45
|
+
|
|
46
|
+
```ts
|
|
47
|
+
const basic = Buffer.from(
|
|
48
|
+
`${process.env.CUVO_CLIENT_ID}:${process.env.CUVO_CLIENT_SECRET}`,
|
|
49
|
+
).toString("base64");
|
|
50
|
+
|
|
51
|
+
const response = await fetch("https://developers.cuvo.co/api/auth/oauth2/token", {
|
|
52
|
+
method: "POST",
|
|
53
|
+
headers: {
|
|
54
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
55
|
+
authorization: `Basic ${basic}`,
|
|
56
|
+
},
|
|
57
|
+
body: new URLSearchParams({
|
|
58
|
+
grant_type: "client_credentials",
|
|
59
|
+
scope: "patients:write cases:write events:read",
|
|
60
|
+
resource: "https://api.cuvo.co",
|
|
61
|
+
}),
|
|
62
|
+
});
|
|
63
|
+
const { access_token } = (await response.json()) as { access_token: string };
|
|
64
|
+
|
|
65
|
+
const cuvo = createCuvoClient({ apiKey: access_token, organization: "org_…" });
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`resource` is not optional. It is what the provider reads to set the token's audience, and the API
|
|
69
|
+
refuses a token whose audience does not name it, so an exchange without it mints a token that fails
|
|
70
|
+
on the first call.
|
|
71
|
+
|
|
72
|
+
Scopes are per credential and are intersected with what the organization granted: a key with
|
|
73
|
+
`cases:write` acting for an organization that granted only `cases:read` may read and nothing more.
|
|
74
|
+
Tokens are short lived, so mint one per run rather than caching it past its expiry.
|
|
75
|
+
|
|
76
|
+
`Cuvo-Organization` names the organization you are acting for. It is required whenever the
|
|
77
|
+
credential holds more than one grant, and the client sends it on every call once you name it.
|
|
78
|
+
|
|
79
|
+
The client adds three things to every call, which is most of why it exists:
|
|
80
|
+
|
|
81
|
+
- `Authorization: Bearer <key>`, and `Cuvo-Organization` when you named one.
|
|
82
|
+
- An `Idempotency-Key` on every POST, PATCH and DELETE. Pass your own to make a retry from your
|
|
83
|
+
own job queue replay instead of creating a second row.
|
|
84
|
+
- Retries on 429 and on the server faults a second attempt can clear, with exponential backoff,
|
|
85
|
+
jitter, and `Retry-After` when the server names a delay. A write with no idempotency key is
|
|
86
|
+
never retried.
|
|
87
|
+
|
|
88
|
+
## File a case
|
|
89
|
+
|
|
90
|
+
```ts
|
|
91
|
+
import { createCuvoClient, unwrap } from "@cuvo-health-us/api";
|
|
92
|
+
|
|
93
|
+
const cuvo = createCuvoClient({ apiKey: process.env.CUVO_API_KEY! });
|
|
94
|
+
|
|
95
|
+
const patient = unwrap(
|
|
96
|
+
await cuvo.POST("/v1/patients", {
|
|
97
|
+
body: {
|
|
98
|
+
first_name: "Ada",
|
|
99
|
+
last_name: "Lovelace",
|
|
100
|
+
date_of_birth: "1990-04-14",
|
|
101
|
+
sex_at_birth: "female",
|
|
102
|
+
email: "ada@example.com",
|
|
103
|
+
phone: "+14155550123",
|
|
104
|
+
address: {
|
|
105
|
+
line1: "1 Market St",
|
|
106
|
+
city: "San Francisco",
|
|
107
|
+
state: "CA",
|
|
108
|
+
postal_code: "94105",
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
}),
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
// A case needs the consents the patient gave before it can be filed.
|
|
115
|
+
const consents = await Promise.all(
|
|
116
|
+
(["telehealth", "privacy"] as const).map(async (kind) =>
|
|
117
|
+
unwrap(
|
|
118
|
+
await cuvo.POST("/v1/patients/{id}/consents", {
|
|
119
|
+
params: { path: { id: patient.id } },
|
|
120
|
+
body: { kind, version: "2026-01" },
|
|
121
|
+
}),
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const filed = unwrap(
|
|
127
|
+
await cuvo.POST("/v1/cases", {
|
|
128
|
+
body: {
|
|
129
|
+
patient: patient.id,
|
|
130
|
+
requested_medications: [
|
|
131
|
+
{
|
|
132
|
+
medication_id: "med_sema_0_25",
|
|
133
|
+
quantity: 4,
|
|
134
|
+
refills: 0,
|
|
135
|
+
days_supply: 28,
|
|
136
|
+
directions: "Inject 0.25 mg subcutaneously once weekly.",
|
|
137
|
+
},
|
|
138
|
+
],
|
|
139
|
+
answers: [
|
|
140
|
+
{ id: "q_pregnant", question: "Are you pregnant?", answer: false, type: "boolean" },
|
|
141
|
+
],
|
|
142
|
+
consent_ids: consents.map((consent) => consent.id),
|
|
143
|
+
hold: false,
|
|
144
|
+
},
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
console.log(filed.id, filed.status); // case_… queued
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
`unwrap` turns a failure into a thrown `CuvoApiError` carrying `status`, `code`, `issues` and
|
|
152
|
+
`requestId`. Drop it and use the generated `{ data, error }` pair instead when you would rather
|
|
153
|
+
branch than catch.
|
|
154
|
+
|
|
155
|
+
## Page through a list
|
|
156
|
+
|
|
157
|
+
```ts
|
|
158
|
+
import { paginate, unwrap } from "@cuvo-health-us/api";
|
|
159
|
+
|
|
160
|
+
const inReview = paginate(async (starting_after) =>
|
|
161
|
+
unwrap(
|
|
162
|
+
await cuvo.GET("/v1/cases", {
|
|
163
|
+
params: { query: { limit: 100, status: "in_review", starting_after } },
|
|
164
|
+
}),
|
|
165
|
+
),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
for await (const item of inReview) {
|
|
169
|
+
console.log(item.id, item.status);
|
|
170
|
+
}
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
The iterator is lazy and follows `starting_after` until the API says there is no more. Pass the
|
|
174
|
+
cursor into your own query rather than handing the filters over, so `status` stays checked
|
|
175
|
+
against that endpoint's enum instead of widening to `string`.
|
|
176
|
+
|
|
177
|
+
## Receive a webhook
|
|
178
|
+
|
|
179
|
+
Verify before you parse, and verify the raw bytes. Re-serializing the body changes key order and
|
|
180
|
+
every signature fails.
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { expandEvent, parseEvent, verifySignature } from "@cuvo-health-us/api";
|
|
184
|
+
|
|
185
|
+
export async function POST(request: Request) {
|
|
186
|
+
const rawBody = await request.text();
|
|
187
|
+
|
|
188
|
+
const verified = verifySignature({
|
|
189
|
+
rawBody,
|
|
190
|
+
signatureHeader: request.headers.get("Cuvo-Signature"),
|
|
191
|
+
secret: process.env.CUVO_WEBHOOK_SECRET!,
|
|
192
|
+
});
|
|
193
|
+
if (!verified) return new Response("bad signature", { status: 400 });
|
|
194
|
+
|
|
195
|
+
const event = parseEvent(rawBody);
|
|
196
|
+
|
|
197
|
+
// Delivery is at-least-once: dedupe on event.id before you act.
|
|
198
|
+
if (await alreadyHandled(event.id)) return new Response(null, { status: 200 });
|
|
199
|
+
|
|
200
|
+
if (event.type === "case.approved.v1") {
|
|
201
|
+
// Events are thin. This returns the embedded resource when the endpoint opted into
|
|
202
|
+
// include_resource, and reads it back otherwise.
|
|
203
|
+
const approved = await expandEvent(cuvo, event);
|
|
204
|
+
console.log(approved);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return new Response(null, { status: 200 });
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
During the 24 hour grace after a secret rotation a delivery carries two signatures.
|
|
212
|
+
`verifySignature` accepts a match against either, so you can move to the new secret whenever you
|
|
213
|
+
like inside the window.
|
|
214
|
+
|
|
215
|
+
`parseEvent` returns the event keyed by its `type`, so the branch above knows `event.data` is a
|
|
216
|
+
`Case` and `expandEvent` answers with one. An event read from `GET /v1/events` arrives as the
|
|
217
|
+
contract's single `Event` shape instead, because that is what one schema for the whole catalog
|
|
218
|
+
can say; pass it through `typedEvent` for the same narrowing:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { paginate, typedEvent, unwrap } from "@cuvo-health-us/api";
|
|
222
|
+
|
|
223
|
+
for await (const row of paginate(async (starting_after) =>
|
|
224
|
+
unwrap(await cuvo.GET("/v1/events", { params: { query: { since, starting_after } } })),
|
|
225
|
+
)) {
|
|
226
|
+
const event = typedEvent(row);
|
|
227
|
+
if (event.type === "order.shipped.v1") console.log(event.resource.id);
|
|
228
|
+
}
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
`typedEvent` also refuses a body that pairs an event type with the wrong resource, which no
|
|
232
|
+
delivery from Cuvo does.
|
|
233
|
+
|
|
234
|
+
`expandEvent` returns `undefined` for messages, prescriptions, consents and charges: v1 reads
|
|
235
|
+
those through their case or patient rather than on their own id. Turn on `include_resource` on the
|
|
236
|
+
endpoint, or pass `include_resource: true` to `GET /v1/events`, and the resource arrives embedded
|
|
237
|
+
as `data` with no second call.
|
|
238
|
+
|
|
239
|
+
## Development
|
|
240
|
+
|
|
241
|
+
```sh
|
|
242
|
+
pnpm install
|
|
243
|
+
pnpm generate # rewrite src/generated/v1.d.ts from api/openapi/v1.yaml
|
|
244
|
+
pnpm check:generated # fail if the committed types no longer match the contract
|
|
245
|
+
pnpm gates # typecheck, lint, dead code, tests, drift
|
|
246
|
+
pnpm build # compile src to dist, the published shape
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
`src/generated/v1.d.ts` is committed and never edited by hand. `pnpm build` compiles everything
|
|
250
|
+
but the tests into `dist` and copies that declaration file beside the output, because a
|
|
251
|
+
declaration-only input produces no emit of its own.
|
|
252
|
+
|
|
253
|
+
## Publishing
|
|
254
|
+
|
|
255
|
+
The package publishes from `dist`, under the `next` tag, at the prerelease version in
|
|
256
|
+
`package.json`. `pnpm pack` builds the tarball without sending anything.
|
|
257
|
+
|
|
258
|
+
```sh
|
|
259
|
+
pnpm pack # inspect the tarball first
|
|
260
|
+
npm publish --tag next # requires an npm login with access to the @cuvo scope
|
|
261
|
+
```
|
|
262
|
+
|
|
263
|
+
Nothing goes out under `latest` until the contract is stable and the founder says so: a stable tag
|
|
264
|
+
is what a partner's `pnpm add @cuvo-health-us/api` resolves to by default.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Client } from "openapi-fetch";
|
|
2
|
+
import type { paths } from "./generated/v1.js";
|
|
3
|
+
/** The public API. Test and live mode are selected by the key, not by the host. */
|
|
4
|
+
export declare const DEFAULT_BASE_URL = "https://api.cuvo.co";
|
|
5
|
+
export declare const DEFAULT_MAX_RETRIES = 3;
|
|
6
|
+
export interface CuvoClientOptions {
|
|
7
|
+
/**
|
|
8
|
+
* `cuvo_sk_test_…`, `cuvo_sk_live_…`, or an OAuth access token: the API takes all three as the
|
|
9
|
+
* same bearer, so `@cuvo-health-us/cli` passes the token it was issued straight into this option. Never
|
|
10
|
+
* ship a live key to a browser.
|
|
11
|
+
*/
|
|
12
|
+
apiKey: string;
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
/** Required when the credential holds a grant to more than one organization. */
|
|
15
|
+
organization?: string;
|
|
16
|
+
/** Swap the transport, for a proxy, a test double, or a runtime without a global `fetch`. */
|
|
17
|
+
fetch?: typeof globalThis.fetch;
|
|
18
|
+
/** Attempts after the first, on 429 and retryable 5xx. `0` disables retrying. */
|
|
19
|
+
maxRetries?: number;
|
|
20
|
+
}
|
|
21
|
+
/** The generated client: one method per HTTP verb, typed by path from the v1 contract. */
|
|
22
|
+
export type CuvoClient = Client<paths>;
|
|
23
|
+
/**
|
|
24
|
+
* Build a client for one credential.
|
|
25
|
+
*
|
|
26
|
+
* ```ts
|
|
27
|
+
* const cuvo = createCuvoClient({ apiKey: process.env.CUVO_API_KEY! });
|
|
28
|
+
* const { data, error } = await cuvo.POST("/v1/patients", { body: patient });
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export declare function createCuvoClient(options: CuvoClientOptions): CuvoClient;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import createClient, {} from "openapi-fetch";
|
|
2
|
+
import { createRetryingFetch, IDEMPOTENCY_KEY_HEADER } from "./retry.js";
|
|
3
|
+
/** The public API. Test and live mode are selected by the key, not by the host. */
|
|
4
|
+
export const DEFAULT_BASE_URL = "https://api.cuvo.co";
|
|
5
|
+
export const DEFAULT_MAX_RETRIES = 3;
|
|
6
|
+
/** The methods the contract requires an `Idempotency-Key` on. */
|
|
7
|
+
const WRITE_METHODS = new Set(["POST", "PATCH", "DELETE"]);
|
|
8
|
+
/**
|
|
9
|
+
* Authorization, the acting organization, and an idempotency key for every write.
|
|
10
|
+
*
|
|
11
|
+
* The key matters most. The contract requires one on every POST, PATCH and DELETE, and a caller
|
|
12
|
+
* who has to invent one per call will eventually reuse a constant and turn every create after
|
|
13
|
+
* the first into a replay. A caller who wants their own key still wins: this only fills a gap.
|
|
14
|
+
*/
|
|
15
|
+
function cuvoHeaders(apiKey, organization) {
|
|
16
|
+
return {
|
|
17
|
+
onRequest({ request }) {
|
|
18
|
+
request.headers.set("Authorization", `Bearer ${apiKey}`);
|
|
19
|
+
if (organization !== undefined)
|
|
20
|
+
request.headers.set("Cuvo-Organization", organization);
|
|
21
|
+
if (WRITE_METHODS.has(request.method.toUpperCase()) &&
|
|
22
|
+
!request.headers.has(IDEMPOTENCY_KEY_HEADER)) {
|
|
23
|
+
request.headers.set(IDEMPOTENCY_KEY_HEADER, globalThis.crypto.randomUUID());
|
|
24
|
+
}
|
|
25
|
+
return request;
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build a client for one credential.
|
|
31
|
+
*
|
|
32
|
+
* ```ts
|
|
33
|
+
* const cuvo = createCuvoClient({ apiKey: process.env.CUVO_API_KEY! });
|
|
34
|
+
* const { data, error } = await cuvo.POST("/v1/patients", { body: patient });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export function createCuvoClient(options) {
|
|
38
|
+
const { apiKey, baseUrl = DEFAULT_BASE_URL, organization, maxRetries = DEFAULT_MAX_RETRIES, } = options;
|
|
39
|
+
if (apiKey.length === 0)
|
|
40
|
+
throw new Error("createCuvoClient needs an API key.");
|
|
41
|
+
const client = createClient({
|
|
42
|
+
baseUrl,
|
|
43
|
+
fetch: createRetryingFetch({
|
|
44
|
+
fetch: options.fetch ?? globalThis.fetch,
|
|
45
|
+
maxRetries,
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
client.use(cuvoHeaders(apiKey, organization));
|
|
49
|
+
return client;
|
|
50
|
+
}
|
package/dist/errors.d.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { components } from "./generated/v1.js";
|
|
2
|
+
/** RFC 9457 problem details, the one error body every endpoint answers with. */
|
|
3
|
+
export type Problem = components["schemas"]["Problem"];
|
|
4
|
+
/** One validation failure, addressed by the dotted path of the field that produced it. */
|
|
5
|
+
export type ProblemIssue = NonNullable<Problem["issues"]>[number];
|
|
6
|
+
/**
|
|
7
|
+
* A request the API refused, or an answer it could not have sent.
|
|
8
|
+
*
|
|
9
|
+
* `code` is the stable machine string to branch on; `message` is the problem's `detail`, which
|
|
10
|
+
* the contract guarantees is safe to show an end user. `problem` carries the body verbatim for
|
|
11
|
+
* the RFC's `type` and `title`, which nothing branches on but a bug report wants.
|
|
12
|
+
*/
|
|
13
|
+
export declare class CuvoApiError extends Error {
|
|
14
|
+
/** The stable machine string, for example `case_not_found` or `idempotency_key_reused`. */
|
|
15
|
+
readonly code: string;
|
|
16
|
+
readonly status: number;
|
|
17
|
+
/** Present on a 400: the fields that failed validation. Empty on every other status. */
|
|
18
|
+
readonly issues: readonly ProblemIssue[];
|
|
19
|
+
/** The server's id for this request. Quote it in a support ticket. */
|
|
20
|
+
readonly requestId: string | undefined;
|
|
21
|
+
readonly problem: Problem;
|
|
22
|
+
constructor(problem: Problem);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Turn openapi-fetch's `{ data, error }` into a value or a throw.
|
|
26
|
+
*
|
|
27
|
+
* The generated client returns errors rather than throwing, which is right for a caller that
|
|
28
|
+
* wants to branch on both. Everything inside this SDK, and most callers, want the throwing form.
|
|
29
|
+
*/
|
|
30
|
+
export declare function unwrap<Data>(result: {
|
|
31
|
+
data?: Data;
|
|
32
|
+
error?: unknown;
|
|
33
|
+
response: Response;
|
|
34
|
+
}): Data;
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A request the API refused, or an answer it could not have sent.
|
|
3
|
+
*
|
|
4
|
+
* `code` is the stable machine string to branch on; `message` is the problem's `detail`, which
|
|
5
|
+
* the contract guarantees is safe to show an end user. `problem` carries the body verbatim for
|
|
6
|
+
* the RFC's `type` and `title`, which nothing branches on but a bug report wants.
|
|
7
|
+
*/
|
|
8
|
+
export class CuvoApiError extends Error {
|
|
9
|
+
/** The stable machine string, for example `case_not_found` or `idempotency_key_reused`. */
|
|
10
|
+
code;
|
|
11
|
+
status;
|
|
12
|
+
/** Present on a 400: the fields that failed validation. Empty on every other status. */
|
|
13
|
+
issues;
|
|
14
|
+
/** The server's id for this request. Quote it in a support ticket. */
|
|
15
|
+
requestId;
|
|
16
|
+
problem;
|
|
17
|
+
constructor(problem) {
|
|
18
|
+
super(problem.detail);
|
|
19
|
+
this.name = "CuvoApiError";
|
|
20
|
+
this.code = problem.code;
|
|
21
|
+
this.status = problem.status;
|
|
22
|
+
this.issues = problem.issues ?? [];
|
|
23
|
+
this.requestId = problem.request_id;
|
|
24
|
+
this.problem = problem;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Coerce whatever came back into problem details. A gateway between the caller and the API can
|
|
29
|
+
* answer with HTML or nothing at all, and an SDK that throws `undefined.code` on a 502 is worse
|
|
30
|
+
* than useless during exactly the incident it should be explaining.
|
|
31
|
+
*/
|
|
32
|
+
function toProblem(status, body) {
|
|
33
|
+
const candidate = body;
|
|
34
|
+
if (candidate && typeof candidate.code === "string" && typeof candidate.detail === "string") {
|
|
35
|
+
return { ...candidate, status: candidate.status ?? status };
|
|
36
|
+
}
|
|
37
|
+
return {
|
|
38
|
+
type: "about:blank",
|
|
39
|
+
title: "Unexpected response",
|
|
40
|
+
status,
|
|
41
|
+
code: "unexpected_response",
|
|
42
|
+
detail: `The API answered ${status} with a body that is not problem details.`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Turn openapi-fetch's `{ data, error }` into a value or a throw.
|
|
47
|
+
*
|
|
48
|
+
* The generated client returns errors rather than throwing, which is right for a caller that
|
|
49
|
+
* wants to branch on both. Everything inside this SDK, and most callers, want the throwing form.
|
|
50
|
+
*/
|
|
51
|
+
export function unwrap(result) {
|
|
52
|
+
if (result.error !== undefined) {
|
|
53
|
+
throw new CuvoApiError(toProblem(result.response.status, result.error));
|
|
54
|
+
}
|
|
55
|
+
if (result.data === undefined) {
|
|
56
|
+
throw new CuvoApiError(toProblem(result.response.status, undefined));
|
|
57
|
+
}
|
|
58
|
+
return result.data;
|
|
59
|
+
}
|
package/dist/events.d.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type { CuvoClient } from "./client.js";
|
|
2
|
+
import type { components } from "./generated/v1.js";
|
|
3
|
+
/**
|
|
4
|
+
* An event: what happened, to which resource, and the status that resource is in now. This is
|
|
5
|
+
* the body of every webhook delivery and every row of `GET /v1/events`.
|
|
6
|
+
*
|
|
7
|
+
* This is the contract's own shape, where `data` is `unknown` because one schema describes every
|
|
8
|
+
* event in the catalog. `CuvoTypedEvent` below is the same thing keyed by `type`, which is what a
|
|
9
|
+
* handler wants.
|
|
10
|
+
*/
|
|
11
|
+
export type CuvoEvent = components["schemas"]["Event"];
|
|
12
|
+
/** The catalog, for example `case.approved.v1`. Versioned, so a payload change is a new type. */
|
|
13
|
+
export type CuvoEventType = CuvoEvent["type"];
|
|
14
|
+
export type CuvoEventResourceType = CuvoEvent["resource"]["type"];
|
|
15
|
+
/**
|
|
16
|
+
* THE TABLE: which resource each event in the catalog is about.
|
|
17
|
+
*
|
|
18
|
+
* It mirrors `RESOURCE_OF` in api/lib/events/clinical-refs.ts, and for the same reason: the
|
|
19
|
+
* prefix of an event name is USUALLY its resource, and `case.message_created.v1` is the one
|
|
20
|
+
* where it is not. The fact is about a message; the case it belongs to rides in the payload.
|
|
21
|
+
*
|
|
22
|
+
* `satisfies Record<CuvoEventType, …>` is the gate. An event type added to the contract, or one
|
|
23
|
+
* renamed, fails to compile here until this table names its resource, which is what keeps
|
|
24
|
+
* `CuvoTypedEvent` honest without a second source of truth.
|
|
25
|
+
*/
|
|
26
|
+
declare const RESOURCE_OF: {
|
|
27
|
+
readonly "patient.created.v1": "patient";
|
|
28
|
+
readonly "patient.updated.v1": "patient";
|
|
29
|
+
readonly "patient.deleted.v1": "patient";
|
|
30
|
+
readonly "case.received.v1": "case";
|
|
31
|
+
readonly "case.queued.v1": "case";
|
|
32
|
+
readonly "case.in_review.v1": "case";
|
|
33
|
+
readonly "case.waiting_on_patient.v1": "case";
|
|
34
|
+
readonly "case.waiting_on_integration.v1": "case";
|
|
35
|
+
readonly "case.approved.v1": "case";
|
|
36
|
+
readonly "case.declined.v1": "case";
|
|
37
|
+
readonly "case.cancelled.v1": "case";
|
|
38
|
+
readonly "case.withdrawn.v1": "case";
|
|
39
|
+
readonly "case.question_asked.v1": "case";
|
|
40
|
+
readonly "case.message_created.v1": "message";
|
|
41
|
+
readonly "prescription.created.v1": "prescription";
|
|
42
|
+
readonly "prescription.updated.v1": "prescription";
|
|
43
|
+
readonly "order.created.v1": "order";
|
|
44
|
+
readonly "order.at_pharmacy.v1": "order";
|
|
45
|
+
readonly "order.shipped.v1": "order";
|
|
46
|
+
readonly "order.delivered.v1": "order";
|
|
47
|
+
readonly "order.blocked.v1": "order";
|
|
48
|
+
readonly "order.failed.v1": "order";
|
|
49
|
+
readonly "consent.recorded.v1": "consent";
|
|
50
|
+
readonly "file.attached.v1": "file";
|
|
51
|
+
readonly "charge.created.v1": "charge";
|
|
52
|
+
readonly "webhook_endpoint.disabled.v1": "webhook_endpoint";
|
|
53
|
+
};
|
|
54
|
+
/** The resource type `T` is about, at the type level: `"case.approved.v1"` is a `"case"`. */
|
|
55
|
+
export type CuvoEventResourceTypeOf<T extends CuvoEventType> = (typeof RESOURCE_OF)[T];
|
|
56
|
+
/**
|
|
57
|
+
* Each resource type as the API returns it.
|
|
58
|
+
*
|
|
59
|
+
* `charge` is `never`: v1 publishes no charge DTO, so nothing the contract describes can be
|
|
60
|
+
* embedded in a charge event or read back for one. Saying `unknown` there would swallow every
|
|
61
|
+
* other member of the union at the first `|`.
|
|
62
|
+
*/
|
|
63
|
+
interface ResourceDtos {
|
|
64
|
+
patient: components["schemas"]["Patient"];
|
|
65
|
+
case: components["schemas"]["Case"];
|
|
66
|
+
message: components["schemas"]["Message"];
|
|
67
|
+
prescription: components["schemas"]["Prescription"];
|
|
68
|
+
order: components["schemas"]["Order"];
|
|
69
|
+
consent: components["schemas"]["Consent"];
|
|
70
|
+
file: components["schemas"]["File"];
|
|
71
|
+
charge: never;
|
|
72
|
+
webhook_endpoint: components["schemas"]["WebhookEndpoint"];
|
|
73
|
+
}
|
|
74
|
+
/** The resource DTO an event of type `T` carries and points at. */
|
|
75
|
+
export type CuvoEventResourceOf<T extends CuvoEventType> = ResourceDtos[CuvoEventResourceTypeOf<T>];
|
|
76
|
+
/** Every resource an event can point at, as the API returns it. */
|
|
77
|
+
export type ExpandedResource = ResourceDtos[CuvoEventResourceType];
|
|
78
|
+
/** One event of the catalog, with its resource type and its embedded resource both pinned. */
|
|
79
|
+
export type CuvoEventOf<T extends CuvoEventType> = Omit<CuvoEvent, "type" | "resource" | "data"> & {
|
|
80
|
+
type: T;
|
|
81
|
+
resource: {
|
|
82
|
+
type: CuvoEventResourceTypeOf<T>;
|
|
83
|
+
id: string;
|
|
84
|
+
};
|
|
85
|
+
/** Present when the endpoint opted into `include_resource`, absent otherwise. */
|
|
86
|
+
data?: CuvoEventResourceOf<T>;
|
|
87
|
+
};
|
|
88
|
+
/**
|
|
89
|
+
* The catalog as a union discriminated by `type`.
|
|
90
|
+
*
|
|
91
|
+
* ```ts
|
|
92
|
+
* if (event.type === "case.approved.v1") {
|
|
93
|
+
* event.data?.decided_at; // a Case, checked. No cast, no second lookup table.
|
|
94
|
+
* }
|
|
95
|
+
* ```
|
|
96
|
+
*/
|
|
97
|
+
export type CuvoTypedEvent = {
|
|
98
|
+
[T in CuvoEventType]: CuvoEventOf<T>;
|
|
99
|
+
}[CuvoEventType];
|
|
100
|
+
/**
|
|
101
|
+
* Key an event by its `type`, checking the one thing the type system cannot see: that the
|
|
102
|
+
* resource the body names is the resource that event type is about.
|
|
103
|
+
*
|
|
104
|
+
* `parseEvent` runs this over every webhook body. Call it on an event read from
|
|
105
|
+
* `GET /v1/events`, whose generated type is the contract's single `Event` shape, to get the same
|
|
106
|
+
* narrowing there. A body that pairs a type with the wrong resource did not come from Cuvo, and
|
|
107
|
+
* a cast that assumed otherwise would hand a handler a `Case` that is really a `Message`.
|
|
108
|
+
*/
|
|
109
|
+
export declare function typedEvent(event: CuvoEvent): CuvoTypedEvent;
|
|
110
|
+
/**
|
|
111
|
+
* Fetch the resource an event is about.
|
|
112
|
+
*
|
|
113
|
+
* Events are thin on purpose: ids, type, status, timestamps. An endpoint that opted into
|
|
114
|
+
* `include_resource` already carries the resource as `data`, and this returns that without a
|
|
115
|
+
* round trip. Otherwise it reads the resource back.
|
|
116
|
+
*
|
|
117
|
+
* The return type follows the event: hand it a `case.approved.v1` and you get a `Case`, with no
|
|
118
|
+
* second narrowing at the call site.
|
|
119
|
+
*
|
|
120
|
+
* `undefined` means the v1 contract has no read addressed by that id alone: messages,
|
|
121
|
+
* prescriptions and consents are listed through their case or patient, and charges have no
|
|
122
|
+
* public read until billing ships. Turn on `include_resource` for endpoints that need those, or
|
|
123
|
+
* read the parent case. The return type says `| undefined` so this cannot be forgotten.
|
|
124
|
+
*/
|
|
125
|
+
export declare function expandEvent<T extends CuvoEventType>(client: CuvoClient, event: CuvoEventOf<T>): Promise<CuvoEventResourceOf<T> | undefined>;
|
|
126
|
+
export {};
|
package/dist/events.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { unwrap } from "./errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* THE TABLE: which resource each event in the catalog is about.
|
|
4
|
+
*
|
|
5
|
+
* It mirrors `RESOURCE_OF` in api/lib/events/clinical-refs.ts, and for the same reason: the
|
|
6
|
+
* prefix of an event name is USUALLY its resource, and `case.message_created.v1` is the one
|
|
7
|
+
* where it is not. The fact is about a message; the case it belongs to rides in the payload.
|
|
8
|
+
*
|
|
9
|
+
* `satisfies Record<CuvoEventType, …>` is the gate. An event type added to the contract, or one
|
|
10
|
+
* renamed, fails to compile here until this table names its resource, which is what keeps
|
|
11
|
+
* `CuvoTypedEvent` honest without a second source of truth.
|
|
12
|
+
*/
|
|
13
|
+
const RESOURCE_OF = {
|
|
14
|
+
"patient.created.v1": "patient",
|
|
15
|
+
"patient.updated.v1": "patient",
|
|
16
|
+
"patient.deleted.v1": "patient",
|
|
17
|
+
"case.received.v1": "case",
|
|
18
|
+
"case.queued.v1": "case",
|
|
19
|
+
"case.in_review.v1": "case",
|
|
20
|
+
"case.waiting_on_patient.v1": "case",
|
|
21
|
+
"case.waiting_on_integration.v1": "case",
|
|
22
|
+
"case.approved.v1": "case",
|
|
23
|
+
"case.declined.v1": "case",
|
|
24
|
+
"case.cancelled.v1": "case",
|
|
25
|
+
"case.withdrawn.v1": "case",
|
|
26
|
+
"case.question_asked.v1": "case",
|
|
27
|
+
"case.message_created.v1": "message",
|
|
28
|
+
"prescription.created.v1": "prescription",
|
|
29
|
+
"prescription.updated.v1": "prescription",
|
|
30
|
+
"order.created.v1": "order",
|
|
31
|
+
"order.at_pharmacy.v1": "order",
|
|
32
|
+
"order.shipped.v1": "order",
|
|
33
|
+
"order.delivered.v1": "order",
|
|
34
|
+
"order.blocked.v1": "order",
|
|
35
|
+
"order.failed.v1": "order",
|
|
36
|
+
"consent.recorded.v1": "consent",
|
|
37
|
+
"file.attached.v1": "file",
|
|
38
|
+
"charge.created.v1": "charge",
|
|
39
|
+
"webhook_endpoint.disabled.v1": "webhook_endpoint",
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* Key an event by its `type`, checking the one thing the type system cannot see: that the
|
|
43
|
+
* resource the body names is the resource that event type is about.
|
|
44
|
+
*
|
|
45
|
+
* `parseEvent` runs this over every webhook body. Call it on an event read from
|
|
46
|
+
* `GET /v1/events`, whose generated type is the contract's single `Event` shape, to get the same
|
|
47
|
+
* narrowing there. A body that pairs a type with the wrong resource did not come from Cuvo, and
|
|
48
|
+
* a cast that assumed otherwise would hand a handler a `Case` that is really a `Message`.
|
|
49
|
+
*/
|
|
50
|
+
export function typedEvent(event) {
|
|
51
|
+
const expected = RESOURCE_OF[event.type];
|
|
52
|
+
if (expected === undefined) {
|
|
53
|
+
throw new Error(`${event.type} is not an event type this version of the SDK knows.`);
|
|
54
|
+
}
|
|
55
|
+
if (event.resource.type !== expected) {
|
|
56
|
+
throw new Error(`${event.type} is an event about a ${expected}, but this one names a ${event.resource.type}.`);
|
|
57
|
+
}
|
|
58
|
+
return event;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Fetch the resource an event is about.
|
|
62
|
+
*
|
|
63
|
+
* Events are thin on purpose: ids, type, status, timestamps. An endpoint that opted into
|
|
64
|
+
* `include_resource` already carries the resource as `data`, and this returns that without a
|
|
65
|
+
* round trip. Otherwise it reads the resource back.
|
|
66
|
+
*
|
|
67
|
+
* The return type follows the event: hand it a `case.approved.v1` and you get a `Case`, with no
|
|
68
|
+
* second narrowing at the call site.
|
|
69
|
+
*
|
|
70
|
+
* `undefined` means the v1 contract has no read addressed by that id alone: messages,
|
|
71
|
+
* prescriptions and consents are listed through their case or patient, and charges have no
|
|
72
|
+
* public read until billing ships. Turn on `include_resource` for endpoints that need those, or
|
|
73
|
+
* read the parent case. The return type says `| undefined` so this cannot be forgotten.
|
|
74
|
+
*/
|
|
75
|
+
export async function expandEvent(client, event) {
|
|
76
|
+
if (event.data !== undefined)
|
|
77
|
+
return event.data;
|
|
78
|
+
const params = { path: { id: event.resource.id } };
|
|
79
|
+
// The read is chosen by the resource the event names, and `RESOURCE_OF` has already tied that
|
|
80
|
+
// to `T`. The generated client types each path's answer independently, so the assignment back
|
|
81
|
+
// to `CuvoEventResourceOf<T>` is the one place this file asserts what the table proved.
|
|
82
|
+
const resourceType = event.resource.type;
|
|
83
|
+
const resource = await (async () => {
|
|
84
|
+
switch (resourceType) {
|
|
85
|
+
case "patient":
|
|
86
|
+
return unwrap(await client.GET("/v1/patients/{id}", { params }));
|
|
87
|
+
case "case":
|
|
88
|
+
return unwrap(await client.GET("/v1/cases/{id}", { params }));
|
|
89
|
+
case "order":
|
|
90
|
+
return unwrap(await client.GET("/v1/orders/{id}", { params }));
|
|
91
|
+
case "file":
|
|
92
|
+
return unwrap(await client.GET("/v1/files/{id}", { params }));
|
|
93
|
+
case "webhook_endpoint":
|
|
94
|
+
return unwrap(await client.GET("/v1/webhook_endpoints/{id}", { params }));
|
|
95
|
+
default:
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
})();
|
|
99
|
+
return resource;
|
|
100
|
+
}
|