@owlmeans/api 0.1.18-rc.45 → 0.1.18-rc.47

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 (91) hide show
  1. package/README.md +2 -2
  2. package/agent-meta/manifest.json +2 -2
  3. package/agent-meta/skills/api/SKILL.md +22 -20
  4. package/build/index.d.ts +3 -2
  5. package/build/index.d.ts.map +1 -1
  6. package/build/index.js +1 -1
  7. package/build/index.js.map +1 -1
  8. package/build/service.d.ts +1 -6
  9. package/build/service.d.ts.map +1 -1
  10. package/build/service.js +6 -6
  11. package/build/service.js.map +1 -1
  12. package/build/status/consts.d.ts +5 -0
  13. package/build/status/consts.d.ts.map +1 -0
  14. package/build/status/consts.js +5 -0
  15. package/build/status/consts.js.map +1 -0
  16. package/build/status/consts.local.d.ts +10 -0
  17. package/build/status/consts.local.d.ts.map +1 -0
  18. package/build/status/consts.local.js +10 -0
  19. package/build/status/consts.local.js.map +1 -0
  20. package/build/status/helper.d.ts +4 -0
  21. package/build/status/helper.d.ts.map +1 -0
  22. package/build/status/helper.js +49 -0
  23. package/build/status/helper.js.map +1 -0
  24. package/build/status/index.d.ts +5 -27
  25. package/build/status/index.d.ts.map +1 -1
  26. package/build/status/index.js +6 -62
  27. package/build/status/index.js.map +1 -1
  28. package/build/status/marker.d.ts +1 -10
  29. package/build/status/marker.d.ts.map +1 -1
  30. package/build/status/marker.js +1 -12
  31. package/build/status/marker.js.map +1 -1
  32. package/build/status/types.d.ts +35 -0
  33. package/build/status/types.d.ts.map +1 -0
  34. package/build/status/types.js +2 -0
  35. package/build/status/types.js.map +1 -0
  36. package/build/types.local.d.ts +6 -0
  37. package/build/types.local.d.ts.map +1 -0
  38. package/build/types.local.js +2 -0
  39. package/build/types.local.js.map +1 -0
  40. package/build/utils/body/types.d.ts +36 -0
  41. package/build/utils/body/types.d.ts.map +1 -0
  42. package/build/utils/body/types.js +2 -0
  43. package/build/utils/body/types.js.map +1 -0
  44. package/build/utils/body.d.ts +3 -33
  45. package/build/utils/body.d.ts.map +1 -1
  46. package/build/utils/body.js +51 -66
  47. package/build/utils/body.js.map +1 -1
  48. package/build/utils/consts.d.ts +2 -0
  49. package/build/utils/consts.d.ts.map +1 -0
  50. package/build/utils/consts.js +2 -0
  51. package/build/utils/consts.js.map +1 -0
  52. package/build/utils/consts.local.d.ts +2 -0
  53. package/build/utils/consts.local.d.ts.map +1 -0
  54. package/build/utils/consts.local.js +2 -0
  55. package/build/utils/consts.local.js.map +1 -0
  56. package/build/utils/index.d.ts +2 -1
  57. package/build/utils/index.d.ts.map +1 -1
  58. package/build/utils/index.js +1 -1
  59. package/build/utils/index.js.map +1 -1
  60. package/build/utils/response/types.d.ts +15 -0
  61. package/build/utils/response/types.d.ts.map +1 -0
  62. package/build/utils/response/types.js +2 -0
  63. package/build/utils/response/types.js.map +1 -0
  64. package/build/utils/response.d.ts +4 -0
  65. package/build/utils/response.d.ts.map +1 -0
  66. package/build/utils/response.js +99 -0
  67. package/build/utils/response.js.map +1 -0
  68. package/package.json +14 -14
  69. package/src/index.ts +3 -2
  70. package/src/service.ts +8 -15
  71. package/src/status/consts.local.ts +11 -0
  72. package/src/status/consts.ts +5 -0
  73. package/src/status/helper.ts +63 -0
  74. package/src/status/index.ts +7 -81
  75. package/src/status/marker.ts +3 -21
  76. package/src/status/types.ts +36 -0
  77. package/src/types.local.ts +7 -0
  78. package/src/utils/body/types.ts +37 -0
  79. package/src/utils/body.ts +53 -76
  80. package/src/utils/consts.local.ts +1 -0
  81. package/src/utils/consts.ts +1 -0
  82. package/src/utils/index.ts +2 -1
  83. package/src/utils/response/types.ts +15 -0
  84. package/src/utils/response.ts +117 -0
  85. package/tests/body.spec.ts +30 -31
  86. package/tests/handler.spec.ts +45 -45
  87. package/build/utils/handler.d.ts +0 -11
  88. package/build/utils/handler.d.ts.map +0 -1
  89. package/build/utils/handler.js +0 -95
  90. package/build/utils/handler.js.map +0 -1
  91. package/src/utils/handler.ts +0 -111
