@owlmeans/api 0.1.18-rc.35 → 0.1.18-rc.37
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 +2 -2
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/api/SKILL.md +28 -8
- package/build/consts.d.ts +5 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +5 -0
- package/build/consts.js.map +1 -1
- package/build/errors.d.ts +30 -2
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +46 -4
- package/build/errors.js.map +1 -1
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +1 -0
- package/build/index.js.map +1 -1
- package/build/status/index.d.ts +27 -0
- package/build/status/index.d.ts.map +1 -0
- package/build/status/index.js +59 -0
- package/build/status/index.js.map +1 -0
- package/build/status/marker.d.ts +12 -0
- package/build/status/marker.d.ts.map +1 -0
- package/build/status/marker.js +27 -0
- package/build/status/marker.js.map +1 -0
- package/build/utils/handler.d.ts +7 -0
- package/build/utils/handler.d.ts.map +1 -1
- package/build/utils/handler.js +55 -26
- package/build/utils/handler.js.map +1 -1
- package/package.json +18 -10
- package/src/consts.ts +6 -0
- package/src/errors.ts +55 -4
- package/src/index.ts +4 -1
- package/src/status/index.ts +76 -0
- package/src/status/marker.ts +37 -0
- package/src/utils/handler.ts +65 -25
- package/tests/handler.spec.ts +186 -0
- package/tests/tsconfig.json +11 -0
- package/tsconfig.json +1 -1
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@ HTTP API client service for OwlMeans client contexts — carries entrypoint call
|
|
|
12
12
|
## Installation
|
|
13
13
|
|
|
14
14
|
```bash
|
|
15
|
-
bun add @owlmeans/api@^0.1.18-rc.
|
|
15
|
+
bun add @owlmeans/api@^0.1.18-rc.37
|
|
16
16
|
```
|
|
17
17
|
|
|
18
18
|
## Usage
|
|
@@ -65,7 +65,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
65
65
|
your project's skill store (`.agents/skills/`):
|
|
66
66
|
|
|
67
67
|
```sh
|
|
68
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
68
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.38
|
|
69
69
|
```
|
|
70
70
|
|
|
71
71
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/api",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.37",
|
|
5
|
+
"generatedAt": "2026-09-23T19:57:40.715Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api
|
|
3
|
-
description: How to use @owlmeans/api — the axios-based HTTP client service that carries entrypoint calls between services, its status-to-outcome mapping, typed transport errors and per-request timeout/abort. Auto-invoked when importing the API client, or when ctx.entrypoint(...).call() under the hood is involved.
|
|
3
|
+
description: How to use @owlmeans/api — the axios-based HTTP client service that carries entrypoint calls between services, its status-to-outcome mapping, typed transport errors that keep the HTTP status and incident id (the ./status subpath), and per-request timeout/abort. Auto-invoked when importing the API client, reading a failed call's status, or when ctx.entrypoint(...).call() under the hood is involved.
|
|
4
4
|
user-invocable: false
|
|
5
5
|
---
|
|
6
6
|
<!-- AUTO-GENERATED — do not edit. Regenerate via sync-agent-meta. -->
|
|
@@ -8,7 +8,7 @@ user-invocable: false
|
|
|
8
8
|
# @owlmeans/api
|
|
9
9
|
|
|
10
10
|
**Layer:** Core
|
|
11
|
-
**Install:** `"@owlmeans/api": "^0.1.18-rc.
|
|
11
|
+
**Install:** `"@owlmeans/api": "^0.1.18-rc.37"` in `dependencies`
|
|
12
12
|
|
|
13
13
|
## Key Exports
|
|
14
14
|
|
|
@@ -17,8 +17,13 @@ user-invocable: false
|
|
|
17
17
|
| `createApiService(alias?)` | Factory for the HTTP client service (axios) |
|
|
18
18
|
| `appendApiClient(ctx, alias?)` | Register it and make it the context's default `webService` when none is set |
|
|
19
19
|
| `ApiClient` | Service interface — a single `handler(req, reply)` |
|
|
20
|
-
| `ApiError`, `ApiClientError`, `ServerCrashedError`, `ServerAuthError` | Typed transport errors |
|
|
21
|
-
|
|
|
20
|
+
| `ApiError`, `ApiClientError`, `ServerCrashedError`, `ServerAuthError`, `ApiStatusError` | Typed transport errors; an `ApiClientError` carries the answered `status` and the server's `incidentId` |
|
|
21
|
+
| `httpStatusOf`, `incidentIdOf`, `isIncidentBody`, `parseClientMarker`, `API_CLIENT_MARKER`, `API_STATUS_MARKER` | Read a failure's HTTP status and incident id (also the `./status` subpath) |
|
|
22
|
+
| Constants | Status codes (`OK`, `CREATED`, `ACCEPTED`, `FINISHED`, `UNAUTHORIZED_ERROR`, `FORBIDDEN_ERROR`, `SERVER_ERROR`), `INCIDENT_ID_HEADER` (`X-Incident-ID`), `DEFAULT_ALIAS` (`web-client`) |
|
|
23
|
+
|
|
24
|
+
Subpath `./status` — `httpStatusOf`, `incidentIdOf`, `isIncidentBody`, `parseClientMarker`, the
|
|
25
|
+
markers and `INCIDENT_ID_HEADER`, importing only `@owlmeans/error`: a browser package reads a
|
|
26
|
+
status without pulling axios in.
|
|
22
27
|
|
|
23
28
|
## How a call is carried
|
|
24
29
|
|
|
@@ -56,10 +61,25 @@ included, comes back and is mapped here.
|
|
|
56
61
|
| 201 / 204 | Resolves with the body, or with the response headers when the body is empty |
|
|
57
62
|
| any other | Rejects |
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
`
|
|
61
|
-
|
|
62
|
-
|
|
64
|
+
Every rejection keeps the HTTP status and, when the server sent one, its incident id (the
|
|
65
|
+
`X-Incident-ID` header, read from `AxiosHeaders` or a plain object, else a bare UUID body — a
|
|
66
|
+
browser reads that header cross-origin only when the server exposes it, the body covers the rest):
|
|
67
|
+
|
|
68
|
+
| Body | Rejects with |
|
|
69
|
+
|---|---|
|
|
70
|
+
| Development — the marshaled `ResilientError` (carries the separator) | The original class, rebuilt through the registry, stamped `responseStatus` and `incidentId` |
|
|
71
|
+
| Production — a bare incident UUID; also proxy HTML, framework JSON, anything else | `ServerCrashedError` `api:client:crashed:<id>` (500) · `ServerAuthError` `api:client:auth:<id>` (401) · `ApiClientError` `api:client:forbidden[:<id>]` (403) · `ApiStatusError` `api:client:status:<n>[:<id>]` (every other status) |
|
|
72
|
+
|
|
73
|
+
An `@owlmeans/server-api` boundary in production exposure sends ONLY the incident id, so a typed
|
|
74
|
+
refusal's class never reaches the browser — its status does. Read it with `httpStatusOf(e)`: the
|
|
75
|
+
stamped `responseStatus`, else an `ApiClientError`'s parsed `status`, else a class's declared
|
|
76
|
+
`httpStatus` (a 4xx; a 5xx only with `allowServerErrorStatus`, exactly what the server answers),
|
|
77
|
+
else an `api:client:*` marker in the message or type; `null` when nothing states one. A consumer
|
|
78
|
+
that acts on a refusal checks the class/marker AND the status (`consentRefusalOf(e) ||
|
|
79
|
+
httpStatusOf(e) === 428`). Markers without an id stay as they were (`api:client:crashed:error`,
|
|
80
|
+
`api:client:forbidden`). The status and id are rebuilt from the marker in `finalizeUnmarshal()`,
|
|
81
|
+
so they survive a marshal. No class of this family declares a static `httpStatus`: a server that
|
|
82
|
+
rethrows one still answers 500.
|
|
63
83
|
|
|
64
84
|
**A failure with no answer at all is a different family.** Suppressing status errors does not wrap
|
|
65
85
|
the call: an expired `timeout` (`ECONNABORTED`), an aborted `signal` (`CanceledError` /
|
package/build/consts.d.ts
CHANGED
|
@@ -5,6 +5,11 @@ export declare const OK = 200;
|
|
|
5
5
|
export declare const CREATED = 201;
|
|
6
6
|
export declare const ACCEPTED = 202;
|
|
7
7
|
export declare const FINISHED = 204;
|
|
8
|
+
/**
|
|
9
|
+
* The response header an `@owlmeans/server-api` boundary puts its incident id in — the key to the
|
|
10
|
+
* server log entry that owns a failure's full detail.
|
|
11
|
+
*/
|
|
12
|
+
export declare const INCIDENT_ID_HEADER = "X-Incident-ID";
|
|
8
13
|
export declare const DEFAULT_ALIAS = "web-client";
|
|
9
14
|
export declare const protocols: string[];
|
|
10
15
|
//# sourceMappingURL=consts.d.ts.map
|
package/build/consts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,YAAY,MAAM,CAAA;AAE/B,eAAO,MAAM,kBAAkB,MAAM,CAAA;AAErC,eAAO,MAAM,eAAe,MAAM,CAAA;AAElC,eAAO,MAAM,EAAE,MAAM,CAAA;AAErB,eAAO,MAAM,OAAO,MAAM,CAAA;AAE1B,eAAO,MAAM,QAAQ,MAAM,CAAA;AAE3B,eAAO,MAAM,QAAQ,MAAM,CAAA;AAE3B,eAAO,MAAM,aAAa,eAAe,CAAA;AAEzC,eAAO,MAAM,SAAS,UAAiC,CAAA"}
|
|
1
|
+
{"version":3,"file":"consts.d.ts","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,eAAO,MAAM,YAAY,MAAM,CAAA;AAE/B,eAAO,MAAM,kBAAkB,MAAM,CAAA;AAErC,eAAO,MAAM,eAAe,MAAM,CAAA;AAElC,eAAO,MAAM,EAAE,MAAM,CAAA;AAErB,eAAO,MAAM,OAAO,MAAM,CAAA;AAE1B,eAAO,MAAM,QAAQ,MAAM,CAAA;AAE3B,eAAO,MAAM,QAAQ,MAAM,CAAA;AAE3B;;;GAGG;AACH,eAAO,MAAM,kBAAkB,kBAAkB,CAAA;AAEjD,eAAO,MAAM,aAAa,eAAe,CAAA;AAEzC,eAAO,MAAM,SAAS,UAAiC,CAAA"}
|
package/build/consts.js
CHANGED
|
@@ -5,6 +5,11 @@ export const OK = 200;
|
|
|
5
5
|
export const CREATED = 201;
|
|
6
6
|
export const ACCEPTED = 202;
|
|
7
7
|
export const FINISHED = 204;
|
|
8
|
+
/**
|
|
9
|
+
* The response header an `@owlmeans/server-api` boundary puts its incident id in — the key to the
|
|
10
|
+
* server log entry that owns a failure's full detail.
|
|
11
|
+
*/
|
|
12
|
+
export const INCIDENT_ID_HEADER = 'X-Incident-ID';
|
|
8
13
|
export const DEFAULT_ALIAS = 'web-client';
|
|
9
14
|
export const protocols = ['http', 'https', 'wss', 'ws'];
|
|
10
15
|
//# sourceMappingURL=consts.js.map
|
package/build/consts.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,CAAA;AAE/B,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;AAErC,MAAM,CAAC,MAAM,eAAe,GAAG,GAAG,CAAA;AAElC,MAAM,CAAC,MAAM,EAAE,GAAG,GAAG,CAAA;AAErB,MAAM,CAAC,MAAM,OAAO,GAAG,GAAG,CAAA;AAE1B,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,CAAA;AAE3B,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,CAAA;AAE3B,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAA;AAEzC,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAA"}
|
|
1
|
+
{"version":3,"file":"consts.js","sourceRoot":"","sources":["../src/consts.ts"],"names":[],"mappings":"AACA,MAAM,CAAC,MAAM,YAAY,GAAG,GAAG,CAAA;AAE/B,MAAM,CAAC,MAAM,kBAAkB,GAAG,GAAG,CAAA;AAErC,MAAM,CAAC,MAAM,eAAe,GAAG,GAAG,CAAA;AAElC,MAAM,CAAC,MAAM,EAAE,GAAG,GAAG,CAAA;AAErB,MAAM,CAAC,MAAM,OAAO,GAAG,GAAG,CAAA;AAE1B,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,CAAA;AAE3B,MAAM,CAAC,MAAM,QAAQ,GAAG,GAAG,CAAA;AAE3B;;;GAGG;AACH,MAAM,CAAC,MAAM,kBAAkB,GAAG,eAAe,CAAA;AAEjD,MAAM,CAAC,MAAM,aAAa,GAAG,YAAY,CAAA;AAEzC,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,KAAK,EAAE,IAAI,CAAC,CAAA"}
|
package/build/errors.d.ts
CHANGED
|
@@ -3,16 +3,44 @@ export declare class ApiError extends ResilientError {
|
|
|
3
3
|
static typeName: string;
|
|
4
4
|
constructor(message?: string);
|
|
5
5
|
}
|
|
6
|
+
/**
|
|
7
|
+
* A request the peer answered with a failure.
|
|
8
|
+
*
|
|
9
|
+
* `status` is the HTTP status the marker in the message states (`api:client:crashed:<id>` is 500,
|
|
10
|
+
* `api:client:auth:<id>` 401, `api:client:forbidden[:<id>]` 403, `api:client:status:<n>[:<id>]`
|
|
11
|
+
* any other); `incidentId` is the server's correlation id when the marker carries one. Only
|
|
12
|
+
* `type` and `message` survive a marshal, so both are rebuilt from the message in
|
|
13
|
+
* `finalizeUnmarshal()`. No class of this family declares a static `httpStatus`: a server that
|
|
14
|
+
* rethrows one answers 500, as before.
|
|
15
|
+
*/
|
|
6
16
|
export declare class ApiClientError extends ApiError {
|
|
7
17
|
static typeName: string;
|
|
18
|
+
/** The HTTP status the peer answered with, when the message states one. */
|
|
19
|
+
status?: number;
|
|
8
20
|
constructor(message?: string);
|
|
21
|
+
protected applyStatus(): void;
|
|
22
|
+
finalizeUnmarshal(): void;
|
|
9
23
|
}
|
|
24
|
+
/** The peer answered 500. Marker `api:client:crashed:<incident id | error>`. */
|
|
10
25
|
export declare class ServerCrashedError extends ApiClientError {
|
|
11
26
|
static typeName: string;
|
|
12
|
-
constructor(
|
|
27
|
+
constructor(incidentId?: string);
|
|
13
28
|
}
|
|
29
|
+
/** The peer answered 401. Marker `api:client:auth:<incident id | error>`. */
|
|
14
30
|
export declare class ServerAuthError extends ApiClientError {
|
|
15
31
|
static typeName: string;
|
|
16
|
-
constructor(
|
|
32
|
+
constructor(incidentId?: string);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The peer answered any other non-2xx status with no typed error in the body — a production
|
|
36
|
+
* incident id, a proxy's HTML page, a framework's JSON. Marker `api:client:status:<n>[:<id>]`.
|
|
37
|
+
*/
|
|
38
|
+
export declare class ApiStatusError extends ApiClientError {
|
|
39
|
+
static typeName: string;
|
|
40
|
+
/** The marker body: `<status>[:<incident id>]`. */
|
|
41
|
+
static encode(status: number, incidentId?: string): string;
|
|
42
|
+
constructor(status: number, incidentId?: string);
|
|
43
|
+
/** A marker body (`ApiStatusError.encode`) or a marshaled message — the error registry's path. */
|
|
44
|
+
constructor(message: string);
|
|
17
45
|
}
|
|
18
46
|
//# sourceMappingURL=errors.d.ts.map
|
package/build/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAGhD,qBAAa,QAAS,SAAQ,cAAc;IAC1C,OAAuB,QAAQ,SAAa;IAE5C,YAAY,OAAO,GAAE,MAAgB,EAEpC;CACF;AAED;;;;;;;;;GASG;AACH,qBAAa,cAAe,SAAQ,QAAQ;IAC1C,OAAuB,QAAQ,SAAmB;IAElD,2EAA2E;IACpE,MAAM,CAAC,EAAE,MAAM,CAAA;IAEtB,YAAY,OAAO,GAAE,MAAgB,EAIpC;IAED,SAAS,CAAC,WAAW,IAAI,IAAI,CAM5B;IAEQ,iBAAiB,IAAI,IAAI,CAEjC;CACF;AAED,gFAAgF;AAChF,qBAAa,kBAAmB,SAAQ,cAAc;IACpD,OAAuB,QAAQ,SAA4C;IAE3E,YAAY,UAAU,CAAC,EAAE,MAAM,EAG9B;CACF;AAED,6EAA6E;AAC7E,qBAAa,eAAgB,SAAQ,cAAc;IACjD,OAAuB,QAAQ,SAAyC;IAExE,YAAY,UAAU,CAAC,EAAE,MAAM,EAG9B;CACF;AAED;;;GAGG;AACH,qBAAa,cAAe,SAAQ,cAAc;IAChD,OAAuB,QAAQ,SAAqC;IAEpE,mDAAmD;IACnD,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,GAAG,MAAM,CAEzD;IAED,YAAY,MAAM,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM,EAAC;IAChD,kGAAkG;IAClG,YAAY,OAAO,EAAE,MAAM,EAAC;CAK7B"}
|
package/build/errors.js
CHANGED
|
@@ -1,33 +1,75 @@
|
|
|
1
1
|
import { ResilientError } from '@owlmeans/error';
|
|
2
|
+
import { parseClientMarker } from './status/marker.js';
|
|
2
3
|
export class ApiError extends ResilientError {
|
|
3
4
|
static typeName = 'ApiError';
|
|
4
5
|
constructor(message = 'error') {
|
|
5
6
|
super(ApiError.typeName, `api:${message}`);
|
|
6
7
|
}
|
|
7
8
|
}
|
|
9
|
+
/**
|
|
10
|
+
* A request the peer answered with a failure.
|
|
11
|
+
*
|
|
12
|
+
* `status` is the HTTP status the marker in the message states (`api:client:crashed:<id>` is 500,
|
|
13
|
+
* `api:client:auth:<id>` 401, `api:client:forbidden[:<id>]` 403, `api:client:status:<n>[:<id>]`
|
|
14
|
+
* any other); `incidentId` is the server's correlation id when the marker carries one. Only
|
|
15
|
+
* `type` and `message` survive a marshal, so both are rebuilt from the message in
|
|
16
|
+
* `finalizeUnmarshal()`. No class of this family declares a static `httpStatus`: a server that
|
|
17
|
+
* rethrows one answers 500, as before.
|
|
18
|
+
*/
|
|
8
19
|
export class ApiClientError extends ApiError {
|
|
9
20
|
static typeName = 'ApiClientError';
|
|
21
|
+
/** The HTTP status the peer answered with, when the message states one. */
|
|
22
|
+
status;
|
|
10
23
|
constructor(message = 'error') {
|
|
11
24
|
super(`client:${message}`);
|
|
12
25
|
this.type = ApiClientError.typeName;
|
|
26
|
+
this.applyStatus();
|
|
27
|
+
}
|
|
28
|
+
applyStatus() {
|
|
29
|
+
const marker = parseClientMarker(this.message);
|
|
30
|
+
this.status = marker?.status;
|
|
31
|
+
if (marker?.incidentId != null) {
|
|
32
|
+
this.incidentId ??= marker.incidentId;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
finalizeUnmarshal() {
|
|
36
|
+
this.applyStatus();
|
|
13
37
|
}
|
|
14
38
|
}
|
|
39
|
+
/** The peer answered 500. Marker `api:client:crashed:<incident id | error>`. */
|
|
15
40
|
export class ServerCrashedError extends ApiClientError {
|
|
16
41
|
static typeName = `${ApiClientError.typeName}ServerCrashed`;
|
|
17
|
-
constructor(
|
|
18
|
-
super(`crashed:${
|
|
42
|
+
constructor(incidentId) {
|
|
43
|
+
super(`crashed:${incidentId ?? 'error'}`);
|
|
19
44
|
this.type = ServerCrashedError.typeName;
|
|
20
45
|
}
|
|
21
46
|
}
|
|
47
|
+
/** The peer answered 401. Marker `api:client:auth:<incident id | error>`. */
|
|
22
48
|
export class ServerAuthError extends ApiClientError {
|
|
23
49
|
static typeName = `${ApiClientError.typeName}ServerAuth`;
|
|
24
|
-
constructor(
|
|
25
|
-
super(`auth:${
|
|
50
|
+
constructor(incidentId) {
|
|
51
|
+
super(`auth:${incidentId ?? 'error'}`);
|
|
26
52
|
this.type = ServerAuthError.typeName;
|
|
27
53
|
}
|
|
28
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* The peer answered any other non-2xx status with no typed error in the body — a production
|
|
57
|
+
* incident id, a proxy's HTML page, a framework's JSON. Marker `api:client:status:<n>[:<id>]`.
|
|
58
|
+
*/
|
|
59
|
+
export class ApiStatusError extends ApiClientError {
|
|
60
|
+
static typeName = `${ApiClientError.typeName}Status`;
|
|
61
|
+
/** The marker body: `<status>[:<incident id>]`. */
|
|
62
|
+
static encode(status, incidentId) {
|
|
63
|
+
return incidentId != null && incidentId !== '' ? `${status}:${incidentId}` : `${status}`;
|
|
64
|
+
}
|
|
65
|
+
constructor(status = 'error', incidentId) {
|
|
66
|
+
super(`status:${typeof status === 'number' ? ApiStatusError.encode(status, incidentId) : status}`);
|
|
67
|
+
this.type = ApiStatusError.typeName;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
29
70
|
ResilientError.registerErrorClass(ApiError);
|
|
30
71
|
ResilientError.registerErrorClass(ApiClientError);
|
|
31
72
|
ResilientError.registerErrorClass(ServerCrashedError);
|
|
32
73
|
ResilientError.registerErrorClass(ServerAuthError);
|
|
74
|
+
ResilientError.registerErrorClass(ApiStatusError);
|
|
33
75
|
//# sourceMappingURL=errors.js.map
|
package/build/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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"}
|
package/build/index.d.ts
CHANGED
|
@@ -2,4 +2,6 @@ export * from './errors.js';
|
|
|
2
2
|
export * from './consts.js';
|
|
3
3
|
export * from './service.js';
|
|
4
4
|
export type * from './types.js';
|
|
5
|
+
export { httpStatusOf, incidentIdOf, isIncidentBody, parseClientMarker, API_CLIENT_MARKER, API_STATUS_MARKER, } from './status/index.js';
|
|
6
|
+
export type { ClientMarker, ResponseStatusCarrier } from './status/index.js';
|
|
5
7
|
//# sourceMappingURL=index.d.ts.map
|
package/build/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAC5B,mBAAmB,YAAY,CAAA;AAC/B,OAAO,EACL,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,iBAAiB,GACpG,MAAM,mBAAmB,CAAA;AAC1B,YAAY,EAAE,YAAY,EAAE,qBAAqB,EAAE,MAAM,mBAAmB,CAAA"}
|
package/build/index.js
CHANGED
package/build/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,aAAa,CAAA;AAC3B,cAAc,aAAa,CAAA;AAC3B,cAAc,cAAc,CAAA;AAE5B,OAAO,EACL,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,iBAAiB,GACpG,MAAM,mBAAmB,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { INCIDENT_ID_HEADER } from '../consts.js';
|
|
2
|
+
export { API_CLIENT_MARKER, API_STATUS_MARKER, parseClientMarker } from './marker.js';
|
|
3
|
+
export type { ClientMarker } from './marker.js';
|
|
4
|
+
/** The HTTP status the API client stamps on an error it rebuilt from a response body. */
|
|
5
|
+
export interface ResponseStatusCarrier {
|
|
6
|
+
responseStatus?: number;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* The HTTP status an error answers to, or `null` when nothing states one.
|
|
10
|
+
*
|
|
11
|
+
* Read in order: the status the API client stamped on the error it rebuilt from a response
|
|
12
|
+
* (`responseStatus`); the status an `ApiClientError` parsed from its marker; the `httpStatus` its
|
|
13
|
+
* class declares — a 4xx always, a 5xx only with `allowServerErrorStatus`, exactly what an
|
|
14
|
+
* `@owlmeans/server-api` boundary answers with; finally an `api:client:*` marker in the message or
|
|
15
|
+
* the type of anything else. This is what lets a browser recognise a refusal from its status
|
|
16
|
+
* alone when a production body carries nothing but an incident id.
|
|
17
|
+
*/
|
|
18
|
+
export declare const httpStatusOf: (error: unknown) => number | null;
|
|
19
|
+
/** The server's incident id carried by an error — its own field, else its marker — or `null`. */
|
|
20
|
+
export declare const incidentIdOf: (error: unknown) => string | null;
|
|
21
|
+
/**
|
|
22
|
+
* Whether a response body is a production incident body — the bare incident UUID an
|
|
23
|
+
* `@owlmeans/server-api` boundary sends instead of the marshaled error. A development body
|
|
24
|
+
* carries the `ResilientError` separator and is never one.
|
|
25
|
+
*/
|
|
26
|
+
export declare const isIncidentBody: (data: unknown) => data is string;
|
|
27
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/status/index.ts"],"names":[],"mappings":"AAIA,OAAO,EAAE,kBAAkB,EAAE,MAAM,cAAc,CAAA;AACjD,OAAO,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,iBAAiB,EAAE,MAAM,aAAa,CAAA;AACrF,YAAY,EAAE,YAAY,EAAE,MAAM,aAAa,CAAA;AAE/C,yFAAyF;AACzF,MAAM,WAAW,qBAAqB;IACpC,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB;AAaD;;;;;;;;;GASG;AACH,eAAO,MAAM,YAAY,UAAW,OAAO,KAAG,MAAM,GAAG,IAkBtD,CAAA;AAED,iGAAiG;AACjG,eAAO,MAAM,YAAY,UAAW,OAAO,KAAG,MAAM,GAAG,IAUtD,CAAA;AAID;;;;GAIG;AACH,eAAO,MAAM,cAAc,SAAU,OAAO,KAAG,IAAI,IAAI,MACyC,CAAA"}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { ResilientError } from '@owlmeans/error';
|
|
2
|
+
import { ApiClientError } from '../errors.js';
|
|
3
|
+
import { parseClientMarker } from './marker.js';
|
|
4
|
+
export { INCIDENT_ID_HEADER } from '../consts.js';
|
|
5
|
+
export { API_CLIENT_MARKER, API_STATUS_MARKER, parseClientMarker } from './marker.js';
|
|
6
|
+
const httpStatus = (value) => typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599 ? value : null;
|
|
7
|
+
/** The first `api:client:*` marker in an error's `message`, else in its `type`. */
|
|
8
|
+
const markerOf = (error) => {
|
|
9
|
+
const { message, type } = error;
|
|
10
|
+
return (typeof message === 'string' ? parseClientMarker(message) : null)
|
|
11
|
+
?? (typeof type === 'string' ? parseClientMarker(type) : null);
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The HTTP status an error answers to, or `null` when nothing states one.
|
|
15
|
+
*
|
|
16
|
+
* Read in order: the status the API client stamped on the error it rebuilt from a response
|
|
17
|
+
* (`responseStatus`); the status an `ApiClientError` parsed from its marker; the `httpStatus` its
|
|
18
|
+
* class declares — a 4xx always, a 5xx only with `allowServerErrorStatus`, exactly what an
|
|
19
|
+
* `@owlmeans/server-api` boundary answers with; finally an `api:client:*` marker in the message or
|
|
20
|
+
* the type of anything else. This is what lets a browser recognise a refusal from its status
|
|
21
|
+
* alone when a production body carries nothing but an incident id.
|
|
22
|
+
*/
|
|
23
|
+
export const httpStatusOf = (error) => {
|
|
24
|
+
if (error == null || typeof error !== 'object') {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
const stamped = httpStatus(error.responseStatus);
|
|
28
|
+
if (stamped != null) {
|
|
29
|
+
return stamped;
|
|
30
|
+
}
|
|
31
|
+
if (error instanceof ApiClientError && httpStatus(error.status) != null) {
|
|
32
|
+
return error.status;
|
|
33
|
+
}
|
|
34
|
+
const declaration = error.constructor;
|
|
35
|
+
const declared = httpStatus(declaration?.httpStatus);
|
|
36
|
+
if (declared != null && (declared < 500 || declaration?.allowServerErrorStatus === true)) {
|
|
37
|
+
return declared;
|
|
38
|
+
}
|
|
39
|
+
return markerOf(error)?.status ?? null;
|
|
40
|
+
};
|
|
41
|
+
/** The server's incident id carried by an error — its own field, else its marker — or `null`. */
|
|
42
|
+
export const incidentIdOf = (error) => {
|
|
43
|
+
if (error == null || typeof error !== 'object') {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
const own = error.incidentId;
|
|
47
|
+
if (typeof own === 'string' && own !== '') {
|
|
48
|
+
return own;
|
|
49
|
+
}
|
|
50
|
+
return markerOf(error)?.incidentId ?? null;
|
|
51
|
+
};
|
|
52
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
53
|
+
/**
|
|
54
|
+
* Whether a response body is a production incident body — the bare incident UUID an
|
|
55
|
+
* `@owlmeans/server-api` boundary sends instead of the marshaled error. A development body
|
|
56
|
+
* carries the `ResilientError` separator and is never one.
|
|
57
|
+
*/
|
|
58
|
+
export const isIncidentBody = (data) => typeof data === 'string' && !data.includes(ResilientError.separator) && UUID.test(data.trim());
|
|
59
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/status/index.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** The prefix every `ApiClientError` message carries. */
|
|
2
|
+
export declare const API_CLIENT_MARKER = "api:client:";
|
|
3
|
+
/** The marker of an `ApiStatusError`: `api:client:status:<n>[:<incident id>]`. */
|
|
4
|
+
export declare const API_STATUS_MARKER = "api:client:status:";
|
|
5
|
+
/** What an `api:client:*` marker states. */
|
|
6
|
+
export interface ClientMarker {
|
|
7
|
+
status?: number;
|
|
8
|
+
incidentId?: string;
|
|
9
|
+
}
|
|
10
|
+
/** Read the first `api:client:*` marker in a text, or `null` when it carries none. */
|
|
11
|
+
export declare const parseClientMarker: (text: string) => ClientMarker | null;
|
|
12
|
+
//# sourceMappingURL=marker.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"marker.d.ts","sourceRoot":"","sources":["../../src/status/marker.ts"],"names":[],"mappings":"AAAA,yDAAyD;AACzD,eAAO,MAAM,iBAAiB,gBAAgB,CAAA;AAE9C,kFAAkF;AAClF,eAAO,MAAM,iBAAiB,uBAAgC,CAAA;AAY9D,4CAA4C;AAC5C,MAAM,WAAW,YAAY;IAC3B,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,sFAAsF;AACtF,eAAO,MAAM,iBAAiB,SAAU,MAAM,KAAG,YAAY,GAAG,IAa/D,CAAA"}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** The prefix every `ApiClientError` message carries. */
|
|
2
|
+
export const API_CLIENT_MARKER = 'api:client:';
|
|
3
|
+
/** The marker of an `ApiStatusError`: `api:client:status:<n>[:<incident id>]`. */
|
|
4
|
+
export const API_STATUS_MARKER = `${API_CLIENT_MARKER}status:`;
|
|
5
|
+
/**
|
|
6
|
+
* `crashed` / `auth` / `forbidden` / `status:<n>` / the legacy bare `<n>`, then an optional
|
|
7
|
+
* `:<incident id>`. The look-ahead refuses a match that runs on into more marker text, so a
|
|
8
|
+
* doubled prefix (a subclass constructor handed a whole marshaled message) resolves to the inner,
|
|
9
|
+
* complete marker.
|
|
10
|
+
*/
|
|
11
|
+
const MARKER = /api:client:(?:(crashed|auth|forbidden)|status:(\d{3})|(\d{3}))(?::([\w.-]{1,128}))?(?![\w.:-])/;
|
|
12
|
+
const NAMED_STATUS = { crashed: 500, auth: 401, forbidden: 403 };
|
|
13
|
+
/** Read the first `api:client:*` marker in a text, or `null` when it carries none. */
|
|
14
|
+
export const parseClientMarker = (text) => {
|
|
15
|
+
const match = MARKER.exec(text);
|
|
16
|
+
if (match == null) {
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
const [, named, coded, legacy, id] = match;
|
|
20
|
+
const code = named != null ? NAMED_STATUS[named] : Number(coded ?? legacy);
|
|
21
|
+
return {
|
|
22
|
+
status: code >= 100 && code <= 599 ? code : undefined,
|
|
23
|
+
// `error` is the placeholder a marker gets when no incident id was known.
|
|
24
|
+
incidentId: id != null && id !== 'error' ? id : undefined,
|
|
25
|
+
};
|
|
26
|
+
};
|
|
27
|
+
//# sourceMappingURL=marker.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"marker.js","sourceRoot":"","sources":["../../src/status/marker.ts"],"names":[],"mappings":"AAAA,yDAAyD;AACzD,MAAM,CAAC,MAAM,iBAAiB,GAAG,aAAa,CAAA;AAE9C,kFAAkF;AAClF,MAAM,CAAC,MAAM,iBAAiB,GAAG,GAAG,iBAAiB,SAAS,CAAA;AAE9D;;;;;GAKG;AACH,MAAM,MAAM,GAAG,gGAAgG,CAAA;AAE/G,MAAM,YAAY,GAA2B,EAAE,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,CAAA;AAQxF,sFAAsF;AACtF,MAAM,CAAC,MAAM,iBAAiB,GAAG,CAAC,IAAY,EAAuB,EAAE;IACrE,MAAM,KAAK,GAAG,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAA;IAC/B,IAAI,KAAK,IAAI,IAAI,EAAE,CAAC;QAClB,OAAO,IAAI,CAAA;IACb,CAAC;IACD,MAAM,CAAC,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,EAAE,CAAC,GAAG,KAAK,CAAA;IAC1C,MAAM,IAAI,GAAG,KAAK,IAAI,IAAI,CAAC,CAAC,CAAC,YAAY,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,IAAI,MAAM,CAAC,CAAA;IAE1E,OAAO;QACL,MAAM,EAAE,IAAI,IAAI,GAAG,IAAI,IAAI,IAAI,GAAG,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;QACrD,0EAA0E;QAC1E,UAAU,EAAE,EAAE,IAAI,IAAI,IAAI,EAAE,KAAK,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,SAAS;KAC1D,CAAA;AACH,CAAC,CAAA"}
|
package/build/utils/handler.d.ts
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import type { AbstractResponse } from '@owlmeans/entrypoint';
|
|
2
2
|
import type { AxiosResponse } from 'axios';
|
|
3
|
+
import { ApiClientError } from '../errors.js';
|
|
3
4
|
export declare const processResponse: (response: AxiosResponse, reply: AbstractResponse<any>) => void;
|
|
5
|
+
/**
|
|
6
|
+
* The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
|
|
7
|
+
* `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
|
|
8
|
+
* for every other one.
|
|
9
|
+
*/
|
|
10
|
+
export declare const statusError: (status: number, incidentId?: string) => ApiClientError;
|
|
4
11
|
//# sourceMappingURL=handler.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/utils/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAE5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;AAK1C,eAAO,MAAM,eAAe,aAAc,aAAa,SAAS,gBAAgB,CAAC,GAAG,CAAC,
|
|
1
|
+
{"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/utils/handler.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,sBAAsB,CAAA;AAE5D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;AAK1C,OAAO,EAAE,cAAc,EAAuD,MAAM,cAAc,CAAA;AAIlG,eAAO,MAAM,eAAe,aAAc,aAAa,SAAS,gBAAgB,CAAC,GAAG,CAAC,SAoBpF,CAAA;AA6BD;;;;GAIG;AACH,eAAO,MAAM,WAAW,WAAY,MAAM,eAAe,MAAM,KAAG,cAUjE,CAAA"}
|
package/build/utils/handler.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { EntrypointOutcome } from '@owlmeans/entrypoint';
|
|
2
|
-
import { ACCEPTED, CREATED, FINISHED, FORBIDDEN_ERROR, OK, SERVER_ERROR, UNAUTHORIZED_ERROR } from '../consts.js';
|
|
2
|
+
import { ACCEPTED, CREATED, FINISHED, FORBIDDEN_ERROR, INCIDENT_ID_HEADER, OK, SERVER_ERROR, UNAUTHORIZED_ERROR, } from '../consts.js';
|
|
3
3
|
import { ResilientError } from '@owlmeans/error';
|
|
4
|
-
import { ApiClientError, ServerAuthError, ServerCrashedError } from '../errors.js';
|
|
4
|
+
import { ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError } from '../errors.js';
|
|
5
|
+
import { isIncidentBody } from '../status/index.js';
|
|
5
6
|
export const processResponse = (response, reply) => {
|
|
6
7
|
switch (response.status) {
|
|
7
8
|
case OK:
|
|
@@ -16,33 +17,61 @@ export const processResponse = (response, reply) => {
|
|
|
16
17
|
return processEmptyResponse(response, reply, EntrypointOutcome.Finished);
|
|
17
18
|
default:
|
|
18
19
|
console.error(response.status, response.statusText);
|
|
19
|
-
console.error(response.config
|
|
20
|
+
console.error(response.config?.url, response.headers);
|
|
20
21
|
console.error(response.data);
|
|
21
|
-
|
|
22
|
-
if (typeof response.data === 'string') {
|
|
23
|
-
reply.reject(ResilientError.ensure(response.data, true));
|
|
24
|
-
return;
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
catch {
|
|
28
|
-
console.error(`Server returned an unrecognizable text error: ${response.status} ${response.data}`);
|
|
29
|
-
}
|
|
30
|
-
switch (response.status) {
|
|
31
|
-
case SERVER_ERROR:
|
|
32
|
-
reply.reject(new ServerCrashedError());
|
|
33
|
-
return;
|
|
34
|
-
case UNAUTHORIZED_ERROR:
|
|
35
|
-
reply.reject(new ServerAuthError());
|
|
36
|
-
return;
|
|
37
|
-
case FORBIDDEN_ERROR:
|
|
38
|
-
reply.reject(new ApiClientError('forbidden'));
|
|
39
|
-
return;
|
|
40
|
-
default:
|
|
41
|
-
reply.reject(new ApiClientError(`${response.status}`));
|
|
42
|
-
return;
|
|
43
|
-
}
|
|
22
|
+
reply.reject(failureOf(response));
|
|
44
23
|
}
|
|
45
24
|
};
|
|
25
|
+
/**
|
|
26
|
+
* The error a non-2xx answer rejects with — always carrying the HTTP status and, when the server
|
|
27
|
+
* sent one, its incident id.
|
|
28
|
+
*
|
|
29
|
+
* A development body is the marshaled `ResilientError`: it is rebuilt into its own class and
|
|
30
|
+
* stamped with `responseStatus`. Anything else — the bare incident id of a production body, a
|
|
31
|
+
* proxy's HTML, a framework's JSON — becomes the status error of {@link statusError}.
|
|
32
|
+
*/
|
|
33
|
+
const failureOf = (response) => {
|
|
34
|
+
const incidentId = headerOf(response.headers, INCIDENT_ID_HEADER)
|
|
35
|
+
?? (isIncidentBody(response.data) ? response.data.trim() : undefined);
|
|
36
|
+
if (typeof response.data === 'string' && response.data.includes(ResilientError.separator)) {
|
|
37
|
+
try {
|
|
38
|
+
const error = ResilientError.ensure(response.data, true);
|
|
39
|
+
error.incidentId ??= incidentId;
|
|
40
|
+
Object.assign(error, { responseStatus: response.status });
|
|
41
|
+
return error;
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
console.error(`Server returned an unrecognizable text error: ${response.status} ${response.data}`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return statusError(response.status, incidentId);
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
|
|
51
|
+
* `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
|
|
52
|
+
* for every other one.
|
|
53
|
+
*/
|
|
54
|
+
export const statusError = (status, incidentId) => {
|
|
55
|
+
const error = status === SERVER_ERROR ? new ServerCrashedError(incidentId)
|
|
56
|
+
: status === UNAUTHORIZED_ERROR ? new ServerAuthError(incidentId)
|
|
57
|
+
: status === FORBIDDEN_ERROR ? new ApiClientError(incidentId != null ? `forbidden:${incidentId}` : 'forbidden')
|
|
58
|
+
: new ApiStatusError(status, incidentId);
|
|
59
|
+
if (incidentId != null) {
|
|
60
|
+
error.incidentId = incidentId;
|
|
61
|
+
}
|
|
62
|
+
return Object.assign(error, { responseStatus: status });
|
|
63
|
+
};
|
|
64
|
+
/** One response header, from an `AxiosHeaders` instance or a plain header object alike. */
|
|
65
|
+
const headerOf = (headers, name) => {
|
|
66
|
+
if (headers == null || typeof headers !== 'object') {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
const get = headers.get;
|
|
70
|
+
const viaGet = typeof get === 'function' ? get.call(headers, name) : undefined;
|
|
71
|
+
const value = viaGet ?? Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
72
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
73
|
+
return typeof first === 'string' && first.trim() !== '' ? first.trim() : undefined;
|
|
74
|
+
};
|
|
46
75
|
const processEmptyResponse = (response, reply, outcome) => {
|
|
47
76
|
if (response.data != null && response.data != '') {
|
|
48
77
|
reply.resolve(response.data, outcome);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"handler.js","sourceRoot":"","sources":["../../src/utils/handler.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;AAExD,OAAO,
|
|
1
|
+
{"version":3,"file":"handler.js","sourceRoot":"","sources":["../../src/utils/handler.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/api",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.37",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -b",
|
|
8
8
|
"dev": "sleep 0 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
|
-
"watch": "tsc -b -w --preserveWatchOutput --pretty"
|
|
9
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
10
|
+
"test": "bun test ./tests"
|
|
10
11
|
},
|
|
11
12
|
"main": "build/index.js",
|
|
12
13
|
"module": "build/index.js",
|
|
@@ -18,17 +19,24 @@
|
|
|
18
19
|
"default": "./build/index.js",
|
|
19
20
|
"module": "./build/index.js",
|
|
20
21
|
"types": "./build/index.d.ts"
|
|
22
|
+
},
|
|
23
|
+
"./status": {
|
|
24
|
+
"import": "./build/status/index.js",
|
|
25
|
+
"require": "./build/status/index.js",
|
|
26
|
+
"default": "./build/status/index.js",
|
|
27
|
+
"module": "./build/status/index.js",
|
|
28
|
+
"types": "./build/status/index.d.ts"
|
|
21
29
|
}
|
|
22
30
|
},
|
|
23
31
|
"dependencies": {
|
|
24
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
25
|
-
"@owlmeans/client-config": "^0.1.18-rc.
|
|
26
|
-
"@owlmeans/client-route": "^0.1.18-rc.
|
|
27
|
-
"@owlmeans/config": "^0.1.18-rc.
|
|
28
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
29
|
-
"@owlmeans/error": "^0.1.18-rc.
|
|
30
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
31
|
-
"@owlmeans/route": "^0.1.18-rc.
|
|
32
|
+
"@owlmeans/auth-common": "^0.1.18-rc.37",
|
|
33
|
+
"@owlmeans/client-config": "^0.1.18-rc.37",
|
|
34
|
+
"@owlmeans/client-route": "^0.1.18-rc.38",
|
|
35
|
+
"@owlmeans/config": "^0.1.18-rc.37",
|
|
36
|
+
"@owlmeans/context": "^0.1.18-rc.32",
|
|
37
|
+
"@owlmeans/error": "^0.1.18-rc.35",
|
|
38
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.36",
|
|
39
|
+
"@owlmeans/route": "^0.1.18-rc.33",
|
|
32
40
|
"axios": "^1.7.7",
|
|
33
41
|
"qs": "^6.13.0"
|
|
34
42
|
},
|
package/src/consts.ts
CHANGED
|
@@ -13,6 +13,12 @@ export const ACCEPTED = 202
|
|
|
13
13
|
|
|
14
14
|
export const FINISHED = 204
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* The response header an `@owlmeans/server-api` boundary puts its incident id in — the key to the
|
|
18
|
+
* server log entry that owns a failure's full detail.
|
|
19
|
+
*/
|
|
20
|
+
export const INCIDENT_ID_HEADER = 'X-Incident-ID'
|
|
21
|
+
|
|
16
22
|
export const DEFAULT_ALIAS = 'web-client'
|
|
17
23
|
|
|
18
24
|
export const protocols = ['http', 'https', 'wss', 'ws']
|
package/src/errors.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ResilientError } from '@owlmeans/error'
|
|
2
|
+
import { parseClientMarker } from './status/marker.js'
|
|
2
3
|
|
|
3
4
|
export class ApiError extends ResilientError {
|
|
4
5
|
public static override typeName = 'ApiError'
|
|
@@ -8,34 +9,84 @@ export class ApiError extends ResilientError {
|
|
|
8
9
|
}
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* A request the peer answered with a failure.
|
|
14
|
+
*
|
|
15
|
+
* `status` is the HTTP status the marker in the message states (`api:client:crashed:<id>` is 500,
|
|
16
|
+
* `api:client:auth:<id>` 401, `api:client:forbidden[:<id>]` 403, `api:client:status:<n>[:<id>]`
|
|
17
|
+
* any other); `incidentId` is the server's correlation id when the marker carries one. Only
|
|
18
|
+
* `type` and `message` survive a marshal, so both are rebuilt from the message in
|
|
19
|
+
* `finalizeUnmarshal()`. No class of this family declares a static `httpStatus`: a server that
|
|
20
|
+
* rethrows one answers 500, as before.
|
|
21
|
+
*/
|
|
11
22
|
export class ApiClientError extends ApiError {
|
|
12
23
|
public static override typeName = 'ApiClientError'
|
|
13
24
|
|
|
25
|
+
/** The HTTP status the peer answered with, when the message states one. */
|
|
26
|
+
public status?: number
|
|
27
|
+
|
|
14
28
|
constructor(message: string = 'error') {
|
|
15
29
|
super(`client:${message}`)
|
|
16
30
|
this.type = ApiClientError.typeName
|
|
31
|
+
this.applyStatus()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
protected applyStatus(): void {
|
|
35
|
+
const marker = parseClientMarker(this.message)
|
|
36
|
+
this.status = marker?.status
|
|
37
|
+
if (marker?.incidentId != null) {
|
|
38
|
+
this.incidentId ??= marker.incidentId
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
override finalizeUnmarshal(): void {
|
|
43
|
+
this.applyStatus()
|
|
17
44
|
}
|
|
18
45
|
}
|
|
19
46
|
|
|
47
|
+
/** The peer answered 500. Marker `api:client:crashed:<incident id | error>`. */
|
|
20
48
|
export class ServerCrashedError extends ApiClientError {
|
|
21
49
|
public static override typeName = `${ApiClientError.typeName}ServerCrashed`
|
|
22
50
|
|
|
23
|
-
constructor(
|
|
24
|
-
super(`crashed:${
|
|
51
|
+
constructor(incidentId?: string) {
|
|
52
|
+
super(`crashed:${incidentId ?? 'error'}`)
|
|
25
53
|
this.type = ServerCrashedError.typeName
|
|
26
54
|
}
|
|
27
55
|
}
|
|
28
56
|
|
|
57
|
+
/** The peer answered 401. Marker `api:client:auth:<incident id | error>`. */
|
|
29
58
|
export class ServerAuthError extends ApiClientError {
|
|
30
59
|
public static override typeName = `${ApiClientError.typeName}ServerAuth`
|
|
31
60
|
|
|
32
|
-
constructor(
|
|
33
|
-
super(`auth:${
|
|
61
|
+
constructor(incidentId?: string) {
|
|
62
|
+
super(`auth:${incidentId ?? 'error'}`)
|
|
34
63
|
this.type = ServerAuthError.typeName
|
|
35
64
|
}
|
|
36
65
|
}
|
|
37
66
|
|
|
67
|
+
/**
|
|
68
|
+
* The peer answered any other non-2xx status with no typed error in the body — a production
|
|
69
|
+
* incident id, a proxy's HTML page, a framework's JSON. Marker `api:client:status:<n>[:<id>]`.
|
|
70
|
+
*/
|
|
71
|
+
export class ApiStatusError extends ApiClientError {
|
|
72
|
+
public static override typeName = `${ApiClientError.typeName}Status`
|
|
73
|
+
|
|
74
|
+
/** The marker body: `<status>[:<incident id>]`. */
|
|
75
|
+
static encode(status: number, incidentId?: string): string {
|
|
76
|
+
return incidentId != null && incidentId !== '' ? `${status}:${incidentId}` : `${status}`
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
constructor(status: number, incidentId?: string)
|
|
80
|
+
/** A marker body (`ApiStatusError.encode`) or a marshaled message — the error registry's path. */
|
|
81
|
+
constructor(message: string)
|
|
82
|
+
constructor(status: number | string = 'error', incidentId?: string) {
|
|
83
|
+
super(`status:${typeof status === 'number' ? ApiStatusError.encode(status, incidentId) : status}`)
|
|
84
|
+
this.type = ApiStatusError.typeName
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
38
88
|
ResilientError.registerErrorClass(ApiError)
|
|
39
89
|
ResilientError.registerErrorClass(ApiClientError)
|
|
40
90
|
ResilientError.registerErrorClass(ServerCrashedError)
|
|
41
91
|
ResilientError.registerErrorClass(ServerAuthError)
|
|
92
|
+
ResilientError.registerErrorClass(ApiStatusError)
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
|
|
2
1
|
export * from './errors.js'
|
|
3
2
|
export * from './consts.js'
|
|
4
3
|
export * from './service.js'
|
|
5
4
|
export type * from './types.js'
|
|
5
|
+
export {
|
|
6
|
+
httpStatusOf, incidentIdOf, isIncidentBody, parseClientMarker, API_CLIENT_MARKER, API_STATUS_MARKER,
|
|
7
|
+
} from './status/index.js'
|
|
8
|
+
export type { ClientMarker, ResponseStatusCarrier } from './status/index.js'
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { ResilientError } from '@owlmeans/error'
|
|
2
|
+
import { ApiClientError } from '../errors.js'
|
|
3
|
+
import { parseClientMarker } from './marker.js'
|
|
4
|
+
|
|
5
|
+
export { INCIDENT_ID_HEADER } from '../consts.js'
|
|
6
|
+
export { API_CLIENT_MARKER, API_STATUS_MARKER, parseClientMarker } from './marker.js'
|
|
7
|
+
export type { ClientMarker } from './marker.js'
|
|
8
|
+
|
|
9
|
+
/** The HTTP status the API client stamps on an error it rebuilt from a response body. */
|
|
10
|
+
export interface ResponseStatusCarrier {
|
|
11
|
+
responseStatus?: number
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const httpStatus = (value: unknown): number | null =>
|
|
15
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599 ? value : null
|
|
16
|
+
|
|
17
|
+
/** The first `api:client:*` marker in an error's `message`, else in its `type`. */
|
|
18
|
+
const markerOf = (error: object) => {
|
|
19
|
+
const { message, type } = error as { message?: unknown, type?: unknown }
|
|
20
|
+
|
|
21
|
+
return (typeof message === 'string' ? parseClientMarker(message) : null)
|
|
22
|
+
?? (typeof type === 'string' ? parseClientMarker(type) : null)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The HTTP status an error answers to, or `null` when nothing states one.
|
|
27
|
+
*
|
|
28
|
+
* Read in order: the status the API client stamped on the error it rebuilt from a response
|
|
29
|
+
* (`responseStatus`); the status an `ApiClientError` parsed from its marker; the `httpStatus` its
|
|
30
|
+
* class declares — a 4xx always, a 5xx only with `allowServerErrorStatus`, exactly what an
|
|
31
|
+
* `@owlmeans/server-api` boundary answers with; finally an `api:client:*` marker in the message or
|
|
32
|
+
* the type of anything else. This is what lets a browser recognise a refusal from its status
|
|
33
|
+
* alone when a production body carries nothing but an incident id.
|
|
34
|
+
*/
|
|
35
|
+
export const httpStatusOf = (error: unknown): number | null => {
|
|
36
|
+
if (error == null || typeof error !== 'object') {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
const stamped = httpStatus((error as ResponseStatusCarrier).responseStatus)
|
|
40
|
+
if (stamped != null) {
|
|
41
|
+
return stamped
|
|
42
|
+
}
|
|
43
|
+
if (error instanceof ApiClientError && httpStatus(error.status) != null) {
|
|
44
|
+
return error.status!
|
|
45
|
+
}
|
|
46
|
+
const declaration = error.constructor as { httpStatus?: unknown, allowServerErrorStatus?: unknown } | undefined
|
|
47
|
+
const declared = httpStatus(declaration?.httpStatus)
|
|
48
|
+
if (declared != null && (declared < 500 || declaration?.allowServerErrorStatus === true)) {
|
|
49
|
+
return declared
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return markerOf(error)?.status ?? null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The server's incident id carried by an error — its own field, else its marker — or `null`. */
|
|
56
|
+
export const incidentIdOf = (error: unknown): string | null => {
|
|
57
|
+
if (error == null || typeof error !== 'object') {
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
60
|
+
const own = (error as { incidentId?: unknown }).incidentId
|
|
61
|
+
if (typeof own === 'string' && own !== '') {
|
|
62
|
+
return own
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return markerOf(error)?.incidentId ?? null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether a response body is a production incident body — the bare incident UUID an
|
|
72
|
+
* `@owlmeans/server-api` boundary sends instead of the marshaled error. A development body
|
|
73
|
+
* carries the `ResilientError` separator and is never one.
|
|
74
|
+
*/
|
|
75
|
+
export const isIncidentBody = (data: unknown): data is string =>
|
|
76
|
+
typeof data === 'string' && !data.includes(ResilientError.separator) && UUID.test(data.trim())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** The prefix every `ApiClientError` message carries. */
|
|
2
|
+
export const API_CLIENT_MARKER = 'api:client:'
|
|
3
|
+
|
|
4
|
+
/** The marker of an `ApiStatusError`: `api:client:status:<n>[:<incident id>]`. */
|
|
5
|
+
export const API_STATUS_MARKER = `${API_CLIENT_MARKER}status:`
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* `crashed` / `auth` / `forbidden` / `status:<n>` / the legacy bare `<n>`, then an optional
|
|
9
|
+
* `:<incident id>`. The look-ahead refuses a match that runs on into more marker text, so a
|
|
10
|
+
* doubled prefix (a subclass constructor handed a whole marshaled message) resolves to the inner,
|
|
11
|
+
* complete marker.
|
|
12
|
+
*/
|
|
13
|
+
const MARKER = /api:client:(?:(crashed|auth|forbidden)|status:(\d{3})|(\d{3}))(?::([\w.-]{1,128}))?(?![\w.:-])/
|
|
14
|
+
|
|
15
|
+
const NAMED_STATUS: Record<string, number> = { crashed: 500, auth: 401, forbidden: 403 }
|
|
16
|
+
|
|
17
|
+
/** What an `api:client:*` marker states. */
|
|
18
|
+
export interface ClientMarker {
|
|
19
|
+
status?: number
|
|
20
|
+
incidentId?: string
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read the first `api:client:*` marker in a text, or `null` when it carries none. */
|
|
24
|
+
export const parseClientMarker = (text: string): ClientMarker | null => {
|
|
25
|
+
const match = MARKER.exec(text)
|
|
26
|
+
if (match == null) {
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
const [, named, coded, legacy, id] = match
|
|
30
|
+
const code = named != null ? NAMED_STATUS[named] : Number(coded ?? legacy)
|
|
31
|
+
|
|
32
|
+
return {
|
|
33
|
+
status: code >= 100 && code <= 599 ? code : undefined,
|
|
34
|
+
// `error` is the placeholder a marker gets when no incident id was known.
|
|
35
|
+
incidentId: id != null && id !== 'error' ? id : undefined,
|
|
36
|
+
}
|
|
37
|
+
}
|
package/src/utils/handler.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { AbstractResponse } from '@owlmeans/entrypoint'
|
|
2
2
|
import { EntrypointOutcome } from '@owlmeans/entrypoint'
|
|
3
3
|
import type { AxiosResponse } from 'axios'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
ACCEPTED, CREATED, FINISHED, FORBIDDEN_ERROR, INCIDENT_ID_HEADER, OK, SERVER_ERROR, UNAUTHORIZED_ERROR,
|
|
6
|
+
} from '../consts.js'
|
|
5
7
|
import { ResilientError } from '@owlmeans/error'
|
|
6
|
-
import { ApiClientError, ServerAuthError, ServerCrashedError } from '../errors.js'
|
|
8
|
+
import { ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError } from '../errors.js'
|
|
9
|
+
import { isIncidentBody } from '../status/index.js'
|
|
10
|
+
import type { ResponseStatusCarrier } from '../status/index.js'
|
|
7
11
|
|
|
8
12
|
export const processResponse = (response: AxiosResponse, reply: AbstractResponse<any>) => {
|
|
9
13
|
switch (response.status) {
|
|
@@ -20,34 +24,70 @@ export const processResponse = (response: AxiosResponse, reply: AbstractResponse
|
|
|
20
24
|
default:
|
|
21
25
|
|
|
22
26
|
console.error(response.status, response.statusText)
|
|
23
|
-
console.error(response.config
|
|
27
|
+
console.error(response.config?.url, response.headers)
|
|
24
28
|
console.error(response.data)
|
|
25
29
|
|
|
26
|
-
|
|
27
|
-
if (typeof response.data === 'string') {
|
|
28
|
-
reply.reject(ResilientError.ensure(response.data, true))
|
|
29
|
-
return
|
|
30
|
-
}
|
|
31
|
-
} catch {
|
|
32
|
-
console.error(`Server returned an unrecognizable text error: ${response.status} ${response.data}`)
|
|
33
|
-
}
|
|
34
|
-
switch (response.status) {
|
|
35
|
-
case SERVER_ERROR:
|
|
36
|
-
reply.reject(new ServerCrashedError())
|
|
37
|
-
return
|
|
38
|
-
case UNAUTHORIZED_ERROR:
|
|
39
|
-
reply.reject(new ServerAuthError())
|
|
40
|
-
return
|
|
41
|
-
case FORBIDDEN_ERROR:
|
|
42
|
-
reply.reject(new ApiClientError('forbidden'))
|
|
43
|
-
return
|
|
44
|
-
default:
|
|
45
|
-
reply.reject(new ApiClientError(`${response.status}`))
|
|
46
|
-
return
|
|
47
|
-
}
|
|
30
|
+
reply.reject(failureOf(response))
|
|
48
31
|
}
|
|
49
32
|
}
|
|
50
33
|
|
|
34
|
+
/**
|
|
35
|
+
* The error a non-2xx answer rejects with — always carrying the HTTP status and, when the server
|
|
36
|
+
* sent one, its incident id.
|
|
37
|
+
*
|
|
38
|
+
* A development body is the marshaled `ResilientError`: it is rebuilt into its own class and
|
|
39
|
+
* stamped with `responseStatus`. Anything else — the bare incident id of a production body, a
|
|
40
|
+
* proxy's HTML, a framework's JSON — becomes the status error of {@link statusError}.
|
|
41
|
+
*/
|
|
42
|
+
const failureOf = (response: AxiosResponse): Error => {
|
|
43
|
+
const incidentId = headerOf(response.headers, INCIDENT_ID_HEADER)
|
|
44
|
+
?? (isIncidentBody(response.data) ? response.data.trim() : undefined)
|
|
45
|
+
|
|
46
|
+
if (typeof response.data === 'string' && response.data.includes(ResilientError.separator)) {
|
|
47
|
+
try {
|
|
48
|
+
const error = ResilientError.ensure(response.data, true)
|
|
49
|
+
error.incidentId ??= incidentId
|
|
50
|
+
Object.assign(error, { responseStatus: response.status } satisfies ResponseStatusCarrier)
|
|
51
|
+
|
|
52
|
+
return error
|
|
53
|
+
} catch {
|
|
54
|
+
console.error(`Server returned an unrecognizable text error: ${response.status} ${response.data}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return statusError(response.status, incidentId)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
|
|
63
|
+
* `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
|
|
64
|
+
* for every other one.
|
|
65
|
+
*/
|
|
66
|
+
export const statusError = (status: number, incidentId?: string): ApiClientError => {
|
|
67
|
+
const error = status === SERVER_ERROR ? new ServerCrashedError(incidentId)
|
|
68
|
+
: status === UNAUTHORIZED_ERROR ? new ServerAuthError(incidentId)
|
|
69
|
+
: status === FORBIDDEN_ERROR ? new ApiClientError(incidentId != null ? `forbidden:${incidentId}` : 'forbidden')
|
|
70
|
+
: new ApiStatusError(status, incidentId)
|
|
71
|
+
if (incidentId != null) {
|
|
72
|
+
error.incidentId = incidentId
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return Object.assign(error, { responseStatus: status } satisfies ResponseStatusCarrier)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One response header, from an `AxiosHeaders` instance or a plain header object alike. */
|
|
79
|
+
const headerOf = (headers: unknown, name: string): string | undefined => {
|
|
80
|
+
if (headers == null || typeof headers !== 'object') {
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
const get = (headers as { get?: unknown }).get
|
|
84
|
+
const viaGet = typeof get === 'function' ? get.call(headers, name) : undefined
|
|
85
|
+
const value = viaGet ?? Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]
|
|
86
|
+
const first = Array.isArray(value) ? value[0] : value
|
|
87
|
+
|
|
88
|
+
return typeof first === 'string' && first.trim() !== '' ? first.trim() : undefined
|
|
89
|
+
}
|
|
90
|
+
|
|
51
91
|
const processEmptyResponse = (response: AxiosResponse, reply: AbstractResponse<any>, outcome: EntrypointOutcome) => {
|
|
52
92
|
if (response.data != null && response.data != '') {
|
|
53
93
|
reply.resolve(response.data, outcome)
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AxiosHeaders } from 'axios'
|
|
3
|
+
import type { AxiosResponse } from 'axios'
|
|
4
|
+
import type { AbstractResponse } from '@owlmeans/entrypoint'
|
|
5
|
+
import { EntrypointOutcome } from '@owlmeans/entrypoint'
|
|
6
|
+
import { ResilientError } from '@owlmeans/error'
|
|
7
|
+
import {
|
|
8
|
+
ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError, httpStatusOf, incidentIdOf,
|
|
9
|
+
INCIDENT_ID_HEADER,
|
|
10
|
+
} from '../src/index.js'
|
|
11
|
+
import { processResponse, statusError } from '../src/utils/handler.js'
|
|
12
|
+
import * as status from '../src/status/index.js'
|
|
13
|
+
|
|
14
|
+
const INCIDENT = '6f1c1a52-8a0e-4f5e-9a7c-1f2d3e4c5b6a'
|
|
15
|
+
|
|
16
|
+
class ConsentRequired extends ResilientError {
|
|
17
|
+
public static override typeName = 'ApiSpecConsentRequired'
|
|
18
|
+
public static httpStatus = 428
|
|
19
|
+
|
|
20
|
+
constructor(message: string = 'error') {
|
|
21
|
+
super(ConsentRequired.typeName, `spec:consent-required:${message}`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
ResilientError.registerErrorClass(ConsentRequired)
|
|
25
|
+
|
|
26
|
+
class Unavailable extends ResilientError {
|
|
27
|
+
public static override typeName = 'ApiSpecUnavailable'
|
|
28
|
+
public static httpStatus = 503
|
|
29
|
+
|
|
30
|
+
constructor(message: string = 'error') {
|
|
31
|
+
super(Unavailable.typeName, message)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const respond = (
|
|
36
|
+
statusCode: number, data: unknown, headers: Record<string, string> | AxiosHeaders = {}
|
|
37
|
+
): AbstractResponse<unknown> => {
|
|
38
|
+
const reply: AbstractResponse<unknown> = {
|
|
39
|
+
resolve: (value, outcome) => { reply.value = value; reply.outcome = outcome },
|
|
40
|
+
reject: error => { reply.error = error },
|
|
41
|
+
}
|
|
42
|
+
processResponse({
|
|
43
|
+
status: statusCode, statusText: '', data, headers, config: { url: '/spec' },
|
|
44
|
+
} as unknown as AxiosResponse, reply)
|
|
45
|
+
|
|
46
|
+
return reply
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const quiet = console.error
|
|
50
|
+
beforeAll(() => { console.error = () => {} })
|
|
51
|
+
afterAll(() => { console.error = quiet })
|
|
52
|
+
|
|
53
|
+
describe('2xx', () => {
|
|
54
|
+
test('resolve exactly as before', () => {
|
|
55
|
+
expect(respond(200, { ok: true })).toMatchObject({ value: { ok: true }, outcome: EntrypointOutcome.Ok })
|
|
56
|
+
expect(respond(202, 'queued')).toMatchObject({ value: 'queued', outcome: EntrypointOutcome.Accepted })
|
|
57
|
+
expect(respond(204, '', { 'x-a': '1' })).toMatchObject({ value: { 'x-a': '1' }, outcome: EntrypointOutcome.Finished })
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
describe('a production incident body keeps its status and incident id', () => {
|
|
62
|
+
test('428 with the header becomes ApiStatusError', () => {
|
|
63
|
+
const { error } = respond(428, INCIDENT, { [INCIDENT_ID_HEADER.toLowerCase()]: INCIDENT })
|
|
64
|
+
expect(error).toBeInstanceOf(ApiStatusError)
|
|
65
|
+
expect(error!.message).toBe(`api:client:status:428:${INCIDENT}`)
|
|
66
|
+
expect(httpStatusOf(error)).toBe(428)
|
|
67
|
+
expect(incidentIdOf(error)).toBe(INCIDENT)
|
|
68
|
+
expect((error as ApiStatusError).status).toBe(428)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('the header is read from AxiosHeaders too, and the body alone suffices', () => {
|
|
72
|
+
const headers = new AxiosHeaders({ [INCIDENT_ID_HEADER]: INCIDENT })
|
|
73
|
+
expect(incidentIdOf(respond(409, 'x', headers).error)).toBe(INCIDENT)
|
|
74
|
+
expect(incidentIdOf(respond(402, INCIDENT).error)).toBe(INCIDENT)
|
|
75
|
+
expect(httpStatusOf(respond(402, INCIDENT).error)).toBe(402)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test('500, 401 and 403 keep their classes and markers', () => {
|
|
79
|
+
const crashed = respond(500, INCIDENT).error
|
|
80
|
+
expect(crashed).toBeInstanceOf(ServerCrashedError)
|
|
81
|
+
expect(crashed!.message).toBe(`api:client:crashed:${INCIDENT}`)
|
|
82
|
+
expect(httpStatusOf(crashed)).toBe(500)
|
|
83
|
+
|
|
84
|
+
const auth = respond(401, INCIDENT).error
|
|
85
|
+
expect(auth).toBeInstanceOf(ServerAuthError)
|
|
86
|
+
expect(auth!.message).toBe(`api:client:auth:${INCIDENT}`)
|
|
87
|
+
expect(httpStatusOf(auth)).toBe(401)
|
|
88
|
+
|
|
89
|
+
const forbidden = respond(403, INCIDENT).error
|
|
90
|
+
expect(forbidden).toBeInstanceOf(ApiClientError)
|
|
91
|
+
expect(forbidden).not.toBeInstanceOf(ApiStatusError)
|
|
92
|
+
expect(forbidden!.message).toBe(`api:client:forbidden:${INCIDENT}`)
|
|
93
|
+
expect(httpStatusOf(forbidden)).toBe(403)
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
test('without an incident id the markers stay as before', () => {
|
|
97
|
+
expect(statusError(500).message).toBe('api:client:crashed:error')
|
|
98
|
+
expect(statusError(403).message).toBe('api:client:forbidden')
|
|
99
|
+
expect(incidentIdOf(statusError(500))).toBeNull()
|
|
100
|
+
expect(statusError(418).message).toBe('api:client:status:418')
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('a proxy HTML page or framework JSON becomes a status error', () => {
|
|
104
|
+
const html = respond(502, '<html><body>Bad Gateway</body></html>').error
|
|
105
|
+
expect(html).toBeInstanceOf(ApiStatusError)
|
|
106
|
+
expect(httpStatusOf(html)).toBe(502)
|
|
107
|
+
expect(incidentIdOf(html)).toBeNull()
|
|
108
|
+
|
|
109
|
+
const json = respond(400, { statusCode: 400, error: 'Bad Request', message: 'body/x' }).error
|
|
110
|
+
expect(json).toBeInstanceOf(ApiStatusError)
|
|
111
|
+
expect(httpStatusOf(json)).toBe(400)
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
test('no class declares a static httpStatus, so a rethrow upstream stays 500', () => {
|
|
115
|
+
for (const error of [statusError(428), statusError(500), statusError(401), statusError(403)]) {
|
|
116
|
+
expect((error.constructor as { httpStatus?: unknown }).httpStatus).toBeUndefined()
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('a development body stays its typed class', () => {
|
|
122
|
+
test('rebuilt with responseStatus and incident id', () => {
|
|
123
|
+
const body = ResilientError.marshal(new ConsentRequired('2'), { includeStack: true, incidentId: INCIDENT }).message
|
|
124
|
+
const { error } = respond(428, body, { [INCIDENT_ID_HEADER]: INCIDENT })
|
|
125
|
+
expect(error).toBeInstanceOf(ConsentRequired)
|
|
126
|
+
expect(error!.message).toBe('spec:consent-required:2')
|
|
127
|
+
expect((error as { responseStatus?: number }).responseStatus).toBe(428)
|
|
128
|
+
expect(httpStatusOf(error)).toBe(428)
|
|
129
|
+
expect(incidentIdOf(error)).toBe(INCIDENT)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a typed api error keeps its marker fields', () => {
|
|
133
|
+
const body = ResilientError.marshal(new ApiStatusError(409, INCIDENT)).message
|
|
134
|
+
const error = respond(409, body).error as ApiStatusError
|
|
135
|
+
expect(error).toBeInstanceOf(ApiStatusError)
|
|
136
|
+
expect(error.status).toBe(409)
|
|
137
|
+
expect(error.incidentId).toBe(INCIDENT)
|
|
138
|
+
})
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
describe('marshal round trips', () => {
|
|
142
|
+
const roundTrip = (error: Error): ResilientError => ResilientError.ensure(ResilientError.marshal(error))
|
|
143
|
+
|
|
144
|
+
test('keep status and incident id of every client error', () => {
|
|
145
|
+
const cases: Array<[ApiClientError, number]> = [
|
|
146
|
+
[new ApiStatusError(428, INCIDENT), 428],
|
|
147
|
+
[new ServerCrashedError(INCIDENT), 500],
|
|
148
|
+
[new ServerAuthError(INCIDENT), 401],
|
|
149
|
+
[new ApiClientError(`forbidden:${INCIDENT}`), 403],
|
|
150
|
+
]
|
|
151
|
+
for (const [error, code] of cases) {
|
|
152
|
+
const back = roundTrip(error)
|
|
153
|
+
expect(back.constructor).toBe(error.constructor)
|
|
154
|
+
expect((back as ApiClientError).status).toBe(code)
|
|
155
|
+
expect(httpStatusOf(back)).toBe(code)
|
|
156
|
+
expect(incidentIdOf(back)).toBe(INCIDENT)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
|
|
160
|
+
test('a legacy bare-status marker still reads', () => {
|
|
161
|
+
expect(httpStatusOf(new ApiClientError('404'))).toBe(404)
|
|
162
|
+
})
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
describe('./status', () => {
|
|
166
|
+
test('a declared static 428 answers without any marker; an undeclared 5xx does not', () => {
|
|
167
|
+
expect(status.httpStatusOf(new ConsentRequired())).toBe(428)
|
|
168
|
+
expect(status.httpStatusOf(new Unavailable())).toBeNull()
|
|
169
|
+
expect(status.httpStatusOf(new Error('plain'))).toBeNull()
|
|
170
|
+
expect(status.httpStatusOf(null)).toBeNull()
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
test('reads a marker from a plain error message or a type', () => {
|
|
174
|
+
expect(status.httpStatusOf(new Error(`${status.API_STATUS_MARKER}428:${INCIDENT}`))).toBe(428)
|
|
175
|
+
expect(status.httpStatusOf({ type: 'api:client:auth:error' })).toBe(401)
|
|
176
|
+
expect(status.incidentIdOf(new Error(`api:client:crashed:${INCIDENT}`))).toBe(INCIDENT)
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
test('isIncidentBody accepts a bare incident UUID only', () => {
|
|
180
|
+
expect(status.isIncidentBody(INCIDENT)).toBe(true)
|
|
181
|
+
expect(status.isIncidentBody(`${INCIDENT}\n`)).toBe(true)
|
|
182
|
+
expect(status.isIncidentBody('Unauthorized')).toBe(false)
|
|
183
|
+
expect(status.isIncidentBody(`ApiError|||api:x|||${INCIDENT}`)).toBe(false)
|
|
184
|
+
expect(status.isIncidentBody({ id: INCIDENT })).toBe(false)
|
|
185
|
+
})
|
|
186
|
+
})
|
package/tsconfig.json
CHANGED