@owlmeans/api 0.1.18-rc.46 → 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 (60) 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 +2 -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.js +6 -6
  9. package/build/service.js.map +1 -1
  10. package/build/status/helper.d.ts +4 -0
  11. package/build/status/helper.d.ts.map +1 -0
  12. package/build/status/helper.js +49 -0
  13. package/build/status/helper.js.map +1 -0
  14. package/build/status/index.d.ts +3 -22
  15. package/build/status/index.d.ts.map +1 -1
  16. package/build/status/index.js +4 -61
  17. package/build/status/index.js.map +1 -1
  18. package/build/status/types.d.ts +24 -0
  19. package/build/status/types.d.ts.map +1 -1
  20. package/build/utils/body/types.d.ts +36 -0
  21. package/build/utils/body/types.d.ts.map +1 -0
  22. package/build/utils/{types.js.map → body/types.js.map} +1 -1
  23. package/build/utils/body.d.ts +3 -38
  24. package/build/utils/body.d.ts.map +1 -1
  25. package/build/utils/body.js +49 -66
  26. package/build/utils/body.js.map +1 -1
  27. package/build/utils/index.d.ts +2 -1
  28. package/build/utils/index.d.ts.map +1 -1
  29. package/build/utils/index.js +1 -1
  30. package/build/utils/index.js.map +1 -1
  31. package/build/utils/response/types.d.ts +15 -0
  32. package/build/utils/response/types.d.ts.map +1 -0
  33. package/build/utils/response/types.js +2 -0
  34. package/build/utils/response/types.js.map +1 -0
  35. package/build/utils/response.d.ts +4 -0
  36. package/build/utils/response.d.ts.map +1 -0
  37. package/build/utils/response.js +99 -0
  38. package/build/utils/response.js.map +1 -0
  39. package/package.json +11 -11
  40. package/src/index.ts +2 -2
  41. package/src/service.ts +6 -6
  42. package/src/status/helper.ts +63 -0
  43. package/src/status/index.ts +5 -76
  44. package/src/status/types.ts +25 -0
  45. package/src/utils/body/types.ts +37 -0
  46. package/src/utils/body.ts +50 -67
  47. package/src/utils/index.ts +2 -1
  48. package/src/utils/response/types.ts +15 -0
  49. package/src/utils/response.ts +117 -0
  50. package/tests/body.spec.ts +29 -29
  51. package/tests/handler.spec.ts +45 -45
  52. package/build/utils/handler.d.ts +0 -11
  53. package/build/utils/handler.d.ts.map +0 -1
  54. package/build/utils/handler.js +0 -95
  55. package/build/utils/handler.js.map +0 -1
  56. package/build/utils/types.d.ts +0 -11
  57. package/build/utils/types.d.ts.map +0 -1
  58. package/src/utils/handler.ts +0 -110
  59. package/src/utils/types.ts +0 -12
  60. /package/build/utils/{types.js → body/types.js} +0 -0
package/src/utils/body.ts CHANGED
@@ -2,86 +2,69 @@ import qs from 'qs'
2
2
  import { RouteMethod } from '@owlmeans/route'
3
3
  import { FORM_CONTENT_TYPE } from './consts.local.js'
4
4
  import { JSON_CONTENT_TYPE } from './consts.js'
5
- import type { RequestBody } from './types.js'
5
+ import type { BodyUtils, RequestBody } from './body/types.js'
6
6
 
