@webpieces/http-client-core 0.4.690 → 0.4.692
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/package.json +2 -2
- package/src/ClientErrorTranslator.d.ts +22 -8
- package/src/ClientErrorTranslator.js +31 -17
- package/src/ClientErrorTranslator.js.map +1 -1
- package/src/ProxyClient.d.ts +38 -2
- package/src/ProxyClient.js +16 -4
- package/src/ProxyClient.js.map +1 -1
- package/src/RequestOutcome.d.ts +14 -8
- package/src/RequestOutcome.js +7 -4
- package/src/RequestOutcome.js.map +1 -1
- package/src/TranslatedFailure.d.ts +50 -0
- package/src/TranslatedFailure.js +48 -0
- package/src/TranslatedFailure.js.map +1 -0
- package/src/index.d.ts +1 -0
- package/src/index.js +3 -1
- package/src/index.js.map +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/http-client-core",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.692",
|
|
4
4
|
"description": "Isomorphic core of the webpieces HTTP client: the decorator-driven ProxyClient, error translation, and the Proxy trap shared by http-client-node and http-client-browser",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -21,6 +21,6 @@
|
|
|
21
21
|
"access": "public"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|
|
24
|
-
"@webpieces/core-util": "0.4.
|
|
24
|
+
"@webpieces/core-util": "0.4.692"
|
|
25
25
|
}
|
|
26
26
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ProtocolError } from '@webpieces/core-util';
|
|
2
|
+
import { TranslatedFailure } from './TranslatedFailure';
|
|
2
3
|
/**
|
|
3
4
|
* ClientErrorTranslator - Translates HTTP error responses to HttpError exceptions.
|
|
4
5
|
*
|
|
@@ -7,16 +8,33 @@ import { ProtocolError } from '@webpieces/core-util';
|
|
|
7
8
|
*
|
|
8
9
|
* Architecture:
|
|
9
10
|
* - Server: HttpError → ExpressWrapper.handleError() → ProtocolError JSON
|
|
10
|
-
* - Client: ProtocolError JSON → ClientErrorTranslator.translateError() →
|
|
11
|
+
* - Client: ProtocolError JSON → ClientErrorTranslator.translateError() → TranslatedFailure
|
|
11
12
|
*
|
|
12
13
|
* This achieves symmetric error handling - server throws typed exceptions,
|
|
13
14
|
* client receives typed exceptions.
|
|
15
|
+
*
|
|
16
|
+
* It returns a {@link TranslatedFailure} rather than a bare `Error` because the mapping is only HALF
|
|
17
|
+
* the decision. It is ISOMORPHIC — the same mapping runs in a browser and in a server — and the two
|
|
18
|
+
* environments must NOT do the same thing with a downstream 4xx (see
|
|
19
|
+
* `ProxyClient.adaptDownstreamFailure`). The wrapper carries the one fact that hook cannot recover
|
|
20
|
+
* on its own: whether the APP claimed this status, or the built-in default did.
|
|
14
21
|
*/
|
|
15
22
|
export declare class ClientErrorTranslator {
|
|
16
23
|
/**
|
|
17
|
-
* Parse error response and
|
|
24
|
+
* Parse an error response and decide which error the caller should see, and who decided it.
|
|
25
|
+
*
|
|
26
|
+
* App-registered translations win, so an app can reconstruct its OWN error types (e.g. a custom
|
|
27
|
+
* 460) AND override built-ins. `undefined` means "not mine" — fall through to
|
|
28
|
+
* {@link builtInError}, which stays the generic default. Symmetric with the server's
|
|
29
|
+
* ExpressWrapper.handleError(), which consults ClientRegistry.tryTranslateToWire() first.
|
|
18
30
|
*
|
|
19
|
-
*
|
|
31
|
+
* @param response - Fetch Response object
|
|
32
|
+
* @param protocolError - Parsed ProtocolError from response body
|
|
33
|
+
* @returns the chosen error plus its provenance and the downstream status
|
|
34
|
+
*/
|
|
35
|
+
static translateError(response: Response, protocolError: ProtocolError): TranslatedFailure;
|
|
36
|
+
/**
|
|
37
|
+
* The built-in status → error mapping (symmetric with the server's ExpressWrapper.handleError()):
|
|
20
38
|
* - 400 → HttpBadRequestError (with field, guiAlertMessage)
|
|
21
39
|
* - 266 → HttpUserError (with errorCode) - 2xx code for user validation
|
|
22
40
|
* - 401 → HttpUnauthorizedError
|
|
@@ -29,10 +47,6 @@ export declare class ClientErrorTranslator {
|
|
|
29
47
|
* - 504 → HttpGatewayTimeoutError
|
|
30
48
|
* - 598 → HttpVendorError (with waitSeconds) - custom status code
|
|
31
49
|
* - other → generic HttpError
|
|
32
|
-
*
|
|
33
|
-
* @param response - Fetch Response object
|
|
34
|
-
* @param protocolError - Parsed ProtocolError from response body
|
|
35
|
-
* @returns HttpError subclass instance
|
|
36
50
|
*/
|
|
37
|
-
static
|
|
51
|
+
private static builtInError;
|
|
38
52
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.ClientErrorTranslator = void 0;
|
|
4
4
|
const core_util_1 = require("@webpieces/core-util");
|
|
5
|
+
const TranslatedFailure_1 = require("./TranslatedFailure");
|
|
5
6
|
/**
|
|
6
7
|
* ClientErrorTranslator - Translates HTTP error responses to HttpError exceptions.
|
|
7
8
|
*
|
|
@@ -10,16 +11,41 @@ const core_util_1 = require("@webpieces/core-util");
|
|
|
10
11
|
*
|
|
11
12
|
* Architecture:
|
|
12
13
|
* - Server: HttpError → ExpressWrapper.handleError() → ProtocolError JSON
|
|
13
|
-
* - Client: ProtocolError JSON → ClientErrorTranslator.translateError() →
|
|
14
|
+
* - Client: ProtocolError JSON → ClientErrorTranslator.translateError() → TranslatedFailure
|
|
14
15
|
*
|
|
15
16
|
* This achieves symmetric error handling - server throws typed exceptions,
|
|
16
17
|
* client receives typed exceptions.
|
|
18
|
+
*
|
|
19
|
+
* It returns a {@link TranslatedFailure} rather than a bare `Error` because the mapping is only HALF
|
|
20
|
+
* the decision. It is ISOMORPHIC — the same mapping runs in a browser and in a server — and the two
|
|
21
|
+
* environments must NOT do the same thing with a downstream 4xx (see
|
|
22
|
+
* `ProxyClient.adaptDownstreamFailure`). The wrapper carries the one fact that hook cannot recover
|
|
23
|
+
* on its own: whether the APP claimed this status, or the built-in default did.
|
|
17
24
|
*/
|
|
18
25
|
class ClientErrorTranslator {
|
|
19
26
|
/**
|
|
20
|
-
* Parse error response and
|
|
27
|
+
* Parse an error response and decide which error the caller should see, and who decided it.
|
|
28
|
+
*
|
|
29
|
+
* App-registered translations win, so an app can reconstruct its OWN error types (e.g. a custom
|
|
30
|
+
* 460) AND override built-ins. `undefined` means "not mine" — fall through to
|
|
31
|
+
* {@link builtInError}, which stays the generic default. Symmetric with the server's
|
|
32
|
+
* ExpressWrapper.handleError(), which consults ClientRegistry.tryTranslateToWire() first.
|
|
21
33
|
*
|
|
22
|
-
*
|
|
34
|
+
* @param response - Fetch Response object
|
|
35
|
+
* @param protocolError - Parsed ProtocolError from response body
|
|
36
|
+
* @returns the chosen error plus its provenance and the downstream status
|
|
37
|
+
*/
|
|
38
|
+
// webpieces-disable no-function-outside-class -- pure, stateless status-to-type mapping with nothing to inject, called from a BROWSER bundle where no DI container exists; static is the established idiom of this class
|
|
39
|
+
static translateError(response, protocolError) {
|
|
40
|
+
const statusCode = response.status;
|
|
41
|
+
const custom = core_util_1.ClientRegistry.tryTranslateFromWire(statusCode, protocolError);
|
|
42
|
+
if (custom !== undefined) {
|
|
43
|
+
return new TranslatedFailure_1.TranslatedFailure(custom, true, statusCode);
|
|
44
|
+
}
|
|
45
|
+
return new TranslatedFailure_1.TranslatedFailure(ClientErrorTranslator.builtInError(response, protocolError), false, statusCode);
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* The built-in status → error mapping (symmetric with the server's ExpressWrapper.handleError()):
|
|
23
49
|
* - 400 → HttpBadRequestError (with field, guiAlertMessage)
|
|
24
50
|
* - 266 → HttpUserError (with errorCode) - 2xx code for user validation
|
|
25
51
|
* - 401 → HttpUnauthorizedError
|
|
@@ -32,24 +58,12 @@ class ClientErrorTranslator {
|
|
|
32
58
|
* - 504 → HttpGatewayTimeoutError
|
|
33
59
|
* - 598 → HttpVendorError (with waitSeconds) - custom status code
|
|
34
60
|
* - other → generic HttpError
|
|
35
|
-
*
|
|
36
|
-
* @param response - Fetch Response object
|
|
37
|
-
* @param protocolError - Parsed ProtocolError from response body
|
|
38
|
-
* @returns HttpError subclass instance
|
|
39
61
|
*/
|
|
40
|
-
static
|
|
62
|
+
// webpieces-disable no-function-outside-class -- private helper of the static above; same reason
|
|
63
|
+
static builtInError(response, protocolError) {
|
|
41
64
|
const statusCode = response.status;
|
|
42
65
|
const message = protocolError.message || response.statusText || 'Unknown error';
|
|
43
66
|
const subType = protocolError.subType;
|
|
44
|
-
// App-registered translations win, so an app can reconstruct its OWN error types (e.g. a
|
|
45
|
-
// custom 460) AND override built-ins. `undefined` means "not mine" — fall through to the
|
|
46
|
-
// built-in switch below, which stays the generic default. Symmetric with the server's
|
|
47
|
-
// ExpressWrapper.handleError(), which consults ClientRegistry.tryTranslateToWire() first.
|
|
48
|
-
const custom = core_util_1.ClientRegistry.tryTranslateFromWire(statusCode, protocolError);
|
|
49
|
-
if (custom !== undefined) {
|
|
50
|
-
return custom;
|
|
51
|
-
}
|
|
52
|
-
// Map status codes to error types (symmetric with server's ExpressWrapper.handleError())
|
|
53
67
|
switch (statusCode) {
|
|
54
68
|
case 400:
|
|
55
69
|
return new core_util_1.HttpBadRequestError(message, protocolError.field, protocolError.guiAlertMessage);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ClientErrorTranslator.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ClientErrorTranslator.ts"],"names":[],"mappings":";;;AAAA,oDAe8B;
|
|
1
|
+
{"version":3,"file":"ClientErrorTranslator.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ClientErrorTranslator.ts"],"names":[],"mappings":";;;AAAA,oDAe8B;AAC9B,2DAAwD;AAExD;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,qBAAqB;IAC9B;;;;;;;;;;;OAWG;IACH,yNAAyN;IACzN,MAAM,CAAC,cAAc,CAAC,QAAkB,EAAE,aAA4B;QAClE,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC;QAEnC,MAAM,MAAM,GAAG,0BAAc,CAAC,oBAAoB,CAAC,UAAU,EAAE,aAAa,CAAC,CAAC;QAC9E,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;YACvB,OAAO,IAAI,qCAAiB,CAAC,MAAM,EAAE,IAAI,EAAE,UAAU,CAAC,CAAC;QAC3D,CAAC;QAED,OAAO,IAAI,qCAAiB,CACxB,qBAAqB,CAAC,YAAY,CAAC,QAAQ,EAAE,aAAa,CAAC,EAC3D,KAAK,EACL,UAAU,CACb,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,iGAAiG;IACzF,MAAM,CAAC,YAAY,CAAC,QAAkB,EAAE,aAA4B;QACxE,MAAM,UAAU,GAAG,QAAQ,CAAC,MAAM,CAAC;QACnC,MAAM,OAAO,GAAG,aAAa,CAAC,OAAO,IAAI,QAAQ,CAAC,UAAU,IAAI,eAAe,CAAC;QAChF,MAAM,OAAO,GAAG,aAAa,CAAC,OAAO,CAAC;QAEtC,QAAQ,UAAU,EAAE,CAAC;YACjB,KAAK,GAAG;gBACJ,OAAO,IAAI,+BAAmB,CAC1B,OAAO,EACP,aAAa,CAAC,KAAK,EACnB,aAAa,CAAC,eAAe,CAChC,CAAC;YAEN,KAAK,GAAG,EAAE,sDAAsD;gBAC5D,OAAO,IAAI,yBAAa,CAAC,OAAO,EAAE,aAAa,CAAC,SAAS,CAAC,CAAC;YAE/D,KAAK,GAAG;gBACJ,OAAO,IAAI,iCAAqB,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;YAEvD,KAAK,GAAG;gBACJ,OAAO,IAAI,8BAAkB,CAAC,OAAO,CAAC,CAAC;YAE3C,KAAK,GAAG;gBACJ,OAAO,IAAI,6BAAiB,CAAC,OAAO,CAAC,CAAC;YAE1C,KAAK,GAAG;gBACJ,OAAO,IAAI,4BAAgB,CAAC,OAAO,CAAC,CAAC;YAEzC,KAAK,GAAG;gBACJ,OAAO,IAAI,mCAAuB,CAAC,OAAO,CAAC,CAAC;YAEhD,KAAK,GAAG;gBACJ,OAAO,IAAI,+BAAmB,CAAC,OAAO,CAAC,CAAC;YAE5C,KAAK,GAAG;gBACJ,OAAO,IAAI,uCAA2B,CAAC,OAAO,CAAC,CAAC;YAEpD,KAAK,GAAG;gBACJ,OAAO,IAAI,mCAAuB,CAAC,OAAO,CAAC,CAAC;YAEhD,KAAK,GAAG,EAAE,0EAA0E;gBAChF,OAAO,IAAI,2BAAe,CAAC,OAAO,EAAE,aAAa,CAAC,WAAW,CAAC,CAAC;YAEnE;gBACI,oFAAoF;gBACpF,iFAAiF;gBACjF,OAAO,IAAI,qBAAS,CAChB,OAAO,IAAI,kCAAkC,UAAU,EAAE,EACzD,UAAU,EACV,OAAO,CACV,CAAC;QACV,CAAC;IACL,CAAC;CACJ;AAlGD,sDAkGC","sourcesContent":["import {\n ProtocolError,\n ClientRegistry,\n HttpError,\n HttpBadRequestError,\n HttpUserError,\n HttpVendorError,\n HttpUnauthorizedError,\n HttpForbiddenError,\n HttpNotFoundError,\n HttpTimeoutError,\n HttpInternalServerError,\n HttpBadGatewayError,\n HttpServiceUnavailableError,\n HttpGatewayTimeoutError,\n} from '@webpieces/core-util';\nimport { TranslatedFailure } from './TranslatedFailure';\n\n/**\n * ClientErrorTranslator - Translates HTTP error responses to HttpError exceptions.\n *\n * This is the CLIENT-SIDE reverse of ExpressWrapper.handleError() on the server.\n * It reconstructs typed HttpError exceptions from ProtocolError JSON responses.\n *\n * Architecture:\n * - Server: HttpError → ExpressWrapper.handleError() → ProtocolError JSON\n * - Client: ProtocolError JSON → ClientErrorTranslator.translateError() → TranslatedFailure\n *\n * This achieves symmetric error handling - server throws typed exceptions,\n * client receives typed exceptions.\n *\n * It returns a {@link TranslatedFailure} rather than a bare `Error` because the mapping is only HALF\n * the decision. It is ISOMORPHIC — the same mapping runs in a browser and in a server — and the two\n * environments must NOT do the same thing with a downstream 4xx (see\n * `ProxyClient.adaptDownstreamFailure`). The wrapper carries the one fact that hook cannot recover\n * on its own: whether the APP claimed this status, or the built-in default did.\n */\nexport class ClientErrorTranslator {\n /**\n * Parse an error response and decide which error the caller should see, and who decided it.\n *\n * App-registered translations win, so an app can reconstruct its OWN error types (e.g. a custom\n * 460) AND override built-ins. `undefined` means \"not mine\" — fall through to\n * {@link builtInError}, which stays the generic default. Symmetric with the server's\n * ExpressWrapper.handleError(), which consults ClientRegistry.tryTranslateToWire() first.\n *\n * @param response - Fetch Response object\n * @param protocolError - Parsed ProtocolError from response body\n * @returns the chosen error plus its provenance and the downstream status\n */\n // webpieces-disable no-function-outside-class -- pure, stateless status-to-type mapping with nothing to inject, called from a BROWSER bundle where no DI container exists; static is the established idiom of this class\n static translateError(response: Response, protocolError: ProtocolError): TranslatedFailure {\n const statusCode = response.status;\n\n const custom = ClientRegistry.tryTranslateFromWire(statusCode, protocolError);\n if (custom !== undefined) {\n return new TranslatedFailure(custom, true, statusCode);\n }\n\n return new TranslatedFailure(\n ClientErrorTranslator.builtInError(response, protocolError),\n false,\n statusCode,\n );\n }\n\n /**\n * The built-in status → error mapping (symmetric with the server's ExpressWrapper.handleError()):\n * - 400 → HttpBadRequestError (with field, guiAlertMessage)\n * - 266 → HttpUserError (with errorCode) - 2xx code for user validation\n * - 401 → HttpUnauthorizedError\n * - 403 → HttpForbiddenError\n * - 404 → HttpNotFoundError\n * - 408 → HttpTimeoutError\n * - 500 → HttpInternalServerError\n * - 502 → HttpBadGatewayError\n * - 503 → HttpServiceUnavailableError\n * - 504 → HttpGatewayTimeoutError\n * - 598 → HttpVendorError (with waitSeconds) - custom status code\n * - other → generic HttpError\n */\n // webpieces-disable no-function-outside-class -- private helper of the static above; same reason\n private static builtInError(response: Response, protocolError: ProtocolError): Error {\n const statusCode = response.status;\n const message = protocolError.message || response.statusText || 'Unknown error';\n const subType = protocolError.subType;\n\n switch (statusCode) {\n case 400:\n return new HttpBadRequestError(\n message,\n protocolError.field,\n protocolError.guiAlertMessage,\n );\n\n case 266: // HttpUserError - 2xx code for user validation errors\n return new HttpUserError(message, protocolError.errorCode);\n\n case 401:\n return new HttpUnauthorizedError(message, subType);\n\n case 403:\n return new HttpForbiddenError(message);\n\n case 404:\n return new HttpNotFoundError(message);\n\n case 408:\n return new HttpTimeoutError(message);\n\n case 500:\n return new HttpInternalServerError(message);\n\n case 502:\n return new HttpBadGatewayError(message);\n\n case 503:\n return new HttpServiceUnavailableError(message);\n\n case 504:\n return new HttpGatewayTimeoutError(message);\n\n case 598: // HttpVendorError - custom status code for vendor/external service errors\n return new HttpVendorError(message, protocolError.waitSeconds);\n\n default:\n // Unknown status code and no app translation claimed it: still a real HttpError (so\n // `err instanceof HttpError` holds after the RPC hop), carrying the status code.\n return new HttpError(\n message || `could not translate statusCode=${statusCode}`,\n statusCode,\n subType,\n );\n }\n }\n}\n"]}
|
package/src/ProxyClient.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { AuthMeta, DestinationTrust, RouteMetadata, LogApiCallImpl } from '@webpieces/core-util';
|
|
2
2
|
import { ApiPrototype } from './ApiPrototype';
|
|
3
3
|
import { RequestOutcome } from './RequestOutcome';
|
|
4
|
+
import { TranslatedFailure } from './TranslatedFailure';
|
|
4
5
|
/**
|
|
5
6
|
* ProxyClient - the HTTP call engine behind one API contract's client proxy.
|
|
6
7
|
*
|
|
@@ -65,6 +66,32 @@ export declare abstract class ProxyClient {
|
|
|
65
66
|
* first call in production. The default accepts everything.
|
|
66
67
|
*/
|
|
67
68
|
protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void;
|
|
69
|
+
/**
|
|
70
|
+
* Adapt a translated downstream failure into the error THIS environment's caller should see.
|
|
71
|
+
*
|
|
72
|
+
* THE INVARIANT, and the reason this hook exists at all:
|
|
73
|
+
*
|
|
74
|
+
* A status received from a downstream dependency describes OUR request to it. It is never the
|
|
75
|
+
* status we return to OUR caller. The server that answered 404 is correct; the server that
|
|
76
|
+
* asked for a route that does not exist is broken, and must say so as a 500.
|
|
77
|
+
*
|
|
78
|
+
* That invariant reads differently in the two environments, which is exactly why the ISOMORPHIC
|
|
79
|
+
* {@link ClientErrorTranslator} cannot settle it:
|
|
80
|
+
* - BROWSER: the client IS the end user's agent, so the downstream IS the answer. Pass it through
|
|
81
|
+
* unchanged.
|
|
82
|
+
* - NODE: server-to-server. A 4xx from a dependency is a caller-side defect (wrong path, wrong
|
|
83
|
+
* base URL, an undeployed dependency, bad service credentials), so the caller owns it as a 500.
|
|
84
|
+
*
|
|
85
|
+
* ABSTRACT, not a defaulted pass-through, for the same reason
|
|
86
|
+
* {@link outboundContextHeaders} takes a required `destination`: a permissive default puts the
|
|
87
|
+
* wrong answer one keystroke away. A new environment subclass must SAY which of the two it is,
|
|
88
|
+
* and there are exactly two subclasses in the repo, so the compile error is the migration.
|
|
89
|
+
*
|
|
90
|
+
* @param failure - the translated error, its provenance (app-registered vs built-in), and the
|
|
91
|
+
* downstream status
|
|
92
|
+
* @param callId - `ApiName.methodName`, so a rewritten message can still name the call
|
|
93
|
+
*/
|
|
94
|
+
protected abstract adaptDownstreamFailure(failure: TranslatedFailure, callId: string): Error;
|
|
68
95
|
/**
|
|
69
96
|
* Fires immediately BEFORE `fetch`, once per RPC — the progress "start marker". Symmetric with
|
|
70
97
|
* {@link onRequestEnd}: every start is followed by exactly one end, on every path, so a listener
|
|
@@ -155,8 +182,17 @@ export declare abstract class ProxyClient {
|
|
|
155
182
|
* malformed still throws (that one is a genuine server bug), and the END marker must fire for it
|
|
156
183
|
* too — an unreported end leaves the app's progress bar spinning forever.
|
|
157
184
|
*
|
|
158
|
-
* `translated` is
|
|
159
|
-
*
|
|
185
|
+
* `translated` is what ClientErrorTranslator picked, and translateError RETURNS a
|
|
186
|
+
* {@link TranslatedFailure} — so nothing in this seam is ever `unknown`.
|
|
187
|
+
*
|
|
188
|
+
* The translated failure then goes through {@link adaptDownstreamFailure}, which is where the two
|
|
189
|
+
* environments part company (browser rethrows it, node turns a downstream 4xx into its own 500).
|
|
190
|
+
*
|
|
191
|
+
* The RequestOutcome reported to {@link onRequestEnd} carries the POST-adapt error, deliberately:
|
|
192
|
+
* a lifecycle listener must see the SAME error the caller sees, or a progress bar / error toast
|
|
193
|
+
* says 404 while the thrown exception says 500. That is the identical rule the network-reject path
|
|
194
|
+
* already follows (it classifies BEFORE onRequestEnd for exactly this reason). The pre-adapt error
|
|
195
|
+
* is not lost — it is the adapted error's `httpCause`.
|
|
160
196
|
*/
|
|
161
197
|
private endWithTypedFailure;
|
|
162
198
|
}
|
package/src/ProxyClient.js
CHANGED
|
@@ -284,8 +284,17 @@ class ProxyClient {
|
|
|
284
284
|
* malformed still throws (that one is a genuine server bug), and the END marker must fire for it
|
|
285
285
|
* too — an unreported end leaves the app's progress bar spinning forever.
|
|
286
286
|
*
|
|
287
|
-
* `translated` is
|
|
288
|
-
*
|
|
287
|
+
* `translated` is what ClientErrorTranslator picked, and translateError RETURNS a
|
|
288
|
+
* {@link TranslatedFailure} — so nothing in this seam is ever `unknown`.
|
|
289
|
+
*
|
|
290
|
+
* The translated failure then goes through {@link adaptDownstreamFailure}, which is where the two
|
|
291
|
+
* environments part company (browser rethrows it, node turns a downstream 4xx into its own 500).
|
|
292
|
+
*
|
|
293
|
+
* The RequestOutcome reported to {@link onRequestEnd} carries the POST-adapt error, deliberately:
|
|
294
|
+
* a lifecycle listener must see the SAME error the caller sees, or a progress bar / error toast
|
|
295
|
+
* says 404 while the thrown exception says 500. That is the identical rule the network-reject path
|
|
296
|
+
* already follows (it classifies BEFORE onRequestEnd for exactly this reason). The pre-adapt error
|
|
297
|
+
* is not lost — it is the adapted error's `httpCause`.
|
|
289
298
|
*/
|
|
290
299
|
async endWithTypedFailure(response, route, callId) {
|
|
291
300
|
let translated;
|
|
@@ -298,11 +307,14 @@ class ProxyClient {
|
|
|
298
307
|
catch (err) {
|
|
299
308
|
const error = (0, core_util_1.toError)(err);
|
|
300
309
|
// The response CLAIMED JSON and was not parseable — report that failure as the outcome.
|
|
310
|
+
// It never reaches adaptDownstreamFailure: there is no translated status to adapt, and a
|
|
311
|
+
// body that broke its own content-type promise is already a defect, not a status answer.
|
|
301
312
|
this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, response.status, response.headers, error));
|
|
302
313
|
return error;
|
|
303
314
|
}
|
|
304
|
-
|
|
305
|
-
|
|
315
|
+
const adapted = this.adaptDownstreamFailure(translated, callId);
|
|
316
|
+
this.onRequestEnd(route, new RequestOutcome_1.RequestOutcome(false, response.status, response.headers, adapted));
|
|
317
|
+
return adapted;
|
|
306
318
|
}
|
|
307
319
|
}
|
|
308
320
|
exports.ProxyClient = ProxyClient;
|
package/src/ProxyClient.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isApiPath,\n getApiPath,\n getEndpoints,\n getAuthMeta,\n isFormPost,\n isRawBody,\n getMaskSpec,\n AuthMeta,\n DestinationTrust,\n RouteMetadata,\n LogApiCall,\n LogApiCallImpl,\n ApiMethodInfo,\n toError,\n NetworkRejectClassifier,\n} from '@webpieces/core-util';\nimport { ApiPrototype } from './ApiPrototype';\nimport { ClientErrorTranslator } from './ClientErrorTranslator';\nimport { RequestOutcome } from './RequestOutcome';\nimport { ResponseBodyReader } from './ResponseBodyReader';\n\n/**\n * ProxyClient - the HTTP call engine behind one API contract's client proxy.\n *\n * Contains ONLY what a browser can run: the route map built from the contract's decorators, URL\n * assembly, `fetch`, error translation, and logging. It holds no context object, no credentials,\n * and no recorder — it ASKS ITSELF for those through the hooks below, and each subclass answers\n * from its own environment.\n *\n * That is why the class is abstract rather than parameterized by a collaborator: a shared\n * header-provider seam would drag Node's AsyncLocalStorage vocabulary into a browser bundle and the\n * browser's store vocabulary into a server, and neither has any use for the other.\n *\n * NodeProxyClient (@webpieces/http-client-node) -> RequestContext, Secrets, mintIdToken, recording\n * BrowserProxyClient (@webpieces/http-client-browser) -> an app-held store, no credentials, no recording\n *\n * TWO-PHASE: collaborators arrive on the subclass constructor (so a DI container can supply them),\n * while the per-client state — which contract, which target — arrives on the subclass's `init`,\n * which calls {@link initRoutes}. That is what lets a factory hold a `Provider<ProxyClient>` and\n * hand out a fresh, independently-configured client per contract.\n */\nexport abstract class ProxyClient {\n // Assigned by initRoutes(), which every subclass's init() calls immediately after construction.\n private routeMap!: Map<string, RouteMetadata>;\n private apiName!: string;\n\n // Stateless + dependency-free, so the browser bundle keeps no DI on the fetch path.\n private readonly networkRejectClassifier = new NetworkRejectClassifier();\n\n // Same shape and same reason: stateless, so it is constructed here rather than injected.\n private readonly bodyReader = new ResponseBodyReader();\n\n constructor(protected readonly logApiCall: LogApiCallImpl = LogApiCall) {}\n\n // ---------------------------------------------------------------- environment hooks\n\n /** The callee's base URL. Async because a server may derive it from container metadata. */\n protected abstract resolveBaseUrl(): Promise<string>;\n\n /**\n * Context headers to put on the wire. Server reads RequestContext; browser reads its store.\n *\n * `destination` is derived from THIS route's auth mode and decides whether TRUSTED context keys\n * (`x-user-id`, `x-org-id`, `x-webpieces-roles`) may ride along — see {@link DestinationTrust}.\n * It is a required argument on purpose: a defaulted \"send everything\" would put the permissive\n * answer one keystroke away and make the safe one opt-in.\n *\n * RENAMED from `outboundHeaders()` in the same change that added `destination`, and the rename IS\n * the migration. TypeScript accepts an override that declares FEWER parameters than its base, so a\n * downstream `protected override outboundHeaders(): Map<string, string>` would have kept compiling\n * and silently ignored the gate — the permissive behaviour surviving as a second spelling. Against\n * the NEW name that subclass fails twice over: `override` names a member the base no longer has,\n * and this abstract member is left unimplemented.\n */\n protected abstract outboundContextHeaders(destination: DestinationTrust): Map<string, string>;\n\n /**\n * Attach the endpoint's outbound credential. Service-to-service auth (@AuthOidc bearer,\n * @AuthSharedSecret value) is a SERVER concept; a browser has neither a minter nor a Secrets\n * store, so it attaches nothing and its user JWT simply travels as a transferred context key.\n */\n protected async attachOutboundAuth(\n _route: RouteMetadata,\n _baseUrl: string,\n _httpHeaders: Record<string, string>,\n ): Promise<void> {}\n\n /**\n * Run the call. The default just logs it. Test-case RECORDING is a server concept, so\n * NodeProxyClient overrides this to capture the call when a recorder is in the context.\n *\n * Context fields are NOT passed in: a logging backend stamps them onto every record itself.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // apiClass = the CONTRACT name (this.apiName, e.g. 'SaveApi') so this client log line MATCHES\n // the server's for the same call. A client has no impl class, so controllerName is omitted.\n const info = new ApiMethodInfo('client', this.apiName, route.methodName, undefined, route.mask);\n return this.logApiCall.execute(info, requestDto, method);\n }\n\n /**\n * Reject, at bind time, an endpoint this environment cannot satisfy — e.g. a browser cannot\n * mint the OIDC token an @AuthOidc endpoint demands. Surfacing it here beats failing on the\n * first call in production. The default accepts everything.\n */\n protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void {}\n\n /**\n * Fires immediately BEFORE `fetch`, once per RPC — the progress \"start marker\". Symmetric with\n * {@link onRequestEnd}: every start is followed by exactly one end, on every path, so a listener\n * can drive a counter (bar on / bar off) without leaking a permanently-spinning bar.\n *\n * The default is a no-op, so every existing subclass is unaffected.\n */\n protected onRequestStart(_route: RouteMetadata): void {}\n\n /**\n * Fires exactly ONCE after the call settles, on EVERY path (2xx, HTTP error, network reject) —\n * the \"stop marker\", carrying how it settled.\n *\n * Subsumes the older header-only hook: this is the ONLY place the `fetch` Response — and thus its\n * `Headers` — exists, so an app that needs to read a response header (e.g. a server-version stamp\n * for client↔server version matching) reads `outcome.headers`, still BEFORE the body is consumed\n * and on both the ok and error paths. `outcome.ok`/`outcome.error` add the success-or-error\n * signal the header-only seam could not give.\n *\n * The default is a no-op, so every existing subclass is unaffected.\n */\n protected onRequestEnd(_route: RouteMetadata, _outcome: RequestOutcome): void {}\n\n // ---------------------------------------------------------------- contract binding\n\n /**\n * Bind this client to one API contract: read @ApiPath/@Endpoint/@Auth* off the prototype and\n * build the route map once. Each subclass's `init(api, config)` stores its own config, then\n * calls this.\n *\n * @throws Error if the prototype lacks @ApiPath, or declares an endpoint this environment\n * cannot satisfy (see {@link assertEndpointSupported}).\n */\n protected initRoutes(apiPrototype: ApiPrototype<object>): void {\n if (!isApiPath(apiPrototype)) {\n const className = apiPrototype.name || 'Unknown';\n throw new Error(`Class ${className} must be decorated with @ApiPath()`);\n }\n\n const basePath = getApiPath(apiPrototype)!;\n const endpoints = getEndpoints(apiPrototype) || {};\n\n // apiName as the class name so client logs read \"SaveApi.save\", not \"undefined.save\"\n this.apiName = apiPrototype.name || 'UnknownApi';\n\n this.routeMap = new Map<string, RouteMetadata>();\n for (const [methodName, endpointPath] of Object.entries(endpoints)) {\n const fullPath = basePath + endpointPath;\n // Capture the endpoint's auth mode so the client can mint delivery auth per\n // @AuthOidc / @AuthSharedSecret, exactly as the server verifies it.\n const authMeta = getAuthMeta(apiPrototype, methodName);\n this.assertEndpointSupported(authMeta, methodName);\n const formPost = isFormPost(apiPrototype, methodName);\n this.routeMap.set(\n methodName,\n new RouteMetadata(\n 'POST', fullPath, methodName, this.apiName, authMeta, undefined, formPost,\n getMaskSpec(apiPrototype, methodName), isRawBody(apiPrototype, methodName),\n ),\n );\n }\n }\n\n /** The contract's class name, for logs and recordings. */\n protected contractName(): string {\n return this.apiName;\n }\n\n /** Check if a route exists for the given method name. */\n hasRoute(methodName: string): boolean {\n return this.routeMap.has(methodName);\n }\n\n /**\n * Get route metadata for a method name.\n * @throws Error if no route found\n */\n getRoute(methodName: string): RouteMetadata {\n const route = this.routeMap.get(methodName);\n if (!route) {\n throw new Error(`No route found for method ${methodName}`);\n }\n return route;\n }\n\n // ---------------------------------------------------------------- the call\n\n /**\n * FAIL FAST, PER METHOD, at call time: some endpoints exist for a caller that is not us, and this\n * proxy could only ever build a request they are obliged to reject. Refusing here rather than at\n * bind time means an api that MIXES such endpoints with normal ones still yields a working client\n * for the normal ones; only calling the un-callable method throws.\n *\n * @throws Error naming the endpoint, what it declared, and who its real caller is.\n */\n private refuseEndpointNoClientCanCall(route: RouteMetadata): void {\n // formPost exists ONLY for EXTERNAL inbound webhooks (e.g. Twilio is the caller). This proxy\n // JSON.stringifies the body, so calling one would silently send a wrong-encoded body.\n if (route.formPost) {\n throw new Error(\n `${this.apiName}.${route.methodName} is @Endpoint(..., { formPost: true }) — the ` +\n `webpieces client does not support calling form-encoded endpoints yet. formPost is ` +\n `for EXTERNAL inbound webhooks (e.g. Twilio) only. If this endpoint needs a ` +\n `service-to-service client, set formPost:false (or remove it) so it uses JSON.`,\n );\n }\n const authMode = route.authMeta?.mode;\n // @AuthApiKey: the credential is a CUSTOMER-held key, and the header carrying it is the app's\n // ApiKeyHook's choice, so this client has nothing to send and the call is a guaranteed 401.\n if (authMode?.kind === 'apikey') {\n throw new Error(\n `${this.apiName}.${route.methodName} is @AuthApiKey('${authMode.regime}') — only the partner ` +\n `holding that api key can call it, and the header carrying it is the app's ApiKeyHook's choice, ` +\n `so a webpieces client has no credential to send.`,\n );\n }\n // @AuthWebhook: verified by the VENDOR's signature over the request, which nothing here can produce.\n if (authMode?.kind === 'webhook') {\n throw new Error(\n `${this.apiName}.${route.methodName} is @AuthWebhook('${authMode.name}') — only ` +\n `${authMode.name} can call it, because only ${authMode.name} can produce the signature ` +\n `its WebhookAuthCallback verifies. It is not callable from a webpieces client.`,\n );\n }\n }\n\n /**\n * Make an HTTP request based on route metadata and arguments.\n *\n * All endpoints are POST-only. The request body is the first argument.\n */\n // webpieces-disable no-any-unknown -- proxy method: the request DTO (args) + response are erased at the client boundary\n async makeRequest(route: RouteMetadata, args: any[]): Promise<any> {\n this.refuseEndpointNoClientCanCall(route);\n // Resolved per call (memoized underneath on a server), so building a client stayed synchronous.\n const baseUrl = await this.resolveBaseUrl();\n const url = `${baseUrl}${route.path}`;\n\n const httpHeaders: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n // Transferred context, request-id chained. The server impl throws here when there is no\n // active RequestContext — an outbound call with no trace is a bug, not a default. The\n // destination's own auth mode decides whether trusted keys are part of that set.\n const contextHeaders = this.outboundContextHeaders(DestinationTrust.forAuthMode(route.authMeta?.mode));\n for (const entry of contextHeaders.entries()) {\n httpHeaders[entry[0]] = entry[1];\n }\n\n await this.attachOutboundAuth(route, baseUrl, httpHeaders);\n\n const options: RequestInit = {\n method: route.httpMethod,\n headers: httpHeaders,\n };\n\n // POST body is the first argument as JSON\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n let requestDto: unknown;\n if (args.length > 0) {\n requestDto = args[0];\n options.body = JSON.stringify(requestDto);\n }\n\n // Wrap fetch in a method for LogApiCall.execute\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n const method = async (): Promise<unknown> => {\n return this.executeFetch(url, options, route);\n };\n\n return await this.execute(route, requestDto, method);\n }\n\n /**\n * Execute the fetch request and handle response.\n *\n * Brackets the call with the lifecycle seam: {@link onRequestStart} once before `fetch`, then\n * {@link onRequestEnd} exactly once on each of the three ways a call can settle. The end hook\n * fires BEFORE the throw on both failure paths, so a listener always sees the stop marker even\n * though the caller sees an exception.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async executeFetch(url: string, options: RequestInit, route: RouteMetadata): Promise<unknown> {\n this.onRequestStart(route);\n\n // A network reject (offline, DNS, CORS preflight) means no Response ever existed, so there is\n // no status and no headers to report — only status 0 and the failure itself. toNetworkError\n // turns that reject into a typed OfflineError (a genuine bug passes through untouched), and we\n // classify BEFORE onRequestEnd so a lifecycle listener sees the SAME typed error the caller will.\n let response: Response;\n // webpieces-disable no-unmanaged-exceptions -- translate a network reject into a lifecycle END, then rethrow\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to\n response = await fetch(url, options);\n } catch (err: unknown) {\n const error = toError(err);\n const networkError = this.networkRejectClassifier.toNetworkError(error, url);\n this.onRequestEnd(route, new RequestOutcome(false, 0, undefined, networkError));\n throw networkError;\n }\n\n const callId = `${this.apiName}.${route.methodName}`;\n if (response.ok) {\n return this.readSuccessBody(response, route, callId);\n }\n throw await this.endWithTypedFailure(response, route, callId);\n }\n\n /**\n * Read a 2xx body, reporting the END marker on both outcomes.\n *\n * The content-type gate is the same one the error path uses: a 2xx that is not JSON (a proxy's\n * captive-portal page, an SPA index.html served by a misrouted CDN) is reported for WHAT ARRIVED,\n * instead of `SyntaxError: Unexpected token '<'`, which names nothing a reader can act on.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async readSuccessBody(response: Response, route: RouteMetadata, callId: string): Promise<unknown> {\n // webpieces-disable no-unmanaged-exceptions -- a malformed 2xx body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!this.bodyReader.isJson(response)) {\n throw new Error(this.bodyReader.describeForeignBody(response, callId, await response.text()));\n }\n const body = await response.json();\n this.onRequestEnd(route, new RequestOutcome(true, response.status, response.headers));\n return body;\n } catch (err: unknown) {\n const error = toError(err);\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n throw error;\n }\n }\n\n /**\n * Turn a non-2xx response into the error the caller will see, firing the END marker first — so a\n * listener always gets its stop marker even though the caller sees an exception. RETURNS the\n * error rather than throwing it, which keeps the one `throw` visible at the call site.\n *\n * The headers still reach the seam here, so a version (or any future) header is observed even on\n * error responses.\n *\n * The body is read through {@link ResponseBodyReader}, which parses ONLY a body whose\n * content-type says it is JSON. An infra 502/503/504 (load balancer, proxy, cold start on a\n * scale-to-zero backend) serves HTML, and parsing that used to throw `SyntaxError: Unexpected\n * token '<'` — discarding the status, so the caller could not tell a booting server from a broken\n * client. It now becomes a synthesized ProtocolError translated BY STATUS, i.e. a real\n * `HttpBadGatewayError` / `HttpServiceUnavailableError` / `HttpGatewayTimeoutError`.\n *\n * The try/catch stays, for a NARROWER job than before: a body that DECLARED json and was\n * malformed still throws (that one is a genuine server bug), and the END marker must fire for it\n * too — an unreported end leaves the app's progress bar spinning forever.\n *\n * `translated` is the HttpError subclass ClientErrorTranslator picked, and translateError RETURNS\n * Error — so nothing in this seam is ever `unknown`.\n */\n private async endWithTypedFailure(response: Response, route: RouteMetadata, callId: string): Promise<Error> {\n let translated: Error;\n // webpieces-disable no-unmanaged-exceptions -- a malformed JSON error body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const protocolError = await this.bodyReader.readErrorBody(response, callId);\n translated = ClientErrorTranslator.translateError(response, protocolError);\n } catch (err: unknown) {\n const error = toError(err);\n // The response CLAIMED JSON and was not parseable — report that failure as the outcome.\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n return error;\n }\n\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, translated));\n return translated;\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"ProxyClient.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/ProxyClient.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n isApiPath,\n getApiPath,\n getEndpoints,\n getAuthMeta,\n isFormPost,\n isRawBody,\n getMaskSpec,\n AuthMeta,\n DestinationTrust,\n RouteMetadata,\n LogApiCall,\n LogApiCallImpl,\n ApiMethodInfo,\n toError,\n NetworkRejectClassifier,\n} from '@webpieces/core-util';\nimport { ApiPrototype } from './ApiPrototype';\nimport { ClientErrorTranslator } from './ClientErrorTranslator';\nimport { RequestOutcome } from './RequestOutcome';\nimport { ResponseBodyReader } from './ResponseBodyReader';\nimport { TranslatedFailure } from './TranslatedFailure';\n\n/**\n * ProxyClient - the HTTP call engine behind one API contract's client proxy.\n *\n * Contains ONLY what a browser can run: the route map built from the contract's decorators, URL\n * assembly, `fetch`, error translation, and logging. It holds no context object, no credentials,\n * and no recorder — it ASKS ITSELF for those through the hooks below, and each subclass answers\n * from its own environment.\n *\n * That is why the class is abstract rather than parameterized by a collaborator: a shared\n * header-provider seam would drag Node's AsyncLocalStorage vocabulary into a browser bundle and the\n * browser's store vocabulary into a server, and neither has any use for the other.\n *\n * NodeProxyClient (@webpieces/http-client-node) -> RequestContext, Secrets, mintIdToken, recording\n * BrowserProxyClient (@webpieces/http-client-browser) -> an app-held store, no credentials, no recording\n *\n * TWO-PHASE: collaborators arrive on the subclass constructor (so a DI container can supply them),\n * while the per-client state — which contract, which target — arrives on the subclass's `init`,\n * which calls {@link initRoutes}. That is what lets a factory hold a `Provider<ProxyClient>` and\n * hand out a fresh, independently-configured client per contract.\n */\nexport abstract class ProxyClient {\n // Assigned by initRoutes(), which every subclass's init() calls immediately after construction.\n private routeMap!: Map<string, RouteMetadata>;\n private apiName!: string;\n\n // Stateless + dependency-free, so the browser bundle keeps no DI on the fetch path.\n private readonly networkRejectClassifier = new NetworkRejectClassifier();\n\n // Same shape and same reason: stateless, so it is constructed here rather than injected.\n private readonly bodyReader = new ResponseBodyReader();\n\n constructor(protected readonly logApiCall: LogApiCallImpl = LogApiCall) {}\n\n // ---------------------------------------------------------------- environment hooks\n\n /** The callee's base URL. Async because a server may derive it from container metadata. */\n protected abstract resolveBaseUrl(): Promise<string>;\n\n /**\n * Context headers to put on the wire. Server reads RequestContext; browser reads its store.\n *\n * `destination` is derived from THIS route's auth mode and decides whether TRUSTED context keys\n * (`x-user-id`, `x-org-id`, `x-webpieces-roles`) may ride along — see {@link DestinationTrust}.\n * It is a required argument on purpose: a defaulted \"send everything\" would put the permissive\n * answer one keystroke away and make the safe one opt-in.\n *\n * RENAMED from `outboundHeaders()` in the same change that added `destination`, and the rename IS\n * the migration. TypeScript accepts an override that declares FEWER parameters than its base, so a\n * downstream `protected override outboundHeaders(): Map<string, string>` would have kept compiling\n * and silently ignored the gate — the permissive behaviour surviving as a second spelling. Against\n * the NEW name that subclass fails twice over: `override` names a member the base no longer has,\n * and this abstract member is left unimplemented.\n */\n protected abstract outboundContextHeaders(destination: DestinationTrust): Map<string, string>;\n\n /**\n * Attach the endpoint's outbound credential. Service-to-service auth (@AuthOidc bearer,\n * @AuthSharedSecret value) is a SERVER concept; a browser has neither a minter nor a Secrets\n * store, so it attaches nothing and its user JWT simply travels as a transferred context key.\n */\n protected async attachOutboundAuth(\n _route: RouteMetadata,\n _baseUrl: string,\n _httpHeaders: Record<string, string>,\n ): Promise<void> {}\n\n /**\n * Run the call. The default just logs it. Test-case RECORDING is a server concept, so\n * NodeProxyClient overrides this to capture the call when a recorder is in the context.\n *\n * Context fields are NOT passed in: a logging backend stamps them onto every record itself.\n */\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n protected async execute(\n route: RouteMetadata,\n requestDto: unknown,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n method: () => Promise<unknown>,\n // webpieces-disable no-any-unknown -- DTO types are erased at the proxy boundary\n ): Promise<unknown> {\n // apiClass = the CONTRACT name (this.apiName, e.g. 'SaveApi') so this client log line MATCHES\n // the server's for the same call. A client has no impl class, so controllerName is omitted.\n const info = new ApiMethodInfo('client', this.apiName, route.methodName, undefined, route.mask);\n return this.logApiCall.execute(info, requestDto, method);\n }\n\n /**\n * Reject, at bind time, an endpoint this environment cannot satisfy — e.g. a browser cannot\n * mint the OIDC token an @AuthOidc endpoint demands. Surfacing it here beats failing on the\n * first call in production. The default accepts everything.\n */\n protected assertEndpointSupported(_authMeta: AuthMeta | undefined, _methodName: string): void {}\n\n /**\n * Adapt a translated downstream failure into the error THIS environment's caller should see.\n *\n * THE INVARIANT, and the reason this hook exists at all:\n *\n * A status received from a downstream dependency describes OUR request to it. It is never the\n * status we return to OUR caller. The server that answered 404 is correct; the server that\n * asked for a route that does not exist is broken, and must say so as a 500.\n *\n * That invariant reads differently in the two environments, which is exactly why the ISOMORPHIC\n * {@link ClientErrorTranslator} cannot settle it:\n * - BROWSER: the client IS the end user's agent, so the downstream IS the answer. Pass it through\n * unchanged.\n * - NODE: server-to-server. A 4xx from a dependency is a caller-side defect (wrong path, wrong\n * base URL, an undeployed dependency, bad service credentials), so the caller owns it as a 500.\n *\n * ABSTRACT, not a defaulted pass-through, for the same reason\n * {@link outboundContextHeaders} takes a required `destination`: a permissive default puts the\n * wrong answer one keystroke away. A new environment subclass must SAY which of the two it is,\n * and there are exactly two subclasses in the repo, so the compile error is the migration.\n *\n * @param failure - the translated error, its provenance (app-registered vs built-in), and the\n * downstream status\n * @param callId - `ApiName.methodName`, so a rewritten message can still name the call\n */\n protected abstract adaptDownstreamFailure(failure: TranslatedFailure, callId: string): Error;\n\n /**\n * Fires immediately BEFORE `fetch`, once per RPC — the progress \"start marker\". Symmetric with\n * {@link onRequestEnd}: every start is followed by exactly one end, on every path, so a listener\n * can drive a counter (bar on / bar off) without leaking a permanently-spinning bar.\n *\n * The default is a no-op, so every existing subclass is unaffected.\n */\n protected onRequestStart(_route: RouteMetadata): void {}\n\n /**\n * Fires exactly ONCE after the call settles, on EVERY path (2xx, HTTP error, network reject) —\n * the \"stop marker\", carrying how it settled.\n *\n * Subsumes the older header-only hook: this is the ONLY place the `fetch` Response — and thus its\n * `Headers` — exists, so an app that needs to read a response header (e.g. a server-version stamp\n * for client↔server version matching) reads `outcome.headers`, still BEFORE the body is consumed\n * and on both the ok and error paths. `outcome.ok`/`outcome.error` add the success-or-error\n * signal the header-only seam could not give.\n *\n * The default is a no-op, so every existing subclass is unaffected.\n */\n protected onRequestEnd(_route: RouteMetadata, _outcome: RequestOutcome): void {}\n\n // ---------------------------------------------------------------- contract binding\n\n /**\n * Bind this client to one API contract: read @ApiPath/@Endpoint/@Auth* off the prototype and\n * build the route map once. Each subclass's `init(api, config)` stores its own config, then\n * calls this.\n *\n * @throws Error if the prototype lacks @ApiPath, or declares an endpoint this environment\n * cannot satisfy (see {@link assertEndpointSupported}).\n */\n protected initRoutes(apiPrototype: ApiPrototype<object>): void {\n if (!isApiPath(apiPrototype)) {\n const className = apiPrototype.name || 'Unknown';\n throw new Error(`Class ${className} must be decorated with @ApiPath()`);\n }\n\n const basePath = getApiPath(apiPrototype)!;\n const endpoints = getEndpoints(apiPrototype) || {};\n\n // apiName as the class name so client logs read \"SaveApi.save\", not \"undefined.save\"\n this.apiName = apiPrototype.name || 'UnknownApi';\n\n this.routeMap = new Map<string, RouteMetadata>();\n for (const [methodName, endpointPath] of Object.entries(endpoints)) {\n const fullPath = basePath + endpointPath;\n // Capture the endpoint's auth mode so the client can mint delivery auth per\n // @AuthOidc / @AuthSharedSecret, exactly as the server verifies it.\n const authMeta = getAuthMeta(apiPrototype, methodName);\n this.assertEndpointSupported(authMeta, methodName);\n const formPost = isFormPost(apiPrototype, methodName);\n this.routeMap.set(\n methodName,\n new RouteMetadata(\n 'POST', fullPath, methodName, this.apiName, authMeta, undefined, formPost,\n getMaskSpec(apiPrototype, methodName), isRawBody(apiPrototype, methodName),\n ),\n );\n }\n }\n\n /** The contract's class name, for logs and recordings. */\n protected contractName(): string {\n return this.apiName;\n }\n\n /** Check if a route exists for the given method name. */\n hasRoute(methodName: string): boolean {\n return this.routeMap.has(methodName);\n }\n\n /**\n * Get route metadata for a method name.\n * @throws Error if no route found\n */\n getRoute(methodName: string): RouteMetadata {\n const route = this.routeMap.get(methodName);\n if (!route) {\n throw new Error(`No route found for method ${methodName}`);\n }\n return route;\n }\n\n // ---------------------------------------------------------------- the call\n\n /**\n * FAIL FAST, PER METHOD, at call time: some endpoints exist for a caller that is not us, and this\n * proxy could only ever build a request they are obliged to reject. Refusing here rather than at\n * bind time means an api that MIXES such endpoints with normal ones still yields a working client\n * for the normal ones; only calling the un-callable method throws.\n *\n * @throws Error naming the endpoint, what it declared, and who its real caller is.\n */\n private refuseEndpointNoClientCanCall(route: RouteMetadata): void {\n // formPost exists ONLY for EXTERNAL inbound webhooks (e.g. Twilio is the caller). This proxy\n // JSON.stringifies the body, so calling one would silently send a wrong-encoded body.\n if (route.formPost) {\n throw new Error(\n `${this.apiName}.${route.methodName} is @Endpoint(..., { formPost: true }) — the ` +\n `webpieces client does not support calling form-encoded endpoints yet. formPost is ` +\n `for EXTERNAL inbound webhooks (e.g. Twilio) only. If this endpoint needs a ` +\n `service-to-service client, set formPost:false (or remove it) so it uses JSON.`,\n );\n }\n const authMode = route.authMeta?.mode;\n // @AuthApiKey: the credential is a CUSTOMER-held key, and the header carrying it is the app's\n // ApiKeyHook's choice, so this client has nothing to send and the call is a guaranteed 401.\n if (authMode?.kind === 'apikey') {\n throw new Error(\n `${this.apiName}.${route.methodName} is @AuthApiKey('${authMode.regime}') — only the partner ` +\n `holding that api key can call it, and the header carrying it is the app's ApiKeyHook's choice, ` +\n `so a webpieces client has no credential to send.`,\n );\n }\n // @AuthWebhook: verified by the VENDOR's signature over the request, which nothing here can produce.\n if (authMode?.kind === 'webhook') {\n throw new Error(\n `${this.apiName}.${route.methodName} is @AuthWebhook('${authMode.name}') — only ` +\n `${authMode.name} can call it, because only ${authMode.name} can produce the signature ` +\n `its WebhookAuthCallback verifies. It is not callable from a webpieces client.`,\n );\n }\n }\n\n /**\n * Make an HTTP request based on route metadata and arguments.\n *\n * All endpoints are POST-only. The request body is the first argument.\n */\n // webpieces-disable no-any-unknown -- proxy method: the request DTO (args) + response are erased at the client boundary\n async makeRequest(route: RouteMetadata, args: any[]): Promise<any> {\n this.refuseEndpointNoClientCanCall(route);\n // Resolved per call (memoized underneath on a server), so building a client stayed synchronous.\n const baseUrl = await this.resolveBaseUrl();\n const url = `${baseUrl}${route.path}`;\n\n const httpHeaders: Record<string, string> = {\n 'Content-Type': 'application/json',\n };\n\n // Transferred context, request-id chained. The server impl throws here when there is no\n // active RequestContext — an outbound call with no trace is a bug, not a default. The\n // destination's own auth mode decides whether trusted keys are part of that set.\n const contextHeaders = this.outboundContextHeaders(DestinationTrust.forAuthMode(route.authMeta?.mode));\n for (const entry of contextHeaders.entries()) {\n httpHeaders[entry[0]] = entry[1];\n }\n\n await this.attachOutboundAuth(route, baseUrl, httpHeaders);\n\n const options: RequestInit = {\n method: route.httpMethod,\n headers: httpHeaders,\n };\n\n // POST body is the first argument as JSON\n // webpieces-disable no-any-unknown -- the request DTO's type is erased at the proxy boundary\n let requestDto: unknown;\n if (args.length > 0) {\n requestDto = args[0];\n options.body = JSON.stringify(requestDto);\n }\n\n // Wrap fetch in a method for LogApiCall.execute\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n const method = async (): Promise<unknown> => {\n return this.executeFetch(url, options, route);\n };\n\n return await this.execute(route, requestDto, method);\n }\n\n /**\n * Execute the fetch request and handle response.\n *\n * Brackets the call with the lifecycle seam: {@link onRequestStart} once before `fetch`, then\n * {@link onRequestEnd} exactly once on each of the three ways a call can settle. The end hook\n * fires BEFORE the throw on both failure paths, so a listener always sees the stop marker even\n * though the caller sees an exception.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async executeFetch(url: string, options: RequestInit, route: RouteMetadata): Promise<unknown> {\n this.onRequestStart(route);\n\n // A network reject (offline, DNS, CORS preflight) means no Response ever existed, so there is\n // no status and no headers to report — only status 0 and the failure itself. toNetworkError\n // turns that reject into a typed OfflineError (a genuine bug passes through untouched), and we\n // classify BEFORE onRequestEnd so a lifecycle listener sees the SAME typed error the caller will.\n let response: Response;\n // webpieces-disable no-unmanaged-exceptions -- translate a network reject into a lifecycle END, then rethrow\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // webpieces-disable no-fetch -- this IS the generated-client implementation the rule points everyone to\n response = await fetch(url, options);\n } catch (err: unknown) {\n const error = toError(err);\n const networkError = this.networkRejectClassifier.toNetworkError(error, url);\n this.onRequestEnd(route, new RequestOutcome(false, 0, undefined, networkError));\n throw networkError;\n }\n\n const callId = `${this.apiName}.${route.methodName}`;\n if (response.ok) {\n return this.readSuccessBody(response, route, callId);\n }\n throw await this.endWithTypedFailure(response, route, callId);\n }\n\n /**\n * Read a 2xx body, reporting the END marker on both outcomes.\n *\n * The content-type gate is the same one the error path uses: a 2xx that is not JSON (a proxy's\n * captive-portal page, an SPA index.html served by a misrouted CDN) is reported for WHAT ARRIVED,\n * instead of `SyntaxError: Unexpected token '<'`, which names nothing a reader can act on.\n */\n // webpieces-disable no-any-unknown -- the response DTO's type is erased at the proxy boundary\n private async readSuccessBody(response: Response, route: RouteMetadata, callId: string): Promise<unknown> {\n // webpieces-disable no-unmanaged-exceptions -- a malformed 2xx body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (!this.bodyReader.isJson(response)) {\n throw new Error(this.bodyReader.describeForeignBody(response, callId, await response.text()));\n }\n const body = await response.json();\n this.onRequestEnd(route, new RequestOutcome(true, response.status, response.headers));\n return body;\n } catch (err: unknown) {\n const error = toError(err);\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n throw error;\n }\n }\n\n /**\n * Turn a non-2xx response into the error the caller will see, firing the END marker first — so a\n * listener always gets its stop marker even though the caller sees an exception. RETURNS the\n * error rather than throwing it, which keeps the one `throw` visible at the call site.\n *\n * The headers still reach the seam here, so a version (or any future) header is observed even on\n * error responses.\n *\n * The body is read through {@link ResponseBodyReader}, which parses ONLY a body whose\n * content-type says it is JSON. An infra 502/503/504 (load balancer, proxy, cold start on a\n * scale-to-zero backend) serves HTML, and parsing that used to throw `SyntaxError: Unexpected\n * token '<'` — discarding the status, so the caller could not tell a booting server from a broken\n * client. It now becomes a synthesized ProtocolError translated BY STATUS, i.e. a real\n * `HttpBadGatewayError` / `HttpServiceUnavailableError` / `HttpGatewayTimeoutError`.\n *\n * The try/catch stays, for a NARROWER job than before: a body that DECLARED json and was\n * malformed still throws (that one is a genuine server bug), and the END marker must fire for it\n * too — an unreported end leaves the app's progress bar spinning forever.\n *\n * `translated` is what ClientErrorTranslator picked, and translateError RETURNS a\n * {@link TranslatedFailure} — so nothing in this seam is ever `unknown`.\n *\n * The translated failure then goes through {@link adaptDownstreamFailure}, which is where the two\n * environments part company (browser rethrows it, node turns a downstream 4xx into its own 500).\n *\n * The RequestOutcome reported to {@link onRequestEnd} carries the POST-adapt error, deliberately:\n * a lifecycle listener must see the SAME error the caller sees, or a progress bar / error toast\n * says 404 while the thrown exception says 500. That is the identical rule the network-reject path\n * already follows (it classifies BEFORE onRequestEnd for exactly this reason). The pre-adapt error\n * is not lost — it is the adapted error's `httpCause`.\n */\n private async endWithTypedFailure(response: Response, route: RouteMetadata, callId: string): Promise<Error> {\n let translated: TranslatedFailure;\n // webpieces-disable no-unmanaged-exceptions -- a malformed JSON error body must still report the END marker\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const protocolError = await this.bodyReader.readErrorBody(response, callId);\n translated = ClientErrorTranslator.translateError(response, protocolError);\n } catch (err: unknown) {\n const error = toError(err);\n // The response CLAIMED JSON and was not parseable — report that failure as the outcome.\n // It never reaches adaptDownstreamFailure: there is no translated status to adapt, and a\n // body that broke its own content-type promise is already a defect, not a status answer.\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, error));\n return error;\n }\n\n const adapted = this.adaptDownstreamFailure(translated, callId);\n this.onRequestEnd(route, new RequestOutcome(false, response.status, response.headers, adapted));\n return adapted;\n }\n}\n"]}
|
package/src/RequestOutcome.d.ts
CHANGED
|
@@ -25,10 +25,13 @@ export declare class RequestOutcome {
|
|
|
25
25
|
*/
|
|
26
26
|
readonly headers?: Headers | undefined;
|
|
27
27
|
/**
|
|
28
|
-
* Set on every non-success path: the
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
28
|
+
* Set on every non-success path: for a non-2xx, the error the CALLER will see — i.e. what
|
|
29
|
+
* `ClientErrorTranslator` picked AFTER `ProxyClient.adaptDownstreamFailure` had its say, so a
|
|
30
|
+
* listener never disagrees with the thrown exception (on a server that is the 500 wrapping a
|
|
31
|
+
* downstream 4xx, with the original reachable as `httpCause`). Otherwise the network/parse
|
|
32
|
+
* failure normalized through `toError`. Always a real `Error` — never `unknown`, because
|
|
33
|
+
* nothing here is untyped: `translateError` RETURNS a typed `TranslatedFailure`, and every
|
|
34
|
+
* rejection reaching this class has been through `toError`.
|
|
32
35
|
*/
|
|
33
36
|
readonly error?: Error | undefined;
|
|
34
37
|
constructor(
|
|
@@ -43,10 +46,13 @@ export declare class RequestOutcome {
|
|
|
43
46
|
*/
|
|
44
47
|
headers?: Headers | undefined,
|
|
45
48
|
/**
|
|
46
|
-
* Set on every non-success path: the
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
49
|
+
* Set on every non-success path: for a non-2xx, the error the CALLER will see — i.e. what
|
|
50
|
+
* `ClientErrorTranslator` picked AFTER `ProxyClient.adaptDownstreamFailure` had its say, so a
|
|
51
|
+
* listener never disagrees with the thrown exception (on a server that is the 500 wrapping a
|
|
52
|
+
* downstream 4xx, with the original reachable as `httpCause`). Otherwise the network/parse
|
|
53
|
+
* failure normalized through `toError`. Always a real `Error` — never `unknown`, because
|
|
54
|
+
* nothing here is untyped: `translateError` RETURNS a typed `TranslatedFailure`, and every
|
|
55
|
+
* rejection reaching this class has been through `toError`.
|
|
50
56
|
*/
|
|
51
57
|
error?: Error | undefined);
|
|
52
58
|
}
|
package/src/RequestOutcome.js
CHANGED
|
@@ -33,10 +33,13 @@ class RequestOutcome {
|
|
|
33
33
|
*/
|
|
34
34
|
headers,
|
|
35
35
|
/**
|
|
36
|
-
* Set on every non-success path: the
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
36
|
+
* Set on every non-success path: for a non-2xx, the error the CALLER will see — i.e. what
|
|
37
|
+
* `ClientErrorTranslator` picked AFTER `ProxyClient.adaptDownstreamFailure` had its say, so a
|
|
38
|
+
* listener never disagrees with the thrown exception (on a server that is the 500 wrapping a
|
|
39
|
+
* downstream 4xx, with the original reachable as `httpCause`). Otherwise the network/parse
|
|
40
|
+
* failure normalized through `toError`. Always a real `Error` — never `unknown`, because
|
|
41
|
+
* nothing here is untyped: `translateError` RETURNS a typed `TranslatedFailure`, and every
|
|
42
|
+
* rejection reaching this class has been through `toError`.
|
|
40
43
|
*/
|
|
41
44
|
error) {
|
|
42
45
|
this.ok = ok;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RequestOutcome.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/RequestOutcome.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAa,cAAc;IAGH;IAEA;IAMA;
|
|
1
|
+
{"version":3,"file":"RequestOutcome.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/RequestOutcome.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;GAcG;AACH,MAAa,cAAc;IAGH;IAEA;IAMA;IAUA;IApBpB;IACI,0CAA0C;IAC1B,EAAW;IAC3B,qGAAqG;IACrF,MAAc;IAC9B;;;;OAIG;IACa,OAAiB;IACjC;;;;;;;;OAQG;IACa,KAAa;QAlBb,OAAE,GAAF,EAAE,CAAS;QAEX,WAAM,GAAN,MAAM,CAAQ;QAMd,YAAO,GAAP,OAAO,CAAU;QAUjB,UAAK,GAAL,KAAK,CAAQ;IAC9B,CAAC;CACP;AAvBD,wCAuBC","sourcesContent":["/**\n * How one RPC call SETTLED — the payload of {@link ProxyClient.onRequestEnd}.\n *\n * DATA ONLY (no behavior), so it is a class with an explicit constructor rather than an interface:\n * each of the three settle paths in `ProxyClient.executeFetch` constructs it by name, and a reader\n * can see at the call site which path produced which shape.\n *\n * The three shapes, one per path:\n * - 2xx `new RequestOutcome(true, status, headers)` — no error\n * - HTTP error `new RequestOutcome(false, status, headers, error)` — the translated HttpError\n * - network reject `new RequestOutcome(false, 0, undefined, error)` — no Response ever existed\n *\n * A fourth path exists but is not a fourth SHAPE: a body that fails to parse (an infra 502 serving\n * HTML) settles as an HTTP error carrying the parse failure.\n */\nexport class RequestOutcome {\n constructor(\n /** `response.ok` — true only on a 2xx. */\n public readonly ok: boolean,\n /** The HTTP status; 0 when `fetch` itself rejected (network / offline), where there is no status. */\n public readonly status: number,\n /**\n * The Response headers, present whenever an HTTP Response existed (ok OR error) and absent\n * only on a network reject. Read BEFORE the body is consumed, which is what lets an app pull\n * a server-version stamp off an error response.\n */\n public readonly headers?: Headers,\n /**\n * Set on every non-success path: for a non-2xx, the error the CALLER will see — i.e. what\n * `ClientErrorTranslator` picked AFTER `ProxyClient.adaptDownstreamFailure` had its say, so a\n * listener never disagrees with the thrown exception (on a server that is the 500 wrapping a\n * downstream 4xx, with the original reachable as `httpCause`). Otherwise the network/parse\n * failure normalized through `toError`. Always a real `Error` — never `unknown`, because\n * nothing here is untyped: `translateError` RETURNS a typed `TranslatedFailure`, and every\n * rejection reaching this class has been through `toError`.\n */\n public readonly error?: Error,\n ) {}\n}\n"]}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What {@link ClientErrorTranslator.translateError} decided about ONE non-2xx downstream response —
|
|
3
|
+
* the typed error, plus WHO decided it.
|
|
4
|
+
*
|
|
5
|
+
* DATA ONLY (no behavior), so it is a class with an explicit constructor rather than an interface,
|
|
6
|
+
* exactly like {@link RequestOutcome}.
|
|
7
|
+
*
|
|
8
|
+
* WHY IT CARRIES `appRegistered` AT ALL: the translated error alone is not enough for an environment
|
|
9
|
+
* hook to act on. `HttpNotFoundError` produced by the BUILT-IN 404 branch and `HttpNotFoundError`
|
|
10
|
+
* produced by an app's own `ErrorTranslation` are indistinguishable as values, yet they mean opposite
|
|
11
|
+
* things — the first is the framework's generic default, the second is the app saying out loud, at
|
|
12
|
+
* startup and greppably, "relay this status as my own". `ProxyClient.adaptDownstreamFailure` must
|
|
13
|
+
* honour the second and is free to replace the first, so the provenance has to travel WITH the error
|
|
14
|
+
* rather than be re-derived by consulting `ClientRegistry` a second time.
|
|
15
|
+
*
|
|
16
|
+
* `statusCode` is the status the DOWNSTREAM answered — carried explicitly rather than read back off
|
|
17
|
+
* `error.code`, because an app-registered translation may legitimately return an error whose `code`
|
|
18
|
+
* is nothing like the status that produced it, and need not be an `HttpError` at all.
|
|
19
|
+
*/
|
|
20
|
+
export declare class TranslatedFailure {
|
|
21
|
+
/** The typed error the translator picked for this response. Always a real `Error`. */
|
|
22
|
+
readonly error: Error;
|
|
23
|
+
/**
|
|
24
|
+
* True when an app-registered `ClientRegistry` translation claimed this status — i.e. the app
|
|
25
|
+
* chose this error type deliberately, at startup, in one greppable place. False when the
|
|
26
|
+
* framework's built-in status mapping produced it.
|
|
27
|
+
*
|
|
28
|
+
* This IS the caller's explicit opt-out from any environment-specific rewrite: see
|
|
29
|
+
* `NodeProxyClient.adaptDownstreamFailure`, where an app-registered 4xx wins over the
|
|
30
|
+
* server-to-server 4xx-to-500 wrap.
|
|
31
|
+
*/
|
|
32
|
+
readonly appRegistered: boolean;
|
|
33
|
+
/** The HTTP status the downstream dependency actually answered with. */
|
|
34
|
+
readonly statusCode: number;
|
|
35
|
+
constructor(
|
|
36
|
+
/** The typed error the translator picked for this response. Always a real `Error`. */
|
|
37
|
+
error: Error,
|
|
38
|
+
/**
|
|
39
|
+
* True when an app-registered `ClientRegistry` translation claimed this status — i.e. the app
|
|
40
|
+
* chose this error type deliberately, at startup, in one greppable place. False when the
|
|
41
|
+
* framework's built-in status mapping produced it.
|
|
42
|
+
*
|
|
43
|
+
* This IS the caller's explicit opt-out from any environment-specific rewrite: see
|
|
44
|
+
* `NodeProxyClient.adaptDownstreamFailure`, where an app-registered 4xx wins over the
|
|
45
|
+
* server-to-server 4xx-to-500 wrap.
|
|
46
|
+
*/
|
|
47
|
+
appRegistered: boolean,
|
|
48
|
+
/** The HTTP status the downstream dependency actually answered with. */
|
|
49
|
+
statusCode: number);
|
|
50
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.TranslatedFailure = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* What {@link ClientErrorTranslator.translateError} decided about ONE non-2xx downstream response —
|
|
6
|
+
* the typed error, plus WHO decided it.
|
|
7
|
+
*
|
|
8
|
+
* DATA ONLY (no behavior), so it is a class with an explicit constructor rather than an interface,
|
|
9
|
+
* exactly like {@link RequestOutcome}.
|
|
10
|
+
*
|
|
11
|
+
* WHY IT CARRIES `appRegistered` AT ALL: the translated error alone is not enough for an environment
|
|
12
|
+
* hook to act on. `HttpNotFoundError` produced by the BUILT-IN 404 branch and `HttpNotFoundError`
|
|
13
|
+
* produced by an app's own `ErrorTranslation` are indistinguishable as values, yet they mean opposite
|
|
14
|
+
* things — the first is the framework's generic default, the second is the app saying out loud, at
|
|
15
|
+
* startup and greppably, "relay this status as my own". `ProxyClient.adaptDownstreamFailure` must
|
|
16
|
+
* honour the second and is free to replace the first, so the provenance has to travel WITH the error
|
|
17
|
+
* rather than be re-derived by consulting `ClientRegistry` a second time.
|
|
18
|
+
*
|
|
19
|
+
* `statusCode` is the status the DOWNSTREAM answered — carried explicitly rather than read back off
|
|
20
|
+
* `error.code`, because an app-registered translation may legitimately return an error whose `code`
|
|
21
|
+
* is nothing like the status that produced it, and need not be an `HttpError` at all.
|
|
22
|
+
*/
|
|
23
|
+
class TranslatedFailure {
|
|
24
|
+
error;
|
|
25
|
+
appRegistered;
|
|
26
|
+
statusCode;
|
|
27
|
+
constructor(
|
|
28
|
+
/** The typed error the translator picked for this response. Always a real `Error`. */
|
|
29
|
+
error,
|
|
30
|
+
/**
|
|
31
|
+
* True when an app-registered `ClientRegistry` translation claimed this status — i.e. the app
|
|
32
|
+
* chose this error type deliberately, at startup, in one greppable place. False when the
|
|
33
|
+
* framework's built-in status mapping produced it.
|
|
34
|
+
*
|
|
35
|
+
* This IS the caller's explicit opt-out from any environment-specific rewrite: see
|
|
36
|
+
* `NodeProxyClient.adaptDownstreamFailure`, where an app-registered 4xx wins over the
|
|
37
|
+
* server-to-server 4xx-to-500 wrap.
|
|
38
|
+
*/
|
|
39
|
+
appRegistered,
|
|
40
|
+
/** The HTTP status the downstream dependency actually answered with. */
|
|
41
|
+
statusCode) {
|
|
42
|
+
this.error = error;
|
|
43
|
+
this.appRegistered = appRegistered;
|
|
44
|
+
this.statusCode = statusCode;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.TranslatedFailure = TranslatedFailure;
|
|
48
|
+
//# sourceMappingURL=TranslatedFailure.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"TranslatedFailure.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/TranslatedFailure.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,iBAAiB;IAGN;IAUA;IAEA;IAdpB;IACI,sFAAsF;IACtE,KAAY;IAC5B;;;;;;;;OAQG;IACa,aAAsB;IACtC,wEAAwE;IACxD,UAAkB;QAZlB,UAAK,GAAL,KAAK,CAAO;QAUZ,kBAAa,GAAb,aAAa,CAAS;QAEtB,eAAU,GAAV,UAAU,CAAQ;IACnC,CAAC;CACP;AAjBD,8CAiBC","sourcesContent":["/**\n * What {@link ClientErrorTranslator.translateError} decided about ONE non-2xx downstream response —\n * the typed error, plus WHO decided it.\n *\n * DATA ONLY (no behavior), so it is a class with an explicit constructor rather than an interface,\n * exactly like {@link RequestOutcome}.\n *\n * WHY IT CARRIES `appRegistered` AT ALL: the translated error alone is not enough for an environment\n * hook to act on. `HttpNotFoundError` produced by the BUILT-IN 404 branch and `HttpNotFoundError`\n * produced by an app's own `ErrorTranslation` are indistinguishable as values, yet they mean opposite\n * things — the first is the framework's generic default, the second is the app saying out loud, at\n * startup and greppably, \"relay this status as my own\". `ProxyClient.adaptDownstreamFailure` must\n * honour the second and is free to replace the first, so the provenance has to travel WITH the error\n * rather than be re-derived by consulting `ClientRegistry` a second time.\n *\n * `statusCode` is the status the DOWNSTREAM answered — carried explicitly rather than read back off\n * `error.code`, because an app-registered translation may legitimately return an error whose `code`\n * is nothing like the status that produced it, and need not be an `HttpError` at all.\n */\nexport class TranslatedFailure {\n constructor(\n /** The typed error the translator picked for this response. Always a real `Error`. */\n public readonly error: Error,\n /**\n * True when an app-registered `ClientRegistry` translation claimed this status — i.e. the app\n * chose this error type deliberately, at startup, in one greppable place. False when the\n * framework's built-in status mapping produced it.\n *\n * This IS the caller's explicit opt-out from any environment-specific rewrite: see\n * `NodeProxyClient.adaptDownstreamFailure`, where an app-registered 4xx wins over the\n * server-to-server 4xx-to-500 wrap.\n */\n public readonly appRegistered: boolean,\n /** The HTTP status the downstream dependency actually answered with. */\n public readonly statusCode: number,\n ) {}\n}\n"]}
|
package/src/index.d.ts
CHANGED
|
@@ -29,4 +29,5 @@ export { RequestOutcome } from './RequestOutcome';
|
|
|
29
29
|
export type { ApiPrototype } from './ApiPrototype';
|
|
30
30
|
export { buildClientProxy } from './buildClientProxy';
|
|
31
31
|
export { ClientErrorTranslator } from './ClientErrorTranslator';
|
|
32
|
+
export { TranslatedFailure } from './TranslatedFailure';
|
|
32
33
|
export { ResponseBodyReader } from './ResponseBodyReader';
|
package/src/index.js
CHANGED
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
* a browser bundle, and nothing browser-only (a ContextReader store) reaches a server.
|
|
27
27
|
*/
|
|
28
28
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
-
exports.ResponseBodyReader = exports.ClientErrorTranslator = exports.buildClientProxy = exports.RequestOutcome = exports.ProxyClient = void 0;
|
|
29
|
+
exports.ResponseBodyReader = exports.TranslatedFailure = exports.ClientErrorTranslator = exports.buildClientProxy = exports.RequestOutcome = exports.ProxyClient = void 0;
|
|
30
30
|
var ProxyClient_1 = require("./ProxyClient");
|
|
31
31
|
Object.defineProperty(exports, "ProxyClient", { enumerable: true, get: function () { return ProxyClient_1.ProxyClient; } });
|
|
32
32
|
var RequestOutcome_1 = require("./RequestOutcome");
|
|
@@ -35,6 +35,8 @@ var buildClientProxy_1 = require("./buildClientProxy");
|
|
|
35
35
|
Object.defineProperty(exports, "buildClientProxy", { enumerable: true, get: function () { return buildClientProxy_1.buildClientProxy; } });
|
|
36
36
|
var ClientErrorTranslator_1 = require("./ClientErrorTranslator");
|
|
37
37
|
Object.defineProperty(exports, "ClientErrorTranslator", { enumerable: true, get: function () { return ClientErrorTranslator_1.ClientErrorTranslator; } });
|
|
38
|
+
var TranslatedFailure_1 = require("./TranslatedFailure");
|
|
39
|
+
Object.defineProperty(exports, "TranslatedFailure", { enumerable: true, get: function () { return TranslatedFailure_1.TranslatedFailure; } });
|
|
38
40
|
var ResponseBodyReader_1 = require("./ResponseBodyReader");
|
|
39
41
|
Object.defineProperty(exports, "ResponseBodyReader", { enumerable: true, get: function () { return ResponseBodyReader_1.ResponseBodyReader; } });
|
|
40
42
|
//# sourceMappingURL=index.js.map
|
package/src/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;;;AAEH,6CAA4C;AAAnC,0GAAA,WAAW,OAAA;AACpB,mDAAkD;AAAzC,gHAAA,cAAc,OAAA;AAEvB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,iEAAgE;AAAvD,8HAAA,qBAAqB,OAAA;AAC9B,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA","sourcesContent":["/**\n * @webpieces/http-client-core\n *\n * The ISOMORPHIC core of the webpieces HTTP client — everything that reads an API contract's\n * decorators and turns a method call into an HTTP request, with no opinion about where the\n * magic context comes from or whether a DI container exists.\n *\n * You almost certainly want one of its two environment packages instead:\n * - Server: @webpieces/http-client-node (inversify-wired, reads RequestContext, mints OIDC)\n * - Browser: @webpieces/http-client-browser (no DI — React or Angular, app-managed context store)\n *\n * Architecture:\n * ```\n * http-api (defines the contract)\n * ^\n * +-- http-routing (server: contract -> handlers)\n * +-- http-client-core (contract -> HTTP requests) <- YOU ARE HERE\n * +-- http-client-node (RequestContext + Secrets + OIDC + inversify factory)\n * +-- http-client-browser (app-held store + plain factory, no DI)\n * ```\n *\n * There is no context/credential/recording seam here at all: ProxyClient is ABSTRACT and asks its\n * subclass for the base URL, the context headers, the log map, the outbound credential, and the\n * recorder. Nothing server-only (RequestContext, Secrets, mintIdToken, TestCaseRecorder) can reach\n * a browser bundle, and nothing browser-only (a ContextReader store) reaches a server.\n */\n\nexport { ProxyClient } from './ProxyClient';\nexport { RequestOutcome } from './RequestOutcome';\nexport type { ApiPrototype } from './ApiPrototype';\nexport { buildClientProxy } from './buildClientProxy';\nexport { ClientErrorTranslator } from './ClientErrorTranslator';\nexport { ResponseBodyReader } from './ResponseBodyReader';\n"]}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../../../../packages/http/http-client-core/src/index.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;;;AAEH,6CAA4C;AAAnC,0GAAA,WAAW,OAAA;AACpB,mDAAkD;AAAzC,gHAAA,cAAc,OAAA;AAEvB,uDAAsD;AAA7C,oHAAA,gBAAgB,OAAA;AACzB,iEAAgE;AAAvD,8HAAA,qBAAqB,OAAA;AAC9B,yDAAwD;AAA/C,sHAAA,iBAAiB,OAAA;AAC1B,2DAA0D;AAAjD,wHAAA,kBAAkB,OAAA","sourcesContent":["/**\n * @webpieces/http-client-core\n *\n * The ISOMORPHIC core of the webpieces HTTP client — everything that reads an API contract's\n * decorators and turns a method call into an HTTP request, with no opinion about where the\n * magic context comes from or whether a DI container exists.\n *\n * You almost certainly want one of its two environment packages instead:\n * - Server: @webpieces/http-client-node (inversify-wired, reads RequestContext, mints OIDC)\n * - Browser: @webpieces/http-client-browser (no DI — React or Angular, app-managed context store)\n *\n * Architecture:\n * ```\n * http-api (defines the contract)\n * ^\n * +-- http-routing (server: contract -> handlers)\n * +-- http-client-core (contract -> HTTP requests) <- YOU ARE HERE\n * +-- http-client-node (RequestContext + Secrets + OIDC + inversify factory)\n * +-- http-client-browser (app-held store + plain factory, no DI)\n * ```\n *\n * There is no context/credential/recording seam here at all: ProxyClient is ABSTRACT and asks its\n * subclass for the base URL, the context headers, the log map, the outbound credential, and the\n * recorder. Nothing server-only (RequestContext, Secrets, mintIdToken, TestCaseRecorder) can reach\n * a browser bundle, and nothing browser-only (a ContextReader store) reaches a server.\n */\n\nexport { ProxyClient } from './ProxyClient';\nexport { RequestOutcome } from './RequestOutcome';\nexport type { ApiPrototype } from './ApiPrototype';\nexport { buildClientProxy } from './buildClientProxy';\nexport { ClientErrorTranslator } from './ClientErrorTranslator';\nexport { TranslatedFailure } from './TranslatedFailure';\nexport { ResponseBodyReader } from './ResponseBodyReader';\n"]}
|