@owlmeans/api 0.1.18-rc.5 → 0.1.18-rc.50

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.
Files changed (104) hide show
  1. package/README.md +12 -4
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/api/SKILL.md +117 -11
  4. package/build/consts.d.ts +7 -0
  5. package/build/consts.d.ts.map +1 -1
  6. package/build/consts.js +7 -0
  7. package/build/consts.js.map +1 -1
  8. package/build/errors.d.ts +30 -2
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +46 -4
  11. package/build/errors.js.map +1 -1
  12. package/build/index.d.ts +3 -0
  13. package/build/index.d.ts.map +1 -1
  14. package/build/index.js +1 -0
  15. package/build/index.js.map +1 -1
  16. package/build/service.d.ts +1 -6
  17. package/build/service.d.ts.map +1 -1
  18. package/build/service.js +39 -27
  19. package/build/service.js.map +1 -1
  20. package/build/status/consts.d.ts +5 -0
  21. package/build/status/consts.d.ts.map +1 -0
  22. package/build/status/consts.js +5 -0
  23. package/build/status/consts.js.map +1 -0
  24. package/build/status/consts.local.d.ts +10 -0
  25. package/build/status/consts.local.d.ts.map +1 -0
  26. package/build/status/consts.local.js +10 -0
  27. package/build/status/consts.local.js.map +1 -0
  28. package/build/status/helper.d.ts +4 -0
  29. package/build/status/helper.d.ts.map +1 -0
  30. package/build/status/helper.js +49 -0
  31. package/build/status/helper.js.map +1 -0
  32. package/build/status/index.d.ts +9 -0
  33. package/build/status/index.d.ts.map +1 -0
  34. package/build/status/index.js +9 -0
  35. package/build/status/index.js.map +1 -0
  36. package/build/status/marker.d.ts +3 -0
  37. package/build/status/marker.d.ts.map +1 -0
  38. package/build/status/marker.js +16 -0
  39. package/build/status/marker.js.map +1 -0
  40. package/build/status/types.d.ts +35 -0
  41. package/build/status/types.d.ts.map +1 -0
  42. package/build/status/types.js +2 -0
  43. package/build/status/types.js.map +1 -0
  44. package/build/types.local.d.ts +6 -0
  45. package/build/types.local.d.ts.map +1 -0
  46. package/build/types.local.js +2 -0
  47. package/build/types.local.js.map +1 -0
  48. package/build/utils/body/types.d.ts +36 -0
  49. package/build/utils/body/types.d.ts.map +1 -0
  50. package/build/utils/body/types.js +2 -0
  51. package/build/utils/body/types.js.map +1 -0
  52. package/build/utils/body.d.ts +4 -0
  53. package/build/utils/body.d.ts.map +1 -0
  54. package/build/utils/body.js +56 -0
  55. package/build/utils/body.js.map +1 -0
  56. package/build/utils/consts.d.ts +2 -0
  57. package/build/utils/consts.d.ts.map +1 -0
  58. package/build/utils/consts.js +2 -0
  59. package/build/utils/consts.js.map +1 -0
  60. package/build/utils/consts.local.d.ts +2 -0
  61. package/build/utils/consts.local.d.ts.map +1 -0
  62. package/build/utils/consts.local.js +2 -0
  63. package/build/utils/consts.local.js.map +1 -0
  64. package/build/utils/handler.d.ts +7 -0
  65. package/build/utils/handler.d.ts.map +1 -1
  66. package/build/utils/handler.js +66 -28
  67. package/build/utils/handler.js.map +1 -1
  68. package/build/utils/index.d.ts +2 -1
  69. package/build/utils/index.d.ts.map +1 -1
  70. package/build/utils/index.js +1 -1
  71. package/build/utils/index.js.map +1 -1
  72. package/build/utils/response/types.d.ts +15 -0
  73. package/build/utils/response/types.d.ts.map +1 -0
  74. package/build/utils/response/types.js +2 -0
  75. package/build/utils/response/types.js.map +1 -0
  76. package/build/utils/response.d.ts +4 -0
  77. package/build/utils/response.d.ts.map +1 -0
  78. package/build/utils/response.js +99 -0
  79. package/build/utils/response.js.map +1 -0
  80. package/package.json +23 -13
  81. package/src/consts.ts +8 -0
  82. package/src/errors.ts +55 -4
  83. package/src/index.ts +5 -1
  84. package/src/service.ts +48 -40
  85. package/src/status/consts.local.ts +11 -0
  86. package/src/status/consts.ts +5 -0
  87. package/src/status/helper.ts +63 -0
  88. package/src/status/index.ts +11 -0
  89. package/src/status/marker.ts +19 -0
  90. package/src/status/types.ts +36 -0
  91. package/src/types.local.ts +7 -0
  92. package/src/utils/body/types.ts +37 -0
  93. package/src/utils/body.ts +70 -0
  94. package/src/utils/consts.local.ts +1 -0
  95. package/src/utils/consts.ts +1 -0
  96. package/src/utils/index.ts +2 -1
  97. package/src/utils/response/types.ts +15 -0
  98. package/src/utils/response.ts +117 -0
  99. package/tests/body.spec.ts +124 -0
  100. package/tests/handler.spec.ts +229 -0
  101. package/tests/tsconfig.json +11 -0
  102. package/tsconfig.json +1 -1
  103. package/build/.gitkeep +0 -0
  104. package/src/utils/handler.ts +0 -61
