@owlmeans/server-api 0.1.18-rc.25 → 0.1.18-rc.26
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 +179 -45
- package/agent-meta/manifest.json +2 -2
- package/agent-meta/skills/server-api/SKILL.md +26 -8
- 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/protocol.d.ts +20 -1
- package/build/protocol.d.ts.map +1 -1
- package/build/protocol.js +48 -3
- package/build/protocol.js.map +1 -1
- package/package.json +13 -11
- package/src/errors.ts +18 -0
- package/src/protocol.ts +85 -4
- package/tests/handlers.spec.ts +83 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +1 -0
package/README.md
CHANGED
|
@@ -1,78 +1,212 @@
|
|
|
1
1
|
# @owlmeans/server-api
|
|
2
2
|
|
|
3
|
-
Fastify
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
-
|
|
9
|
-
- `createApiServer` / `appendApiServer` initialize the HTTP server in a context
|
|
10
|
-
- Not typically used directly — import handlers from `@owlmeans/server-app`
|
|
3
|
+
The Fastify HTTP server of an OwlMeans backend and the protocol-bound handler factories that serve
|
|
4
|
+
it. An application imports `handlers<Context>()` in every HTTP handler module (often through the
|
|
5
|
+
`@owlmeans/server-app` re-export) and `holdApiPort` for a guarded boot. The server itself —
|
|
6
|
+
`appendApiServer` — is already added by `@owlmeans/server-app`'s `makeContext`; call it directly
|
|
7
|
+
only when composing a context without that package. WebSocket routes belong to
|
|
8
|
+
`@owlmeans/server-socket`, and calls to other services go through the `@owlmeans/api` client.
|
|
11
9
|
|
|
12
10
|
## Installation
|
|
13
11
|
|
|
14
12
|
```bash
|
|
15
|
-
bun add @owlmeans/server-api@^0.1.18-rc.
|
|
13
|
+
bun add @owlmeans/server-api@^0.1.18-rc.25
|
|
16
14
|
```
|
|
17
15
|
|
|
16
|
+
## Concepts
|
|
17
|
+
|
|
18
|
+
- **API server** — the `api-server` service (`context.getApiServer()`), holding a `FastifyInstance`
|
|
19
|
+
and `listen()`. At context init it builds its route table from the registered entrypoints: backend
|
|
20
|
+
routes of this `cfg.service` only, excluding socket and queue protocols.
|
|
21
|
+
- **Request pipeline** — intermediates run first; then the protocol's guards (the first guard whose
|
|
22
|
+
`match` hits handles the request and sets `request.auth`, and the organization entity is attached);
|
|
23
|
+
then its gates (`gate.assert`); then the handler.
|
|
24
|
+
- **Protocol-bound handler** — `handlers<Context>().body / .params / .request` infer the request
|
|
25
|
+
sections and the response type from the declaration. A returned value resolves the entrypoint with
|
|
26
|
+
`EntrypointOutcome.Ok` (HTTP 200); a thrown error rejects it.
|
|
27
|
+
- **Validation** — the contract's schemas are compiled with AJV (`removeAdditional`, `useDefaults`,
|
|
28
|
+
`coerceTypes`), so a handler receives cleaned, coerced input.
|
|
29
|
+
- **Error mapping** — `AuthForbidden` / `AccessError` answer 403, `AuthorizationError` /
|
|
30
|
+
`AuthFailedError` answer 401, anything else 500, with the marshalled `ResilientError` as the body.
|
|
31
|
+
|
|
18
32
|
## Usage
|
|
19
33
|
|
|
20
|
-
|
|
34
|
+
### Body, params and request handlers
|
|
21
35
|
|
|
22
|
-
```
|
|
36
|
+
```ts
|
|
23
37
|
import { handlers } from '@owlmeans/server-api'
|
|
24
38
|
import { bind } from '@owlmeans/server-entrypoint'
|
|
25
|
-
import {
|
|
39
|
+
import { requireEntityKey } from '@owlmeans/auth-common'
|
|
40
|
+
import { projectProtocols } from 'my-app-common'
|
|
41
|
+
import type { Context } from './context.js'
|
|
26
42
|
|
|
27
43
|
const api = handlers<Context>()
|
|
28
44
|
|
|
29
|
-
//
|
|
30
|
-
const create = api.body(
|
|
31
|
-
|
|
32
|
-
return await ctx.project().create({ ...payload, entityId: req.entity!.id })
|
|
33
|
-
})
|
|
45
|
+
// body: the validated body first, then context and the full request
|
|
46
|
+
const create = api.body(projectProtocols.create, async (payload, context, request) =>
|
|
47
|
+
context.project().create({ ...payload, entityId: requireEntityKey(request) }))
|
|
34
48
|
|
|
35
|
-
//
|
|
36
|
-
const get = api.params(
|
|
37
|
-
|
|
38
|
-
})
|
|
49
|
+
// params: the validated URL params first
|
|
50
|
+
const get = api.params(projectProtocols.get, async ({ id }, context, request) =>
|
|
51
|
+
context.project().load({ id, entityId: requireEntityKey(request) }))
|
|
39
52
|
|
|
40
|
-
//
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
})
|
|
53
|
+
// request: every typed section plus request metadata (auth, entity, headers)
|
|
54
|
+
const search = api.request(projectProtocols.search, async (request, context) =>
|
|
55
|
+
context.project().list({ entityId: requireEntityKey(request), ...request.query }))
|
|
44
56
|
|
|
45
|
-
export const
|
|
46
|
-
bind(
|
|
47
|
-
bind(
|
|
48
|
-
bind(
|
|
57
|
+
export const serverBindings = [
|
|
58
|
+
bind(projectProtocols.base),
|
|
59
|
+
bind(projectProtocols.create, create),
|
|
60
|
+
bind(projectProtocols.get, get),
|
|
61
|
+
bind(projectProtocols.search, search),
|
|
49
62
|
]
|
|
50
63
|
```
|
|
51
64
|
|
|
52
|
-
|
|
65
|
+
### Refusing a request
|
|
66
|
+
|
|
67
|
+
Guards and gates run before the handler; a handler still checks ownership and throws a typed error,
|
|
68
|
+
which the server maps onto a status.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
import { AuthForbidden } from '@owlmeans/auth'
|
|
72
|
+
|
|
73
|
+
// the protocol declares both `params` and `body`
|
|
74
|
+
export const rename = api.body(projectProtocols.rename, async ({ name }, context, request) => {
|
|
75
|
+
const entityId = requireEntityKey(request)
|
|
76
|
+
const project = await context.project().load({ id: request.params.id, entityId })
|
|
77
|
+
if (project == null) {
|
|
78
|
+
throw new AuthForbidden('project') // 403
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return context.project().update({ ...project, name })
|
|
82
|
+
})
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
### File upload
|
|
53
86
|
|
|
54
|
-
|
|
87
|
+
`uploadedFile(request)` is the only place a handler touches the Fastify multipart API. The protocol
|
|
88
|
+
must declare a `body` section.
|
|
55
89
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
90
|
+
```ts
|
|
91
|
+
import { handlers, NoFileError, uploadedFile } from '@owlmeans/server-api'
|
|
92
|
+
|
|
93
|
+
export const upload = api.request(invoiceProtocols.attach, async (request, context) => {
|
|
94
|
+
const file = await uploadedFile(request)
|
|
95
|
+
if (file == null) {
|
|
96
|
+
throw new NoFileError()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const content = await file.toBuffer()
|
|
100
|
+
|
|
101
|
+
return context.attachment().create({
|
|
102
|
+
entityId: requireEntityKey(request),
|
|
103
|
+
name: file.filename,
|
|
104
|
+
mimetype: file.mimetype,
|
|
105
|
+
size: content.length,
|
|
106
|
+
})
|
|
107
|
+
})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Holding the port during boot
|
|
111
|
+
|
|
112
|
+
```ts
|
|
113
|
+
import { holdApiPort } from '@owlmeans/server-api'
|
|
114
|
+
|
|
115
|
+
const hold = await holdApiPort(cfg, { okPath: '/healthz' }) // 200 on /healthz, 503 elsewhere
|
|
116
|
+
await context.configure().init()
|
|
117
|
+
await hold.release()
|
|
118
|
+
await context.getApiServer().listen()
|
|
62
119
|
```
|
|
63
120
|
|
|
64
|
-
|
|
65
|
-
|
|
121
|
+
### A context without `@owlmeans/server-app`
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { makeServerContext } from '@owlmeans/server-context'
|
|
125
|
+
import { appendApiServer } from '@owlmeans/server-api'
|
|
66
126
|
|
|
67
|
-
|
|
127
|
+
const context = appendApiServer(makeServerContext(cfg))
|
|
128
|
+
context.registerEntrypoints(serverBindings)
|
|
129
|
+
await context.configure().init()
|
|
130
|
+
|
|
131
|
+
// the Fastify instance, for plugins the framework does not register
|
|
132
|
+
context.getApiServer().server.addHook('onResponse', async (request, reply) => {
|
|
133
|
+
console.log(request.url, reply.statusCode)
|
|
134
|
+
})
|
|
68
135
|
|
|
69
|
-
|
|
136
|
+
await context.getApiServer().listen()
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## API
|
|
70
140
|
|
|
71
|
-
|
|
141
|
+
### Root export
|
|
142
|
+
|
|
143
|
+
| Symbol | Kind | Purpose |
|
|
144
|
+
|---|---|---|
|
|
145
|
+
| `handlers<Context>()` | function | Factories `body(protocol, fn)`, `params(protocol, fn)`, `request(protocol, fn)` returning `BoundEntrypointHandler<Protocol>` |
|
|
146
|
+
| `uploadedFile(request)` | function | The multipart file of a request, or `undefined` |
|
|
147
|
+
| `extractUploadedFile(req)` | function | Same boundary for an untyped `AbstractRequest`; returns `Promise<UploadedFile \| undefined>` |
|
|
148
|
+
| `holdApiPort(cfg, opts?)` | function | Bind the service port before init; returns `ApiPortHold` with `release()` |
|
|
149
|
+
| `createApiServer(alias)` | function | Create the Fastify API server service |
|
|
150
|
+
| `appendApiServer(ctx, alias = DEFAULT_ALIAS)` | function | Register the API server and `context.getApiServer()` |
|
|
151
|
+
| `handleBody`, `handleParams`, `handleRequest`, `handleIntermediate` | function | Unbound compatibility wrappers returning `RefedEntrypointHandler` |
|
|
152
|
+
| `AuthFailedError`, `AccessError`, `NoFileError` | class | Registered `ResilientError` subclasses (401, 403, 500) |
|
|
153
|
+
| `DEFAULT_ALIAS` | const | `'api-server'` |
|
|
154
|
+
| `PORT` | const | `80`, used when the service route declares no port |
|
|
155
|
+
| `CLOSED_HOST`, `OPENED_HOST` | const | `'127.0.0.1'` and `'0.0.0.0'`; a service with `opened: true` listens on the second |
|
|
156
|
+
| `ApiServer`, `ApiServerAppend` | type | The server service and its context mixin |
|
|
157
|
+
| `ApiPortHold`, `ApiPortHoldOptions` | type | `holdApiPort` result and options (`okPath?`, `payload?`) |
|
|
158
|
+
| `Config`, `Context` | type | `ServerConfig` and `ServerContext & ApiServerAppend` |
|
|
159
|
+
| `Request`, `Response` | type | Fastify request and reply |
|
|
160
|
+
| `UploadedFile` | type | Fastify `MultipartFile` |
|
|
161
|
+
|
|
162
|
+
### `./utils` subpath
|
|
163
|
+
|
|
164
|
+
For transport packages (such as `@owlmeans/server-socket`) that reuse the HTTP pipeline; application
|
|
165
|
+
code does not need it.
|
|
166
|
+
|
|
167
|
+
| Symbol | Kind | Purpose |
|
|
168
|
+
|---|---|---|
|
|
169
|
+
| `authorize(context, module, req, reply)` | function | Run guards, set `auth`, call `attachEntity` |
|
|
170
|
+
| `createServerHandler(module, location)` | function | The Fastify handler: authorize, gates, handle, respond |
|
|
171
|
+
| `canServeModule(context, module)` | function | Whether an entrypoint belongs on this HTTP server |
|
|
172
|
+
| `provideRequest(alias, req, provision?)` | function | Build an `AbstractRequest` from a Fastify request |
|
|
173
|
+
| `executeResponse(response, reply, throwOnError?)` | function | Send an `AbstractResponse` onto a reply |
|
|
174
|
+
| `handleError(error, reply)` | function | Map an error onto 401 / 403 / 500 |
|
|
175
|
+
| `fixFormatDates(schema)` | function | Rewrite `date-time` object schemas as strings |
|
|
176
|
+
| `populateContext(req, context)`, `extractContext(req, ctx?, location?)` | function | Carry the request-scoped context on the raw request |
|
|
177
|
+
|
|
178
|
+
## Common pitfalls
|
|
179
|
+
|
|
180
|
+
- `.body` and `.params` exist only for a protocol that declares that section; for anything else use
|
|
181
|
+
`.request`.
|
|
182
|
+
- Do not reach into `request.original` from application code — `uploadedFile(request)` is the
|
|
183
|
+
multipart boundary.
|
|
184
|
+
- Prefer `handlers<Context>()` over the unbound `handle*` wrappers, and never hand-write a
|
|
185
|
+
`RefedEntrypointHandler` for an HTTP route.
|
|
186
|
+
- A handler is wrapped by `handlers<Context>()` **exactly once**: wrap a plain function where it is
|
|
187
|
+
bound (`bind(p, api.body(p, create))`), or bind the already-bound export directly
|
|
188
|
+
(`const create = api.body(p, ...)` then `bind(p, create)`) — never both. `tsc` rejects a double
|
|
189
|
+
wrap (`TS2345 "BoundEntrypointHandler<…> is not assignable"`); at runtime a handler already bound
|
|
190
|
+
to the SAME protocol is returned unchanged with a one-time console warning, and anything else that
|
|
191
|
+
is not a plain function fails that one route with `HandlerMisconfiguredError` instead of the
|
|
192
|
+
opaque `TypeError: handler is not a function`.
|
|
193
|
+
- A guard only authenticates. Gates authorize, and the handler keeps its organization check keyed on
|
|
194
|
+
`requireEntityKey(request)` — never an id read from the token.
|
|
195
|
+
- `request.entity` is absent when no entity resolver is registered; `requireEntityKey` falls back to
|
|
196
|
+
the slug there, `requireEntity` throws.
|
|
197
|
+
- A handler always answers 200 on success. The port comes from `cfg.services[cfg.service]`
|
|
198
|
+
(`internalPort ?? port ?? 80`).
|
|
199
|
+
- A `holdApiPort` bind failure must end the process with a non-zero exit.
|
|
200
|
+
- WebSocket routes use `connection(protocol, callback)` from `@owlmeans/server-socket`.
|
|
201
|
+
|
|
202
|
+
## Related packages
|
|
72
203
|
|
|
73
|
-
- [`@owlmeans/server-app`](../server-app) — application bootstrap and convenience re-exports
|
|
74
204
|
- [`@owlmeans/server-entrypoint`](../server-entrypoint) — `bind()` attaches handlers to protocols
|
|
75
|
-
- [`@owlmeans/server-
|
|
205
|
+
- [`@owlmeans/server-app`](../server-app) — application bootstrap and re-exports
|
|
206
|
+
- [`@owlmeans/server-socket`](../server-socket) — the WebSocket counterpart of `handlers`
|
|
207
|
+
- [`@owlmeans/entrypoint`](../entrypoint) — `contract`, `typed`, request/response types
|
|
208
|
+
- [`@owlmeans/auth-common`](../auth-common) — `requireEntityKey`, `attachEntity`, guard aliases
|
|
209
|
+
- [`@owlmeans/api`](../api) — the client that calls these endpoints from other services
|
|
76
210
|
|
|
77
211
|
<!-- owlmeans:agent-guidance:start -->
|
|
78
212
|
## Agent guidance
|
|
@@ -82,7 +216,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
82
216
|
your project's skill store (`.agents/skills/`):
|
|
83
217
|
|
|
84
218
|
```sh
|
|
85
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
219
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.21
|
|
86
220
|
```
|
|
87
221
|
|
|
88
222
|
The embedded files are version-matched to this package release. Do not edit them
|
package/agent-meta/manifest.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@owlmeans/server-api",
|
|
4
|
-
"version": "0.1.18-rc.
|
|
5
|
-
"generatedAt": "2026-09-
|
|
4
|
+
"version": "0.1.18-rc.26",
|
|
5
|
+
"generatedAt": "2026-09-15T12:21:31.872Z",
|
|
6
6
|
"canonicalRepo": "https://github.com/owlmeans/common",
|
|
7
7
|
"entries": [
|
|
8
8
|
{
|
|
@@ -7,7 +7,7 @@ user-invocable: false
|
|
|
7
7
|
|
|
8
8
|
# @owlmeans/server-api
|
|
9
9
|
|
|
10
|
-
**Install:** `bun add @owlmeans/server-api@^0.1.18-rc.
|
|
10
|
+
**Install:** `bun add @owlmeans/server-api@^0.1.18-rc.25`
|
|
11
11
|
|
|
12
12
|
Make handlers from the protocol declaration so input and output types stay coupled to the shared
|
|
13
13
|
contract:
|
|
@@ -15,22 +15,22 @@ contract:
|
|
|
15
15
|
```ts
|
|
16
16
|
const api = handlers<AppContext>()
|
|
17
17
|
|
|
18
|
-
const create = api.body(
|
|
18
|
+
const create = api.body(projectProtocols.create, async (body, context, request) =>
|
|
19
19
|
context.projects.create(body, request.auth)
|
|
20
20
|
)
|
|
21
21
|
|
|
22
|
-
const get = api.params(
|
|
22
|
+
const get = api.params(projectProtocols.get, async ({ id }, context) =>
|
|
23
23
|
context.projects.get(id)
|
|
24
24
|
)
|
|
25
25
|
|
|
26
|
-
const search = api.request(
|
|
26
|
+
const search = api.request(projectProtocols.search, async (request, context) =>
|
|
27
27
|
context.projects.search(request.query)
|
|
28
28
|
)
|
|
29
29
|
|
|
30
|
-
export const
|
|
31
|
-
bind(
|
|
32
|
-
bind(
|
|
33
|
-
bind(
|
|
30
|
+
export const serverBindings = [
|
|
31
|
+
bind(projectProtocols.create, create),
|
|
32
|
+
bind(projectProtocols.get, get),
|
|
33
|
+
bind(projectProtocols.search, search),
|
|
34
34
|
]
|
|
35
35
|
```
|
|
36
36
|
|
|
@@ -38,6 +38,24 @@ export const entrypoints = [
|
|
|
38
38
|
any protocol and receives all its typed sections plus request metadata. A successful callback
|
|
39
39
|
return resolves the entrypoint with `EntrypointOutcome.Ok`; a thrown error rejects it.
|
|
40
40
|
|
|
41
|
+
## Wrap exactly once
|
|
42
|
+
|
|
43
|
+
A handler is wrapped by `handlers<Context>()` exactly once — either shape above (create the bound
|
|
44
|
+
handler and bind it directly) is correct on its own. Never combine them: a handler module that
|
|
45
|
+
already exports a bound handler must be bound directly, not wrapped again where it is bound.
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
// WRONG — bound once in the handler module, wrapped a second time here
|
|
49
|
+
export const create = api.body(projectProtocols.create, async (body, context) => ...)
|
|
50
|
+
bind(projectProtocols.create, api.body(projectProtocols.create, create))
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
`tsc` rejects the double wrap (`TS2345 "Argument of type 'BoundEntrypointHandler<…>' is not
|
|
54
|
+
assignable"`). At runtime, `body`/`params`/`request` return an already-bound handler for the SAME
|
|
55
|
+
protocol unchanged, with a one-time warning; anything else that is not a plain function fails only
|
|
56
|
+
that one route with `HandlerMisconfiguredError`, instead of the opaque
|
|
57
|
+
`TypeError: handler is not a function`.
|
|
58
|
+
|
|
41
59
|
`uploadedFile(request)` is the Fastify multipart boundary. Keep raw Fastify access there rather
|
|
42
60
|
than reaching through `request.original` in application code.
|
|
43
61
|
|
package/build/errors.d.ts
CHANGED
|
@@ -11,4 +11,16 @@ export declare class NoFileError extends ApiError {
|
|
|
11
11
|
static typeName: string;
|
|
12
12
|
constructor();
|
|
13
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* `handlers().body/params/request` was handed something it cannot serve — not a plain callback,
|
|
16
|
+
* and not a `BoundEntrypointHandler` bound to the SAME protocol it is being wrapped for. Thrown
|
|
17
|
+
* from inside the bound handler's own request path, so the module still loads and every OTHER
|
|
18
|
+
* route on it still answers; only the misconfigured one reports why, as a 500 rather than the
|
|
19
|
+
* opaque `TypeError: handler is not a function` a double wrap used to fail with. See
|
|
20
|
+
* `handlers()` in `protocol.ts` for what does and does not reach here.
|
|
21
|
+
*/
|
|
22
|
+
export declare class HandlerMisconfiguredError extends ApiError {
|
|
23
|
+
static typeName: string;
|
|
24
|
+
constructor(message?: string);
|
|
25
|
+
}
|
|
14
26
|
//# sourceMappingURL=errors.d.ts.map
|
package/build/errors.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAGxC,qBAAa,eAAgB,SAAQ,QAAQ;IAC3C,OAAuB,QAAQ,SAAoB;IAEnD,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAsC;IAErE,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAgB;IAE/C,cAGC;CACF"}
|
|
1
|
+
{"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AAGxC,qBAAa,eAAgB,SAAQ,QAAQ;IAC3C,OAAuB,QAAQ,SAAoB;IAEnD,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAsC;IAErE,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF;AAED,qBAAa,WAAY,SAAQ,QAAQ;IACvC,OAAuB,QAAQ,SAAgB;IAE/C,cAGC;CACF;AAED;;;;;;;GAOG;AACH,qBAAa,yBAA0B,SAAQ,QAAQ;IACrD,OAAuB,QAAQ,SAA8B;IAE7D,YAAY,OAAO,GAAE,MAAgB,EAGpC;CACF"}
|
package/build/errors.js
CHANGED
|
@@ -21,7 +21,23 @@ export class NoFileError extends ApiError {
|
|
|
21
21
|
this.type = NoFileError.typeName;
|
|
22
22
|
}
|
|
23
23
|
}
|
|
24
|
+
/**
|
|
25
|
+
* `handlers().body/params/request` was handed something it cannot serve — not a plain callback,
|
|
26
|
+
* and not a `BoundEntrypointHandler` bound to the SAME protocol it is being wrapped for. Thrown
|
|
27
|
+
* from inside the bound handler's own request path, so the module still loads and every OTHER
|
|
28
|
+
* route on it still answers; only the misconfigured one reports why, as a 500 rather than the
|
|
29
|
+
* opaque `TypeError: handler is not a function` a double wrap used to fail with. See
|
|
30
|
+
* `handlers()` in `protocol.ts` for what does and does not reach here.
|
|
31
|
+
*/
|
|
32
|
+
export class HandlerMisconfiguredError extends ApiError {
|
|
33
|
+
static typeName = 'HandlerMisconfiguredError';
|
|
34
|
+
constructor(message = 'error') {
|
|
35
|
+
super(`handler:misconfigured:${message}`);
|
|
36
|
+
this.type = HandlerMisconfiguredError.typeName;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
24
39
|
ResilientError.registerErrorClass(AuthFailedError);
|
|
25
40
|
ResilientError.registerErrorClass(AccessError);
|
|
26
41
|
ResilientError.registerErrorClass(NoFileError);
|
|
42
|
+
ResilientError.registerErrorClass(HandlerMisconfiguredError);
|
|
27
43
|
//# sourceMappingURL=errors.js.map
|
package/build/errors.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,MAAM,OAAO,eAAgB,SAAQ,QAAQ;IACpC,MAAM,CAAU,QAAQ,GAAG,iBAAiB,CAAA;IAEnD,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAA;QACxB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC,QAAQ,CAAA;IACtC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,SAAS,eAAe,CAAC,QAAQ,EAAE,CAAA;IAErE,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;QAC1B,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,aAAa,CAAA;IAE/C;QACE,KAAK,CAAC,SAAS,CAAC,CAAA;QAChB,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED,cAAc,CAAC,kBAAkB,CAAC,eAAe,CAAC,CAAA;AAClD,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA;AAC9C,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA"}
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAA;AACxC,OAAO,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAA;AAEhD,MAAM,OAAO,eAAgB,SAAQ,QAAQ;IACpC,MAAM,CAAU,QAAQ,GAAG,iBAAiB,CAAA;IAEnD,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,QAAQ,OAAO,EAAE,CAAC,CAAA;QACxB,IAAI,CAAC,IAAI,GAAG,eAAe,CAAC,QAAQ,CAAA;IACtC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,SAAS,eAAe,CAAC,QAAQ,EAAE,CAAA;IAErE,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,UAAU,OAAO,EAAE,CAAC,CAAA;QAC1B,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED,MAAM,OAAO,WAAY,SAAQ,QAAQ;IAChC,MAAM,CAAU,QAAQ,GAAG,aAAa,CAAA;IAE/C;QACE,KAAK,CAAC,SAAS,CAAC,CAAA;QAChB,IAAI,CAAC,IAAI,GAAG,WAAW,CAAC,QAAQ,CAAA;IAClC,CAAC;CACF;AAED;;;;;;;GAOG;AACH,MAAM,OAAO,yBAA0B,SAAQ,QAAQ;IAC9C,MAAM,CAAU,QAAQ,GAAG,2BAA2B,CAAA;IAE7D,YAAY,OAAO,GAAW,OAAO;QACnC,KAAK,CAAC,yBAAyB,OAAO,EAAE,CAAC,CAAA;QACzC,IAAI,CAAC,IAAI,GAAG,yBAAyB,CAAC,QAAQ,CAAA;IAChD,CAAC;CACF;AAED,cAAc,CAAC,kBAAkB,CAAC,eAAe,CAAC,CAAA;AAClD,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA;AAC9C,cAAc,CAAC,kBAAkB,CAAC,WAAW,CAAC,CAAA;AAC9C,cAAc,CAAC,kBAAkB,CAAC,yBAAyB,CAAC,CAAA"}
|
package/build/protocol.d.ts
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
import type { BasicConfig, BasicContext } from '@owlmeans/context';
|
|
2
2
|
import type { BodyOf, EntrypointProtocolDeclaration, HandlerRequest, OpenValue, ParamsOf, RequestOf, ResponseOf } from '@owlmeans/entrypoint';
|
|
3
3
|
import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint';
|
|
4
|
-
|
|
4
|
+
/**
|
|
5
|
+
* True for a response the declaration left with no compile-time contract: `typed<any>()` (an
|
|
6
|
+
* `[any] extends [X]` conditional does not produce `any`'s usual both-branches union once the
|
|
7
|
+
* checked type is wrapped in a tuple, so it resolves to a single branch like any other type) and
|
|
8
|
+
* a response inferred as bare `typed()` with nothing to infer from, which resolves to `undefined`
|
|
9
|
+
* with no shape to validate a handler's return against — the same signal a zero-argument
|
|
10
|
+
* `contract()` produces for a genuinely response-less route. Neither means "this handler must
|
|
11
|
+
* return void": it means the declaration did not constrain the response, so the handler is free to
|
|
12
|
+
* return whatever its route actually sends.
|
|
13
|
+
*/
|
|
14
|
+
type IsUntypedResponse<T> = 0 extends (1 & T) ? true : [T] extends [undefined] ? true : false;
|
|
15
|
+
/**
|
|
16
|
+
* The type a bound handler is allowed to return for a given protocol response.
|
|
17
|
+
*
|
|
18
|
+
* A concretely typed or schema-backed `Response` is returned unchanged — the handler must produce
|
|
19
|
+
* exactly that shape. An untyped one (see `IsUntypedResponse` above) relaxes to `any`: the
|
|
20
|
+
* declaration made no promise about the response, so nothing here should force a handler bound to
|
|
21
|
+
* it into returning `void`.
|
|
22
|
+
*/
|
|
23
|
+
type HandlerResponse<Response> = IsUntypedResponse<Response> extends true ? any : Response;
|
|
5
24
|
type MaybePromise<Value> = Value | Promise<Value>;
|
|
6
25
|
type BodyProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends {
|
|
7
26
|
body: OpenValue;
|
package/build/protocol.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAElE,OAAO,KAAK,EACV,MAAM,EAAE,6BAA6B,EAAE,cAAc,EAAe,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAC/G,MAAM,sBAAsB,CAAA;AAE7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAA;
|
|
1
|
+
{"version":3,"file":"protocol.d.ts","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAA;AAElE,OAAO,KAAK,EACV,MAAM,EAAE,6BAA6B,EAAE,cAAc,EAAe,SAAS,EAAE,QAAQ,EAAE,SAAS,EAAE,UAAU,EAC/G,MAAM,sBAAsB,CAAA;AAE7B,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,6BAA6B,CAAA;AAGzE;;;;;;;;;GASG;AACH,KAAK,iBAAiB,CAAC,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,GAAG,CAAC,CAAC,GAAG,IAAI,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC,SAAS,CAAC,GAAG,IAAI,GAAG,KAAK,CAAA;AAE7F;;;;;;;GAOG;AACH,KAAK,eAAe,CAAC,QAAQ,IAAI,iBAAiB,CAAC,QAAQ,CAAC,SAAS,IAAI,GAAG,GAAG,GAAG,QAAQ,CAAA;AAC1F,KAAK,YAAY,CAAC,KAAK,IAAI,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC,CAAA;AACjD,KAAK,YAAY,CAAC,QAAQ,SAAS,6BAA6B,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS;IAAE,IAAI,EAAE,SAAS,CAAA;CAAE,GAC/G,QAAQ,GAAG,KAAK,CAAA;AACpB,KAAK,cAAc,CAAC,QAAQ,SAAS,6BAA6B,IAAI,SAAS,CAAC,QAAQ,CAAC,SAAS;IAAE,MAAM,EAAE,MAAM,CAAA;CAAE,GAChH,QAAQ,GAAG,KAAK,CAAA;AAqGpB;;;GAGG;AACH,eAAO,MAAM,QAAQ,GAAI,OAAO,SAAS,YAAY,CAAC,WAAW,CAAC;IAChE,IAAI,GAAG,QAAQ,SAAS,6BAA6B,YACzC,QAAQ,WACT,QAAQ,SAAS,YAAY,CAAC,QAAQ,CAAC,GAAG,CAC/C,OAAO,EAAE,MAAM,CAAC,QAAQ,CAAC,EACzB,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,KACzC,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,GACtD,KAAK,KACR,sBAAsB,CAAC,QAAQ,CAAC;IAKnC,MAAM,GAAG,QAAQ,SAAS,6BAA6B,YAC3C,QAAQ,WACT,QAAQ,SAAS,cAAc,CAAC,QAAQ,CAAC,GAAG,CACjD,OAAO,EAAE,QAAQ,CAAC,QAAQ,CAAC,EAC3B,OAAO,EAAE,OAAO,EAChB,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,KACzC,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,GACtD,KAAK,KACR,sBAAsB,CAAC,QAAQ,CAAC;IAKnC,OAAO,GAAG,QAAQ,SAAS,6BAA6B,YAC5C,QAAQ,WACT,CACP,OAAO,EAAE,cAAc,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC,EAC5C,OAAO,EAAE,OAAO,KACb,YAAY,CAAC,eAAe,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC,KACvD,sBAAsB,CAAC,QAAQ,CAAC;CACnC,CAAA;AAEF,sGAAsG;AACtG,eAAO,MAAM,YAAY,YACd,cAAc,CAAC;IAAE,IAAI,EAAE,MAAM,CAAA;CAAE,CAAC,KACxC,OAAO,CAAC,OAAO,oBAAoB,EAAE,aAAa,GAAG,SAAS,CAKhE,CAAA"}
|
package/build/protocol.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { assertContext } from '@owlmeans/context';
|
|
2
2
|
import { EntrypointOutcome } from '@owlmeans/entrypoint';
|
|
3
|
+
import { HandlerMisconfiguredError } from './errors.js';
|
|
3
4
|
const contextFor = (request, fallback) => {
|
|
4
5
|
const original = request.original;
|
|
5
6
|
if (original != null && typeof original === 'object' && '_ctx' in original) {
|
|
@@ -22,14 +23,58 @@ const bind = (protocol, execute) => ({
|
|
|
22
23
|
return response.value;
|
|
23
24
|
},
|
|
24
25
|
});
|
|
26
|
+
/**
|
|
27
|
+
* `value` is shaped like a `BoundEntrypointHandler` — never a plain function, which also carries
|
|
28
|
+
* its own `.bind` and would otherwise pass a `typeof value.bind === 'function'` check alone.
|
|
29
|
+
*/
|
|
30
|
+
const isBoundHandler = (value) => typeof value === 'object' && value != null
|
|
31
|
+
&& typeof value.bind === 'function'
|
|
32
|
+
&& value.protocol != null;
|
|
33
|
+
const warnedAliases = new Set();
|
|
34
|
+
/**
|
|
35
|
+
* What `body`/`params`/`request` do with something that is not the plain callback they declare —
|
|
36
|
+
* the shape a handler module wraps its own export in when it is bound TWICE. `null` means the
|
|
37
|
+
* second argument really is a callback and the caller should wrap it as usual.
|
|
38
|
+
*
|
|
39
|
+
* A handler bound to THIS protocol is returned unchanged, with a one-time warning: the module
|
|
40
|
+
* already produced a working `BoundEntrypointHandler`, and re-wrapping it here would only wrap it
|
|
41
|
+
* again — returning it as-is is what makes `bind(protocol, api.body(protocol, alreadyBound))`
|
|
42
|
+
* behave exactly like `bind(protocol, alreadyBound)`, which is what a wrap-once file looks like at
|
|
43
|
+
* this call site. Anything else — a handler bound to a DIFFERENT protocol, or a value that is
|
|
44
|
+
* neither a function nor a bound handler at all — answers every request on this route with
|
|
45
|
+
* {@link HandlerMisconfiguredError} instead of throwing `TypeError: handler is not a function`
|
|
46
|
+
* out of the request pipeline's own `try`, where the type name and the alias it happened on are
|
|
47
|
+
* both lost.
|
|
48
|
+
*/
|
|
49
|
+
const toleratedHandler = (protocol, handler) => {
|
|
50
|
+
if (typeof handler === 'function')
|
|
51
|
+
return null;
|
|
52
|
+
if (isBoundHandler(handler)) {
|
|
53
|
+
if (handler.protocol.alias === protocol.alias) {
|
|
54
|
+
if (!warnedAliases.has(protocol.alias)) {
|
|
55
|
+
warnedAliases.add(protocol.alias);
|
|
56
|
+
console.warn(`[server-api] ${protocol.alias}: handler is already bound — pass it to bind() directly; `
|
|
57
|
+
+ 'wrapping it again here is a type error (TS2345 "BoundEntrypointHandler<…> is not '
|
|
58
|
+
+ 'assignable"), tolerated at runtime for a project generated before the wrap-once rule.');
|
|
59
|
+
}
|
|
60
|
+
return handler;
|
|
61
|
+
}
|
|
62
|
+
return bind(protocol, () => {
|
|
63
|
+
throw new HandlerMisconfiguredError(`${protocol.alias}: handler is bound to a different protocol ('${handler.protocol.alias}')`);
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
return bind(protocol, () => {
|
|
67
|
+
throw new HandlerMisconfiguredError(`${protocol.alias}: handler is not a function`);
|
|
68
|
+
});
|
|
69
|
+
};
|
|
25
70
|
/**
|
|
26
71
|
* Make server handlers that infer request sections and response values from their protocol.
|
|
27
72
|
* The protocol is passed once; application callbacks never name request or reply generics.
|
|
28
73
|
*/
|
|
29
74
|
export const handlers = () => ({
|
|
30
|
-
body: (protocol, handler) => bind(protocol, (request, context) => handler(request.body, context, request)),
|
|
31
|
-
params: (protocol, handler) => bind(protocol, (request, context) => handler(request.params, context, request)),
|
|
32
|
-
request: (protocol, handler) => bind(protocol, handler),
|
|
75
|
+
body: (protocol, handler) => toleratedHandler(protocol, handler) ?? bind(protocol, (request, context) => handler(request.body, context, request)),
|
|
76
|
+
params: (protocol, handler) => toleratedHandler(protocol, handler) ?? bind(protocol, (request, context) => handler(request.params, context, request)),
|
|
77
|
+
request: (protocol, handler) => toleratedHandler(protocol, handler) ?? bind(protocol, handler),
|
|
33
78
|
});
|
|
34
79
|
/** Access a Fastify-specific upload through an explicit boundary helper, never `request.original`. */
|
|
35
80
|
export const uploadedFile = async (request) => {
|
package/build/protocol.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,mBAAmB,CAAA;AAEjD,OAAO,EAAE,iBAAiB,EAAE,MAAM,sBAAsB,CAAA;
|
|
1
|
+
{"version":3,"file":"protocol.js","sourceRoot":"","sources":["../src/protocol.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/server-api",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.26",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
7
7
|
"build": "tsc -b",
|
|
8
8
|
"dev": "sleep 264 && nodemon -e ts,tsx,json --watch src --exec \"tsc -p ./tsconfig.json\"",
|
|
9
|
-
"watch": "tsc -b -w --preserveWatchOutput --pretty"
|
|
9
|
+
"watch": "tsc -b -w --preserveWatchOutput --pretty",
|
|
10
|
+
"test": "bun test ./tests"
|
|
10
11
|
},
|
|
11
12
|
"main": "build/index.js",
|
|
12
13
|
"module": "build/index.js",
|
|
@@ -32,15 +33,15 @@
|
|
|
32
33
|
"@fastify/helmet": "^13.1.0",
|
|
33
34
|
"@fastify/middie": "^9.0.2",
|
|
34
35
|
"@fastify/multipart": "^9.0.1",
|
|
35
|
-
"@owlmeans/api": "^0.1.18-rc.
|
|
36
|
-
"@owlmeans/auth": "^0.1.18-rc.
|
|
37
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
38
|
-
"@owlmeans/context": "^0.1.18-rc.
|
|
39
|
-
"@owlmeans/error": "^0.1.18-rc.
|
|
40
|
-
"@owlmeans/entrypoint": "^0.1.18-rc.
|
|
41
|
-
"@owlmeans/route": "^0.1.18-rc.
|
|
42
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
43
|
-
"@owlmeans/server-entrypoint": "^0.1.18-rc.
|
|
36
|
+
"@owlmeans/api": "^0.1.18-rc.22",
|
|
37
|
+
"@owlmeans/auth": "^0.1.18-rc.18",
|
|
38
|
+
"@owlmeans/auth-common": "^0.1.18-rc.22",
|
|
39
|
+
"@owlmeans/context": "^0.1.18-rc.17",
|
|
40
|
+
"@owlmeans/error": "^0.1.18-rc.17",
|
|
41
|
+
"@owlmeans/entrypoint": "^0.1.18-rc.20",
|
|
42
|
+
"@owlmeans/route": "^0.1.18-rc.18",
|
|
43
|
+
"@owlmeans/server-context": "^0.1.18-rc.21",
|
|
44
|
+
"@owlmeans/server-entrypoint": "^0.1.18-rc.20",
|
|
44
45
|
"ajv-errors": "^3.0.0",
|
|
45
46
|
"fastify": "^5.0.0",
|
|
46
47
|
"fastify-raw-body": "^5.0.0",
|
|
@@ -52,6 +53,7 @@
|
|
|
52
53
|
},
|
|
53
54
|
"devDependencies": {
|
|
54
55
|
"@owlmeans/dep-config": "workspace:*",
|
|
56
|
+
"@types/bun": "^1.4.0",
|
|
55
57
|
"@types/node": "^26.1.0",
|
|
56
58
|
"nodemon": "^3.1.14",
|
|
57
59
|
"typescript": "^7.0.2"
|
package/src/errors.ts
CHANGED
|
@@ -29,7 +29,25 @@ export class NoFileError extends ApiError {
|
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* `handlers().body/params/request` was handed something it cannot serve — not a plain callback,
|
|
34
|
+
* and not a `BoundEntrypointHandler` bound to the SAME protocol it is being wrapped for. Thrown
|
|
35
|
+
* from inside the bound handler's own request path, so the module still loads and every OTHER
|
|
36
|
+
* route on it still answers; only the misconfigured one reports why, as a 500 rather than the
|
|
37
|
+
* opaque `TypeError: handler is not a function` a double wrap used to fail with. See
|
|
38
|
+
* `handlers()` in `protocol.ts` for what does and does not reach here.
|
|
39
|
+
*/
|
|
40
|
+
export class HandlerMisconfiguredError extends ApiError {
|
|
41
|
+
public static override typeName = 'HandlerMisconfiguredError'
|
|
42
|
+
|
|
43
|
+
constructor(message: string = 'error') {
|
|
44
|
+
super(`handler:misconfigured:${message}`)
|
|
45
|
+
this.type = HandlerMisconfiguredError.typeName
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
32
49
|
ResilientError.registerErrorClass(AuthFailedError)
|
|
33
50
|
ResilientError.registerErrorClass(AccessError)
|
|
34
51
|
ResilientError.registerErrorClass(NoFileError)
|
|
52
|
+
ResilientError.registerErrorClass(HandlerMisconfiguredError)
|
|
35
53
|
|
package/src/protocol.ts
CHANGED
|
@@ -6,8 +6,29 @@ import type {
|
|
|
6
6
|
} from '@owlmeans/entrypoint'
|
|
7
7
|
import type { AbstractRequest } from '@owlmeans/entrypoint'
|
|
8
8
|
import type { BoundEntrypointHandler } from '@owlmeans/server-entrypoint'
|
|
9
|
+
import { HandlerMisconfiguredError } from './errors.js'
|
|
9
10
|
|
|
10
|
-
|
|
11
|
+
/**
|
|
12
|
+
* True for a response the declaration left with no compile-time contract: `typed<any>()` (an
|
|
13
|
+
* `[any] extends [X]` conditional does not produce `any`'s usual both-branches union once the
|
|
14
|
+
* checked type is wrapped in a tuple, so it resolves to a single branch like any other type) and
|
|
15
|
+
* a response inferred as bare `typed()` with nothing to infer from, which resolves to `undefined`
|
|
16
|
+
* with no shape to validate a handler's return against — the same signal a zero-argument
|
|
17
|
+
* `contract()` produces for a genuinely response-less route. Neither means "this handler must
|
|
18
|
+
* return void": it means the declaration did not constrain the response, so the handler is free to
|
|
19
|
+
* return whatever its route actually sends.
|
|
20
|
+
*/
|
|
21
|
+
type IsUntypedResponse<T> = 0 extends (1 & T) ? true : [T] extends [undefined] ? true : false
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The type a bound handler is allowed to return for a given protocol response.
|
|
25
|
+
*
|
|
26
|
+
* A concretely typed or schema-backed `Response` is returned unchanged — the handler must produce
|
|
27
|
+
* exactly that shape. An untyped one (see `IsUntypedResponse` above) relaxes to `any`: the
|
|
28
|
+
* declaration made no promise about the response, so nothing here should force a handler bound to
|
|
29
|
+
* it into returning `void`.
|
|
30
|
+
*/
|
|
31
|
+
type HandlerResponse<Response> = IsUntypedResponse<Response> extends true ? any : Response
|
|
11
32
|
type MaybePromise<Value> = Value | Promise<Value>
|
|
12
33
|
type BodyProtocol<Protocol extends EntrypointProtocolDeclaration> = RequestOf<Protocol> extends { body: OpenValue }
|
|
13
34
|
? Protocol : never
|
|
@@ -53,6 +74,66 @@ const bind = <Protocol extends EntrypointProtocolDeclaration, Context extends Ba
|
|
|
53
74
|
},
|
|
54
75
|
})
|
|
55
76
|
|
|
77
|
+
/** Structural, not `instanceof`: a `BoundEntrypointHandler` crosses no class boundary a runtime check could use. */
|
|
78
|
+
type AnyBoundHandler = BoundEntrypointHandler<EntrypointProtocolDeclaration>
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `value` is shaped like a `BoundEntrypointHandler` — never a plain function, which also carries
|
|
82
|
+
* its own `.bind` and would otherwise pass a `typeof value.bind === 'function'` check alone.
|
|
83
|
+
*/
|
|
84
|
+
const isBoundHandler = (value: unknown): value is AnyBoundHandler =>
|
|
85
|
+
typeof value === 'object' && value != null
|
|
86
|
+
&& typeof (value as { bind?: unknown }).bind === 'function'
|
|
87
|
+
&& (value as { protocol?: unknown }).protocol != null
|
|
88
|
+
|
|
89
|
+
const warnedAliases = new Set<string>()
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* What `body`/`params`/`request` do with something that is not the plain callback they declare —
|
|
93
|
+
* the shape a handler module wraps its own export in when it is bound TWICE. `null` means the
|
|
94
|
+
* second argument really is a callback and the caller should wrap it as usual.
|
|
95
|
+
*
|
|
96
|
+
* A handler bound to THIS protocol is returned unchanged, with a one-time warning: the module
|
|
97
|
+
* already produced a working `BoundEntrypointHandler`, and re-wrapping it here would only wrap it
|
|
98
|
+
* again — returning it as-is is what makes `bind(protocol, api.body(protocol, alreadyBound))`
|
|
99
|
+
* behave exactly like `bind(protocol, alreadyBound)`, which is what a wrap-once file looks like at
|
|
100
|
+
* this call site. Anything else — a handler bound to a DIFFERENT protocol, or a value that is
|
|
101
|
+
* neither a function nor a bound handler at all — answers every request on this route with
|
|
102
|
+
* {@link HandlerMisconfiguredError} instead of throwing `TypeError: handler is not a function`
|
|
103
|
+
* out of the request pipeline's own `try`, where the type name and the alias it happened on are
|
|
104
|
+
* both lost.
|
|
105
|
+
*/
|
|
106
|
+
const toleratedHandler = <Protocol extends EntrypointProtocolDeclaration>(
|
|
107
|
+
protocol: Protocol, handler: unknown
|
|
108
|
+
): BoundEntrypointHandler<Protocol> | null => {
|
|
109
|
+
if (typeof handler === 'function') return null
|
|
110
|
+
|
|
111
|
+
if (isBoundHandler(handler)) {
|
|
112
|
+
if (handler.protocol.alias === protocol.alias) {
|
|
113
|
+
if (!warnedAliases.has(protocol.alias)) {
|
|
114
|
+
warnedAliases.add(protocol.alias)
|
|
115
|
+
console.warn(
|
|
116
|
+
`[server-api] ${protocol.alias}: handler is already bound — pass it to bind() directly; `
|
|
117
|
+
+ 'wrapping it again here is a type error (TS2345 "BoundEntrypointHandler<…> is not '
|
|
118
|
+
+ 'assignable"), tolerated at runtime for a project generated before the wrap-once rule.'
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return handler as unknown as BoundEntrypointHandler<Protocol>
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
|
|
126
|
+
throw new HandlerMisconfiguredError(
|
|
127
|
+
`${protocol.alias}: handler is bound to a different protocol ('${handler.protocol.alias}')`
|
|
128
|
+
)
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return bind<Protocol, BasicContext<BasicConfig>>(protocol, () => {
|
|
133
|
+
throw new HandlerMisconfiguredError(`${protocol.alias}: handler is not a function`)
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
|
|
56
137
|
/**
|
|
57
138
|
* Make server handlers that infer request sections and response values from their protocol.
|
|
58
139
|
* The protocol is passed once; application callbacks never name request or reply generics.
|
|
@@ -66,7 +147,7 @@ export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
|
|
|
66
147
|
request: HandlerRequest<RequestOf<Protocol>>,
|
|
67
148
|
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
|
|
68
149
|
: never,
|
|
69
|
-
): BoundEntrypointHandler<Protocol> => bind<Protocol, Context>(protocol, (request, context) =>
|
|
150
|
+
): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
|
|
70
151
|
(handler as (
|
|
71
152
|
payload: BodyOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
|
|
72
153
|
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.body as BodyOf<Protocol>, context, request)),
|
|
@@ -79,7 +160,7 @@ export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
|
|
|
79
160
|
request: HandlerRequest<RequestOf<Protocol>>,
|
|
80
161
|
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>
|
|
81
162
|
: never,
|
|
82
|
-
): BoundEntrypointHandler<Protocol> => bind<Protocol, Context>(protocol, (request, context) =>
|
|
163
|
+
): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, (request, context) =>
|
|
83
164
|
(handler as (
|
|
84
165
|
payload: ParamsOf<Protocol>, context: Context, request: HandlerRequest<RequestOf<Protocol>>,
|
|
85
166
|
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>)(request.params as ParamsOf<Protocol>, context, request)),
|
|
@@ -90,7 +171,7 @@ export const handlers = <Context extends BasicContext<BasicConfig>>() => ({
|
|
|
90
171
|
request: HandlerRequest<RequestOf<Protocol>>,
|
|
91
172
|
context: Context,
|
|
92
173
|
) => MaybePromise<HandlerResponse<ResponseOf<Protocol>>>,
|
|
93
|
-
): BoundEntrypointHandler<Protocol> => bind<Protocol, Context>(protocol, handler),
|
|
174
|
+
): BoundEntrypointHandler<Protocol> => toleratedHandler(protocol, handler) ?? bind<Protocol, Context>(protocol, handler),
|
|
94
175
|
})
|
|
95
176
|
|
|
96
177
|
/** Access a Fastify-specific upload through an explicit boundary helper, never `request.original`. */
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { EntrypointProtocolDeclaration } from '@owlmeans/entrypoint'
|
|
3
|
+
import { handlers } from '../src/protocol.js'
|
|
4
|
+
import { HandlerMisconfiguredError } from '../src/errors.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* A protocol-bound implementation is run with the smallest binding context the transport needs —
|
|
8
|
+
* same pattern as `@owlmeans/server-auth-token`'s own `handlers.spec.ts`.
|
|
9
|
+
*/
|
|
10
|
+
const invoke = async (handler: any, context: any, req: any = {}): Promise<any> => {
|
|
11
|
+
const res: any = { resolve: (value: unknown) => { res.value = value }, reject: (e: Error) => { res.error = e } }
|
|
12
|
+
await handler.bind({ ref: { ctx: context } })(req, res)
|
|
13
|
+
if (res.error != null) throw res.error
|
|
14
|
+
|
|
15
|
+
return res.value
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const protocolFor = (alias: string): EntrypointProtocolDeclaration =>
|
|
19
|
+
({ alias } as unknown as EntrypointProtocolDeclaration)
|
|
20
|
+
|
|
21
|
+
const context = { alias: 'test-context' } as any
|
|
22
|
+
const api = handlers<any>()
|
|
23
|
+
|
|
24
|
+
describe('@owlmeans/server-api — handlers() wrap-once tolerance', () => {
|
|
25
|
+
test('a plain function is wrapped once and called with the context', async () => {
|
|
26
|
+
const protocol = protocolFor('api.thing.plain')
|
|
27
|
+
const handler = api.request(protocol, async (_req: any, ctx: any) => ({ ok: true, ctx }))
|
|
28
|
+
|
|
29
|
+
const result = await invoke(handler, context, {})
|
|
30
|
+
expect(result).toEqual({ ok: true, ctx: context })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('a handler already bound to the SAME protocol is returned unchanged by body/params/request and still answers', async () => {
|
|
34
|
+
for (const method of ['body', 'params', 'request'] as const) {
|
|
35
|
+
const protocol = protocolFor(`api.thing.${method}`)
|
|
36
|
+
const bound = api.request(protocol, async () => ({ from: method }))
|
|
37
|
+
|
|
38
|
+
const wrapped = (api as any)[method](protocol, bound)
|
|
39
|
+
expect(wrapped).toBe(bound)
|
|
40
|
+
|
|
41
|
+
const result = await invoke(wrapped, context, {})
|
|
42
|
+
expect(result).toEqual({ from: method })
|
|
43
|
+
}
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
test('a handler bound to a DIFFERENT protocol answers with HandlerMisconfiguredError', async () => {
|
|
47
|
+
const protocolA = protocolFor('api.thing.mismatch-a')
|
|
48
|
+
const protocolB = protocolFor('api.thing.mismatch-b')
|
|
49
|
+
const boundToA = api.request(protocolA, async () => ({ from: 'a' }))
|
|
50
|
+
|
|
51
|
+
const wrapped = api.request(protocolB, boundToA as any)
|
|
52
|
+
expect(wrapped).not.toBe(boundToA)
|
|
53
|
+
await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('a value that is neither a function nor a bound handler answers with HandlerMisconfiguredError', async () => {
|
|
57
|
+
const protocol = protocolFor('api.thing.not-a-function')
|
|
58
|
+
const wrapped = api.request(protocol, { not: 'a function' } as any)
|
|
59
|
+
|
|
60
|
+
await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test('the double-wrap warning fires once per alias, not once per call', async () => {
|
|
64
|
+
const protocol = protocolFor('api.thing.warn-once')
|
|
65
|
+
const bound = api.request(protocol, async () => 'ok')
|
|
66
|
+
|
|
67
|
+
const warnings: unknown[][] = []
|
|
68
|
+
const original = console.warn
|
|
69
|
+
console.warn = (...args: unknown[]) => { warnings.push(args) }
|
|
70
|
+
try {
|
|
71
|
+
// Each call is a deliberate double wrap — the exact shape `tsc` rejects at the real call
|
|
72
|
+
// site (TS2345) — so it is exercised here through `any`, the same way `body`/`params`
|
|
73
|
+
// are reached dynamically above.
|
|
74
|
+
(api as any).request(protocol, bound);
|
|
75
|
+
(api as any).body(protocol, bound);
|
|
76
|
+
(api as any).params(protocol, bound)
|
|
77
|
+
} finally {
|
|
78
|
+
console.warn = original
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
expect(warnings.length).toBe(1)
|
|
82
|
+
})
|
|
83
|
+
})
|