@owlmeans/server-api 0.1.18-rc.6 → 0.1.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +203 -46
- package/agent-meta/manifest.json +3 -3
- package/agent-meta/skills/server-api/SKILL.md +142 -22
- package/build/consts.local.d.ts +2 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +2 -0
- package/build/consts.local.js.map +1 -0
- package/build/errors.d.ts +12 -0
- package/build/errors.d.ts.map +1 -1
- package/build/errors.js +16 -0
- package/build/errors.js.map +1 -1
- package/build/helper.d.ts +3 -6
- package/build/helper.d.ts.map +1 -1
- package/build/helper.js +10 -1
- package/build/helper.js.map +1 -1
- package/build/hold.d.ts +35 -0
- package/build/hold.d.ts.map +1 -0
- package/build/hold.js +57 -0
- package/build/hold.js.map +1 -0
- package/build/index.d.ts +2 -0
- package/build/index.d.ts.map +1 -1
- package/build/index.js +2 -0
- package/build/index.js.map +1 -1
- package/build/protocol.d.ts +18 -0
- package/build/protocol.d.ts.map +1 -0
- package/build/protocol.js +87 -0
- package/build/protocol.js.map +1 -0
- package/build/server.d.ts +2 -3
- package/build/server.d.ts.map +1 -1
- package/build/server.js +42 -34
- package/build/server.js.map +1 -1
- package/build/types.d.ts +31 -2
- package/build/types.d.ts.map +1 -1
- package/build/types.local.d.ts +38 -0
- package/build/types.local.d.ts.map +1 -0
- package/build/types.local.js +2 -0
- package/build/types.local.js.map +1 -0
- package/build/utils/consts.d.ts +2 -0
- package/build/utils/consts.d.ts.map +1 -0
- package/build/utils/consts.js +2 -0
- package/build/utils/consts.js.map +1 -0
- package/build/utils/consts.local.d.ts +5 -0
- package/build/utils/consts.local.d.ts.map +1 -0
- package/build/utils/consts.local.js +5 -0
- package/build/utils/consts.local.js.map +1 -0
- package/build/utils/context/types.d.ts +13 -0
- package/build/utils/context/types.d.ts.map +1 -0
- package/build/utils/context/types.js +2 -0
- package/build/utils/context/types.js.map +1 -0
- package/build/utils/context.d.ts +3 -3
- package/build/utils/context.d.ts.map +1 -1
- package/build/utils/context.js +5 -2
- package/build/utils/context.js.map +1 -1
- package/build/utils/error/types.d.ts +52 -0
- package/build/utils/error/types.d.ts.map +1 -0
- package/build/utils/error/types.js +2 -0
- package/build/utils/error/types.js.map +1 -0
- package/build/utils/error.d.ts +5 -2
- package/build/utils/error.d.ts.map +1 -1
- package/build/utils/error.js +135 -13
- package/build/utils/error.js.map +1 -1
- package/build/utils/guards.d.ts +1 -5
- package/build/utils/guards.d.ts.map +1 -1
- package/build/utils/guards.js +15 -19
- package/build/utils/guards.js.map +1 -1
- package/build/utils/index.d.ts +6 -1
- package/build/utils/index.d.ts.map +1 -1
- package/build/utils/index.js +2 -1
- package/build/utils/index.js.map +1 -1
- package/build/utils/log/consts.d.ts +14 -0
- package/build/utils/log/consts.d.ts.map +1 -0
- package/build/utils/log/consts.js +17 -0
- package/build/utils/log/consts.js.map +1 -0
- package/build/utils/log/types.d.ts +42 -0
- package/build/utils/log/types.d.ts.map +1 -0
- package/build/utils/log/types.js +2 -0
- package/build/utils/log/types.js.map +1 -0
- package/build/utils/log.d.ts +4 -0
- package/build/utils/log.d.ts.map +1 -0
- package/build/utils/log.js +170 -0
- package/build/utils/log.js.map +1 -0
- package/build/utils/payload/types.d.ts +18 -0
- package/build/utils/payload/types.d.ts.map +1 -0
- package/build/utils/payload/types.js +2 -0
- package/build/utils/payload/types.js.map +1 -0
- package/build/utils/payload.d.ts +3 -15
- package/build/utils/payload.d.ts.map +1 -1
- package/build/utils/payload.js +41 -57
- package/build/utils/payload.js.map +1 -1
- package/build/utils/schema.d.ts +7 -0
- package/build/utils/schema.d.ts.map +1 -0
- package/build/utils/schema.js +20 -0
- package/build/utils/schema.js.map +1 -0
- package/build/utils/server.d.ts +3 -6
- package/build/utils/server.d.ts.map +1 -1
- package/build/utils/server.js +12 -13
- package/build/utils/server.js.map +1 -1
- package/build/utils/types.d.ts +18 -0
- package/build/utils/types.d.ts.map +1 -0
- package/build/utils/types.js +2 -0
- package/build/utils/types.js.map +1 -0
- package/build/utils/types.local.d.ts +9 -0
- package/build/utils/types.local.d.ts.map +1 -0
- package/build/utils/types.local.js +2 -0
- package/build/utils/types.local.js.map +1 -0
- package/package.json +23 -19
- package/src/consts.local.ts +1 -0
- package/src/errors.ts +18 -0
- package/src/helper.ts +13 -8
- package/src/hold.ts +66 -0
- package/src/index.ts +2 -0
- package/src/protocol.ts +149 -0
- package/src/server.ts +72 -69
- package/src/types.local.ts +44 -0
- package/src/types.ts +38 -4
- package/src/utils/consts.local.ts +7 -0
- package/src/utils/consts.ts +1 -0
- package/src/utils/context/types.ts +13 -0
- package/src/utils/context.ts +9 -4
- package/src/utils/error/types.ts +52 -0
- package/src/utils/error.ts +157 -13
- package/src/utils/guards.ts +16 -26
- package/src/utils/index.ts +6 -1
- package/src/utils/log/consts.ts +18 -0
- package/src/utils/log/types.ts +43 -0
- package/src/utils/log.ts +193 -0
- package/src/utils/payload/types.ts +18 -0
- package/src/utils/payload.ts +42 -60
- package/src/utils/schema.ts +23 -0
- package/src/utils/server.ts +16 -21
- package/src/utils/types.local.ts +12 -0
- package/src/utils/types.ts +18 -0
- package/tests/error-status.spec.ts +349 -0
- package/tests/handlers.spec.ts +83 -0
- package/tests/json-body.spec.ts +72 -0
- package/tests/log-adapter.spec.ts +62 -0
- package/tests/log-redaction.spec.ts +181 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -0
- package/build/.gitkeep +0 -0
package/src/utils/payload.ts
CHANGED
|
@@ -1,72 +1,54 @@
|
|
|
1
1
|
import { EntrypointOutcome } from '@owlmeans/entrypoint'
|
|
2
2
|
import type { AbstractResponse, AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
3
|
import type { Request, Response } from '../types.js'
|
|
4
|
-
import { ACCEPTED, CREATED, OK
|
|
5
|
-
import
|
|
4
|
+
import { ACCEPTED, CREATED, OK } from '@owlmeans/api'
|
|
5
|
+
import { httpErrorHelper } from './error.js'
|
|
6
|
+
import type { PayloadHelper } from './payload/types.js'
|
|
6
7
|
|
|
7
|
-
export const
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
* Emits the response onto the fastify reply if the handler produced an
|
|
23
|
-
* error or an outcome. Returns `true` when something was actually sent so
|
|
24
|
-
* callers don't rely on `reply.sent`, which in fastify v5 only flips once
|
|
25
|
-
* the raw socket finishes writing (asynchronously) and therefore reads
|
|
26
|
-
* `false` synchronously right after `reply.send()`.
|
|
27
|
-
*
|
|
28
|
-
* @throws {Error}
|
|
29
|
-
*/
|
|
30
|
-
export const executeResponse = <T>(response: AbstractResponse<T>, reply: Response, throwOnError?: boolean): boolean => {
|
|
31
|
-
if (response.error != null) {
|
|
32
|
-
if (throwOnError ?? false) {
|
|
33
|
-
throw response.error
|
|
8
|
+
export const createPayloadHelper = (): PayloadHelper => {
|
|
9
|
+
const provideRequest = (alias: string, req: Request, provision?: boolean): AbstractRequest => {
|
|
10
|
+
provision = provision ?? false
|
|
11
|
+
return {
|
|
12
|
+
alias,
|
|
13
|
+
auth: (req as any)._auth ?? undefined,
|
|
14
|
+
// Resolved once by `authorize` and carried by reference, exactly like `_auth`, so a handler
|
|
15
|
+
// reached through any path sees the same entity the guard admitted.
|
|
16
|
+
entity: (req as any)._entity ?? undefined,
|
|
17
|
+
params: req.params as Record<string, string | number | undefined | null>,
|
|
18
|
+
body: req.body as Record<string, any>,
|
|
19
|
+
headers: req.headers,
|
|
20
|
+
query: req.query as Record<string, string | number | undefined | null>,
|
|
21
|
+
path: req.url,
|
|
22
|
+
original: provision ? req : undefined
|
|
34
23
|
}
|
|
35
|
-
reply.code(SERVER_ERROR).send(response.error.message)
|
|
36
|
-
return true
|
|
37
|
-
} else if (response.outcome != null) {
|
|
38
|
-
switch (response.outcome) {
|
|
39
|
-
case EntrypointOutcome.Accepted:
|
|
40
|
-
reply.code(ACCEPTED).send(response.value)
|
|
41
|
-
break
|
|
42
|
-
case EntrypointOutcome.Created:
|
|
43
|
-
reply.code(CREATED).send(response.value)
|
|
44
|
-
break
|
|
45
|
-
case EntrypointOutcome.Ok:
|
|
46
|
-
default:
|
|
47
|
-
reply.code(OK).send(response.value)
|
|
48
|
-
}
|
|
49
|
-
return true
|
|
50
24
|
}
|
|
51
25
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
26
|
+
const executeResponse = <T>(response: AbstractResponse<T>, reply: Response, throwOnError?: boolean): boolean => {
|
|
27
|
+
if (response.error != null) {
|
|
28
|
+
if (throwOnError ?? false) {
|
|
29
|
+
throw response.error
|
|
30
|
+
}
|
|
31
|
+
httpErrorHelper.handleError(response.error, reply)
|
|
32
|
+
return true
|
|
33
|
+
} else if (response.outcome != null) {
|
|
34
|
+
switch (response.outcome) {
|
|
35
|
+
case EntrypointOutcome.Accepted:
|
|
36
|
+
reply.code(ACCEPTED).send(response.value)
|
|
37
|
+
break
|
|
38
|
+
case EntrypointOutcome.Created:
|
|
39
|
+
reply.code(CREATED).send(response.value)
|
|
40
|
+
break
|
|
41
|
+
case EntrypointOutcome.Ok:
|
|
42
|
+
default:
|
|
43
|
+
reply.code(OK).send(response.value)
|
|
63
44
|
}
|
|
64
|
-
|
|
65
|
-
schema.properties = Object.fromEntries(
|
|
66
|
-
Object.entries(schema.properties).map(([key, value]) => [key, fixFormatDates(value as AnySchemaObject)])
|
|
67
|
-
)
|
|
45
|
+
return true
|
|
68
46
|
}
|
|
47
|
+
|
|
48
|
+
return false
|
|
69
49
|
}
|
|
70
50
|
|
|
71
|
-
return
|
|
51
|
+
return { provideRequest, executeResponse }
|
|
72
52
|
}
|
|
53
|
+
|
|
54
|
+
export const payloadHelper = createPayloadHelper()
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { AnySchemaObject } from 'ajv'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A request schema Fastify can serve: a `date-time` object becomes a `date-time` string (and loses
|
|
5
|
+
* its `required` list), recursively through object properties. Mutates and returns `schema`.
|
|
6
|
+
*/
|
|
7
|
+
export const fixFormatDates = (schema: AnySchemaObject): AnySchemaObject => {
|
|
8
|
+
if (schema.type === 'object') {
|
|
9
|
+
if (schema.format === 'date-time') {
|
|
10
|
+
schema.type = 'string'
|
|
11
|
+
schema.format = 'date-time'
|
|
12
|
+
if (schema.required != null) {
|
|
13
|
+
delete schema.required
|
|
14
|
+
}
|
|
15
|
+
} else if (schema.properties != null) {
|
|
16
|
+
schema.properties = Object.fromEntries(
|
|
17
|
+
Object.entries(schema.properties).map(([key, value]) => [key, fixFormatDates(value as AnySchemaObject)])
|
|
18
|
+
)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return schema
|
|
23
|
+
}
|
package/src/utils/server.ts
CHANGED
|
@@ -1,29 +1,27 @@
|
|
|
1
|
-
import { AppType } from '@owlmeans/context'
|
|
1
|
+
import { AppType, assertContext } from '@owlmeans/context'
|
|
2
2
|
import type { FastifyReply, FastifyRequest } from 'fastify'
|
|
3
|
-
import { assertContext } from '@owlmeans/context'
|
|
4
3
|
import type { FixerService, ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
5
|
-
import type
|
|
6
|
-
import type { GateService } from '@owlmeans/entrypoint'
|
|
7
|
-
import { provideResponse } from '@owlmeans/entrypoint'
|
|
8
|
-
import type { ServerContext, ServerConfig } from '@owlmeans/server-context'
|
|
4
|
+
import { type CommonEntrypoint, type GateService, provideResponse } from '@owlmeans/entrypoint'
|
|
9
5
|
import { ResilientError } from '@owlmeans/error'
|
|
10
6
|
import { OK } from '@owlmeans/api'
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
7
|
+
import { logger } from '@owlmeans/log'
|
|
8
|
+
import { httpErrorHelper } from './error.js'
|
|
9
|
+
import { payloadHelper } from './payload.js'
|
|
13
10
|
import { authorize } from './guards.js'
|
|
14
11
|
import { RouteProtocols } from '@owlmeans/route'
|
|
12
|
+
import type { ServerConfig2, ServerContext2 } from './types.local.js'
|
|
15
13
|
|
|
16
|
-
type Config = ServerConfig
|
|
17
|
-
type Context = ServerContext<Config>
|
|
18
14
|
|
|
19
|
-
export const canServeModule = (context:
|
|
15
|
+
export const canServeModule = (context: ServerContext2, module: CommonEntrypoint): module is ServerEntrypoint<unknown> => {
|
|
20
16
|
if (module.route.route.type !== AppType.Backend) {
|
|
21
17
|
return false
|
|
22
18
|
}
|
|
23
19
|
if (module.route.route.service != null && module.route.route.service !== context.cfg.service) {
|
|
24
20
|
return false
|
|
25
21
|
}
|
|
26
|
-
|
|
22
|
+
// HTTP serves only its own protocol. Custom transports are handled by their owning packages.
|
|
23
|
+
if (module.route.route.protocol != null
|
|
24
|
+
&& module.route.route.protocol !== RouteProtocols.WEB) {
|
|
27
25
|
return false
|
|
28
26
|
}
|
|
29
27
|
|
|
@@ -33,20 +31,20 @@ export const canServeModule = (context: Context, module: CommonEntrypoint): modu
|
|
|
33
31
|
export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, location: string) =>
|
|
34
32
|
async (req: FastifyRequest, reply: FastifyReply) => {
|
|
35
33
|
// We passed context using fastify request object
|
|
36
|
-
let context = assertContext<
|
|
34
|
+
let context = assertContext<ServerConfig2, ServerContext2>((req as any)._ctx, location)
|
|
37
35
|
try {
|
|
38
36
|
const authorized = await authorize(context, module, req, reply)
|
|
39
37
|
context = authorized[0]
|
|
40
38
|
module = authorized[1]
|
|
41
39
|
|
|
42
40
|
const response = provideResponse(reply)
|
|
43
|
-
const request = provideRequest(module.alias, req, true)
|
|
41
|
+
const request = payloadHelper.provideRequest(module.alias, req, true)
|
|
44
42
|
|
|
45
43
|
const gates = module.getGates()
|
|
46
44
|
for (const [srv, params] of gates) {
|
|
47
45
|
const gate: GateService = context.service(srv)
|
|
48
46
|
await gate.assert(request, response, params)
|
|
49
|
-
executeResponse(response, reply, true)
|
|
47
|
+
payloadHelper.executeResponse(response, reply, true)
|
|
50
48
|
}
|
|
51
49
|
|
|
52
50
|
await module.handle(request, response)
|
|
@@ -57,20 +55,17 @@ export const createServerHandler = (module: ServerEntrypoint<FastifyRequest>, lo
|
|
|
57
55
|
// synchronously right after `reply.send()`. Track the emitted state
|
|
58
56
|
// explicitly instead, and only fall back to a default response when
|
|
59
57
|
// neither `executeResponse` nor the handler itself (hijack) replied.
|
|
60
|
-
const responded = executeResponse(response, reply, true)
|
|
58
|
+
const responded = payloadHelper.executeResponse(response, reply, true)
|
|
61
59
|
if (!responded && !reply.sent) {
|
|
62
|
-
|
|
60
|
+
logger('http').warn(`SENDS DEFAULT RESPONSE: ${module.alias}`)
|
|
63
61
|
reply.code(OK).send(response.value)
|
|
64
62
|
}
|
|
65
63
|
} catch (error) {
|
|
66
|
-
console.error(`Error in ${module.alias} (${location})`)
|
|
67
|
-
console.error(JSON.stringify(error, null, 2))
|
|
68
|
-
console.error(error)
|
|
69
64
|
if (module.fixer != null) {
|
|
70
65
|
const fixer: FixerService = context.service(module.fixer)
|
|
71
66
|
fixer.handle(reply, ResilientError.ensure(error as Error))
|
|
72
67
|
return
|
|
73
68
|
}
|
|
74
|
-
handleError(error as Error, reply)
|
|
69
|
+
httpErrorHelper.handleError(error as Error, reply, httpErrorHelper.errorExposure(context.cfg))
|
|
75
70
|
}
|
|
76
71
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
2
|
+
import type { Config as ApiConfig } from '../types.js'
|
|
3
|
+
|
|
4
|
+
export type Config = ServerConfig
|
|
5
|
+
|
|
6
|
+
export interface Context<C extends Config = Config> extends ServerContext<C> { }
|
|
7
|
+
|
|
8
|
+
export type Severity = 'debug' | 'info' | 'warn' | 'error'
|
|
9
|
+
|
|
10
|
+
export type ServerConfig2 = ApiConfig
|
|
11
|
+
|
|
12
|
+
export type ServerContext2 = ServerContext<ServerConfig2>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an error class may declare about the HTTP status it is answered with.
|
|
3
|
+
*
|
|
4
|
+
* Structural on purpose: the classes that declare it live in packages that must not depend on an
|
|
5
|
+
* HTTP server (`@owlmeans/payment`, a product's contract package), so they write a plain
|
|
6
|
+
* `public static httpStatus = 409` and nothing imports this type.
|
|
7
|
+
*/
|
|
8
|
+
export interface HttpStatusDeclaration {
|
|
9
|
+
httpStatus?: unknown
|
|
10
|
+
/** Explicit opt-in for a declared 5xx response. Client-error declarations need no opt-in. */
|
|
11
|
+
allowServerErrorStatus?: unknown
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SerializedHttpError {
|
|
15
|
+
status: number
|
|
16
|
+
body: string
|
|
17
|
+
incidentId: string
|
|
18
|
+
}
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import Fastify from 'fastify'
|
|
3
|
+
import type { FastifyInstance } from 'fastify'
|
|
4
|
+
import { AuthForbidden, AuthorizationError, AuthUnknown } from '@owlmeans/auth'
|
|
5
|
+
import { ResilientError, SEPARATOR } from '@owlmeans/error'
|
|
6
|
+
import { provideResponse } from '@owlmeans/entrypoint'
|
|
7
|
+
import { httpErrorHelper } from '../src/utils/error.js'
|
|
8
|
+
import { INCIDENT_ID_HEADER } from '../src/utils/consts.js'
|
|
9
|
+
import { payloadHelper } from '../src/utils/payload.js'
|
|
10
|
+
import { AccessError, AuthFailedError } from '../src/errors.js'
|
|
11
|
+
import { DENIAL_KIND_HEADER, ACCESS_DENIED_KIND, apiStatusHelper } from '@owlmeans/api'
|
|
12
|
+
import {
|
|
13
|
+
MigrationError, MisshapedRecord, RecordExists, RecordUpdateFailed, UnknownRecordError,
|
|
14
|
+
UnsupportedArgumentError, UnsupportedMethodError,
|
|
15
|
+
} from '@owlmeans/resource'
|
|
16
|
+
|
|
17
|
+
class OutOfCredit extends ResilientError {
|
|
18
|
+
public static override typeName = 'ServerApiSpecOutOfCredit'
|
|
19
|
+
public static httpStatus = 402
|
|
20
|
+
|
|
21
|
+
constructor(message: string = 'error') {
|
|
22
|
+
super(OutOfCredit.typeName, `out-of-credit:${message}`)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Inherits 402 without redeclaring it. */
|
|
27
|
+
class OutOfCreditForStory extends OutOfCredit {
|
|
28
|
+
public static override typeName = 'ServerApiSpecOutOfCreditForStory'
|
|
29
|
+
|
|
30
|
+
constructor() {
|
|
31
|
+
super('story')
|
|
32
|
+
this.type = OutOfCreditForStory.typeName
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Redeclares its parent's status to a conflict. */
|
|
37
|
+
class Busy extends OutOfCredit {
|
|
38
|
+
public static override typeName = 'ServerApiSpecBusy'
|
|
39
|
+
public static override httpStatus = 409
|
|
40
|
+
|
|
41
|
+
constructor() {
|
|
42
|
+
super('busy')
|
|
43
|
+
this.type = Busy.typeName
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** An auth refusal that also declares a status: the auth mapping still wins. */
|
|
48
|
+
class DeclaringForbidden extends AuthForbidden {
|
|
49
|
+
public static override typeName = 'ServerApiSpecDeclaringForbidden'
|
|
50
|
+
public static httpStatus = 402
|
|
51
|
+
|
|
52
|
+
constructor() {
|
|
53
|
+
super('declaring')
|
|
54
|
+
this.type = DeclaringForbidden.typeName
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
class TemporarilyUnavailable extends ResilientError {
|
|
59
|
+
public static override typeName = 'ServerApiSpecTemporarilyUnavailable'
|
|
60
|
+
public static httpStatus = 503
|
|
61
|
+
public static allowServerErrorStatus = true
|
|
62
|
+
|
|
63
|
+
constructor() {
|
|
64
|
+
super(TemporarilyUnavailable.typeName, 'temporarily-unavailable')
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class Throttled extends ResilientError {
|
|
69
|
+
public static override typeName = 'ServerApiSpecThrottled'
|
|
70
|
+
public static httpStatus = 429
|
|
71
|
+
public readonly retryAfter = 17.2
|
|
72
|
+
|
|
73
|
+
constructor() {
|
|
74
|
+
super(Throttled.typeName, 'throttled')
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const declaring = (typeName: string, httpStatus: unknown) => {
|
|
79
|
+
class Declaring extends ResilientError {
|
|
80
|
+
public static override typeName = typeName
|
|
81
|
+
public static httpStatus = httpStatus
|
|
82
|
+
|
|
83
|
+
constructor(message: string = 'error') {
|
|
84
|
+
super(typeName, message)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return Declaring
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A duplicate module copy of the error family — what a process loading `@owlmeans/error` through
|
|
92
|
+
* several `node_modules` paths holds: the same registered names, none of the imported classes.
|
|
93
|
+
*/
|
|
94
|
+
class ForeignResilientError extends Error {
|
|
95
|
+
public static typeName = 'ResilientError'
|
|
96
|
+
public type: string
|
|
97
|
+
|
|
98
|
+
constructor(type: string, message: string) {
|
|
99
|
+
super(message)
|
|
100
|
+
this.type = type
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
class ForeignAuthFailedError extends ForeignResilientError {
|
|
105
|
+
public static override typeName = 'AuthFailedError'
|
|
106
|
+
|
|
107
|
+
constructor() {
|
|
108
|
+
super(ForeignAuthFailedError.typeName, 'api:auth:error')
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class ForeignAuthorizationError extends ForeignResilientError {
|
|
113
|
+
public static override typeName = 'AuthorizationError'
|
|
114
|
+
|
|
115
|
+
constructor(message: string = 'error') {
|
|
116
|
+
super(ForeignAuthorizationError.typeName, `auth:authorization:${message}`)
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
class ForeignAuthForbidden extends ForeignAuthorizationError {
|
|
121
|
+
public static override typeName = 'ForbiddenAuthorizationError'
|
|
122
|
+
|
|
123
|
+
constructor(message: string = 'error') {
|
|
124
|
+
super(`forbidden:${message}`)
|
|
125
|
+
this.type = ForeignAuthForbidden.typeName
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** A subclass whose own name embeds nothing of its parent's, like `EntitlementRefusal`. */
|
|
130
|
+
class ForeignEntitlementRefusal extends ForeignAuthForbidden {
|
|
131
|
+
public static override typeName = 'EntitlementRefusal'
|
|
132
|
+
|
|
133
|
+
constructor() {
|
|
134
|
+
super('entitlement')
|
|
135
|
+
this.type = ForeignEntitlementRefusal.typeName
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** A declared refusal no registry of this process knows. */
|
|
140
|
+
class ForeignOutOfCredit extends ForeignResilientError {
|
|
141
|
+
public static override typeName = 'ServerApiSpecForeignOutOfCredit'
|
|
142
|
+
public static httpStatus = 402
|
|
143
|
+
|
|
144
|
+
constructor() {
|
|
145
|
+
super(ForeignOutOfCredit.typeName, 'out-of-credit:foreign')
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const typed = (type: string) => Object.assign(new Error('typed'), { type })
|
|
150
|
+
|
|
151
|
+
ResilientError.registerErrorClass(OutOfCredit)
|
|
152
|
+
ResilientError.registerErrorClass(OutOfCreditForStory)
|
|
153
|
+
ResilientError.registerErrorClass(Busy)
|
|
154
|
+
ResilientError.registerErrorClass(DeclaringForbidden)
|
|
155
|
+
|
|
156
|
+
let server: FastifyInstance
|
|
157
|
+
let thrown: unknown
|
|
158
|
+
|
|
159
|
+
beforeAll(async () => {
|
|
160
|
+
server = Fastify()
|
|
161
|
+
server.get('/handle', async (_req, reply) => {
|
|
162
|
+
httpErrorHelper.handleError(thrown as Error, reply)
|
|
163
|
+
return reply
|
|
164
|
+
})
|
|
165
|
+
server.get('/handle-development', async (_req, reply) => {
|
|
166
|
+
httpErrorHelper.handleError(thrown as Error, reply, 'development')
|
|
167
|
+
return reply
|
|
168
|
+
})
|
|
169
|
+
server.get('/execute', async (_req, reply) => {
|
|
170
|
+
const response = provideResponse(reply)
|
|
171
|
+
response.reject(thrown as Error)
|
|
172
|
+
payloadHelper.executeResponse(response, reply)
|
|
173
|
+
return reply
|
|
174
|
+
})
|
|
175
|
+
await server.ready()
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
afterAll(async () => {
|
|
179
|
+
await server.close()
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
const answer = async (error: unknown, path: string = '/handle') => {
|
|
183
|
+
thrown = error
|
|
184
|
+
const reply = await server.inject({ method: 'GET', url: path })
|
|
185
|
+
return {
|
|
186
|
+
status: reply.statusCode,
|
|
187
|
+
body: reply.body,
|
|
188
|
+
incidentId: reply.headers[INCIDENT_ID_HEADER.toLowerCase()] as string,
|
|
189
|
+
retryAfter: reply.headers['retry-after'],
|
|
190
|
+
denialKind: reply.headers[DENIAL_KIND_HEADER.toLowerCase()],
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
describe('@owlmeans/server-api — production error exposure', () => {
|
|
195
|
+
test('returns only an incident id in production and correlates it with the header', async () => {
|
|
196
|
+
const response = await answer(new Error('private-marker'))
|
|
197
|
+
expect(response.body).toBe(response.incidentId)
|
|
198
|
+
expect(response.body).not.toContain('private-marker')
|
|
199
|
+
expect(response.body).not.toContain(SEPARATOR)
|
|
200
|
+
expect(response.body).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
test('includes stacks only under explicit development exposure and safely serializes SyntaxError', async () => {
|
|
204
|
+
const development = await answer(new Error('development-marker'), '/handle-development')
|
|
205
|
+
const [type, message, stack, incidentId] = development.body.split(SEPARATOR, 4)
|
|
206
|
+
expect(type).toBe(ResilientError.typeName)
|
|
207
|
+
expect(message).toBe('development-marker')
|
|
208
|
+
expect(stack).toContain('development-marker')
|
|
209
|
+
expect(incidentId).toBe(development.incidentId)
|
|
210
|
+
|
|
211
|
+
const syntax = await answer(new SyntaxError('bad wiring'))
|
|
212
|
+
expect(syntax.status).toBe(500)
|
|
213
|
+
expect(syntax.body).toBe(syntax.incidentId)
|
|
214
|
+
expect(syntax.body).not.toContain('bad wiring')
|
|
215
|
+
|
|
216
|
+
expect(httpErrorHelper.errorExposure()).toBe('production')
|
|
217
|
+
expect(httpErrorHelper.errorExposure({ http: { errors: { exposure: 'development' } } })).toBe('development')
|
|
218
|
+
})
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
describe('@owlmeans/server-api — handleError status', () => {
|
|
222
|
+
test('keeps the auth mappings: forbidden 403, unauthenticated 401', async () => {
|
|
223
|
+
expect((await answer(new AuthForbidden('x'))).status).toBe(403)
|
|
224
|
+
expect((await answer(new AccessError())).status).toBe(403)
|
|
225
|
+
expect((await answer(new AuthorizationError('x'))).status).toBe(401)
|
|
226
|
+
expect((await answer(new AuthFailedError())).status).toBe(401)
|
|
227
|
+
expect((await answer(new DeclaringForbidden())).status).toBe(403)
|
|
228
|
+
expect((await answer(new AuthForbidden('permission'))).denialKind).toBe(ACCESS_DENIED_KIND)
|
|
229
|
+
expect((await answer(new AccessError())).denialKind).toBe(ACCESS_DENIED_KIND)
|
|
230
|
+
expect((await answer(new DeclaringForbidden())).denialKind).toBeUndefined()
|
|
231
|
+
expect((await answer(new ForeignEntitlementRefusal())).denialKind).toBeUndefined()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
test('honours a 4xx a class declares — inherited, redeclared, or after a marshal hop', async () => {
|
|
235
|
+
const refused = await answer(new OutOfCredit('create'))
|
|
236
|
+
expect(refused.status).toBe(402)
|
|
237
|
+
expect(refused.body).toBe(refused.incidentId)
|
|
238
|
+
|
|
239
|
+
expect((await answer(new OutOfCreditForStory())).status).toBe(402)
|
|
240
|
+
expect((await answer(new Busy())).status).toBe(409)
|
|
241
|
+
expect((await answer(ResilientError.marshal(new Busy()))).status).toBe(409)
|
|
242
|
+
expect((await answer(new ForeignOutOfCredit())).status).toBe(402)
|
|
243
|
+
})
|
|
244
|
+
|
|
245
|
+
test('answers 500 for no declaration, or one outside the client-error range', async () => {
|
|
246
|
+
expect((await answer(new ResilientError('Plain', 'plain'))).status).toBe(500)
|
|
247
|
+
expect((await answer(new Error('boom'))).status).toBe(500)
|
|
248
|
+
for (const status of [500, 503, 200, 302, 399, 600, 402.5, '402', null]) {
|
|
249
|
+
const Declaring = declaring(`ServerApiSpecDeclaring${String(status)}`, status)
|
|
250
|
+
expect((await answer(new Declaring())).status).toBe(500)
|
|
251
|
+
}
|
|
252
|
+
for (const type of ['AuthFailedErrorX', 'NotAuthorizationError', 'Forbidden', 'ResilientError']) {
|
|
253
|
+
expect((await answer(typed(type))).status).toBe(500)
|
|
254
|
+
expect((await answer(new ForeignResilientError(type, 'near-miss'))).status).toBe(500)
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
test('an unknown authentication method or identity answers 400, as thrown and after a marshal hop', async () => {
|
|
259
|
+
expect((await answer(new AuthUnknown('email-otp'))).status).toBe(400)
|
|
260
|
+
expect((await answer(ResilientError.marshal(new AuthUnknown('entity')))).status).toBe(400)
|
|
261
|
+
expect((await answer(new AuthUnknown(), '/execute')).status).toBe(400)
|
|
262
|
+
})
|
|
263
|
+
|
|
264
|
+
test('honours an explicitly exposed 5xx without opening arbitrary server statuses', async () => {
|
|
265
|
+
expect((await answer(new TemporarilyUnavailable())).status).toBe(503)
|
|
266
|
+
const Unexposed = declaring('ServerApiSpecUnexposed503', 503)
|
|
267
|
+
expect((await answer(new Unexposed())).status).toBe(500)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
test('emits Retry-After for a throttled response', async () => {
|
|
271
|
+
const response = await answer(new Throttled())
|
|
272
|
+
expect(response.status).toBe(429)
|
|
273
|
+
expect(response.retryAfter).toBe('18')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
test('executeResponse answers a rejected response with the same status', async () => {
|
|
277
|
+
expect((await answer(new OutOfCredit('create'), '/execute')).status).toBe(402)
|
|
278
|
+
expect((await answer(new AuthForbidden('x'), '/execute')).status).toBe(403)
|
|
279
|
+
expect((await answer(new ResilientError('Plain', 'plain'), '/execute')).status).toBe(500)
|
|
280
|
+
})
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
describe('@owlmeans/server-api — auth status across a rebuild and duplicate module copies', () => {
|
|
284
|
+
test('an AuthFailedError answers 401 as thrown and after a marshal round trip', async () => {
|
|
285
|
+
expect((await answer(new AuthFailedError())).status).toBe(401)
|
|
286
|
+
expect((await answer(ResilientError.marshal(new AuthFailedError()))).status).toBe(401)
|
|
287
|
+
expect(httpErrorHelper.errorStatus(ResilientError.ensure(ResilientError.marshal(new AuthFailedError())))).toBe(401)
|
|
288
|
+
expect((await answer(new AuthFailedError(), '/execute')).status).toBe(401)
|
|
289
|
+
})
|
|
290
|
+
|
|
291
|
+
test('a class from another module copy answers by its registered type name', async () => {
|
|
292
|
+
// The rebuild alone loses it — the regression this resolution exists for.
|
|
293
|
+
expect(httpErrorHelper.errorStatus(ResilientError.ensure(new ForeignAuthFailedError()))).toBe(500)
|
|
294
|
+
expect((await answer(new ForeignAuthFailedError())).status).toBe(401)
|
|
295
|
+
expect((await answer(new ForeignAuthorizationError())).status).toBe(401)
|
|
296
|
+
expect((await answer(new ForeignAuthForbidden())).status).toBe(403)
|
|
297
|
+
expect((await answer(new ForeignAuthForbidden(), '/execute')).status).toBe(403)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
test('the family is read off the instance type or the constructor chain, 403 before 401', async () => {
|
|
301
|
+
expect((await answer(typed('AuthFailedError'))).status).toBe(401)
|
|
302
|
+
expect((await answer(typed('ForbiddenAuthorizationError'))).status).toBe(403)
|
|
303
|
+
expect((await answer(typed('AccessAuthFailedError'))).status).toBe(403)
|
|
304
|
+
// Its own type names nothing of the family; only the chain does.
|
|
305
|
+
expect((await answer(new ForeignEntitlementRefusal())).status).toBe(403)
|
|
306
|
+
})
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
describe('@owlmeans/server-api — storage refusals of @owlmeans/resource', () => {
|
|
310
|
+
test('an absent record answers 404 and a taken id or key 409 — as thrown and after a marshal hop', async () => {
|
|
311
|
+
expect((await answer(new UnknownRecordError('shelf/3'))).status).toBe(404)
|
|
312
|
+
expect((await answer(new RecordExists('id-present'))).status).toBe(409)
|
|
313
|
+
expect((await answer(ResilientError.marshal(new UnknownRecordError('shelf/3')))).status).toBe(404)
|
|
314
|
+
expect((await answer(ResilientError.marshal(new RecordExists('isbn')))).status).toBe(409)
|
|
315
|
+
expect((await answer(new UnknownRecordError('shelf/3'), '/execute')).status).toBe(404)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
test('a storage fault still answers 500', async () => {
|
|
319
|
+
for (const fault of [
|
|
320
|
+
new MisshapedRecord('x'), new RecordUpdateFailed('x'), new UnsupportedArgumentError('ttl'),
|
|
321
|
+
new UnsupportedMethodError('watch'), new MigrationError('x'),
|
|
322
|
+
]) {
|
|
323
|
+
expect([fault.type, (await answer(fault)).status]).toEqual([fault.type, 500])
|
|
324
|
+
}
|
|
325
|
+
})
|
|
326
|
+
|
|
327
|
+
test('a development body rebuilds into the same class, and the status the client stamps agrees with it', async () => {
|
|
328
|
+
for (const [error, status] of [
|
|
329
|
+
[new UnknownRecordError('shelf/3'), 404], [new RecordExists('isbn'), 409],
|
|
330
|
+
] as const) {
|
|
331
|
+
const response = await answer(error, '/handle-development')
|
|
332
|
+
expect(response.status).toBe(status)
|
|
333
|
+
// What `@owlmeans/api` does with a non-2xx body carrying the separator.
|
|
334
|
+
const rebuilt = Object.assign(ResilientError.ensure(response.body, true), { responseStatus: response.status })
|
|
335
|
+
expect(rebuilt.constructor).toBe(error.constructor)
|
|
336
|
+
expect(rebuilt.message).toBe(error.message)
|
|
337
|
+
expect(apiStatusHelper.httpStatusOf(rebuilt)).toBe(status)
|
|
338
|
+
}
|
|
339
|
+
const rebuilt = ResilientError.ensure((await answer(new UnknownRecordError('shelf/3'), '/handle-development')).body, true)
|
|
340
|
+
expect((rebuilt as UnknownRecordError).id).toBe('shelf/3')
|
|
341
|
+
})
|
|
342
|
+
|
|
343
|
+
test('a production body is the incident id alone, under the declared status', async () => {
|
|
344
|
+
const response = await answer(new UnknownRecordError('shelf/3'))
|
|
345
|
+
expect(response.status).toBe(404)
|
|
346
|
+
expect(response.body).toBe(response.incidentId)
|
|
347
|
+
expect(response.body).not.toContain('shelf')
|
|
348
|
+
})
|
|
349
|
+
})
|