@@ -0,0 +1,99 @@
1
+ import { EntrypointOutcome } from '@owlmeans/entrypoint';
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
+ import { ResilientError } from '@owlmeans/error';
4
+ import { ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError } from '../errors.js';
5
+ import { apiStatusHelper } from '../status/helper.js';
6
+ import { logger } from '@owlmeans/log';
7
+ export const createResponseUtils = () => {
8
+ const log = logger('api');
9
+ const processResponse = (response, reply) => {
10
+ switch (response.status) {
11
+ case OK:
12
+ reply.resolve(response.data, EntrypointOutcome.Ok);
13
+ return;
14
+ case ACCEPTED:
15
+ reply.resolve(response.data, EntrypointOutcome.Accepted);
16
+ return;
17
+ case CREATED:
18
+ return processEmptyResponse(response, reply, EntrypointOutcome.Created);
19
+ case FINISHED:
20
+ return processEmptyResponse(response, reply, EntrypointOutcome.Finished);
21
+ default:
22
+ // A refusal is ordinary traffic (401, 403, 409, 428): debug, and never the headers.
23
+ log.debug('Request answered with a failure', {
24
+ status: response.status, statusText: response.statusText, url: response.config?.url,
25
+ incidentId: headerOf(response.headers, INCIDENT_ID_HEADER),
26
+ });
27
+ reply.reject(failureOf(response));
28
+ }
29
+ };
30
+ /**
31
+ * The error a non-2xx answer rejects with — always carrying the HTTP status and, when the server
32
+ * sent one, its incident id.
33
+ *
34
+ * A development body is the marshaled `ResilientError`: it is rebuilt into its own class and
35
+ * stamped with `responseStatus`. Anything else — the bare incident id of a production body, a
36
+ * proxy's HTML, a framework's JSON — becomes the status error of {@link statusError}.
37
+ */
38
+ const failureOf = (response) => {
39
+ const incidentId = headerOf(response.headers, INCIDENT_ID_HEADER)
40
+ ?? (apiStatusHelper.isIncidentBody(response.data) ? response.data.trim() : undefined);
41
+ const denialKind = response.status === FORBIDDEN_ERROR
42
+ && headerOf(response.headers, DENIAL_KIND_HEADER) === ACCESS_DENIED_KIND
43
+ ? ACCESS_DENIED_KIND : undefined;
44
+ if (typeof response.data === 'string' && response.data.includes(ResilientError.separator)) {
45
+ try {
46
+ const error = ResilientError.ensure(response.data, true);
47
+ error.incidentId ??= incidentId;
48
+ Object.assign(error, {
49
+ responseStatus: response.status, denialKind,
50
+ });
51
+ return error;
52
+ }
53
+ catch {
54
+ log.warn('Server returned an unrecognizable text error', { status: response.status, body: response.data });
55
+ }
56
+ }
57
+ return Object.assign(statusError(response.status, incidentId), { denialKind });
58
+ };
59
+ /**
60
+ * The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
61
+ * `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
62
+ * for every other one.
63
+ */
64
+ const statusError = (status, incidentId) => {
65
+ const error = status === SERVER_ERROR ? new ServerCrashedError(incidentId)
66
+ : status === UNAUTHORIZED_ERROR ? new ServerAuthError(incidentId)
67
+ : status === FORBIDDEN_ERROR ? new ApiClientError(incidentId != null ? `forbidden:${incidentId}` : 'forbidden')
68
+ : new ApiStatusError(status, incidentId);
69
+ if (incidentId != null) {
70
+ error.incidentId = incidentId;
71
+ }
72
+ return Object.assign(error, { responseStatus: status });
73
+ };
74
+ /** One response header, from an `AxiosHeaders` instance or a plain header object alike. */
75
+ const headerOf = (headers, name) => {
76
+ if (headers == null || typeof headers !== 'object') {
77
+ return undefined;
78
+ }
79
+ const get = headers.get;
80
+ const viaGet = typeof get === 'function' ? get.call(headers, name) : undefined;
81
+ const value = viaGet ?? Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1];
82
+ const first = Array.isArray(value) ? value[0] : value;
83
+ return typeof first === 'string' && first.trim() !== '' ? first.trim() : undefined;
84
+ };
85
+ const processEmptyResponse = (response, reply, outcome) => {
86
+ if (response.data != null && response.data != '') {
87
+ reply.resolve(response.data, outcome);
88
+ return;
89
+ }
90
+ if (response.headers.toJSON != null && typeof response.headers.toJSON === 'function') {
91
+ reply.resolve(response.headers.toJSON(), outcome);
92
+ return;
93
+ }
94
+ reply.resolve(response.headers, outcome);
95
+ };
96
+ return { processResponse, statusError };
97
+ };
98
+ export const responseUtils = createResponseUtils();
99
+ //# sourceMappingURL=response.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response.js","sourceRoot":"","sources":["../../src/utils/response.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.5",
3
+ "version": "0.1.18-rc.50",
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,23 +19,32 @@
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.5",
25
- "@owlmeans/client-config": "^0.1.18-rc.5",
26
- "@owlmeans/client-route": "^0.1.18-rc.5",
27
- "@owlmeans/config": "^0.1.18-rc.5",
28
- "@owlmeans/context": "^0.1.18-rc.5",
29
- "@owlmeans/error": "^0.1.18-rc.5",
30
- "@owlmeans/entrypoint": "^0.1.18-rc.5",
31
- "@owlmeans/route": "^0.1.18-rc.5",
32
- "axios": "^1.7.7",
33
- "qs": "^6.13.0"
32
+ "@owlmeans/auth-common": "^0.1.18-rc.50",
33
+ "@owlmeans/client-config": "^0.1.18-rc.45",
34
+ "@owlmeans/client-route": "^0.1.18-rc.51",
35
+ "@owlmeans/config": "^0.1.18-rc.44",
36
+ "@owlmeans/context": "^0.1.18-rc.34",
37
+ "@owlmeans/entrypoint": "^0.1.18-rc.41",
38
+ "@owlmeans/error": "^0.1.18-rc.38",
39
+ "@owlmeans/log": "^0.1.18-rc.3",
40
+ "@owlmeans/route": "^0.1.18-rc.35",
41
+ "axios": "^1.20.0",
42
+ "qs": "^6.16.0"
34
43
  },
