@owlmeans/server-api 0.1.18-rc.44 → 0.1.18-rc.46
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
CHANGED
|
@@ -10,7 +10,7 @@ only when composing a context without that package. WebSocket routes belong to
|
|
|
10
10
|
## Installation
|
|
11
11
|
|
|
12
12
|
```bash
|
|
13
|
-
bun add @owlmeans/server-api@^0.1.18-rc.
|
|
13
|
+
bun add @owlmeans/server-api@^0.1.18-rc.46
|
|
14
14
|
```
|
|
15
15
|
|
|
16
16
|
## Concepts
|
|
@@ -240,7 +240,7 @@ This package ships embedded agent skills under `agent-meta/`. After installing y
|
|
|
240
240
|
your project's skill store (`.agents/skills/`):
|
|
241
241
|
|
|
242
242
|
```sh
|
|
243
|
-
npx @owlmeans/agent-skills@^0.1.18-rc.
|
|
243
|
+
npx @owlmeans/agent-skills@^0.1.18-rc.44
|
|
244
244
|
```
|
|
245
245
|
|
|
246
246
|
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.46",
|
|
5
|
+
"generatedAt": "2026-09-30T09:41:02.819Z",
|
|
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.46`
|
|
11
11
|
|
|
12
12
|
Make handlers from the protocol declaration so input and output types stay coupled to the shared
|
|
13
13
|
contract:
|
|
@@ -38,6 +38,12 @@ export const serverBindings = [
|
|
|
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
|
+
A body contract may be a scalar (`schema<string>({ type: 'string' })`, a number, a boolean): the
|
|
42
|
+
server's JSON parser takes any JSON value, so `"abc"`, `42` or `false` reach the handler as that
|
|
43
|
+
value. An unquoted string or an empty body under `application/json` is invalid JSON and answers
|
|
44
|
+
400 before the handler runs — `@owlmeans/api` never sends either (it quotes a scalar body).
|
|
45
|
+
`tests/json-body.spec.ts` runs it through `appendApiServer`.
|
|
46
|
+
|
|
41
47
|
## Wrap exactly once
|
|
42
48
|
|
|
43
49
|
A handler is wrapped by `handlers<Context>()` exactly once — either shape above (create the bound
|
|
@@ -74,6 +80,9 @@ exposure) and a status from `errorStatus(error)` (`./utils`), resolved in this o
|
|
|
74
80
|
that is understood and refused — and a missing configuration, a broken peer, a timeout or a bug
|
|
75
81
|
stays 500, because monitoring, logs, proxies and retry logic read a 5xx as the server failing.
|
|
76
82
|
The table and the leaf-class rule are the `error` skill's.
|
|
83
|
+
- The storage refusals of `@owlmeans/resource` declare theirs: `UnknownRecordError` answers 404,
|
|
84
|
+
`RecordExists` 409, every other `ResourceError` 500 — so a handler that lets a `get()` miss
|
|
85
|
+
escape answers 404, and one reading a row the request did not address uses `load` instead.
|
|
77
86
|
- Declare it on the class: `public static httpStatus = 409` (`override` only when an ancestor
|
|
78
87
|
already declares one). The declaration is structural — the package declaring an error never
|
|
79
88
|
imports `@owlmeans/server-api` — and a static property is inherited, so a subclass answers its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@owlmeans/server-api",
|
|
3
|
-
"version": "0.1.18-rc.
|
|
3
|
+
"version": "0.1.18-rc.46",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -33,14 +33,14 @@
|
|
|
33
33
|
"@fastify/helmet": "^13.1.0",
|
|
34
34
|
"@fastify/middie": "^9.0.2",
|
|
35
35
|
"@fastify/multipart": "^9.0.1",
|
|
36
|
-
"@owlmeans/api": "^0.1.18-rc.
|
|
36
|
+
"@owlmeans/api": "^0.1.18-rc.42",
|
|
37
37
|
"@owlmeans/auth": "^0.1.18-rc.36",
|
|
38
|
-
"@owlmeans/auth-common": "^0.1.18-rc.
|
|
38
|
+
"@owlmeans/auth-common": "^0.1.18-rc.42",
|
|
39
39
|
"@owlmeans/context": "^0.1.18-rc.32",
|
|
40
40
|
"@owlmeans/error": "^0.1.18-rc.36",
|
|
41
41
|
"@owlmeans/entrypoint": "^0.1.18-rc.37",
|
|
42
42
|
"@owlmeans/route": "^0.1.18-rc.33",
|
|
43
|
-
"@owlmeans/server-context": "^0.1.18-rc.
|
|
43
|
+
"@owlmeans/server-context": "^0.1.18-rc.38",
|
|
44
44
|
"@owlmeans/server-entrypoint": "^0.1.18-rc.36",
|
|
45
45
|
"ajv-errors": "^3.0.0",
|
|
46
46
|
"fastify": "^5.0.0",
|
|
@@ -53,6 +53,7 @@
|
|
|
53
53
|
},
|
|
54
54
|
"devDependencies": {
|
|
55
55
|
"@owlmeans/dep-config": "workspace:*",
|
|
56
|
+
"@owlmeans/resource": "^0.1.18-rc.37",
|
|
56
57
|
"@types/bun": "^1.4.0",
|
|
57
58
|
"@types/node": "^26.1.0",
|
|
58
59
|
"nodemon": "^3.1.14",
|
|
@@ -7,7 +7,11 @@ import { provideResponse } from '@owlmeans/entrypoint'
|
|
|
7
7
|
import { errorExposure, errorStatus, handleError, INCIDENT_ID_HEADER } from '../src/utils/error.js'
|
|
8
8
|
import { executeResponse } from '../src/utils/payload.js'
|
|
9
9
|
import { AccessError, AuthFailedError } from '../src/errors.js'
|
|
10
|
-
import { DENIAL_KIND_HEADER, ACCESS_DENIED_KIND } from '@owlmeans/api'
|
|
10
|
+
import { DENIAL_KIND_HEADER, ACCESS_DENIED_KIND, httpStatusOf } from '@owlmeans/api'
|
|
11
|
+
import {
|
|
12
|
+
MigrationError, MisshapedRecord, RecordExists, RecordUpdateFailed, UnknownRecordError,
|
|
13
|
+
UnsupportedArgumentError, UnsupportedMethodError,
|
|
14
|
+
} from '@owlmeans/resource'
|
|
11
15
|
|
|
12
16
|
class OutOfCredit extends ResilientError {
|
|
13
17
|
public static override typeName = 'ServerApiSpecOutOfCredit'
|
|
@@ -180,7 +184,7 @@ const answer = async (error: unknown, path: string = '/handle') => {
|
|
|
180
184
|
return {
|
|
181
185
|
status: reply.statusCode,
|
|
182
186
|
body: reply.body,
|
|
183
|
-
incidentId: reply.headers[INCIDENT_ID_HEADER.toLowerCase()],
|
|
187
|
+
incidentId: reply.headers[INCIDENT_ID_HEADER.toLowerCase()] as string,
|
|
184
188
|
retryAfter: reply.headers['retry-after'],
|
|
185
189
|
denialKind: reply.headers[DENIAL_KIND_HEADER.toLowerCase()],
|
|
186
190
|
}
|
|
@@ -294,3 +298,45 @@ describe('@owlmeans/server-api — auth status across a rebuild and duplicate mo
|
|
|
294
298
|
expect((await answer(new ForeignEntitlementRefusal())).status).toBe(403)
|
|
295
299
|
})
|
|
296
300
|
})
|
|
301
|
+
|
|
302
|
+
describe('@owlmeans/server-api — storage refusals of @owlmeans/resource', () => {
|
|
303
|
+
test('an absent record answers 404 and a taken id or key 409 — as thrown and after a marshal hop', async () => {
|
|
304
|
+
expect((await answer(new UnknownRecordError('shelf/3'))).status).toBe(404)
|
|
305
|
+
expect((await answer(new RecordExists('id-present'))).status).toBe(409)
|
|
306
|
+
expect((await answer(ResilientError.marshal(new UnknownRecordError('shelf/3')))).status).toBe(404)
|
|
307
|
+
expect((await answer(ResilientError.marshal(new RecordExists('isbn')))).status).toBe(409)
|
|
308
|
+
expect((await answer(new UnknownRecordError('shelf/3'), '/execute')).status).toBe(404)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
test('a storage fault still answers 500', async () => {
|
|
312
|
+
for (const fault of [
|
|
313
|
+
new MisshapedRecord('x'), new RecordUpdateFailed('x'), new UnsupportedArgumentError('ttl'),
|
|
314
|
+
new UnsupportedMethodError('watch'), new MigrationError('x'),
|
|
315
|
+
]) {
|
|
316
|
+
expect([fault.type, (await answer(fault)).status]).toEqual([fault.type, 500])
|
|
317
|
+
}
|
|
318
|
+
})
|
|
319
|
+
|
|
320
|
+
test('a development body rebuilds into the same class, and the status the client stamps agrees with it', async () => {
|
|
321
|
+
for (const [error, status] of [
|
|
322
|
+
[new UnknownRecordError('shelf/3'), 404], [new RecordExists('isbn'), 409],
|
|
323
|
+
] as const) {
|
|
324
|
+
const response = await answer(error, '/handle-development')
|
|
325
|
+
expect(response.status).toBe(status)
|
|
326
|
+
// What `@owlmeans/api` does with a non-2xx body carrying the separator.
|
|
327
|
+
const rebuilt = Object.assign(ResilientError.ensure(response.body, true), { responseStatus: response.status })
|
|
328
|
+
expect(rebuilt.constructor).toBe(error.constructor)
|
|
329
|
+
expect(rebuilt.message).toBe(error.message)
|
|
330
|
+
expect(httpStatusOf(rebuilt)).toBe(status)
|
|
331
|
+
}
|
|
332
|
+
const rebuilt = ResilientError.ensure((await answer(new UnknownRecordError('shelf/3'), '/handle-development')).body, true)
|
|
333
|
+
expect((rebuilt as UnknownRecordError).id).toBe('shelf/3')
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
test('a production body is the incident id alone, under the declared status', async () => {
|
|
337
|
+
const response = await answer(new UnknownRecordError('shelf/3'))
|
|
338
|
+
expect(response.status).toBe(404)
|
|
339
|
+
expect(response.body).toBe(response.incidentId)
|
|
340
|
+
expect(response.body).not.toContain('shelf')
|
|
341
|
+
})
|
|
342
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
|
|
2
|
+
import { AppType } from '@owlmeans/context'
|
|
3
|
+
import { contract, protocol, schema, typed } from '@owlmeans/entrypoint'
|
|
4
|
+
import { backend, route, RouteMethod } from '@owlmeans/route'
|
|
5
|
+
import { config, makeServerContext } from '@owlmeans/server-context'
|
|
6
|
+
import type { ServerConfig, ServerContext } from '@owlmeans/server-context'
|
|
7
|
+
import { bind } from '@owlmeans/server-entrypoint'
|
|
8
|
+
import type { FastifyInstance } from 'fastify'
|
|
9
|
+
import { handlers } from '../src/protocol.js'
|
|
10
|
+
import { appendApiServer } from '../src/server.js'
|
|
11
|
+
|
|
12
|
+
interface Echo { value: unknown, type: string }
|
|
13
|
+
|
|
14
|
+
const echoOf = <T>(alias: string, body: ReturnType<typeof schema<T>>) => protocol(
|
|
15
|
+
route(`server-api-test:${alias}`, `/echo/${alias}`, backend(null, RouteMethod.POST)),
|
|
16
|
+
contract(body, typed<Echo>()),
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
const text = echoOf('text', schema<string>({ type: 'string' }))
|
|
20
|
+
const count = echoOf('count', schema<number>({ type: 'number' }))
|
|
21
|
+
const flag = echoOf('flag', schema<boolean>({ type: 'boolean' }))
|
|
22
|
+
const record = echoOf('record', schema<{ a: number }>({ type: 'object', properties: { a: { type: 'number' } }, required: ['a'] }))
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A scalar JSON body through the package's own server — `appendApiServer`, its Fastify instance,
|
|
26
|
+
* its body validation and a protocol-bound handler — exactly as `@owlmeans/api` now sends it:
|
|
27
|
+
* JSON text. The client half of the round trip is `@owlmeans/api`'s `body.spec.ts`.
|
|
28
|
+
*/
|
|
29
|
+
describe('@owlmeans/server-api — a scalar JSON body', () => {
|
|
30
|
+
let server: FastifyInstance
|
|
31
|
+
let context: ServerContext<ServerConfig>
|
|
32
|
+
|
|
33
|
+
beforeAll(async () => {
|
|
34
|
+
const service = 'server-api-tests'
|
|
35
|
+
context = makeServerContext(config<ServerConfig>(service, {
|
|
36
|
+
services: { [service]: { service, type: AppType.Backend, host: '127.0.0.1', default: true } },
|
|
37
|
+
} as Partial<ServerConfig>)) as ServerContext<ServerConfig>
|
|
38
|
+
const served = appendApiServer(context)
|
|
39
|
+
const api = handlers<ServerContext<ServerConfig>>()
|
|
40
|
+
for (const echo of [text, count, flag, record]) {
|
|
41
|
+
context.registerEntrypoint(bind(echo, api.request(echo, async request => ({ value: request.body, type: typeof request.body }))))
|
|
42
|
+
}
|
|
43
|
+
context.configure()
|
|
44
|
+
await context.init()
|
|
45
|
+
server = served.getApiServer().server
|
|
46
|
+
await server.ready()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterAll(async () => { await server?.close() })
|
|
50
|
+
|
|
51
|
+
const post = async (alias: string, payload: string, type: string = 'application/json') =>
|
|
52
|
+
await server.inject({ method: 'POST', url: `/echo/${alias}`, headers: { 'content-type': type }, payload })
|
|
53
|
+
|
|
54
|
+
const echoed = async (alias: string, payload: string): Promise<Echo> => (await post(alias, payload)).json<Echo>()
|
|
55
|
+
|
|
56
|
+
test('a JSON string, number or boolean reaches the handler as that value', async () => {
|
|
57
|
+
expect(await echoed('text', '"abc"')).toEqual({ value: 'abc', type: 'string' })
|
|
58
|
+
expect(await echoed('text', '""')).toEqual({ value: '', type: 'string' })
|
|
59
|
+
expect(await echoed('text', '"123"')).toEqual({ value: '123', type: 'string' })
|
|
60
|
+
expect(await echoed('count', '42')).toEqual({ value: 42, type: 'number' })
|
|
61
|
+
expect(await echoed('flag', 'false')).toEqual({ value: false, type: 'boolean' })
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('an object body is unchanged', async () => {
|
|
65
|
+
expect(await echoed('record', '{"a":1}')).toEqual({ value: { a: 1 }, type: 'object' })
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test('an unquoted string or an empty JSON body is refused with 400 — what the client no longer sends', async () => {
|
|
69
|
+
expect((await post('text', 'abc')).statusCode).toBe(400)
|
|
70
|
+
expect((await post('text', '')).statusCode).toBe(400)
|
|
71
|
+
})
|
|
72
|
+
})
|