@@ -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()
@@ -1,85 +1,11 @@
1
- import { ResilientError } from '@owlmeans/error'
2
- import { ApiClientError } from '../errors.js'
3
- import { parseClientMarker } from './marker.js'
1
+ import { apiStatusHelper } from './helper.js'
4
2
 
5
3
  export { INCIDENT_ID_HEADER } from '../consts.js'
6
4
  export { DENIAL_KIND_HEADER, ACCESS_DENIED_KIND } from '../consts.js'
7
- import { ACCESS_DENIED_KIND } from '../consts.js'
8
- export { API_CLIENT_MARKER, API_STATUS_MARKER, parseClientMarker } from './marker.js'
9
- export type { ClientMarker } from './marker.js'
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'
10
9
 
11
- /** The HTTP status the API client stamps on an error it rebuilt from a response body. */
12
- export interface ResponseStatusCarrier {
13
- responseStatus?: number
14
- denialKind?: string
15
- }
16
-
17
- /** True only for a server-labelled auth/IAM refusal, never an unrelated 403. */
18
- export const isAccessDenied = (error: unknown): boolean =>
19
- httpStatusOf(error) === 403
20
- && typeof error === 'object' && error != null
21
- && (error as ResponseStatusCarrier).denialKind === ACCESS_DENIED_KIND
22
-
23
- const httpStatus = (value: unknown): number | null =>
24
- typeof value === 'number' && Number.isInteger(value) && value >= 100 && value <= 599 ? value : null
25
-
26
- /** The first `api:client:*` marker in an error's `message`, else in its `type`. */
27
- const markerOf = (error: object) => {
28
- const { message, type } = error as { message?: unknown, type?: unknown }
29
-
30
- return (typeof message === 'string' ? parseClientMarker(message) : null)
31
- ?? (typeof type === 'string' ? parseClientMarker(type) : null)
32
- }
33
-
34
- /**
35
- * The HTTP status an error answers to, or `null` when nothing states one.
36
- *
37
- * Read in order: the status the API client stamped on the error it rebuilt from a response
38
- * (`responseStatus`); the status an `ApiClientError` parsed from its marker; the `httpStatus` its
39
- * class declares — a 4xx always, a 5xx only with `allowServerErrorStatus`, exactly what an
40
- * `@owlmeans/server-api` boundary answers with; finally an `api:client:*` marker in the message or
41
- * the type of anything else. This is what lets a browser recognise a refusal from its status
42
- * alone when a production body carries nothing but an incident id.
43
- */
44
- export const httpStatusOf = (error: unknown): number | null => {
45
- if (error == null || typeof error !== 'object') {
46
- return null
47
- }
48
- const stamped = httpStatus((error as ResponseStatusCarrier).responseStatus)
49
- if (stamped != null) {
50
- return stamped
51
- }
52
- if (error instanceof ApiClientError && httpStatus(error.status) != null) {
53
- return error.status!
54
- }
55
- const declaration = error.constructor as { httpStatus?: unknown, allowServerErrorStatus?: unknown } | undefined
56
- const declared = httpStatus(declaration?.httpStatus)
57
- if (declared != null && (declared < 500 || declaration?.allowServerErrorStatus === true)) {
58
- return declared
59
- }
60
-
61
- return markerOf(error)?.status ?? null
62
- }
63
-
64
- /** The server's incident id carried by an error — its own field, else its marker — or `null`. */
65
- export const incidentIdOf = (error: unknown): string | null => {
66
- if (error == null || typeof error !== 'object') {
67
- return null
68
- }
69
- const own = (error as { incidentId?: unknown }).incidentId
70
- if (typeof own === 'string' && own !== '') {
71
- return own
72
- }
73
-
74
- return markerOf(error)?.incidentId ?? null
75
- }
76
-
77
- const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
78
-
79
- /**
80
- * Whether a response body is a production incident body — the bare incident UUID an
81
- * `@owlmeans/server-api` boundary sends instead of the marshaled error. A development body
82
- * carries the `ResilientError` separator and is never one.
83
- */
84
- export const isIncidentBody = (data: unknown): data is string =>
85
- typeof data === 'string' && !data.includes(ResilientError.separator) && UUID.test(data.trim())
10
+ /** @deprecated compat:factory-refactor — use `apiStatusHelper.httpStatusOf(…)` */
11
+ export const httpStatusOf = (error: unknown): number | null => apiStatusHelper.httpStatusOf(error)
@@ -1,26 +1,8 @@
1
- /** The prefix every `ApiClientError` message carries. */
2
- export const API_CLIENT_MARKER = 'api:client:'
3
-
4
- /** The marker of an `ApiStatusError`: `api:client:status:<n>[:<incident id>]`. */
5
- export const API_STATUS_MARKER = `${API_CLIENT_MARKER}status:`
6
-
7
- /**
8
- * `crashed` / `auth` / `forbidden` / `status:<n>` / the legacy bare `<n>`, then an optional
9
- * `:<incident id>`. The look-ahead refuses a match that runs on into more marker text, so a
10
- * doubled prefix (a subclass constructor handed a whole marshaled message) resolves to the inner,
11
- * complete marker.
12
- */
13
- const MARKER = /api:client:(?:(crashed|auth|forbidden)|status:(\d{3})|(\d{3}))(?::([\w.-]{1,128}))?(?![\w.:-])/
14
-
15
- const NAMED_STATUS: Record<string, number> = { crashed: 500, auth: 401, forbidden: 403 }
16
-
17
- /** What an `api:client:*` marker states. */
18
- export interface ClientMarker {
19
- status?: number
20
- incidentId?: string
21
- }
22
1
 
