@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,70 @@
1
+ import qs from 'qs'
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'
6
+
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}`
14
+ }
15
+
16
+ const isJsonContentType = (type?: string): boolean =>
17
+ type != null && /^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(type.trim())
18
+
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'
22
+
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')
27
+ }
28
+
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
+ }
35
+ }
36
+
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')
43
+
44
+ return serialized ? body : JSON.stringify(body)
45
+ }
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
+
64
+ return { data: body, verbatim: false }
65
+ }
66
+
67
+ return { contentTypeOf, isJsonContentType, jsonScalarBody, requestBodyOf }
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()
@@ -0,0 +1,124 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { provideResponse, type AbstractRequest } from '@owlmeans/entrypoint'
3
+ import { RouteMethod } from '@owlmeans/route'
4
+ import { createApiService } from '../src/service.js'
5
+ import { bodyUtils } from '../src/utils/body.js'
6
+
7
+ const STRING = { type: 'string' }
8
+
9
+ describe('what a request body becomes', () => {
10
+ test('a scalar on a POST without a content type is JSON text', () => {
11
+ expect(bodyUtils.requestBodyOf('abc', {}, RouteMethod.POST)).toEqual({ data: '"abc"', contentType: 'application/json', verbatim: true })
12
+ expect(bodyUtils.requestBodyOf('', {}, RouteMethod.POST).data).toBe('""')
13
+ expect(bodyUtils.requestBodyOf(5, {}, RouteMethod.POST).data).toBe('5')
14
+ expect(bodyUtils.requestBodyOf(0, {}, RouteMethod.POST).data).toBe('0')
15
+ expect(bodyUtils.requestBodyOf(true, {}, RouteMethod.POST).data).toBe('true')
16
+ expect(bodyUtils.requestBodyOf(false, {}, RouteMethod.POST).data).toBe('false')
17
+ })
18
+
19
+ test('a scalar under an explicit JSON content type is JSON text, on any method', () => {
20
+ expect(bodyUtils.requestBodyOf('abc', { 'Content-Type': 'application/json; charset=utf-8' }, RouteMethod.PUT))
21
+ .toEqual({ data: '"abc"', verbatim: true })
22
+ expect(bodyUtils.requestBodyOf(7, { 'content-type': 'application/merge-patch+json' }, RouteMethod.PATCH).data).toBe('7')
23
+ })
24
+
25
+ test('already-serialized JSON text is sent as it is', () => {
26
+ expect(bodyUtils.requestBodyOf('{"a":1}', {}, RouteMethod.POST).data).toBe('{"a":1}')
27
+ expect(bodyUtils.requestBodyOf('[1,2]', {}, RouteMethod.POST).data).toBe('[1,2]')
28
+ expect(bodyUtils.requestBodyOf('"abc"', {}, RouteMethod.POST).data).toBe('"abc"')
29
+ expect(bodyUtils.requestBodyOf('"abc"', {}, RouteMethod.POST, STRING).data).toBe('"abc"')
30
+ })
31
+
32
+ test('under a string body schema, JSON-looking text is the string the caller means', () => {
33
+ expect(bodyUtils.jsonScalarBody('123', STRING)).toBe('"123"')
34
+ expect(bodyUtils.jsonScalarBody('true', STRING)).toBe('"true"')
35
+ expect(bodyUtils.jsonScalarBody('null', { type: ['string', 'null'] })).toBe('"null"')
36
+ expect(bodyUtils.jsonScalarBody('{"a":1}', STRING)).toBe('"{\\"a\\":1}"')
37
+ expect(bodyUtils.jsonScalarBody('123', { type: 'number' })).toBe('123')
38
+ })
39
+
40
+ test('objects, arrays and everything outside JSON behave as before', () => {
41
+ const object = { a: 1 }
42
+ expect(bodyUtils.requestBodyOf(object, {}, RouteMethod.POST)).toEqual({ data: object, verbatim: false })
43
+ expect(bodyUtils.requestBodyOf([1], {}, RouteMethod.POST)).toEqual({ data: [1], verbatim: false })
44
+ expect(bodyUtils.requestBodyOf(undefined, {}, RouteMethod.POST)).toEqual({ data: undefined, verbatim: false })
45
+ expect(bodyUtils.requestBodyOf('abc', { 'content-type': 'text/plain' }, RouteMethod.POST)).toEqual({ data: 'abc', verbatim: false })
46
+ expect(bodyUtils.requestBodyOf('abc', {}, RouteMethod.PUT)).toEqual({ data: 'abc', verbatim: false })
47
+ expect(bodyUtils.requestBodyOf({ a: '1', b: ['x'] }, { 'content-type': 'application/x-www-form-urlencoded' }, RouteMethod.POST).data)
48
+ .toBe('a=1&b%5B0%5D=x')
49
+ })
50
+
51
+ test('the content-type header is read whatever its case', () => {
52
+ expect(bodyUtils.contentTypeOf({ 'CONTENT-TYPE': 'application/json' })).toBe('application/json')
53
+ expect(bodyUtils.contentTypeOf({ accept: 'application/json' })).toBeUndefined()
54
+ expect(bodyUtils.isJsonContentType('application/vnd.api+json')).toBe(true)
55
+ expect(bodyUtils.isJsonContentType('application/jsonp')).toBe(false)
56
+ expect(bodyUtils.isJsonContentType('text/plain')).toBe(false)
57
+ })
58
+ })
59
+
60
+ /**
61
+ * The real client over real HTTP: an echo server answers the bytes and the content type it
62
+ * received, so what a Fastify JSON parser would read is exactly what is asserted here.
63
+ */
64
+ describe('the API client on the wire', () => {
65
+ let server: ReturnType<typeof Bun.serve>
66
+
67
+ beforeAll(() => {
68
+ server = Bun.serve({
69
+ port: 0,
70
+ fetch: async request => Response.json({ type: request.headers.get('content-type'), raw: await request.text() }),
71
+ })
72
+ })
73
+
74
+ afterAll(() => { server.stop(true) })
75
+
76
+ const send = async (
77
+ body: unknown, opts: { method?: RouteMethod, headers?: Record<string, string>, schema?: object } = {}
78
+ ): Promise<{ type: string | null, raw: string }> => {
79
+ const client = createApiService('test-client')
80
+ client.ctx = {
81
+ cfg: { service: 'api-tests', security: { unsecure: true }, services: {} },
82
+ entrypoint: () => ({
83
+ alias: 'echo',
84
+ route: { route: { method: opts.method ?? RouteMethod.POST } },
85
+ filter: opts.schema != null ? { body: opts.schema } : undefined,
86
+ path: () => '/echo',
87
+ address: () => ({ host: '127.0.0.1', port: server.port, secure: false }),
88
+ }),
89
+ hasService: () => false,
90
+ } as never
91
+ const reply = provideResponse<{ type: string | null, raw: string }>()
92
+ const request = { alias: 'echo', params: {}, query: {}, headers: opts.headers ?? {}, body, path: '/echo' } as AbstractRequest
93
+ await client.handler(request, reply)
94
+ if (reply.error != null) {
95
+ throw reply.error
96
+ }
97
+ return reply.value!
98
+ }
99
+
100
+ test('a bare string, number or boolean arrives as valid JSON of the same value', async () => {
101
+ for (const value of ['abc', '', 'a "quoted" word', 42, 0, true, false]) {
102
+ const received = await send(value)
103
+ expect(received.type).toStartWith('application/json')
104
+ expect(JSON.parse(received.raw)).toEqual(value)
105
+ }
106
+ })
107
+
108
+ test('a string contract carries JSON-looking text as a string', async () => {
109
+ expect(JSON.parse((await send('123', { schema: STRING })).raw)).toBe('123')
110
+ })
111
+
112
+ test('objects, arrays and serialized text arrive as before', async () => {
113
+ expect(await send({ a: 1 })).toEqual({ type: expect.stringContaining('application/json'), raw: '{"a":1}' })
114
+ expect((await send(['x'])).raw).toBe('["x"]')
115
+ expect((await send('{"a":1}')).raw).toBe('{"a":1}')
116
+ expect(await send('plain', { headers: { 'content-type': 'text/plain' } })).toEqual({ type: 'text/plain', raw: 'plain' })
117
+ })
118
+
119
+ test('the caller\'s headers are not rewritten', async () => {
120
+ const headers: Record<string, string> = {}
121
+ await send('abc', { headers })
122
+ expect(headers).toEqual({})
123
+ })
124
+ })
@@ -0,0 +1,229 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import { AxiosHeaders } from 'axios'
3
+ import type { AxiosResponse } from 'axios'
4
+ import type { AbstractResponse } from '@owlmeans/entrypoint'
5
+ import { EntrypointOutcome } from '@owlmeans/entrypoint'
6
+ import { ResilientError } from '@owlmeans/error'
7
+ import { RecordExists, UnknownRecordError } from '@owlmeans/resource'
8
+ import {
9
+ ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError, apiStatusHelper,
10
+ INCIDENT_ID_HEADER,
11
+ DENIAL_KIND_HEADER, ACCESS_DENIED_KIND,
12
+ } from '../src/index.js'
13
+ import { responseUtils } from '../src/utils/response.js'
14
+ import * as statusModule from '../src/status/index.js'
15
+
16
+ const INCIDENT = '6f1c1a52-8a0e-4f5e-9a7c-1f2d3e4c5b6a'
17
+
18
+ class ConsentRequired extends ResilientError {
19
+ public static override typeName = 'ApiSpecConsentRequired'
20
+ public static httpStatus = 428
21
+
22
+ constructor(message: string = 'error') {
23
+ super(ConsentRequired.typeName, `spec:consent-required:${message}`)
24
+ }
25
+ }
26
+ ResilientError.registerErrorClass(ConsentRequired)
27
+
28
+ class Unavailable extends ResilientError {
29
+ public static override typeName = 'ApiSpecUnavailable'
30
+ public static httpStatus = 503
31
+
32
+ constructor(message: string = 'error') {
33
+ super(Unavailable.typeName, message)
34
+ }
35
+ }
36
+
37
+ const respond = (
38
+ statusCode: number, data: unknown, headers: Record<string, string> | AxiosHeaders = {}
39
+ ): AbstractResponse<unknown> => {
40
+ const reply: AbstractResponse<unknown> = {
41
+ resolve: (value, outcome) => { reply.value = value; reply.outcome = outcome },
42
+ reject: error => { reply.error = error },
43
+ }
44
+ responseUtils.processResponse({
45
+ status: statusCode, statusText: '', data, headers, config: { url: '/spec' },
46
+ } as unknown as AxiosResponse, reply)
47
+
48
+ return reply
49
+ }
50
+
51
+ const quiet = console.error
52
+ beforeAll(() => { console.error = () => {} })
53
+ afterAll(() => { console.error = quiet })
54
+
55
+ describe('2xx', () => {
56
+ test('resolve exactly as before', () => {
57
+ expect(respond(200, { ok: true })).toMatchObject({ value: { ok: true }, outcome: EntrypointOutcome.Ok })
58
+ expect(respond(202, 'queued')).toMatchObject({ value: 'queued', outcome: EntrypointOutcome.Accepted })
59
+ expect(respond(204, '', { 'x-a': '1' })).toMatchObject({ value: { 'x-a': '1' }, outcome: EntrypointOutcome.Finished })
60
+ })
61
+ })
62
+
63
+ describe('a production incident body keeps its status and incident id', () => {
64
+ test('only a labelled 403 is an auth/IAM denial', () => {
65
+ const denied = respond(403, INCIDENT, { [DENIAL_KIND_HEADER]: ACCESS_DENIED_KIND }).error
66
+ expect(statusModule.apiStatusHelper.isAccessDenied(denied)).toBe(true)
67
+ expect(statusModule.apiStatusHelper.isAccessDenied(respond(403, INCIDENT).error)).toBe(false)
68
+ expect(statusModule.apiStatusHelper.isAccessDenied(respond(401, INCIDENT, { [DENIAL_KIND_HEADER]: ACCESS_DENIED_KIND }).error)).toBe(false)
69
+ })
70
+ test('428 with the header becomes ApiStatusError', () => {
71
+ const { error } = respond(428, INCIDENT, { [INCIDENT_ID_HEADER.toLowerCase()]: INCIDENT })
72
+ expect(error).toBeInstanceOf(ApiStatusError)
73
+ expect(error!.message).toBe(`api:client:status:428:${INCIDENT}`)
74
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(428)
75
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
76
+ expect((error as ApiStatusError).status).toBe(428)
77
+ })
78
+
79
+ test('the header is read from AxiosHeaders too, and the body alone suffices', () => {
80
+ const headers = new AxiosHeaders({ [INCIDENT_ID_HEADER]: INCIDENT })
81
+ expect(apiStatusHelper.incidentIdOf(respond(409, 'x', headers).error)).toBe(INCIDENT)
82
+ expect(apiStatusHelper.incidentIdOf(respond(402, INCIDENT).error)).toBe(INCIDENT)
83
+ expect(apiStatusHelper.httpStatusOf(respond(402, INCIDENT).error)).toBe(402)
84
+ })
85
+
86
+ test('500, 401 and 403 keep their classes and markers', () => {
87
+ const crashed = respond(500, INCIDENT).error
88
+ expect(crashed).toBeInstanceOf(ServerCrashedError)
89
+ expect(crashed!.message).toBe(`api:client:crashed:${INCIDENT}`)
90
+ expect(apiStatusHelper.httpStatusOf(crashed)).toBe(500)
91
+
92
+ const auth = respond(401, INCIDENT).error
93
+ expect(auth).toBeInstanceOf(ServerAuthError)
94
+ expect(auth!.message).toBe(`api:client:auth:${INCIDENT}`)
95
+ expect(apiStatusHelper.httpStatusOf(auth)).toBe(401)
96
+
97
+ const forbidden = respond(403, INCIDENT).error
98
+ expect(forbidden).toBeInstanceOf(ApiClientError)
99
+ expect(forbidden).not.toBeInstanceOf(ApiStatusError)
100
+ expect(forbidden!.message).toBe(`api:client:forbidden:${INCIDENT}`)
101
+ expect(apiStatusHelper.httpStatusOf(forbidden)).toBe(403)
102
+ })
103
+
104
+ test('without an incident id the markers stay as before', () => {
105
+ expect(responseUtils.statusError(500).message).toBe('api:client:crashed:error')
106
+ expect(responseUtils.statusError(403).message).toBe('api:client:forbidden')
107
+ expect(apiStatusHelper.incidentIdOf(responseUtils.statusError(500))).toBeNull()
108
+ expect(responseUtils.statusError(418).message).toBe('api:client:status:418')
109
+ })
110
+
111
+ test('a proxy HTML page or framework JSON becomes a status error', () => {
112
+ const html = respond(502, '<html><body>Bad Gateway</body></html>').error
113
+ expect(html).toBeInstanceOf(ApiStatusError)
114
+ expect(apiStatusHelper.httpStatusOf(html)).toBe(502)
115
+ expect(apiStatusHelper.incidentIdOf(html)).toBeNull()
116
+
117
+ const json = respond(400, { statusCode: 400, error: 'Bad Request', message: 'body/x' }).error
118
+ expect(json).toBeInstanceOf(ApiStatusError)
119
+ expect(apiStatusHelper.httpStatusOf(json)).toBe(400)
120
+ })
121
+
122
+ test('no class declares a static httpStatus, so a rethrow upstream stays 500', () => {
123
+ for (const error of [responseUtils.statusError(428), responseUtils.statusError(500), responseUtils.statusError(401), responseUtils.statusError(403)]) {
124
+ expect((error.constructor as { httpStatus?: unknown }).httpStatus).toBeUndefined()
125
+ }
126
+ })
127
+ })
128
+
129
+ describe('a development body stays its typed class', () => {
130
+ test('rebuilt with responseStatus and incident id', () => {
131
+ const body = ResilientError.marshal(new ConsentRequired('2'), { includeStack: true, incidentId: INCIDENT }).message
132
+ const { error } = respond(428, body, { [INCIDENT_ID_HEADER]: INCIDENT })
133
+ expect(error).toBeInstanceOf(ConsentRequired)
134
+ expect(error!.message).toBe('spec:consent-required:2')
135
+ expect((error as { responseStatus?: number }).responseStatus).toBe(428)
136
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(428)
137
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
138
+ })
139
+
140
+ test('a typed api error keeps its marker fields', () => {
141
+ const body = ResilientError.marshal(new ApiStatusError(409, INCIDENT)).message
142
+ const error = respond(409, body).error as ApiStatusError
143
+ expect(error).toBeInstanceOf(ApiStatusError)
144
+ expect(error.status).toBe(409)
145
+ expect(error.incidentId).toBe(INCIDENT)
146
+ })
147
+
148
+ test('a storage refusal (404 absent record, 409 taken key) rebuilds into its own class', () => {
149
+ for (const [refusal, code] of [
150
+ [new UnknownRecordError('shelf/3'), 404], [new RecordExists('isbn'), 409],
151
+ ] as const) {
152
+ const body = ResilientError.marshal(refusal, { includeStack: true, incidentId: INCIDENT }).message
153
+ const { error } = respond(code, body, { [INCIDENT_ID_HEADER]: INCIDENT })
154
+ expect(error!.constructor).toBe(refusal.constructor)
155
+ expect(error!.message).toBe(refusal.message)
156
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(code)
157
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
158
+ }
159
+ const body = ResilientError.marshal(new UnknownRecordError('shelf/3')).message
160
+ expect((respond(404, body).error as UnknownRecordError).id).toBe('shelf/3')
161
+ })
162
+
163
+ test('a class this process never registered still answers the status the server sent', () => {
164
+ const body = `ApiSpecUnregisteredRefusal${ResilientError.separator}spec:unregistered:x`
165
+ const { error } = respond(404, body, { [INCIDENT_ID_HEADER]: INCIDENT })
166
+ expect(error).not.toBeInstanceOf(ServerCrashedError)
167
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(404)
168
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
169
+ })
170
+ })
171
+
172
+ describe('a production body of a storage refusal keeps the declared status', () => {
173
+ test('404 and 409 become status errors, not a crash', () => {
174
+ for (const code of [404, 409]) {
175
+ const { error } = respond(code, INCIDENT)
176
+ expect(error).toBeInstanceOf(ApiStatusError)
177
+ expect(error).not.toBeInstanceOf(ServerCrashedError)
178
+ expect(error!.message).toBe(`api:client:status:${code}:${INCIDENT}`)
179
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(code)
180
+ }
181
+ })
182
+ })
183
+
184
+ describe('marshal round trips', () => {
185
+ const roundTrip = (error: Error): ResilientError => ResilientError.ensure(ResilientError.marshal(error))
186
+
187
+ test('keep status and incident id of every client error', () => {
188
+ const cases: Array<[ApiClientError, number]> = [
189
+ [new ApiStatusError(428, INCIDENT), 428],
190
+ [new ServerCrashedError(INCIDENT), 500],
191
+ [new ServerAuthError(INCIDENT), 401],
192
+ [new ApiClientError(`forbidden:${INCIDENT}`), 403],
193
+ ]
194
+ for (const [error, code] of cases) {
195
+ const back = roundTrip(error)
196
+ expect(back.constructor).toBe(error.constructor)
197
+ expect((back as ApiClientError).status).toBe(code)
198
+ expect(apiStatusHelper.httpStatusOf(back)).toBe(code)
199
+ expect(apiStatusHelper.incidentIdOf(back)).toBe(INCIDENT)
200
+ }
201
+ })
202
+
203
+ test('a legacy bare-status marker still reads', () => {
204
+ expect(apiStatusHelper.httpStatusOf(new ApiClientError('404'))).toBe(404)
205
+ })
206
+ })
207
+
208
+ describe('./status', () => {
209
+ test('a declared static 428 answers without any marker; an undeclared 5xx does not', () => {
210
+ expect(statusModule.apiStatusHelper.httpStatusOf(new ConsentRequired())).toBe(428)
211
+ expect(statusModule.apiStatusHelper.httpStatusOf(new Unavailable())).toBeNull()
212
+ expect(statusModule.apiStatusHelper.httpStatusOf(new Error('plain'))).toBeNull()
213
+ expect(statusModule.apiStatusHelper.httpStatusOf(null)).toBeNull()
214
+ })
215
+
216
+ test('reads a marker from a plain error message or a type', () => {
217
+ expect(statusModule.apiStatusHelper.httpStatusOf(new Error(`${statusModule.API_STATUS_MARKER}428:${INCIDENT}`))).toBe(428)
218
+ expect(statusModule.apiStatusHelper.httpStatusOf({ type: 'api:client:auth:error' })).toBe(401)
219
+ expect(statusModule.apiStatusHelper.incidentIdOf(new Error(`api:client:crashed:${INCIDENT}`))).toBe(INCIDENT)
220
+ })
221
+
222
+ test('isIncidentBody accepts a bare incident UUID only', () => {
223
+ expect(statusModule.apiStatusHelper.isIncidentBody(INCIDENT)).toBe(true)
224
+ expect(statusModule.apiStatusHelper.isIncidentBody(`${INCIDENT}\n`)).toBe(true)
225
+ expect(statusModule.apiStatusHelper.isIncidentBody('Unauthorized')).toBe(false)
226
+ expect(statusModule.apiStatusHelper.isIncidentBody(`ApiError|||api:x|||${INCIDENT}`)).toBe(false)
227
+ expect(statusModule.apiStatusHelper.isIncidentBody({ id: INCIDENT })).toBe(false)
228
+ })
229
+ })
@@ -0,0 +1,11 @@
1
+ {
2
+ "extends": [
3
+ "@owlmeans/dep-config/tsconfig.base.json"
4
+ ],
5
+ "compilerOptions": {
6
+ "types": ["bun"],
7
+ "rootDir": "../",
8
+ "noEmit": true
9
+ },
10
+ "include": ["./**/*", "../src/**/*"]
11
+ }
package/tsconfig.json CHANGED
@@ -6,5 +6,5 @@
6
6
  "rootDir": "./src/",
7
7
  "outDir": "./build/"
8
8
  },
9
- "exclude": ["./dist/**/*", "./build/**/*", "./*.ts"]
9
+ "exclude": ["./dist/**/*", "./build/**/*", "./*.ts", "./tests/**/*"]
10
10
  }
package/build/.gitkeep DELETED
File without changes
@@ -1,61 +0,0 @@
1
- import type { AbstractResponse } from '@owlmeans/entrypoint'
2
- import { EntrypointOutcome } from '@owlmeans/entrypoint'
3
- import type { AxiosResponse } from 'axios'
4
- import { ACCEPTED, CREATED, FINISHED, FORBIDDEN_ERROR, OK, SERVER_ERROR, UNAUTHORIZED_ERROR } from '../consts.js'
5
- import { ResilientError } from '@owlmeans/error'
6
- import { ApiClientError, ServerAuthError, ServerCrashedError } from '../errors.js'
7
-
8
- export const processResponse = (response: AxiosResponse, reply: AbstractResponse<any>) => {
9
- switch (response.status) {
10
- case OK:
11
- reply.resolve(response.data, EntrypointOutcome.Ok)
12
- return
13
- case ACCEPTED:
14
- reply.resolve(response.data, EntrypointOutcome.Accepted)
15
- return
16
- case CREATED:
17
- return processEmptyResponse(response, reply, EntrypointOutcome.Created)
18
- case FINISHED:
19
- return processEmptyResponse(response, reply, EntrypointOutcome.Finished)
20
- default:
21
-
22
- console.error(response.status, response.statusText)
23
- console.error(response.config.url, response.headers)
24
- console.error(response.data)
25
-
26
- try {
27
- if (typeof response.data === 'string') {
28
- reply.reject(ResilientError.ensure(response.data, true))
29
- return
30
- }
31
- } catch {
32
- console.error(`Server returned an unrecognizable text error: ${response.status} ${response.data}`)
33
- }
34
- switch (response.status) {
35
- case SERVER_ERROR:
36
- reply.reject(new ServerCrashedError())
37
- return
38
- case UNAUTHORIZED_ERROR:
39
- reply.reject(new ServerAuthError())
40
- return
41
- case FORBIDDEN_ERROR:
42
- reply.reject(new ApiClientError('forbidden'))
43
- return
44
- default:
45
- reply.reject(new ApiClientError(`${response.status}`))
46
- return
47
- }
48
- }
49
- }
50
-
51
- const processEmptyResponse = (response: AxiosResponse, reply: AbstractResponse<any>, outcome: EntrypointOutcome) => {
52
- if (response.data != null && response.data != '') {
53
- reply.resolve(response.data, outcome)
54
- return
55
- }
56
- if (response.headers.toJSON != null && typeof response.headers.toJSON === 'function') {
57
- reply.resolve(response.headers.toJSON(), outcome)
58
- return
59
- }
60
- reply.resolve(response.headers, outcome)
61
- }