@owlmeans/api 0.1.18-rc.4 → 0.1.18-rc.40
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 +4 -4
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/api/SKILL.md +96 -11
- package/build/consts.d.ts +7 -0
- package/build/consts.d.ts.map +1 -1
- package/build/consts.js +7 -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/service.d.ts.map +1 -1
- package/build/service.js +25 -8
- package/build/service.js.map +1 -1
- package/build/status/index.d.ts +31 -0
- package/build/status/index.d.ts.map +1 -0
- package/build/status/index.js +65 -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 +60 -26
- package/build/utils/handler.js.map +1 -1
- package/package.json +18 -10
- package/src/consts.ts +8 -0
- package/src/errors.ts +55 -4
- package/src/index.ts +4 -1
- package/src/service.ts +33 -8
- package/src/status/index.ts +85 -0
- package/src/status/marker.ts +37 -0
- package/src/utils/handler.ts +71 -25
- package/tests/handler.spec.ts +193 -0
- package/tests/tsconfig.json +11 -0
- package/tsconfig.json +1 -1
- package/build/.gitkeep +0 -0
|
@@ -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;
|
|
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;AAM1C,OAAO,EAAE,cAAc,EAAuD,MAAM,cAAc,CAAA;AAIlG,eAAO,MAAM,eAAe,aAAc,aAAa,SAAS,gBAAgB,CAAC,GAAG,CAAC,SAoBpF,CAAA;AAkCD;;;;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, DENIAL_KIND_HEADER, ACCESS_DENIED_KIND, 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,66 @@ 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
|
+
const denialKind = response.status === FORBIDDEN_ERROR
|
|
37
|
+
&& headerOf(response.headers, DENIAL_KIND_HEADER) === ACCESS_DENIED_KIND
|
|
38
|
+
? ACCESS_DENIED_KIND : undefined;
|
|
39
|
+
if (typeof response.data === 'string' && response.data.includes(ResilientError.separator)) {
|
|
40
|
+
try {
|
|
41
|
+
const error = ResilientError.ensure(response.data, true);
|
|
42
|
+
error.incidentId ??= incidentId;
|
|
43
|
+
Object.assign(error, {
|
|
44
|
+
responseStatus: response.status, denialKind,
|
|
45
|
+
});
|
|
46
|
+
return error;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
console.error(`Server returned an unrecognizable text error: ${response.status} ${response.data}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return Object.assign(statusError(response.status, incidentId), { denialKind });
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
|
|
56
|
+
* `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
|
|
57
|
+
* for every other one.
|
|
58
|
+
*/
|
|
59
|
+
export const statusError = (status, incidentId) => {
|
|
60
|
+
const error = status === SERVER_ERROR ? new ServerCrashedError(incidentId)
|
|
61
|
+
: status === UNAUTHORIZED_ERROR ? new ServerAuthError(incidentId)
|
|
62
|
+
: status === FORBIDDEN_ERROR ? new ApiClientError(incidentId != null ? `forbidden:${incidentId}` : 'forbidden')
|
|
63
|
+
: new ApiStatusError(status, incidentId);
|
|
64
|
+
if (incidentId != null) {
|
|
65
|
+
error.incidentId = incidentId;
|
|
66
|
+
}
|
|
67
|
+
return Object.assign(error, { responseStatus: status });
|
|
68
|
+
};
|
|
69
|
+
/** One response header, from an `AxiosHeaders` instance or a plain header object alike. */
|
|
70
|
+
const headerOf = (headers, name) => {
|
|
71
|
+
if (headers == null || typeof headers !== 'object') {
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
const get = headers.get;
|
|
75
|
+
const viaGet = typeof get === 'function' ? get.call(headers, name) : undefined;
|
|
76
|
+
const value = viaGet ?? Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
|
|
77
|
+
const first = Array.isArray(value) ? value[0] : value;
|
|
78
|
+
return typeof first === 'string' && first.trim() !== '' ? first.trim() : undefined;
|
|
79
|
+
};
|
|
46
80
|
const processEmptyResponse = (response, reply, outcome) => {
|
|
47
81
|
if (response.data != null && response.data != '') {
|
|
48
82
|
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.40",
|
|
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.40",
|
|
33
|
+
"@owlmeans/client-config": "^0.1.18-rc.38",
|
|
34
|
+
"@owlmeans/client-route": "^0.1.18-rc.41",
|
|
35
|
+
"@owlmeans/config": "^0.1.18-rc.38",
|
|
36
|
+
"@owlmeans/context": "^0.1.18-rc.32",
|
|
37
|
+
"@owlmeans/error": "^0.1.18-rc.36",
|
|
38
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.37",
|
|
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,14 @@ 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
|
+
export const DENIAL_KIND_HEADER = 'X-OwlMeans-Denial'
|
|
22
|
+
export const ACCESS_DENIED_KIND = 'access-denied'
|
|
23
|
+
|
|
16
24
|
export const DEFAULT_ALIAS = 'web-client'
|
|
17
25
|
|
|
18
26
|
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'
|
package/src/service.ts
CHANGED
|
@@ -5,15 +5,21 @@ import type { ApiClient } from './types.js'
|
|
|
5
5
|
import axios, { AxiosHeaders } from 'axios'
|
|
6
6
|
import type { AxiosRequestTransformer } from 'axios'
|
|
7
7
|
import type { CommonEntrypoint } from '@owlmeans/entrypoint'
|
|
8
|
-
import { DEFAULT_ALIAS } from './consts.js'
|
|
8
|
+
import { DEFAULT_ALIAS, UNAUTHORIZED_ERROR } from './consts.js'
|
|
9
9
|
import { processResponse } from './utils/handler.js'
|
|
10
10
|
import { BasicClientConfig } from '@owlmeans/client-config'
|
|
11
11
|
import qs from 'qs'
|
|
12
12
|
import { makeSecurityHelper } from '@owlmeans/config'
|
|
13
13
|
import { RouteMethod } from '@owlmeans/route'
|
|
14
|
-
import { DEF_AUTH_SRV, TOKEN_UPDATE } from '@owlmeans/auth-common'
|
|
14
|
+
import { AUTH_HEADER, DEF_AUTH_SRV, TOKEN_UPDATE } from '@owlmeans/auth-common'
|
|
15
15
|
import type { AuthService } from '@owlmeans/auth-common'
|
|
16
16
|
|
|
17
|
+
/** Whether a request carried an authentication header of its own. */
|
|
18
|
+
const presented = (headers?: Record<string, unknown>): boolean =>
|
|
19
|
+
headers != null && Object.entries(headers).some(
|
|
20
|
+
([key, value]) => key.toLowerCase() === AUTH_HEADER && value != null && value !== ''
|
|
21
|
+
)
|
|
22
|
+
|
|
17
23
|
type Config = BasicClientConfig
|
|
18
24
|
|
|
19
25
|
interface Context<C extends Config = Config> extends BasicContext<C> {
|
|
@@ -28,9 +34,8 @@ export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
|
|
|
28
34
|
}
|
|
29
35
|
const context = assertContext<Config, Context>(client.ctx, location)
|
|
30
36
|
const module = context.entrypoint<CommonEntrypoint>(request.alias)
|
|
31
|
-
await module.resolve()
|
|
32
37
|
const route = module.route.route
|
|
33
|
-
let path = module.
|
|
38
|
+
let path = module.path()
|
|
34
39
|
const params = extractParams(path)
|
|
35
40
|
path = params.reduce((path, param) => {
|
|
36
41
|
type Key = keyof typeof request.params
|
|
@@ -39,13 +44,15 @@ export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
|
|
|
39
44
|
}
|
|
40
45
|
return path.replace(`:${param}`, `${request.params[param as Key]}`)
|
|
41
46
|
}, path)
|
|
42
|
-
if (route.host == null && request.host == null) {
|
|
43
|
-
throw new SyntaxError(`No host provided in ${module.alias} route`)
|
|
44
|
-
}
|
|
45
47
|
|
|
46
48
|
const helper = makeSecurityHelper(context)
|
|
47
49
|
|
|
48
|
-
|
|
50
|
+
// The entrypoint answers where it lives — host, port, base, protocol and whether the hop is
|
|
51
|
+
// TLS — from its declaration and the service it names. A per-request host still overrides it.
|
|
52
|
+
const url = helper.makeUrl(
|
|
53
|
+
module.address(), path,
|
|
54
|
+
{ host: request.host, base: request.base, forceUnsecure: request.unsecure }
|
|
55
|
+
)
|
|
49
56
|
|
|
50
57
|
let transformer: AxiosRequestTransformer | undefined = undefined
|
|
51
58
|
|
|
@@ -84,6 +91,24 @@ export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
|
|
|
84
91
|
const auth = context.service<AuthService>(DEF_AUTH_SRV)
|
|
85
92
|
const update = headers.get(TOKEN_UPDATE) as string
|
|
86
93
|
await auth.update(update == null || update === '' ? undefined : update)
|
|
94
|
+
} else if (
|
|
95
|
+
response.status === UNAUTHORIZED_ERROR
|
|
96
|
+
&& presented(request.headers)
|
|
97
|
+
&& context.hasService(DEF_AUTH_SRV)
|
|
98
|
+
) {
|
|
99
|
+
/**
|
|
100
|
+
* A session the server has refused is not a session, and keeping it is worse than having
|
|
101
|
+
* none: the application goes on rendering its signed-in tree over a credential that fails
|
|
102
|
+
* every call it makes, and nothing on the client ever asks again — `authenticated()` reads
|
|
103
|
+
* storage and decodes an envelope, it does not consult the server. What that leaves is an
|
|
104
|
+
* app that looks signed in, works at nothing, and offers "Log out" where the control that
|
|
105
|
+
* would fix it should be.
|
|
106
|
+
*
|
|
107
|
+
* Narrow on purpose. Only a request that actually PRESENTED this context's bearer counts,
|
|
108
|
+
* so a 401 from an authentication attempt — bad code, wrong password, an expired challenge
|
|
109
|
+
* — never clears the session of whoever is already signed in.
|
|
110
|
+
*/
|
|
111
|
+
await context.service<AuthService>(DEF_AUTH_SRV).update(undefined)
|
|
87
112
|
}
|
|
88
113
|
|
|
89
114
|
processResponse(response, reply)
|
|
@@ -0,0 +1,85 @@
|
|
|
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 { DENIAL_KIND_HEADER, ACCESS_DENIED_KIND } from '../consts.js'
|
|
7
|
+
import { ACCESS_DENIED_KIND } from '../consts.js'
|
|
8
|
+
export { API_CLIENT_MARKER, API_STATUS_MARKER, parseClientMarker } from './marker.js'
|
|
9
|
+
export type { ClientMarker } from './marker.js'
|
|
10
|
+
|
|
11
|
+
/** The HTTP status the API client stamps on an error it rebuilt from a response body. */
|
|
12
|
+
export interface ResponseStatusCarrier {
|
|
13
|
+
responseStatus?: number
|
|
14
|
+
denialKind?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** True only for a server-labelled auth/IAM refusal, never an unrelated 403. */
|
|
18
|
+
export const isAccessDenied = (error: unknown): boolean =>
|
|
19
|
+
httpStatusOf(error) === 403
|
|
20
|
+
&& typeof error === 'object' && error != null
|
|
21
|
+
&& (error as ResponseStatusCarrier).denialKind === ACCESS_DENIED_KIND
|
|
22
|
+
|
|
23
|
+
const httpStatus = (value: unknown): number | null =>
|
|
24
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599 ? value : null
|
|
25
|
+
|
|
26
|
+
/** The first `api:client:*` marker in an error's `message`, else in its `type`. */
|
|
27
|
+
const markerOf = (error: object) => {
|
|
28
|
+
const { message, type } = error as { message?: unknown, type?: unknown }
|
|
29
|
+
|
|
30
|
+
return (typeof message === 'string' ? parseClientMarker(message) : null)
|
|
31
|
+
?? (typeof type === 'string' ? parseClientMarker(type) : null)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The HTTP status an error answers to, or `null` when nothing states one.
|
|
36
|
+
*
|
|
37
|
+
* Read in order: the status the API client stamped on the error it rebuilt from a response
|
|
38
|
+
* (`responseStatus`); the status an `ApiClientError` parsed from its marker; the `httpStatus` its
|
|
39
|
+
* class declares — a 4xx always, a 5xx only with `allowServerErrorStatus`, exactly what an
|
|
40
|
+
* `@owlmeans/server-api` boundary answers with; finally an `api:client:*` marker in the message or
|
|
41
|
+
* the type of anything else. This is what lets a browser recognise a refusal from its status
|
|
42
|
+
* alone when a production body carries nothing but an incident id.
|
|
43
|
+
*/
|
|
44
|
+
export const httpStatusOf = (error: unknown): number | null => {
|
|
45
|
+
if (error == null || typeof error !== 'object') {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
const stamped = httpStatus((error as ResponseStatusCarrier).responseStatus)
|
|
49
|
+
if (stamped != null) {
|
|
50
|
+
return stamped
|
|
51
|
+
}
|
|
52
|
+
if (error instanceof ApiClientError && httpStatus(error.status) != null) {
|
|
53
|
+
return error.status!
|
|
54
|
+
}
|
|
55
|
+
const declaration = error.constructor as { httpStatus?: unknown, allowServerErrorStatus?: unknown } | undefined
|
|
56
|
+
const declared = httpStatus(declaration?.httpStatus)
|
|
57
|
+
if (declared != null && (declared < 500 || declaration?.allowServerErrorStatus === true)) {
|
|
58
|
+
return declared
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return markerOf(error)?.status ?? null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The server's incident id carried by an error — its own field, else its marker — or `null`. */
|
|
65
|
+
export const incidentIdOf = (error: unknown): string | null => {
|
|
66
|
+
if (error == null || typeof error !== 'object') {
|
|
67
|
+
return null
|
|
68
|
+
}
|
|
69
|
+
const own = (error as { incidentId?: unknown }).incidentId
|
|
70
|
+
if (typeof own === 'string' && own !== '') {
|
|
71
|
+
return own
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return markerOf(error)?.incidentId ?? null
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Whether a response body is a production incident body — the bare incident UUID an
|
|
81
|
+
* `@owlmeans/server-api` boundary sends instead of the marshaled error. A development body
|
|
82
|
+
* carries the `ResilientError` separator and is never one.
|
|
83
|
+
*/
|
|
84
|
+
export const isIncidentBody = (data: unknown): data is string =>
|
|
85
|
+
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
|
+
}
|