7
- /** The request's own `content-type`, whatever case its key is written in. */
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
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}`
12
14
  }
13
- return Array.isArray(value) ? value.join(', ') : `${value}`
14
- }
15
-
16
- /** `application/json` or a `+json` suffix type, with or without parameters. */
17
- const isJsonContentType = (type?: string): boolean =>
18
- type != null && /^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(type.trim())
19
15
 
20
- /** A JSON value no serializer quotes for us: a string, a number or a boolean. */
21
- const isScalar = (value: unknown): value is string | number | boolean =>
22
- typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
16
+ const isJsonContentType = (type?: string): boolean =>
17
+ type != null && /^application\/(?:[^\s;]+\+)?json\s*(?:;|$)/i.test(type.trim())
23
18
 
24
- /** Does the entrypoint's body schema declare a string? */
25
- const stringContract = (schema?: object): boolean => {
26
- const type = (schema as { type?: unknown } | undefined)?.type
27
- return (Array.isArray(type) ? type : [type]).includes('string')
28
- }
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'
29
22
 
30
- const parse = (text: string): { json: true, value: unknown } | { json: false } => {
31
- try {
32
- return { json: true, value: JSON.parse(text) }
33
- } catch {
34
- 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')
35
27
  }
36
- }
37
28
 
38
- /**
39
- * The JSON text a scalar body travels as, so the server parses it back to the same value.
40
- *
41
- * A number or a boolean is `JSON.stringify`'d. A string is a value unless it is already JSON text:
42
- * one that does not parse is quoted; one that parses is sent as it is (the caller serialized it
43
- * itself) — except under a body schema of `type: 'string'`, where only a JSON string literal
44
- * counts as serialized and `123`, `true` or `{…}` are the text the caller means.
45
- */
46
- const jsonScalarBody = (body: string | number | boolean, schema?: object): string => {
47
- if (typeof body !== 'string') {
48
- 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
+ }
49
35
  }
50
- const parsed = parse(body)
51
- const serialized = parsed.json && (!stringContract(schema) || typeof parsed.value === 'string')
52
36
 
53
- return serialized ? body : JSON.stringify(body)
54
- }
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')
55
43
 
56
- /**
57
- * What a request's body becomes on the wire.
58
- *
59
- * - A `x-www-form-urlencoded` request: the body through `qs`.
60
- * - A string, number or boolean under a JSON `content-type` — the caller's, or `application/json`
61
- * supplied for a `POST` that names none — becomes JSON text ({@link jsonScalarBody}).
62
- * - Anything else — objects, arrays, a scalar under another content type — goes to axios as it is,
63
- * which serializes an object or an array as JSON.
64
- */
65
- const requestBodyOf = (
66
- body: unknown, headers: Record<string, unknown> | undefined, method?: RouteMethod, schema?: object
67
- ): RequestBody => {
68
- const type = contentTypeOf(headers)
69
- if (body != null && type?.includes(FORM_CONTENT_TYPE) === true) {
70
- return { data: qs.stringify(body), verbatim: false }
44
+ return serialized ? body : JSON.stringify(body)
71
45
  }
72
- 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
+
73
64
  return { data: body, verbatim: false }
74
65
  }
75
- if (type == null && method === RouteMethod.POST) {
76
- return { data: jsonScalarBody(body, schema), contentType: JSON_CONTENT_TYPE, verbatim: true }
77
- }
78
- if (isJsonContentType(type)) {
79
- return { data: jsonScalarBody(body, schema), verbatim: true }
80
- }
81
66
 
82
- return { data: body, verbatim: false }
67
+ return { contentTypeOf, isJsonContentType, jsonScalarBody, requestBodyOf }
83
68
  }
84
69
 
85
- export const createBodyHelper = () => ({ contentTypeOf, isJsonContentType, jsonScalarBody, requestBodyOf })
86
-
87
- export const bodyHelper = createBodyHelper()
70
+ export const bodyUtils = createBodyUtils()
@@ -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()
@@ -2,58 +2,58 @@ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
2
  import { provideResponse, type AbstractRequest } from '@owlmeans/entrypoint'
3
3
  import { RouteMethod } from '@owlmeans/route'
4
4
  import { createApiService } from '../src/service.js'
5
- import { bodyHelper } from '../src/utils/body.js'
5
+ import { bodyUtils } from '../src/utils/body.js'
6
6
 
7
7
  const STRING = { type: 'string' }
8
8
 
9
9
  describe('what a request body becomes', () => {
10
10
  test('a scalar on a POST without a content type is JSON text', () => {
11
- expect(bodyHelper.requestBodyOf('abc', {}, RouteMethod.POST)).toEqual({ data: '"abc"', contentType: 'application/json', verbatim: true })
12
- expect(bodyHelper.requestBodyOf('', {}, RouteMethod.POST).data).toBe('""')
13
- expect(bodyHelper.requestBodyOf(5, {}, RouteMethod.POST).data).toBe('5')
14
- expect(bodyHelper.requestBodyOf(0, {}, RouteMethod.POST).data).toBe('0')
15
- expect(bodyHelper.requestBodyOf(true, {}, RouteMethod.POST).data).toBe('true')
16
- expect(bodyHelper.requestBodyOf(false, {}, RouteMethod.POST).data).toBe('false')
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
17
  })
18
18
 
19
19
  test('a scalar under an explicit JSON content type is JSON text, on any method', () => {
20
- expect(bodyHelper.requestBodyOf('abc', { 'Content-Type': 'application/json; charset=utf-8' }, RouteMethod.PUT))
20
+ expect(bodyUtils.requestBodyOf('abc', { 'Content-Type': 'application/json; charset=utf-8' }, RouteMethod.PUT))
21
21
  .toEqual({ data: '"abc"', verbatim: true })
22
- expect(bodyHelper.requestBodyOf(7, { 'content-type': 'application/merge-patch+json' }, RouteMethod.PATCH).data).toBe('7')
22
+ expect(bodyUtils.requestBodyOf(7, { 'content-type': 'application/merge-patch+json' }, RouteMethod.PATCH).data).toBe('7')
23
23
  })
24
24
 
25
25
  test('already-serialized JSON text is sent as it is', () => {
26
- expect(bodyHelper.requestBodyOf('{"a":1}', {}, RouteMethod.POST).data).toBe('{"a":1}')
27
- expect(bodyHelper.requestBodyOf('[1,2]', {}, RouteMethod.POST).data).toBe('[1,2]')
28
- expect(bodyHelper.requestBodyOf('"abc"', {}, RouteMethod.POST).data).toBe('"abc"')
29
- expect(bodyHelper.requestBodyOf('"abc"', {}, RouteMethod.POST, STRING).data).toBe('"abc"')
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
30
  })
31
31
 
32
32
  test('under a string body schema, JSON-looking text is the string the caller means', () => {
33
- expect(bodyHelper.jsonScalarBody('123', STRING)).toBe('"123"')
34
- expect(bodyHelper.jsonScalarBody('true', STRING)).toBe('"true"')
35
- expect(bodyHelper.jsonScalarBody('null', { type: ['string', 'null'] })).toBe('"null"')
36
- expect(bodyHelper.jsonScalarBody('{"a":1}', STRING)).toBe('"{\\"a\\":1}"')
37
- expect(bodyHelper.jsonScalarBody('123', { type: 'number' })).toBe('123')
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
38
  })
39
39
 
40
40
  test('objects, arrays and everything outside JSON behave as before', () => {
41
41
  const object = { a: 1 }
42
- expect(bodyHelper.requestBodyOf(object, {}, RouteMethod.POST)).toEqual({ data: object, verbatim: false })
43
- expect(bodyHelper.requestBodyOf([1], {}, RouteMethod.POST)).toEqual({ data: [1], verbatim: false })
44
- expect(bodyHelper.requestBodyOf(undefined, {}, RouteMethod.POST)).toEqual({ data: undefined, verbatim: false })
45
- expect(bodyHelper.requestBodyOf('abc', { 'content-type': 'text/plain' }, RouteMethod.POST)).toEqual({ data: 'abc', verbatim: false })
46
- expect(bodyHelper.requestBodyOf('abc', {}, RouteMethod.PUT)).toEqual({ data: 'abc', verbatim: false })
47
- expect(bodyHelper.requestBodyOf({ a: '1', b: ['x'] }, { 'content-type': 'application/x-www-form-urlencoded' }, RouteMethod.POST).data)
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
48
  .toBe('a=1&b%5B0%5D=x')
49
49
  })
50
50
 
51
51
  test('the content-type header is read whatever its case', () => {
52
- expect(bodyHelper.contentTypeOf({ 'CONTENT-TYPE': 'application/json' })).toBe('application/json')
53
- expect(bodyHelper.contentTypeOf({ accept: 'application/json' })).toBeUndefined()
54
- expect(bodyHelper.isJsonContentType('application/vnd.api+json')).toBe(true)
55
- expect(bodyHelper.isJsonContentType('application/jsonp')).toBe(false)
56
- expect(bodyHelper.isJsonContentType('text/plain')).toBe(false)
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
57
  })
58
58
  })
59
59
 
@@ -6,12 +6,12 @@ import { EntrypointOutcome } from '@owlmeans/entrypoint'
6
6
  import { ResilientError } from '@owlmeans/error'
7
7
  import { RecordExists, UnknownRecordError } from '@owlmeans/resource'
8
8
  import {
9
- ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError, httpStatusOf, incidentIdOf,
9
+ ApiClientError, ApiStatusError, ServerAuthError, ServerCrashedError, apiStatusHelper,
10
10
  INCIDENT_ID_HEADER,
11
11
  DENIAL_KIND_HEADER, ACCESS_DENIED_KIND,
12
12
  } from '../src/index.js'
13
- import { processResponse, statusError } from '../src/utils/handler.js'
14
- import * as status from '../src/status/index.js'
13
+ import { responseUtils } from '../src/utils/response.js'
14
+ import * as statusModule from '../src/status/index.js'
15
15
 
16
16
  const INCIDENT = '6f1c1a52-8a0e-4f5e-9a7c-1f2d3e4c5b6a'
17
17
 
@@ -41,7 +41,7 @@ const respond = (
41
41
  resolve: (value, outcome) => { reply.value = value; reply.outcome = outcome },
42
42
  reject: error => { reply.error = error },
43
43
  }
44
- processResponse({
44
+ responseUtils.processResponse({
45
45
  status: statusCode, statusText: '', data, headers, config: { url: '/spec' },
46
46
  } as unknown as AxiosResponse, reply)
47
47
 
@@ -63,64 +63,64 @@ describe('2xx', () => {
63
63
  describe('a production incident body keeps its status and incident id', () => {
64
64
  test('only a labelled 403 is an auth/IAM denial', () => {
65
65
  const denied = respond(403, INCIDENT, { [DENIAL_KIND_HEADER]: ACCESS_DENIED_KIND }).error
66
- expect(status.isAccessDenied(denied)).toBe(true)
67
- expect(status.isAccessDenied(respond(403, INCIDENT).error)).toBe(false)
68
- expect(status.isAccessDenied(respond(401, INCIDENT, { [DENIAL_KIND_HEADER]: ACCESS_DENIED_KIND }).error)).toBe(false)
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
69
  })
70
70
  test('428 with the header becomes ApiStatusError', () => {
71
71
  const { error } = respond(428, INCIDENT, { [INCIDENT_ID_HEADER.toLowerCase()]: INCIDENT })
72
72
  expect(error).toBeInstanceOf(ApiStatusError)
73
73
  expect(error!.message).toBe(`api:client:status:428:${INCIDENT}`)
74
- expect(httpStatusOf(error)).toBe(428)
75
- expect(incidentIdOf(error)).toBe(INCIDENT)
74
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(428)
75
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
76
76
  expect((error as ApiStatusError).status).toBe(428)
77
77
  })
78
78
 
79
79
  test('the header is read from AxiosHeaders too, and the body alone suffices', () => {
80
80
  const headers = new AxiosHeaders({ [INCIDENT_ID_HEADER]: INCIDENT })
81
- expect(incidentIdOf(respond(409, 'x', headers).error)).toBe(INCIDENT)
82
- expect(incidentIdOf(respond(402, INCIDENT).error)).toBe(INCIDENT)
83
- expect(httpStatusOf(respond(402, INCIDENT).error)).toBe(402)
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
84
  })
85
85
 
86
86
  test('500, 401 and 403 keep their classes and markers', () => {
87
87
  const crashed = respond(500, INCIDENT).error
88
88
  expect(crashed).toBeInstanceOf(ServerCrashedError)
89
89
  expect(crashed!.message).toBe(`api:client:crashed:${INCIDENT}`)
90
- expect(httpStatusOf(crashed)).toBe(500)
90
+ expect(apiStatusHelper.httpStatusOf(crashed)).toBe(500)
91
91
 
92
92
  const auth = respond(401, INCIDENT).error
93
93
  expect(auth).toBeInstanceOf(ServerAuthError)
94
94
  expect(auth!.message).toBe(`api:client:auth:${INCIDENT}`)
95
- expect(httpStatusOf(auth)).toBe(401)
95
+ expect(apiStatusHelper.httpStatusOf(auth)).toBe(401)
96
96
 
97
97
  const forbidden = respond(403, INCIDENT).error
98
98
  expect(forbidden).toBeInstanceOf(ApiClientError)
99
99
  expect(forbidden).not.toBeInstanceOf(ApiStatusError)
100
100
  expect(forbidden!.message).toBe(`api:client:forbidden:${INCIDENT}`)
101
- expect(httpStatusOf(forbidden)).toBe(403)
101
+ expect(apiStatusHelper.httpStatusOf(forbidden)).toBe(403)
102
102
  })
103
103
 
104
104
  test('without an incident id the markers stay as before', () => {
105
- expect(statusError(500).message).toBe('api:client:crashed:error')
106
- expect(statusError(403).message).toBe('api:client:forbidden')
107
- expect(incidentIdOf(statusError(500))).toBeNull()
108
- expect(statusError(418).message).toBe('api:client:status:418')
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
109
  })
110
110
 
111
111
  test('a proxy HTML page or framework JSON becomes a status error', () => {
112
112
  const html = respond(502, '<html><body>Bad Gateway</body></html>').error
113
113
  expect(html).toBeInstanceOf(ApiStatusError)
114
- expect(httpStatusOf(html)).toBe(502)
115
- expect(incidentIdOf(html)).toBeNull()
114
+ expect(apiStatusHelper.httpStatusOf(html)).toBe(502)
115
+ expect(apiStatusHelper.incidentIdOf(html)).toBeNull()
116
116
 
117
117
  const json = respond(400, { statusCode: 400, error: 'Bad Request', message: 'body/x' }).error
118
118
  expect(json).toBeInstanceOf(ApiStatusError)
119
- expect(httpStatusOf(json)).toBe(400)
119
+ expect(apiStatusHelper.httpStatusOf(json)).toBe(400)
120
120
  })
121
121
 
122
122
  test('no class declares a static httpStatus, so a rethrow upstream stays 500', () => {
123
- for (const error of [statusError(428), statusError(500), statusError(401), statusError(403)]) {
123
+ for (const error of [responseUtils.statusError(428), responseUtils.statusError(500), responseUtils.statusError(401), responseUtils.statusError(403)]) {
124
124
  expect((error.constructor as { httpStatus?: unknown }).httpStatus).toBeUndefined()
125
125
  }
126
126
  })
@@ -133,8 +133,8 @@ describe('a development body stays its typed class', () => {
133
133
  expect(error).toBeInstanceOf(ConsentRequired)
134
134
  expect(error!.message).toBe('spec:consent-required:2')
135
135
  expect((error as { responseStatus?: number }).responseStatus).toBe(428)
136
- expect(httpStatusOf(error)).toBe(428)
137
- expect(incidentIdOf(error)).toBe(INCIDENT)
136
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(428)
137
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
138
138
  })
139
139
 
140
140
  test('a typed api error keeps its marker fields', () => {
@@ -153,8 +153,8 @@ describe('a development body stays its typed class', () => {
153
153
  const { error } = respond(code, body, { [INCIDENT_ID_HEADER]: INCIDENT })
154
154
  expect(error!.constructor).toBe(refusal.constructor)
155
155
  expect(error!.message).toBe(refusal.message)
156
- expect(httpStatusOf(error)).toBe(code)
157
- expect(incidentIdOf(error)).toBe(INCIDENT)
156
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(code)
157
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
158
158
  }
159
159
  const body = ResilientError.marshal(new UnknownRecordError('shelf/3')).message
160
160
  expect((respond(404, body).error as UnknownRecordError).id).toBe('shelf/3')
@@ -164,8 +164,8 @@ describe('a development body stays its typed class', () => {
164
164
  const body = `ApiSpecUnregisteredRefusal${ResilientError.separator}spec:unregistered:x`
165
165
  const { error } = respond(404, body, { [INCIDENT_ID_HEADER]: INCIDENT })
166
166
  expect(error).not.toBeInstanceOf(ServerCrashedError)
167
- expect(httpStatusOf(error)).toBe(404)
168
- expect(incidentIdOf(error)).toBe(INCIDENT)
167
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(404)
168
+ expect(apiStatusHelper.incidentIdOf(error)).toBe(INCIDENT)
169
169
  })
170
170
  })
171
171
 
@@ -176,7 +176,7 @@ describe('a production body of a storage refusal keeps the declared status', ()
176
176
  expect(error).toBeInstanceOf(ApiStatusError)
177
177
  expect(error).not.toBeInstanceOf(ServerCrashedError)
178
178
  expect(error!.message).toBe(`api:client:status:${code}:${INCIDENT}`)
179
- expect(httpStatusOf(error)).toBe(code)
179
+ expect(apiStatusHelper.httpStatusOf(error)).toBe(code)
180
180
  }
181
181
  })
182
182
  })
@@ -195,35 +195,35 @@ describe('marshal round trips', () => {
195
195
  const back = roundTrip(error)
196
196
  expect(back.constructor).toBe(error.constructor)
197
197
  expect((back as ApiClientError).status).toBe(code)
198
- expect(httpStatusOf(back)).toBe(code)
199
- expect(incidentIdOf(back)).toBe(INCIDENT)
198
+ expect(apiStatusHelper.httpStatusOf(back)).toBe(code)
199
+ expect(apiStatusHelper.incidentIdOf(back)).toBe(INCIDENT)
200
200
  }
201
201
  })
202
202
 
203
203
  test('a legacy bare-status marker still reads', () => {
204
- expect(httpStatusOf(new ApiClientError('404'))).toBe(404)
204
+ expect(apiStatusHelper.httpStatusOf(new ApiClientError('404'))).toBe(404)
205
205
  })
206
206
  })
207
207
 
208
208
  describe('./status', () => {
209
209
  test('a declared static 428 answers without any marker; an undeclared 5xx does not', () => {
210
- expect(status.httpStatusOf(new ConsentRequired())).toBe(428)
211
- expect(status.httpStatusOf(new Unavailable())).toBeNull()
212
- expect(status.httpStatusOf(new Error('plain'))).toBeNull()
213
- expect(status.httpStatusOf(null)).toBeNull()
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
214
  })
215
215
 
216
216
  test('reads a marker from a plain error message or a type', () => {
217
- expect(status.httpStatusOf(new Error(`${status.API_STATUS_MARKER}428:${INCIDENT}`))).toBe(428)
218
- expect(status.httpStatusOf({ type: 'api:client:auth:error' })).toBe(401)
219
- expect(status.incidentIdOf(new Error(`api:client:crashed:${INCIDENT}`))).toBe(INCIDENT)
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
220
  })
221
221
 
222
222
  test('isIncidentBody accepts a bare incident UUID only', () => {
223
- expect(status.isIncidentBody(INCIDENT)).toBe(true)
224
- expect(status.isIncidentBody(`${INCIDENT}\n`)).toBe(true)
225
- expect(status.isIncidentBody('Unauthorized')).toBe(false)
226
- expect(status.isIncidentBody(`ApiError|||api:x|||${INCIDENT}`)).toBe(false)
227
- expect(status.isIncidentBody({ id: INCIDENT })).toBe(false)
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
228
  })
229
229
  })
@@ -1,11 +0,0 @@
1
- import { type AbstractResponse } from '@owlmeans/entrypoint';
2
- import type { AxiosResponse } from 'axios';
3
- import { ApiClientError } from '../errors.js';
4
- export declare const processResponse: (response: AxiosResponse, reply: AbstractResponse<any>) => void;
5
- /**
6
- * The typed error of a bare status: `ServerCrashedError` (500), `ServerAuthError` (401),
7
- * `ApiClientError('forbidden[:<id>]')` (403), `ApiStatusError` (`api:client:status:<n>[:<id>]`)
8
- * for every other one.
9
- */
10
- export declare const statusError: (status: number, incidentId?: string) => ApiClientError;
11
- //# sourceMappingURL=handler.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"handler.d.ts","sourceRoot":"","sources":["../../src/utils/handler.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,gBAAgB,EAAqB,MAAM,sBAAsB,CAAA;AAC/E,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,OAAO,CAAA;AAM1C,OAAO,EAAE,cAAc,EAAuD,MAAM,cAAc,CAAA;AAOlG,eAAO,MAAM,eAAe,aAAc,aAAa,SAAS,gBAAgB,CAAC,GAAG,CAAC,SAqBpF,CAAA;AAkCD;;;;GAIG;AACH,eAAO,MAAM,WAAW,WAAY,MAAM,eAAe,MAAM,KAAG,cAUjE,CAAA"}