35
44
  "devDependencies": {
36
45
  "@owlmeans/dep-config": "workspace:*",
37
- "@types/qs": "^6.9.16",
46
+ "@owlmeans/resource": "^0.1.18-rc.40",
47
+ "@types/qs": "^6.15.1",
38
48
  "nodemon": "^3.1.14",
39
49
  "typescript": "^7.0.2"
40
50
  },
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(message: string = 'error') {
24
- super(`crashed:${message}`)
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(message: string = 'error') {
33
- super(`auth:${message}`)
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,9 @@
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, apiStatusHelper, createApiStatusHelper, parseClientMarker, API_CLIENT_MARKER, API_STATUS_MARKER,
7
+ } from './status/index.js'
8
+ export type { ClientMarker, ApiStatusHelper } from './status/index.js'
9
+ export type { ResponseStatusCarrier } from './status/types.js'
package/src/service.ts CHANGED
@@ -1,23 +1,21 @@
1
1
  import { assertContext, createService } from '@owlmeans/context'
2
- import type { BasicContext } from '@owlmeans/context'
3
- import { extractParams } from '@owlmeans/client-route'
2
+ import { clientRouteHelper } from '@owlmeans/client-route'
4
3
  import type { ApiClient } from './types.js'
5
4
  import axios, { AxiosHeaders } from 'axios'
6
5
  import type { AxiosRequestTransformer } from 'axios'
7
6
  import type { CommonEntrypoint } from '@owlmeans/entrypoint'
8
- import { DEFAULT_ALIAS } from './consts.js'
9
- import { processResponse } from './utils/handler.js'
10
- import { BasicClientConfig } from '@owlmeans/client-config'
11
- import qs from 'qs'
7
+ import { DEFAULT_ALIAS, UNAUTHORIZED_ERROR } from './consts.js'
8
+ import { responseUtils } from './utils/response.js'
12
9
  import { makeSecurityHelper } from '@owlmeans/config'
13
- import { RouteMethod } from '@owlmeans/route'
14
- import { DEF_AUTH_SRV, TOKEN_UPDATE } from '@owlmeans/auth-common'
15
- import type { AuthService } from '@owlmeans/auth-common'
10
+ import { AUTH_HEADER, DEF_AUTH_SRV, TOKEN_UPDATE, type AuthService } from '@owlmeans/auth-common'
11
+ import type { Config, Context } from './types.local.js'
12
+ import { bodyUtils } from './utils/body.js'
16
13
 
17
- type Config = BasicClientConfig
18
-
19
- interface Context<C extends Config = Config> extends BasicContext<C> {
20
- }
14
+ /** Whether a request carried an authentication header of its own. */
15
+ const presented = (headers?: Record<string, unknown>): boolean =>
16
+ headers != null && Object.entries(headers).some(
17
+ ([key, value]) => key.toLowerCase() === AUTH_HEADER && value != null && value !== ''
18
+ )
21
19
 
22
20
  export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
23
21
  const location = `api.service:${alias}`
@@ -28,10 +26,9 @@ export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
28
26
  }