23
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
+
24
6
  export const parseClientMarker = (text: string): ClientMarker | null => {
25
7
  const match = MARKER.exec(text)
26
8
  if (match == null) {
@@ -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
+ }
package/src/utils/body.ts CHANGED
@@ -1,93 +1,70 @@
1
1
  import qs from 'qs'
2
2
  import { RouteMethod } from '@owlmeans/route'
3
+ import { FORM_CONTENT_TYPE } from './consts.local.js'
4
+ import { JSON_CONTENT_TYPE } from './consts.js'
5
+ import type { BodyUtils, RequestBody } from './body/types.js'
3
6
 
4
- export const JSON_CONTENT_TYPE = 'application/json'
5
-
6
- const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded'
7
-
8
- /** The request's own `content-type`, whatever case its key is written in. */
9
- export const contentTypeOf = (headers?: Record<string, unknown>): string | undefined => {
10
- const value = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === 'content-type')?.[1]
11
- if (value == null || value === '') {
12
- return undefined
7
+ export const createBodyUtils = (): BodyUtils => {
8
+ const contentTypeOf = (headers?: Record<string, unknown>): string | undefined => {
9
+ const value = Object.entries(headers ?? {}).find(([key]) => key.toLowerCase() === 'content-type')?.[1]
10
+ if (value == null || value === '') {
11
+ return undefined
12
+ }
13
+ return Array.isArray(value) ? value.join(', ') : `${value}`
13
14
  }
14
- return Array.isArray(value) ? value.join(', ') : `${value}`
15
- }
16
15
 
17
- /** `application/json` or a `+json` suffix type, with or without parameters. */
18
- export const isJsonContentType = (type?: string): boolean =>
19
- type != null && /^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(type.trim())
16
+ const isJsonContentType = (type?: string): boolean =>
17
+ type != null && /^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(type.trim())
20
18
 
21
- /** A JSON value no serializer quotes for us: a string, a number or a boolean. */
22
- const isScalar = (value: unknown): value is string | number | boolean =>
23
- typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
19
+ /** A JSON value no serializer quotes for us: a string, a number or a boolean. */
20
+ const isScalar = (value: unknown): value is string | number | boolean =>
21
+ typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
24
22
 
25
- /** Does the entrypoint's body schema declare a string? */
26
- const stringContract = (schema?: object): boolean => {
27
- const type = (schema as { type?: unknown } | undefined)?.type
28
- return (Array.isArray(type) ? type : [type]).includes('string')
29
- }
30
-
31
- const parse = (text: string): { json: true, value: unknown } | { json: false } => {
32
- try {
33
- return { json: true, value: JSON.parse(text) }
34
- } catch {
35
- return { json: false }
23
+ /** Does the entrypoint's body schema declare a string? */
24
+ const stringContract = (schema?: object): boolean => {
25
+ const type = (schema as { type?: unknown } | undefined)?.type
26
+ return (Array.isArray(type) ? type : [type]).includes('string')
36
27
  }
37
- }
38
28
 
39
- /**
40
- * The JSON text a scalar body travels as, so the server parses it back to the same value.
41
- *
42
- * A number or a boolean is `JSON.stringify`'d. A string is a value unless it is already JSON text:
43
- * one that does not parse is quoted; one that parses is sent as it is (the caller serialized it
44
- * itself) — except under a body schema of `type: 'string'`, where only a JSON string literal
45
- * counts as serialized and `123`, `true` or `{…}` are the text the caller means.
46
- */
47
- export const jsonScalarBody = (body: string | number | boolean, schema?: object): string => {
48
- if (typeof body !== 'string') {
49
- return JSON.stringify(body)
29
+ const parse = (text: string): { json: true, value: unknown } | { json: false } => {
30
+ try {
31
+ return { json: true, value: JSON.parse(text) }
32
+ } catch {
33
+ return { json: false }
34
+ }
50
35
  }
51
- const parsed = parse(body)
52
- const serialized = parsed.json && (!stringContract(schema) || typeof parsed.value === 'string')
53
-
54
- return serialized ? body : JSON.stringify(body)
55
- }
56
36
 
57
- export interface RequestBody {
58
- /** What axios is handed as `data`. */
59
- data: unknown
60
- /** A `content-type` the request lacked and must carry. */
61
- contentType?: string
62
- /** `data` is final text — axios must not transform it again. */
63
- verbatim: boolean
64
- }
37
+ const jsonScalarBody = (body: string | number | boolean, schema?: object): string => {
38
+ if (typeof body !== 'string') {
39
+ return JSON.stringify(body)
40
+ }
41
+ const parsed = parse(body)
42
+ const serialized = parsed.json && (!stringContract(schema) || typeof parsed.value === 'string')
65
43
 
66
- /**
67
- * What a request's body becomes on the wire.
68
- *
69
- * - A `x-www-form-urlencoded` request: the body through `qs`.
70
- * - A string, number or boolean under a JSON `content-type` — the caller's, or `application/json`
71
- * supplied for a `POST` that names none — becomes JSON text ({@link jsonScalarBody}).
72
- * - Anything else — objects, arrays, a scalar under another content type — goes to axios as it is,
73
- * which serializes an object or an array as JSON.
74
- */
75
- export const requestBodyOf = (
76
- body: unknown, headers: Record<string, unknown> | undefined, method?: RouteMethod, schema?: object
77
- ): RequestBody => {
78
- const type = contentTypeOf(headers)
79
- if (body != null && type?.includes(FORM_CONTENT_TYPE) === true) {
80
- return { data: qs.stringify(body), verbatim: false }
44
+ return serialized ? body : JSON.stringify(body)
81
45
  }
82
- if (!isScalar(body)) {
46
+
47
+ const requestBodyOf = (
48
+ body: unknown, headers: Record<string, unknown> | undefined, method?: RouteMethod, schema?: object
49
+ ): RequestBody => {
50
+ const type = contentTypeOf(headers)
51
+ if (body != null && type?.includes(FORM_CONTENT_TYPE) === true) {
52
+ return { data: qs.stringify(body), verbatim: false }
53
+ }
54
+ if (!isScalar(body)) {
55
+ return { data: body, verbatim: false }
56
+ }
57
+ if (type == null && method === RouteMethod.POST) {
58
+ return { data: jsonScalarBody(body, schema), contentType: JSON_CONTENT_TYPE, verbatim: true }
59
+ }
60
+ if (isJsonContentType(type)) {
61
+ return { data: jsonScalarBody(body, schema), verbatim: true }
62
+ }
63
+
83
64
  return { data: body, verbatim: false }
84
65
  }
85
- if (type == null && method === RouteMethod.POST) {
86
- return { data: jsonScalarBody(body, schema), contentType: JSON_CONTENT_TYPE, verbatim: true }
87
- }
88
- if (isJsonContentType(type)) {
89
- return { data: jsonScalarBody(body, schema), verbatim: true }
90
- }
91
66
 
92
- return { data: body, verbatim: false }
67
+ return { contentTypeOf, isJsonContentType, jsonScalarBody, requestBodyOf }
93
68
  }
69
+
70
+ export const bodyUtils = createBodyUtils()
@@ -0,0 +1 @@
1
+ export const FORM_CONTENT_TYPE = 'application/x-www-form-urlencoded'
@@ -0,0 +1 @@
1
+ export const JSON_CONTENT_TYPE = 'application/json'
@@ -1,2 +1,3 @@
1
1
 
2
- export * from './handler.js'
2
+ export type * from './response/types.js'
3
+ export * from './response.js'
@@ -0,0 +1,15 @@
1
+ import type { AbstractResponse } from '@owlmeans/entrypoint'
2
+ import type { AxiosResponse } from 'axios'
3
+ import type { ApiClientError } from '../../errors.js'
4
+
5
+ /** Turns an axios answer into the entrypoint reply: a resolved value or a typed failure. */
6
+ export interface ResponseUtils {
7
+ /** Resolves `reply` with a 2xx answer's value, or rejects it with the failure of any other status. */
8
+ processResponse: (response: AxiosResponse, reply: AbstractResponse<any>) => void
9
+ /**
10
+ * The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
11
+ * `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
12
+ * for every other one.
13
+ */
14
+ statusError: (status: number, incidentId?: string) => ApiClientError
15
+ }
@@ -0,0 +1,117 @@
1
+ import { type AbstractResponse, EntrypointOutcome } from '@owlmeans/entrypoint'
2
+ import type { AxiosResponse } from 'axios'
3
+ import {
4
+ ACCEPTED, CREATED, FINISHED, FORBIDDEN_ERROR, INCIDENT_ID_HEADER, DENIAL_KIND_HEADER,
5
+ ACCESS_DENIED_KIND, OK, SERVER_ERROR, UNAUTHORIZED_ERROR,
6
+ } from '../consts.js'
7
+ import { ResilientError } from '@owlmeans/error'
8
+ import { ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError } from '../errors.js'
9
+ import { apiStatusHelper } from '../status/helper.js'
10
+ import type { ResponseStatusCarrier } from '../status/types.js'
11
+ import { logger } from '@owlmeans/log'
12
+ import type { ResponseUtils } from './response/types.js'
13
+
14
+ export const createResponseUtils = (): ResponseUtils => {
15
+ const log = logger('api')
16
+
17
+ const processResponse = (response: AxiosResponse, reply: AbstractResponse<any>): void => {
18
+ switch (response.status) {
19
+ case OK:
20
+ reply.resolve(response.data, EntrypointOutcome.Ok)
21
+ return
22
+ case ACCEPTED:
23
+ reply.resolve(response.data, EntrypointOutcome.Accepted)
24
+ return
25
+ case CREATED:
26
+ return processEmptyResponse(response, reply, EntrypointOutcome.Created)
27
+ case FINISHED:
28
+ return processEmptyResponse(response, reply, EntrypointOutcome.Finished)
29
+ default:
30
+ // A refusal is ordinary traffic (401, 403, 409, 428): debug, and never the headers.
31
+ log.debug('Request answered with a failure', {
32
+ status: response.status, statusText: response.statusText, url: response.config?.url,
33
+ incidentId: headerOf(response.headers, INCIDENT_ID_HEADER),
34
+ })
35
+
36
+ reply.reject(failureOf(response))
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The error a non-2xx answer rejects with — always carrying the HTTP status and, when the server
42
+ * sent one, its incident id.
43
+ *
44
+ * A development body is the marshaled `ResilientError`: it is rebuilt into its own class and
45
+ * stamped with `responseStatus`. Anything else — the bare incident id of a production body, a
46
+ * proxy's HTML, a framework's JSON — becomes the status error of {@link statusError}.
47
+ */
48
+ const failureOf = (response: AxiosResponse): Error => {
49
+ const incidentId = headerOf(response.headers, INCIDENT_ID_HEADER)
50
+ ?? (apiStatusHelper.isIncidentBody(response.data) ? response.data.trim() : undefined)
51
+ const denialKind = response.status === FORBIDDEN_ERROR
52
+ && headerOf(response.headers, DENIAL_KIND_HEADER) === ACCESS_DENIED_KIND
53
+ ? ACCESS_DENIED_KIND : undefined
54
+
55
+ if (typeof response.data === 'string' && response.data.includes(ResilientError.separator)) {
56
+ try {
57
+ const error = ResilientError.ensure(response.data, true)
58
+ error.incidentId ??= incidentId
59
+ Object.assign(error, {
60
+ responseStatus: response.status, denialKind,
61
+ } satisfies ResponseStatusCarrier)
62
+
63
+ return error
64
+ } catch {
65
+ log.warn('Server returned an unrecognizable text error', { status: response.status, body: response.data })
66
+ }
67
+ }
68
+
69
+ return Object.assign(statusError(response.status, incidentId), { denialKind })
70
+ }
71
+
72
+ /**
73
+ * The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
74
+ * `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
75
+ * for every other one.
76
+ */
77
+ const statusError = (status: number, incidentId?: string): ApiClientError => {
78
+ const error = status === SERVER_ERROR ? new ServerCrashedError(incidentId)
79
+ : status === UNAUTHORIZED_ERROR ? new ServerAuthError(incidentId)
80
+ : status === FORBIDDEN_ERROR ? new ApiClientError(incidentId != null ? `forbidden:${incidentId}` : 'forbidden')
81
+ : new ApiStatusError(status, incidentId)
82
+ if (incidentId != null) {
83
+ error.incidentId = incidentId
84
+ }
85
+
86
+ return Object.assign(error, { responseStatus: status } satisfies ResponseStatusCarrier)
87
+ }
88
+
89
+ /** One response header, from an `AxiosHeaders` instance or a plain header object alike. */
90
+ const headerOf = (headers: unknown, name: string): string | undefined => {
91
+ if (headers == null || typeof headers !== 'object') {
92
+ return undefined
93
+ }
94
+ const get = (headers as { get?: unknown }).get
95
+ const viaGet = typeof get === 'function' ? get.call(headers, name) : undefined
96
+ const value = viaGet ?? Object.entries(headers).find(([key]) => key.toLowerCase() === name.toLowerCase())?.[1]
97
+ const first = Array.isArray(value) ? value[0] : value
98
+
99
+ return typeof first === 'string' && first.trim() !== '' ? first.trim() : undefined
100
+ }
101
+
102
+ const processEmptyResponse = (response: AxiosResponse, reply: AbstractResponse<any>, outcome: EntrypointOutcome): void => {
103
+ if (response.data != null && response.data != '') {
104
+ reply.resolve(response.data, outcome)
105
+ return
106
+ }
107
+ if (response.headers.toJSON != null && typeof response.headers.toJSON === 'function') {
108
+ reply.resolve(response.headers.toJSON(), outcome)
109
+ return
110
+ }
111
+ reply.resolve(response.headers, outcome)
112
+ }
113
+
114
+ return { processResponse, statusError }
115
+ }
116
+
117
+ export const responseUtils = createResponseUtils()