@owlmeans/server-api 0.1.18-rc.5 → 0.1.18-rc.51
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 +2 -2
- package/agent-meta/skills/server-api/SKILL.md +128 -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 +39 -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 +163 -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.d.ts +11 -0
- package/build/utils/log.d.ts.map +1 -0
- package/build/utils/log.js +57 -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 +22 -18
- 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 +69 -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 +184 -12
- package/src/utils/guards.ts +16 -26
- package/src/utils/index.ts +6 -1
- package/src/utils/log.ts +62 -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 +52 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -0
- package/build/.gitkeep +0 -0
package/src/protocol.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { assertContext, type BasicConfig, type BasicContext } from '@owlmeans/context'
|
|
2
|
+
import { EntrypointOutcome, type BodyOf, type EntrypointProtocolDeclaration, type HandlerRequest, type OpenRequest, type ParamsOf, type RequestOf, type ResponseOf, type AbstractRequest } from '@owlmeans/entrypoint'
|
|
3
|
+
import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint'
|
|
4
|
+
import { HandlerMisconfiguredError } from './errors.js'
|
|
5
|
+
import { logger } from '@owlmeans/log'
|
|
6
|
+
import type { AnyBoundHandler, BodyProtocol, ContextCarrier, HandlerResponse, MaybePromise, ParamsProtocol } from './types.local.js'
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
const contextFor = <Context extends BasicContext<BasicConfig>>(
|
|
10
|
+
request: AbstractRequest,
|
|
11
|
+
fallback: Context,
|
|
12
|
+
): Context => {
|
|
13
|
+
const original = request.original
|
|
14
|
+
if (original != null && typeof original === 'object' && '_ctx' in original) {
|
|
15
|
+
return (original as ContextCarrier<Context>)._ctx ?? fallback
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return fallback
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const bind = <Protocol extends EntrypointProtocolDeclaration, Context extends BasicContext<BasicConfig>>(
|
|
22
|
+
protocol: Protocol,
|
|
23
|
+
execute: (request: HandlerRequest<RequestOf<Protocol>>, context: Context) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>,
|
|
24
|
+
): BoundEntrypointHandler<Protocol> => ({
|
|
25
|
+
protocol,
|
|
26
|
+
bind: ref => async (request, response) => {
|
|
27
|
+
const fallback = assertContext<BasicConfig, Context>(ref.ref?.ctx, protocol.alias)
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const typedRequest = (request as HandlerRequest<OpenRequest>) as HandlerRequest<RequestOf<Protocol>>
|
|
31
|
+
const value = await execute(
|
|
32
|
+
typedRequest,
|
|
33
|
+
contextFor(request, fallback),
|
|
34
|
+
)
|
|
35
|
+
response.resolve(value as ResponseOf<Protocol>, EntrypointOutcome.Ok)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
response.reject(error as Error)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return response.value
|
|
41
|
+
},
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* `value` is shaped like a `BoundEntrypointHandler` — never a plain function, which also carries
|
|
46
|
+
* its own `.bind` and would otherwise pass a `typeof value.bind === 'function'` check alone.
|
|
47
|
+
*/
|
|
48
|
+
const isBoundHandler = (value: unknown): value is AnyBoundHandler =>
|
|
49
|
+
typeof value === 'object' && value != null
|
|
50
|
+
&& typeof (value as { bind?: unknown }).bind === 'function'
|
|
51
|
+
&& (value as { protocol?: unknown }).protocol != null
|
|
52
|
+
|
|
53
|
+
const warnedAliases = new Set<string>()
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What `body`/`params`/`request` do with something that is not the plain callback they declare —
|
|
57
|
+
* the shape a handler module wraps its own export in when it is bound TWICE. `null` means the
|
|
58
|
+
* second argument really is a callback and the caller should wrap it as usual.
|
|
59
|
+
*
|
|
60
|
+
* A handler bound to THIS protocol is returned unchanged, with a one-time warning: the module
|
|
61
|
+
* already produced a working `BoundEntrypointHandler`, and re-wrapping it here would only wrap it
|
|
62
|
+
* again — returning it as-is is what makes `bind(protocol, api.body(protocol, alreadyBound))`
|
|
63
|
+
* behave exactly like `bind(protocol, alreadyBound)`, which is what a wrap-once file looks like at
|
|
64
|
+
* this call site. Anything else — a handler bound to a DIFFERENT protocol, or a value that is
|
|
65
|
+
* neither a function nor a bound handler at all — answers every request on this route with
|
|
66
|
+
* {@link HandlerMisconfiguredError} instead of throwing `TypeError: handler is not a function`
|
|
67
|
+
* out of the request pipeline's own `try`, where the type name and the alias it happened on are
|
|
68
|
+
* both lost.
|
|
69
|
+
*/
|
|
70
|
+
const toleratedHandler = <Protocol extends EntrypointProtocolDeclaration>(
|
|
71
|
+
protocol: Protocol, handler: unknown
|
|
72
|
+
): BoundEntrypointHandler<Protocol> | null => {
|
|
73
|
+
if (typeof handler === 'function') return null
|
|
74
|
+
|
|
75
|
+
if (isBoundHandler(handler)) {
|
|
76
|
+
if (handler.protocol.alias === protocol.alias) {
|
|
77
|
+
if (!warnedAliases.has(protocol.alias)) {
|
|
78
|
+
warnedAliases.add(protocol.alias)
|
|
79
|
+
logger('server-api').warn(
|
|
80
|
+
`${protocol.alias}: handler is already bound — pass it to bind() directly; `
|
|
81
|
+
+ 'wrapping it again here is a type error (TS2345 "BoundEntrypointHandler<…> is not '
|
|
82
|
+
+ 'assignable"), tolerated at runtime for a project generated before the wrap-once rule.'
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return handler as unknown as BoundEntrypointHandler<Protocol>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
|
|
90
|
+
throw new HandlerMisconfiguredError(
|
|
91
|
+
`${protocol.alias}: handler is bound to a different protocol ('${handler.protocol.alias}')`
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
|
|
97
|
+
throw new HandlerMisconfiguredError(`${protocol.alias}: handler is not a function`)
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Make server handlers that infer request sections and response values from their protocol.
|
|
103
|
+
* The protocol is passed once; application callbacks never name request or reply generics.
|
|
104
|
+
*/
|
|
105
|
+
export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
|
|
106
|
+
body: <Protocol extends EntrypointProtocolDeclaration>(
|
|
107
|
+
protocol: Protocol,
|
|
108
|
+
handler: Protocol extends BodyProtocol<Protocol> ? (
|
|
109
|
+
payload: BodyOf<Protocol>,
|
|
110
|
+
context: Context,
|
|
111
|
+
request: HandlerRequest<RequestOf<Protocol>>,
|
|
112
|
+
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
|
|
113
|
+
: never,
|
|
114
|
+
): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
|
|
115
|
+
(handler as (
|
|
116
|
+
payload: BodyOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
|
|
117
|
+
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.body as BodyOf<Protocol>, context, request)),
|
|
118
|
+
|
|
119
|
+
params: <Protocol extends EntrypointProtocolDeclaration>(
|
|
120
|
+
protocol: Protocol,
|
|
121
|
+
handler: Protocol extends ParamsProtocol<Protocol> ? (
|
|
122
|
+
payload: ParamsOf<Protocol>,
|
|
123
|
+
context: Context,
|
|
124
|
+
request: HandlerRequest<RequestOf<Protocol>>,
|
|
125
|
+
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
|
|
126
|
+
: never,
|
|
127
|
+
): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
|
|
128
|
+
(handler as (
|
|
129
|
+
payload: ParamsOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
|
|
130
|
+
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.params as ParamsOf<Protocol>, context, request)),
|
|
131
|
+
|
|
132
|
+
request: <Protocol extends EntrypointProtocolDeclaration>(
|
|
133
|
+
protocol: Protocol,
|
|
134
|
+
handler: (
|
|
135
|
+
request: HandlerRequest<RequestOf<Protocol>>,
|
|
136
|
+
context: Context,
|
|
137
|
+
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>,
|
|
138
|
+
): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, handler),
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
/** Access a Fastify-specific upload through an explicit boundary helper, never `request.original`. */
|
|
142
|
+
export const uploadedFile = async (
|
|
143
|
+
request: HandlerRequest<{ body: object }>,
|
|
144
|
+
): Promise<import('@fastify/multipart').MultipartFile | undefined> => {
|
|
145
|
+
const original = (request as AbstractRequest).original
|
|
146
|
+
if (original == null || typeof original !== 'object' || !('file' in original)) return undefined
|
|
147
|
+
|
|
148
|
+
return (original as import('fastify').FastifyRequest).file()
|
|
149
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -1,13 +1,16 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type { ServerContext } from '@owlmeans/server-context'
|
|
2
2
|
import type { ApiServer, ApiServerAppend } from './types.js'
|
|
3
|
-
import {
|
|
4
|
-
import { canServeModule,
|
|
3
|
+
import { assertContext, createService } from '@owlmeans/context'
|
|
4
|
+
import { canServeModule, payloadHelper, createServerHandler, fixFormatDates } from './utils/index.js'
|
|
5
5
|
import { DEFAULT_ALIAS, CLOSED_HOST, PORT, OPENED_HOST } from './consts.js'
|
|
6
6
|
import type { ServerEntrypoint } from '@owlmeans/server-entrypoint'
|
|
7
7
|
import { RouteMethod } from '@owlmeans/route'
|
|
8
|
-
import { createServerHandler, fixFormatDates } from './utils/index.js'
|
|
9
8
|
import { provideResponse } from '@owlmeans/entrypoint'
|
|
10
9
|
import { TOKEN_UPDATE } from '@owlmeans/auth-common'
|
|
10
|
+
import { INCIDENT_ID_HEADER } from './utils/consts.js'
|
|
11
|
+
import { DENIAL_KIND_HEADER } from '@owlmeans/api'
|
|
12
|
+
import { logger } from '@owlmeans/log'
|
|
13
|
+
import { fastifyLogger } from './utils/log.js'
|
|
11
14
|
|
|
12
15
|
import Fastify from 'fastify'
|
|
13
16
|
import type { FastifyRequest } from 'fastify'
|
|
@@ -20,6 +23,8 @@ import Multipart from '@fastify/multipart'
|
|
|
20
23
|
import formatsPlugin from 'ajv-formats'
|
|
21
24
|
import Ajv from 'ajv'
|
|
22
25
|
import ajvErrors from "ajv-errors"
|
|
26
|
+
import { bodyLimit } from './consts.local.js'
|
|
27
|
+
import type { Config, Context } from './types.local.js'
|
|
23
28
|
|
|
24
29
|
|
|
25
30
|
const ajv = new Ajv({
|
|
@@ -32,32 +37,25 @@ const ajv = new Ajv({
|
|
|
32
37
|
formatsPlugin(ajv)
|
|
33
38
|
ajvErrors(ajv, { singleError: true })
|
|
34
39
|
|
|
35
|
-
type Config = ServerConfig
|
|
36
|
-
type Context = ServerContext<Config>
|
|
37
40
|
|
|
38
|
-
const
|
|
41
|
+
const http = logger('http')
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Fastify logging through `@owlmeans/log`. Fastify's own request/response lines are dropped by the
|
|
45
|
+
* adapter: a request is a debug record written once from `onResponse` (below), and a failed one is
|
|
46
|
+
* classified by `handleError`, so an info-level process is not flooded with every call it serves.
|
|
47
|
+
*/
|
|
48
|
+
const createFastify = () => Fastify({
|
|
49
|
+
loggerInstance: fastifyLogger(http),
|
|
50
|
+
bodyLimit,
|
|
51
|
+
})
|
|
39
52
|
|
|
40
53
|
export const createApiServer = (alias: string): ApiServer => {
|
|
41
54
|
const location = `service:${alias}`
|
|
42
55
|
const _assertContext = (context: Context | undefined): Context => assertContext<Config, Context>(context, location)
|
|
43
56
|
|
|
44
57
|
const service = createService<ApiServer>(alias, {
|
|
45
|
-
server:
|
|
46
|
-
logger: true,
|
|
47
|
-
bodyLimit,
|
|
48
|
-
/*{
|
|
49
|
-
transport: {
|
|
50
|
-
target: 'pino-pretty',
|
|
51
|
-
options: {
|
|
52
|
-
singleLine: true,
|
|
53
|
-
translateTime: 'HH:MM:ss Z',
|
|
54
|
-
ignore: 'pid,hostname',
|
|
55
|
-
},
|
|
56
|
-
},
|
|
57
|
-
}*/
|
|
58
|
-
}),
|
|
59
|
-
|
|
60
|
-
layers: [Layer.System],
|
|
58
|
+
server: createFastify(),
|
|
61
59
|
|
|
62
60
|
listen: async () => {
|
|
63
61
|
const context = _assertContext(service.ctx as Context)
|
|
@@ -67,7 +65,7 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
67
65
|
const port = config?.internalPort ?? config?.port ?? PORT
|
|
68
66
|
const host = config.opened === true ? OPENED_HOST : CLOSED_HOST
|
|
69
67
|
await service.server.listen({ port, host })
|
|
70
|
-
|
|
68
|
+
logger('server').info(`${location}: server listening on ${host}${port != null ? `:${port}` : ''}`, undefined, { event: 'server.listening' })
|
|
71
69
|
|
|
72
70
|
process.on('SIGTERM', () => {
|
|
73
71
|
service.server.close().then(() => {
|
|
@@ -78,7 +76,7 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
78
76
|
}, service => async () => {
|
|
79
77
|
if (service.server.server.listening) {
|
|
80
78
|
await service.server.close()
|
|
81
|
-
service.server =
|
|
79
|
+
service.server = createFastify()
|
|
82
80
|
}
|
|
83
81
|
|
|
84
82
|
const context = _assertContext(service.ctx as Context)
|
|
@@ -86,6 +84,16 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
86
84
|
const server = service.server
|
|
87
85
|
// @TODO We should ensure some way that Resilient Error is thrown and go to the flow
|
|
88
86
|
server.setValidatorCompiler(opts => ajv.compile(opts.schema))
|
|
87
|
+
server.addHook('onResponse', (request, reply, done) => {
|
|
88
|
+
if (http.enabled('debug')) {
|
|
89
|
+
// The path only: a query string can carry a token.
|
|
90
|
+
http.debug('request', {
|
|
91
|
+
method: request.method, path: request.url.split('?')[0], status: reply.statusCode,
|
|
92
|
+
ms: Math.round(reply.elapsedTime),
|
|
93
|
+
})
|
|
94
|
+
}
|
|
95
|
+
done()
|
|
96
|
+
})
|
|
89
97
|
// @TODO It's quite unsafe and should be properly configured
|
|
90
98
|
await server.register(cors, {
|
|
91
99
|
origin: '*',
|
|
@@ -101,7 +109,7 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
101
109
|
...Object.values(RouteMethod).map(method => method.toUpperCase()),
|
|
102
110
|
'HEAD', 'OPTIONS'
|
|
103
111
|
],
|
|
104
|
-
exposedHeaders: [TOKEN_UPDATE]
|
|
112
|
+
exposedHeaders: [TOKEN_UPDATE, INCIDENT_ID_HEADER, DENIAL_KIND_HEADER]
|
|
105
113
|
})
|
|
106
114
|
|
|
107
115
|
await server.register(Multipart, {
|
|
@@ -133,59 +141,53 @@ export const createApiServer = (alias: string): ApiServer => {
|
|
|
133
141
|
return context
|
|
134
142
|
}
|
|
135
143
|
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
// Actually intermediate module can be created without handler by elevate function
|
|
139
|
-
if (module.route.match(request) && module.handle != null) {
|
|
144
|
+
// An intermediate route can be created without a handler by the binding layer.
|
|
145
|
+
if (module.route.match(request, module.mount()) && module.handle != null) {
|
|
140
146
|
const response = provideResponse(reply)
|
|
141
|
-
const currentRequest = provideRequest(module.alias, request, true)
|
|
147
|
+
const currentRequest = payloadHelper.provideRequest(module.alias, request, true)
|
|
142
148
|
currentRequest.original._ctx = context
|
|
143
149
|
const result: Context = await module.handle(currentRequest, response)
|
|
144
150
|
if (result != null) {
|
|
145
151
|
context = result
|
|
146
152
|
}
|
|
147
|
-
responded = executeResponse(response, reply, true) || responded
|
|
153
|
+
responded = payloadHelper.executeResponse(response, reply, true) || responded
|
|
148
154
|
}
|
|
149
155
|
return context
|
|
150
156
|
}, Promise.resolve(context))
|
|
151
157
|
})
|
|
152
158
|
|
|
153
|
-
|
|
154
|
-
context.
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
headers: fixFormatDates(module.filter?.headers ?? {})
|
|
184
|
-
},
|
|
185
|
-
handler: createServerHandler(module, location)
|
|
186
|
-
})
|
|
159
|
+
context.entrypoints<ServerEntrypoint<FastifyRequest>>()
|
|
160
|
+
.filter(module => canServeModule(context, module) && !module.route.isIntermediate())
|
|
161
|
+
.forEach(module => {
|
|
162
|
+
const method = module.route.route.method ?? RouteMethod.GET
|
|
163
|
+
if (module.handle == null) {
|
|
164
|
+
return
|
|
165
|
+
}
|
|
166
|
+
server.route({
|
|
167
|
+
url: module.mount(), method,
|
|
168
|
+
schema: {
|
|
169
|
+
consumes: [
|
|
170
|
+
'application/json',
|
|
171
|
+
'application/x-www-form-urlencoded',
|
|
172
|
+
'multipart/form-data',
|
|
173
|
+
],
|
|
174
|
+
querystring: fixFormatDates(module.filter?.query ?? {}),
|
|
175
|
+
...(
|
|
176
|
+
[
|
|
177
|
+
RouteMethod.POST,
|
|
178
|
+
RouteMethod.PATCH,
|
|
179
|
+
RouteMethod.PUT
|
|
180
|
+
].includes(method)
|
|
181
|
+
? { body: fixFormatDates(module.filter?.body ?? {}) }
|
|
182
|
+
: {}
|
|
183
|
+
),
|
|
184
|
+
params: fixFormatDates(module.filter?.params ?? {}),
|
|
185
|
+
response: module.filter?.response,
|
|
186
|
+
headers: fixFormatDates(module.filter?.headers ?? {})
|
|
187
|
+
},
|
|
188
|
+
handler: createServerHandler(module, location)
|
|
187
189
|
})
|
|
188
|
-
|
|
190
|
+
})
|
|
189
191
|
|
|
190
192
|
service.initialized = true
|
|
191
193
|
})
|
|
@@ -207,5 +209,3 @@ export const appendApiServer = <C extends Config, T extends ServerContext<C>>(
|
|
|
207
209
|
|
|
208
210
|
return context
|
|
209
211
|
}
|
|
210
|
-
|
|
211
|
-
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { EntrypointProtocolDeclaration, OpenValue, RequestOf } from '@owlmeans/entrypoint'
|
|
2
|
+
import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint'
|
|
3
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* True for a response the declaration left with no compile-time contract: `typed<any>()` (an
|
|
7
|
+
* `[any] extends [X]` conditional does not produce `any`'s usual both-branches union once the
|
|
8
|
+
* checked type is wrapped in a tuple, so it resolves to a single branch like any other type) and
|
|
9
|
+
* a response inferred as bare `typed()` with nothing to infer from, which resolves to `undefined`
|
|
10
|
+
* with no shape to validate a handler's return against — the same signal a zero-argument
|
|
11
|
+
* `contract()` produces for a genuinely response-less route. Neither means "this handler must
|
|
12
|
+
* return void": it means the declaration did not constrain the response, so the handler is free to
|
|
13
|
+
* return whatever its route actually sends.
|
|
14
|
+
*/
|
|
15
|
+
export type IsUntypedResponse<T> = 0 extends (1 & T) ? true : [T] extends [undefined] ? true : false
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The type a bound handler is allowed to return for a given protocol response.
|
|
19
|
+
*
|
|
20
|
+
* A concretely typed or schema-backed `Response` is returned unchanged — the handler must produce
|
|
21
|
+
* exactly that shape. An untyped one (see `IsUntypedResponse` above) relaxes to `any`: the
|
|
22
|
+
* declaration made no promise about the response, so nothing here should force a handler bound to
|
|
23
|
+
* it into returning `void`.
|
|
24
|
+
*/
|
|
25
|
+
export type HandlerResponse<Response> = IsUntypedResponse<Response> extends true ? any : Response
|
|
26
|
+
|
|
27
|
+
export type MaybePromise<Value> = Value | Promise<Value>
|
|
28
|
+
|
|
29
|
+
export type BodyProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends { body: OpenValue }
|
|
30
|
+
? Protocol : never
|
|
31
|
+
|
|
32
|
+
export type ParamsProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends { params: object }
|
|
33
|
+
? Protocol : never
|
|
34
|
+
|
|
35
|
+
export type Config = ServerConfig
|
|
36
|
+
|
|
37
|
+
export type Context = ServerContext<Config>
|
|
38
|
+
|
|
39
|
+
export interface ContextCarrier<Context> {
|
|
40
|
+
_ctx?: Context
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Structural, not `instanceof`: a `BoundEntrypointHandler` crosses no class boundary a runtime check could use. */
|
|
44
|
+
export type AnyBoundHandler = BoundEntrypointHandler<EntrypointProtocolDeclaration>
|
package/src/types.ts
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
|
|
2
|
-
import type { InitializedService
|
|
2
|
+
import type { InitializedService } from '@owlmeans/context'
|
|
3
3
|
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
4
4
|
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
5
5
|
import '@fastify/middie/types/index.d.ts'
|
|
6
|
+
import type { MultipartFile } from '@fastify/multipart'
|
|
6
7
|
|
|
7
8
|
export interface ApiServer extends InitializedService {
|
|
8
9
|
server: FastifyInstance
|
|
9
10
|
|
|
10
|
-
layers: [Layer.System]
|
|
11
|
-
|
|
12
11
|
listen: () => Promise<void>
|
|
13
12
|
}
|
|
14
13
|
|
|
@@ -20,7 +19,42 @@ export interface ApiServerAppend {
|
|
|
20
19
|
getApiServer: () => ApiServer
|
|
21
20
|
}
|
|
22
21
|
|
|
23
|
-
export
|
|
22
|
+
export type HttpErrorExposure = 'production' | 'development'
|
|
23
|
+
|
|
24
|
+
export interface HttpErrorsConfig {
|
|
25
|
+
/** Production is the safe default; development is the only mode that exposes stacks. */
|
|
26
|
+
exposure?: HttpErrorExposure
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface HttpConfig {
|
|
30
|
+
errors?: HttpErrorsConfig
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface Config extends ServerConfig {
|
|
34
|
+
http?: HttpConfig
|
|
35
|
+
}
|
|
24
36
|
|
|
25
37
|
export interface Context<C extends Config = Config> extends ServerContext<C>,
|
|
26
38
|
ApiServerAppend { }
|
|
39
|
+
|
|
40
|
+
export interface UploadedFile extends MultipartFile { }
|
|
41
|
+
|
|
42
|
+
export interface ApiPortHoldOptions {
|
|
43
|
+
/**
|
|
44
|
+
* The one path answered 200 while the hold is in place — the app's health endpoint, so
|
|
45
|
+
* whatever supervises the process keeps a conventional liveness answer through the boot.
|
|
46
|
+
* Everything else answers 503 with the same body. Absent, everything is 503.
|
|
47
|
+
*/
|
|
48
|
+
okPath?: string
|
|
49
|
+
/** Body of every answer, evaluated per request so a changing boot phase is reported live. */
|
|
50
|
+
payload?: () => unknown
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ApiPortHold {
|
|
54
|
+
/**
|
|
55
|
+
* Free the port for the real server. Awaits the actual close — `listen()` follows immediately,
|
|
56
|
+
* and a predecessor still bound makes it throw — and force-closes keep-alive sockets, so a
|
|
57
|
+
* supervisor's own poller cannot hold the release open.
|
|
58
|
+
*/
|
|
59
|
+
release: () => Promise<void>
|
|
60
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const INCIDENT_ID_HEADER = 'X-Incident-ID'
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Config, Context } from '../../types.js'
|
|
2
|
+
|
|
3
|
+
/** Carries the serving context on one raw request. */
|
|
4
|
+
export interface RequestContextHelper {
|
|
5
|
+
/** Stores the context on the request. */
|
|
6
|
+
populateContext: <C extends Config, T extends Context<C>>(context: T) => void
|
|
7
|
+
/**
|
|
8
|
+
* The context stored on the request, else `ctx`.
|
|
9
|
+
*
|
|
10
|
+
* @throws {SyntaxError} when neither is there
|
|
11
|
+
*/
|
|
12
|
+
extractContext: <C extends Config, T extends Context<C>>(ctx?: T, location?: string) => T
|
|
13
|
+
}
|
package/src/utils/context.ts
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
import { assertContext } from '@owlmeans/context'
|
|
2
2
|
import type { Config, Context, Request } from '../types.js'
|
|
3
3
|
import { DEFAULT_ALIAS } from '../consts.js'
|
|
4
|
+
import type { RequestContextHelper } from './context/types.js'
|
|
4
5
|
|
|
5
|
-
export const
|
|
6
|
-
|
|
6
|
+
export const makeRequestContextHelper = (req: Request): RequestContextHelper => {
|
|
7
|
+
const populateContext = <C extends Config, T extends Context<C>>(context: T): void =>
|
|
8
|
+
void ((req as any)._ctx = context)
|
|
7
9
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
+
const extractContext = <C extends Config, T extends Context<C>>(ctx?: T, location?: string): T =>
|
|
11
|
+
assertContext<C, T>((req as any)._ctx ?? ctx, location ?? DEFAULT_ALIAS)
|
|
12
|
+
|
|
13
|
+
return { populateContext, extractContext }
|
|
14
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FastifyReply } from 'fastify'
|
|
2
|
+
import type { Config, HttpErrorExposure } from '../../types.js'
|
|
3
|
+
import type { SerializedHttpError } from '../types.js'
|
|
4
|
+
|
|
5
|
+
/** How the HTTP boundary answers a thrown error: its status, its wire body, its headers and its log entry. */
|
|
6
|
+
export interface HttpErrorHelper {
|
|
7
|
+
/**
|
|
8
|
+
* The HTTP status an error's class declares, or `null` when it declares none that may be honoured.
|
|
9
|
+
*
|
|
10
|
+
* Read through the constructor chain — a static property is inherited, so a subclass answers what
|
|
11
|
+
* its nearest declaring ancestor says, and redeclares to change it. An integer 4xx is honoured
|
|
12
|
+
* directly. A 5xx is honoured only with `allowServerErrorStatus = true`, so a domain class must
|
|
13
|
+
* explicitly opt in to exposing a precise availability response. The read is structural, so a
|
|
14
|
+
* class from a duplicate module copy declares exactly what the imported one does.
|
|
15
|
+
*/
|
|
16
|
+
declaredErrorStatus: (error: unknown) => number | null
|
|
17
|
+
/**
|
|
18
|
+
* The HTTP status an error is answered with, read off the error exactly as given.
|
|
19
|
+
*
|
|
20
|
+
* Two families reach here and both must be recognised. `AuthFailedError` / `AccessError` are this
|
|
21
|
+
* package's own, raised by the request pipeline itself. `AuthorizationError` / `AuthForbidden`
|
|
22
|
+
* come from `@owlmeans/auth` and are what the guards and gates actually throw — an unrecognised
|
|
23
|
+
* one answers 500, which reports a refused request as a crashed server: the client cannot tell
|
|
24
|
+
* "sign in again" from "the service is broken", and a consumer watching statuses concludes the
|
|
25
|
+
* application fell over. A family member is recognised by class OR by registered type name, so a
|
|
26
|
+
* class from a duplicate module copy answers the same status.
|
|
27
|
+
*
|
|
28
|
+
* **Order matters.** `AuthForbidden extends AuthorizationError`, so the 403 branch has to be
|
|
29
|
+
* tested first or every refusal of a permission is reported as a failure to authenticate. Both
|
|
30
|
+
* auth branches come before a class's own declaration, so an auth refusal can never be restated
|
|
31
|
+
* as something else. Everything that is neither an auth error nor a class declaring an allowed
|
|
32
|
+
* {@link HttpErrorHelper.declaredErrorStatus} answers 500.
|
|
33
|
+
*/
|
|
34
|
+
errorStatus: (error: unknown) => number
|
|
35
|
+
/** Invalid or absent configuration always resolves to the production-safe policy. */
|
|
36
|
+
errorExposure: (config?: Pick<Config, 'http'>) => HttpErrorExposure
|
|
37
|
+
/** One central wire shape for every server error. */
|
|
38
|
+
serializeError: (error: Error, exposure?: HttpErrorExposure) => SerializedHttpError
|
|
39
|
+
/** Apply safe response metadata carried by an error without coupling its package to Fastify. */
|
|
40
|
+
applyErrorHeaders: (error: unknown, reply: FastifyReply) => void
|
|
41
|
+
/**
|
|
42
|
+
* Answer a thrown error: a status, and the marshalled `ResilientError` as the body.
|
|
43
|
+
*
|
|
44
|
+
* The status is resolved in two steps. The error AS THROWN comes first: it is the one object whose
|
|
45
|
+
* class is certainly the class that was raised, while `ResilientError.ensure` rebuilds through the
|
|
46
|
+
* registry of whichever `@owlmeans/error` copy this module loaded — under duplicate module copies
|
|
47
|
+
* that turns a perfectly recognisable auth refusal into a plain `ResilientError` answering 500.
|
|
48
|
+
* Only when the thrown error answers 500 is the ENSURED error asked, because the rebuild is what
|
|
49
|
+
* gives a status to a marshalled error that crossed a hop as a plain `Error`.
|
|
50
|
+
*/
|
|
51
|
+
handleError: (error: Error, reply: FastifyReply, exposure?: HttpErrorExposure) => void
|
|
52
|
+
}
|