29
27
  const context = assertContext<Config, Context>(client.ctx, location)
30
28
  const module = context.entrypoint<CommonEntrypoint>(request.alias)
31
- await module.resolve()
32
29
  const route = module.route.route
33
- let path = module.getPath()
34
- const params = extractParams(path)
30
+ let path = module.path()
31
+ const params = clientRouteHelper.extractParams(path)
35
32
  path = params.reduce((path, param) => {
36
33
  type Key = keyof typeof request.params
37
34
  if (request.params[param as Key] == null) {
@@ -39,36 +36,29 @@ export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
39
36
  }
40
37
  return path.replace(`:${param}`, `${request.params[param as Key]}`)
41
38
  }, path)
42
- if (route.host == null && request.host == null) {
43
- throw new SyntaxError(`No host provided in ${module.alias} route`)
44
- }
45
39
 
46
40
  const helper = makeSecurityHelper(context)
47
41
 
48
- const url = helper.makeUrl(route, path, { host: request.host, base: request.base, forceUnsecure: request.unsecure })
49
-
50
- let transformer: AxiosRequestTransformer | undefined = undefined
51
-
52
- let body = request.body != null && Object.entries((request.headers ?? {})).find(
53
- ([key, value]) =>
54
- key.toLowerCase() === 'content-type' && value?.includes('application/x-www-form-urlencoded')
55
- ) ? qs.stringify(request.body) : request.body
56
-
57
- // @TODO Probably this hack needs to be made a little bit less dirty
58
- if (typeof body === 'string' && route.method === RouteMethod.POST) {
59
- if (Object.keys(request.headers).every(key => key.toLowerCase() !== 'content-type')) {
60
- request.headers['content-type'] = 'application/json'
61
- // It fixes the case when final rest api under application/json can't
62
- // properly accept canonized jsoned string value (put into quotes)
63
- transformer = data => data
64
- }
65
- }
42
+ // The entrypoint answers where it lives — host, port, base, protocol and whether the hop is
43
+ // TLS — from its declaration and the service it names. A per-request host still overrides it.
44
+ const url = helper.makeUrl(
45
+ module.address(), path,
46
+ { host: request.host, base: request.base, forceUnsecure: request.unsecure }
47
+ )
48
+
49
+ // A scalar JSON body (a string, a number, a boolean) is serialized here, so the server parses
50
+ // back the value the caller sent; objects and arrays are left to axios.
51
+ const body = bodyUtils.requestBodyOf(request.body, request.headers, route.method, module.filter?.body)
52
+ const transformer: AxiosRequestTransformer | undefined = body.verbatim ? data => data : undefined
53
+ const requestHeaders = body.contentType != null
54
+ ? { ...request.headers, 'content-type': body.contentType }
55
+ : request.headers
66
56
 
67
57
  const response = await axios.request({
68
58
  url, method: route.method,
69
59
  params: request.query,
70
- data: body,
71
- headers: request.headers,
60
+ data: body.data,
61
+ headers: requestHeaders,
72
62
  transformRequest: transformer,
73
63
  validateStatus: () => true,
74
64
  // Per-request timeout (ms); axios aborts the request and rejects on expiry.
@@ -84,9 +74,27 @@ export const createApiService = (alias: string = DEFAULT_ALIAS): ApiClient => {
84
74
  const auth = context.service<AuthService>(DEF_AUTH_SRV)
85
75
  const update = headers.get(TOKEN_UPDATE) as string
86
76
  await auth.update(update == null || update === '' ? undefined : update)
77
+ } else if (
78
+ response.status === UNAUTHORIZED_ERROR
79
+ && presented(request.headers)
80
+ && context.hasService(DEF_AUTH_SRV)
81
+ ) {
82
+ /**
83
+ * A session the server has refused is not a session, and keeping it is worse than having
84
+ * none: the application goes on rendering its signed-in tree over a credential that fails
85
+ * every call it makes, and nothing on the client ever asks again — `authenticated()` reads
86
+ * storage and decodes an envelope, it does not consult the server. What that leaves is an
87
+ * app that looks signed in, works at nothing, and offers "Log out" where the control that
88
+ * would fix it should be.
89
+ *
90
+ * Narrow on purpose. Only a request that actually PRESENTED this context's bearer counts,
91
+ * so a 401 from an authentication attempt — bad code, wrong password, an expired challenge
92
+ * — never clears the session of whoever is already signed in.
93
+ */
94
+ await context.service<AuthService>(DEF_AUTH_SRV).update(undefined)
87
95
  }
88
96
 
89
- processResponse(response, reply)
97
+ responseUtils.processResponse(response, reply)
90
98
 
91
99
  return [reply.error ?? reply.value, reply.outcome] as any
92
100
  }
@@ -0,0 +1,11 @@
1
+ export const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
2
+
3
+ /**
4
+ * `crashed` / `auth` / `forbidden` / `status:<n>` / the legacy bare `<n>`, then an optional
5
+ * `:<incident id>`. The look-ahead refuses a match that runs on into more marker text, so a
6
+ * doubled prefix (a subclass constructor handed a whole marshaled message) resolves to the inner,
7
+ * complete marker.
8
+ */
9
+ export const MARKER = /api:client:(?:(crashed|auth|forbidden)|status:(\d{3})|(\d{3}))(?::([\w.-]{1,128}))?(?![\w.:-])/
10
+
11
+ export const NAMED_STATUS: Record<string, number> = { crashed: 500, auth: 401, forbidden: 403 }
@@ -0,0 +1,5 @@
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:`
@@ -0,0 +1,63 @@
1
+ import { ResilientError } from '@owlmeans/error'
2
+ import { ApiClientError } from '../errors.js'
3
+ import { parseClientMarker } from './marker.js'
4
+ import { ACCESS_DENIED_KIND } from '../consts.js'
5
+ import { UUID } from './consts.local.js'
6
+ import type { ApiStatusHelper, ClientMarker, ResponseStatusCarrier } from './types.js'
7
+
8
+ export const createApiStatusHelper = (): ApiStatusHelper => {
9
+ const isAccessDenied = (error: unknown): boolean =>
10
+ httpStatusOf(error) === 403
11
+ && typeof error === 'object' && error != null
12
+ && (error as ResponseStatusCarrier).denialKind === ACCESS_DENIED_KIND
13
+
14
+ const httpStatus = (value: unknown): number | null =>
15
+ typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599 ? value : null
16
+
17
+ /** The first `api:client:*` marker in an error's `message`, else in its `type`. */
18
+ const markerOf = (error: object): ClientMarker | null => {
19
+ const { message, type } = error as { message?: unknown, type?: unknown }
20
+
21
+ return (typeof message === 'string' ? parseClientMarker(message) : null)
22
+ ?? (typeof type === 'string' ? parseClientMarker(type) : null)
23
+ }
24
+
25
+ const httpStatusOf = (error: unknown): number | null => {
26
+ if (error == null || typeof error !== 'object') {
27
+ return null
28
+ }
29
+ const stamped = httpStatus((error as ResponseStatusCarrier).responseStatus)
30
+ if (stamped != null) {
31
+ return stamped
32
+ }
33
+ if (error instanceof ApiClientError && httpStatus(error.status) != null) {
34
+ return error.status!
35
+ }
36
+ const declaration = error.constructor as { httpStatus?: unknown, allowServerErrorStatus?: unknown } | undefined
37
+ const declared = httpStatus(declaration?.httpStatus)
38
+ if (declared != null && (declared < 500 || declaration?.allowServerErrorStatus === true)) {
39
+ return declared
40
+ }
41
+
42
+ return markerOf(error)?.status ?? null
43
+ }
44
+
45
+ const incidentIdOf = (error: unknown): string | null => {
46
+ if (error == null || typeof error !== 'object') {
47
+ return null
48
+ }
49
+ const own = (error as { incidentId?: unknown }).incidentId
50
+ if (typeof own === 'string' && own !== '') {
51
+ return own
52
+ }
53
+
54
+ return markerOf(error)?.incidentId ?? null
55
+ }
56
+
57
+ const isIncidentBody = (data: unknown): data is string =>
58
+ typeof data === 'string' && !data.includes(ResilientError.separator) && UUID.test(data.trim())
59
+
60
+ return { isAccessDenied, httpStatusOf, incidentIdOf, isIncidentBody }
61
+ }
62
+
63
+ export const apiStatusHelper = createApiStatusHelper()
@@ -0,0 +1,11 @@
1
+ import { apiStatusHelper } from './helper.js'
2
+
3
+ export { INCIDENT_ID_HEADER } from '../consts.js'
4
+ export { DENIAL_KIND_HEADER, ACCESS_DENIED_KIND } from '../consts.js'
5
+ export { parseClientMarker } from './marker.js'
6
+ export { API_CLIENT_MARKER, API_STATUS_MARKER } from './consts.js'
7
+ export type { ClientMarker, ResponseStatusCarrier, ApiStatusHelper } from './types.js'
8
+ export { apiStatusHelper, createApiStatusHelper } from './helper.js'
9
+
10
+ /** @deprecated compat:factory-refactor — use `apiStatusHelper.httpStatusOf(…)` */
11
+ export const httpStatusOf = (error: unknown): number | null => apiStatusHelper.httpStatusOf(error)
@@ -0,0 +1,19 @@
1
+
2
+ /** Read the first `api:client:*` marker in a text, or `null` when it carries none. */
3
+ import { MARKER, NAMED_STATUS } from './consts.local.js'
4
+ import type { ClientMarker } from './types.js'
5
+
6
+ export const parseClientMarker = (text: string): ClientMarker | null => {
7
+ const match = MARKER.exec(text)
8
+ if (match == null) {
9
+ return null
10
+ }
11
+ const [, named, coded, legacy, id] = match
12
+ const code = named != null ? NAMED_STATUS[named] : Number(coded ?? legacy)
13
+
14
+ return {
15
+ status: code >= 100 && code <= 599 ? code : undefined,
16
+ // `error` is the placeholder a marker gets when no incident id was known.
17
+ incidentId: id != null && id !== 'error' ? id : undefined,
18
+ }
19
+ }
@@ -0,0 +1,36 @@
1
+ /** The HTTP status the API client stamps on an error it rebuilt from a response body. */
2
+ export interface ResponseStatusCarrier {
3
+ responseStatus?: number
4
+ denialKind?: string
5
+ }
6
+
7
+ /** What an `api:client:*` marker states. */
8
+ export interface ClientMarker {
9
+ status?: number
10
+ incidentId?: string
11
+ }
12
+
13
+ /** Reads the HTTP status, the incident id and the denial kind a failed call carries. */
14
+ export interface ApiStatusHelper {
15
+ /** True only for a server-labelled auth/IAM refusal, never an unrelated 403. */
16
+ isAccessDenied: (error: unknown) => boolean
17
+ /**
18
+ * The HTTP status an error answers to, or `null` when nothing states one.
19
+ *
20
+ * Read in order: the status the API client stamped on the error it rebuilt from a response
21
+ * (`responseStatus`); the status an `ApiClientError` parsed from its marker; the `httpStatus` its
22
+ * class declares — a 4xx always, a 5xx only with `allowServerErrorStatus`, exactly what an
23
+ * `@owlmeans/server-api` boundary answers with; finally an `api:client:*` marker in the message or
24
+ * the type of anything else. This is what lets a browser recognise a refusal from its status
25
+ * alone when a production body carries nothing but an incident id.
26
+ */
27
+ httpStatusOf: (error: unknown) => number | null
28
+ /** The server's incident id carried by an error — its own field, else its marker — or `null`. */
29
+ incidentIdOf: (error: unknown) => string | null
30
+ /**
31
+ * Whether a response body is a production incident body — the bare incident UUID an
32
+ * `@owlmeans/server-api` boundary sends instead of the marshaled error. A development body
33
+ * carries the `ResilientError` separator and is never one.
34
+ */
35
+ isIncidentBody: (data: unknown) => data is string
36
+ }
@@ -0,0 +1,7 @@
1
+ import { BasicClientConfig } from '@owlmeans/client-config'
2
+ import type { BasicContext } from '@owlmeans/context'
3
+
4
+ export type Config = BasicClientConfig
5
+
6
+ export interface Context<C extends Config = Config> extends BasicContext<C> {
7
+ }
@@ -0,0 +1,37 @@
1
+ import type { RouteMethod } from '@owlmeans/route'
2
+
3
+ export interface RequestBody {
4
+ /** What axios is handed as `data`. */
5
+ data: unknown
6
+ /** A `content-type` the request lacked and must carry. */
7
+ contentType?: string
8
+ /** `data` is final text — axios must not transform it again. */
9
+ verbatim: boolean
10
+ }
11
+
12
+ /** How a request's body travels: its content type and the text a scalar body becomes. */
13
+ export interface BodyUtils {
14
+ /** The request's own `content-type`, whatever case its key is written in. */
15
+ contentTypeOf: (headers?: Record<string, unknown>) => string | undefined
16
+ /** `application/json` or a `+json` suffix type, with or without parameters. */
17
+ isJsonContentType: (type?: string) => boolean
18
+ /**
19
+ * The JSON text a scalar body travels as, so the server parses it back to the same value.
20
+ *
21
+ * A number or a boolean is `JSON.stringify`'d. A string is a value unless it is already JSON text:
22
+ * one that does not parse is quoted; one that parses is sent as it is (the caller serialized it
23
+ * itself) — except under a body schema of `type: 'string'`, where only a JSON string literal
24
+ * counts as serialized and `123`, `true` or `{…}` are the text the caller means.
25
+ */
26
+ jsonScalarBody: (body: string | number | boolean, schema?: object) => string
27
+ /**
28
+ * What a request's body becomes on the wire.
29
+ *
30
+ * - A `x-www-form-urlencoded` request: the body through `qs`.
31
+ * - A string, number or boolean under a JSON `content-type` — the caller's, or `application/json`
32
+ * supplied for a `POST` that names none — becomes JSON text ({@link BodyUtils.jsonScalarBody}).
33
+ * - Anything else — objects, arrays, a scalar under another content type — goes to axios as it is,
34
+ * which serializes an object or an array as JSON.
35
+ */
36
+ requestBodyOf: (body: unknown, headers: Record<string, unknown> | undefined, method?: RouteMethod, schema?: object) => RequestBody
37
+ }