@owlmeans/server-api 0.1.18-rc.8 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (140) hide show
  1. package/README.md +203 -46
  2. package/agent-meta/manifest.json +3 -3
  3. package/agent-meta/skills/server-api/SKILL.md +142 -22
  4. package/build/consts.local.d.ts +2 -0
  5. package/build/consts.local.d.ts.map +1 -0
  6. package/build/consts.local.js +2 -0
  7. package/build/consts.local.js.map +1 -0
  8. package/build/errors.d.ts +12 -0
  9. package/build/errors.d.ts.map +1 -1
  10. package/build/errors.js +16 -0
  11. package/build/errors.js.map +1 -1
  12. package/build/helper.d.ts +3 -6
  13. package/build/helper.d.ts.map +1 -1
  14. package/build/helper.js +10 -1
  15. package/build/helper.js.map +1 -1
  16. package/build/hold.d.ts +35 -0
  17. package/build/hold.d.ts.map +1 -0
  18. package/build/hold.js +57 -0
  19. package/build/hold.js.map +1 -0
  20. package/build/index.d.ts +2 -0
  21. package/build/index.d.ts.map +1 -1
  22. package/build/index.js +2 -0
  23. package/build/index.js.map +1 -1
  24. package/build/protocol.d.ts +18 -0
  25. package/build/protocol.d.ts.map +1 -0
  26. package/build/protocol.js +87 -0
  27. package/build/protocol.js.map +1 -0
  28. package/build/server.d.ts +2 -3
  29. package/build/server.d.ts.map +1 -1
  30. package/build/server.js +42 -34
  31. package/build/server.js.map +1 -1
  32. package/build/types.d.ts +31 -2
  33. package/build/types.d.ts.map +1 -1
  34. package/build/types.local.d.ts +38 -0
  35. package/build/types.local.d.ts.map +1 -0
  36. package/build/types.local.js +2 -0
  37. package/build/types.local.js.map +1 -0
  38. package/build/utils/consts.d.ts +2 -0
  39. package/build/utils/consts.d.ts.map +1 -0
  40. package/build/utils/consts.js +2 -0
  41. package/build/utils/consts.js.map +1 -0
  42. package/build/utils/consts.local.d.ts +5 -0
  43. package/build/utils/consts.local.d.ts.map +1 -0
  44. package/build/utils/consts.local.js +5 -0
  45. package/build/utils/consts.local.js.map +1 -0
  46. package/build/utils/context/types.d.ts +13 -0
  47. package/build/utils/context/types.d.ts.map +1 -0
  48. package/build/utils/context/types.js +2 -0
  49. package/build/utils/context/types.js.map +1 -0
  50. package/build/utils/context.d.ts +3 -3
  51. package/build/utils/context.d.ts.map +1 -1
  52. package/build/utils/context.js +5 -2
  53. package/build/utils/context.js.map +1 -1
  54. package/build/utils/error/types.d.ts +52 -0
  55. package/build/utils/error/types.d.ts.map +1 -0
  56. package/build/utils/error/types.js +2 -0
  57. package/build/utils/error/types.js.map +1 -0
  58. package/build/utils/error.d.ts +5 -15
  59. package/build/utils/error.d.ts.map +1 -1
  60. package/build/utils/error.js +134 -29
  61. package/build/utils/error.js.map +1 -1
  62. package/build/utils/guards.d.ts +1 -5
  63. package/build/utils/guards.d.ts.map +1 -1
  64. package/build/utils/guards.js +15 -19
  65. package/build/utils/guards.js.map +1 -1
  66. package/build/utils/index.d.ts +6 -1
  67. package/build/utils/index.d.ts.map +1 -1
  68. package/build/utils/index.js +2 -1
  69. package/build/utils/index.js.map +1 -1
  70. package/build/utils/log/consts.d.ts +14 -0
  71. package/build/utils/log/consts.d.ts.map +1 -0
  72. package/build/utils/log/consts.js +17 -0
  73. package/build/utils/log/consts.js.map +1 -0
  74. package/build/utils/log/types.d.ts +42 -0
  75. package/build/utils/log/types.d.ts.map +1 -0
  76. package/build/utils/log/types.js +2 -0
  77. package/build/utils/log/types.js.map +1 -0
  78. package/build/utils/log.d.ts +4 -0
  79. package/build/utils/log.d.ts.map +1 -0
  80. package/build/utils/log.js +170 -0
  81. package/build/utils/log.js.map +1 -0
  82. package/build/utils/payload/types.d.ts +18 -0
  83. package/build/utils/payload/types.d.ts.map +1 -0
  84. package/build/utils/payload/types.js +2 -0
  85. package/build/utils/payload/types.js.map +1 -0
  86. package/build/utils/payload.d.ts +3 -15
  87. package/build/utils/payload.d.ts.map +1 -1
  88. package/build/utils/payload.js +41 -57
  89. package/build/utils/payload.js.map +1 -1
  90. package/build/utils/schema.d.ts +7 -0
  91. package/build/utils/schema.d.ts.map +1 -0
  92. package/build/utils/schema.js +20 -0
  93. package/build/utils/schema.js.map +1 -0
  94. package/build/utils/server.d.ts +3 -6
  95. package/build/utils/server.d.ts.map +1 -1
  96. package/build/utils/server.js +12 -13
  97. package/build/utils/server.js.map +1 -1
  98. package/build/utils/types.d.ts +18 -0
  99. package/build/utils/types.d.ts.map +1 -0
  100. package/build/utils/types.js +2 -0
  101. package/build/utils/types.js.map +1 -0
  102. package/build/utils/types.local.d.ts +9 -0
  103. package/build/utils/types.local.d.ts.map +1 -0
  104. package/build/utils/types.local.js +2 -0
  105. package/build/utils/types.local.js.map +1 -0
  106. package/package.json +23 -19
  107. package/src/consts.local.ts +1 -0
  108. package/src/errors.ts +18 -0
  109. package/src/helper.ts +13 -8
  110. package/src/hold.ts +66 -0
  111. package/src/index.ts +2 -0
  112. package/src/protocol.ts +149 -0
  113. package/src/server.ts +72 -69
  114. package/src/types.local.ts +44 -0
  115. package/src/types.ts +38 -4
  116. package/src/utils/consts.local.ts +7 -0
  117. package/src/utils/consts.ts +1 -0
  118. package/src/utils/context/types.ts +13 -0
  119. package/src/utils/context.ts +9 -4
  120. package/src/utils/error/types.ts +52 -0
  121. package/src/utils/error.ts +156 -29
  122. package/src/utils/guards.ts +16 -26
  123. package/src/utils/index.ts +6 -1
  124. package/src/utils/log/consts.ts +18 -0
  125. package/src/utils/log/types.ts +43 -0
  126. package/src/utils/log.ts +193 -0
  127. package/src/utils/payload/types.ts +18 -0
  128. package/src/utils/payload.ts +42 -60
  129. package/src/utils/schema.ts +23 -0
  130. package/src/utils/server.ts +16 -21
  131. package/src/utils/types.local.ts +12 -0
  132. package/src/utils/types.ts +18 -0
  133. package/tests/error-status.spec.ts +349 -0
  134. package/tests/handlers.spec.ts +83 -0
  135. package/tests/json-body.spec.ts +72 -0
  136. package/tests/log-adapter.spec.ts +62 -0
  137. package/tests/log-redaction.spec.ts +181 -0
  138. package/tests/tsconfig.json +12 -0
  139. package/tsconfig.json +1 -0
  140. package/build/.gitkeep +0 -0
@@ -0,0 +1,349 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test'
2
+ import Fastify from 'fastify'
3
+ import type { FastifyInstance } from 'fastify'
4
+ import { AuthForbidden, AuthorizationError, AuthUnknown } from '@owlmeans/auth'
5
+ import { ResilientError, SEPARATOR } from '@owlmeans/error'
6
+ import { provideResponse } from '@owlmeans/entrypoint'
7
+ import { httpErrorHelper } from '../src/utils/error.js'
8
+ import { INCIDENT_ID_HEADER } from '../src/utils/consts.js'
9
+ import { payloadHelper } from '../src/utils/payload.js'
10
+ import { AccessError, AuthFailedError } from '../src/errors.js'
11
+ import { DENIAL_KIND_HEADER, ACCESS_DENIED_KIND, apiStatusHelper } from '@owlmeans/api'
12
+ import {
13
+ MigrationError, MisshapedRecord, RecordExists, RecordUpdateFailed, UnknownRecordError,
14
+ UnsupportedArgumentError, UnsupportedMethodError,
15
+ } from '@owlmeans/resource'
16
+
17
+ class OutOfCredit extends ResilientError {
18
+ public static override typeName = 'ServerApiSpecOutOfCredit'
19
+ public static httpStatus = 402
20
+
21
+ constructor(message: string = 'error') {
22
+ super(OutOfCredit.typeName, `out-of-credit:${message}`)
23
+ }
24
+ }
25
+
26
+ /** Inherits 402 without redeclaring it. */
27
+ class OutOfCreditForStory extends OutOfCredit {
28
+ public static override typeName = 'ServerApiSpecOutOfCreditForStory'
29
+
30
+ constructor() {
31
+ super('story')
32
+ this.type = OutOfCreditForStory.typeName
33
+ }
34
+ }
35
+
36
+ /** Redeclares its parent's status to a conflict. */
37
+ class Busy extends OutOfCredit {
38
+ public static override typeName = 'ServerApiSpecBusy'
39
+ public static override httpStatus = 409
40
+
41
+ constructor() {
42
+ super('busy')
43
+ this.type = Busy.typeName
44
+ }
45
+ }
46
+
47
+ /** An auth refusal that also declares a status: the auth mapping still wins. */
48
+ class DeclaringForbidden extends AuthForbidden {
49
+ public static override typeName = 'ServerApiSpecDeclaringForbidden'
50
+ public static httpStatus = 402
51
+
52
+ constructor() {
53
+ super('declaring')
54
+ this.type = DeclaringForbidden.typeName
55
+ }
56
+ }
57
+
58
+ class TemporarilyUnavailable extends ResilientError {
59
+ public static override typeName = 'ServerApiSpecTemporarilyUnavailable'
60
+ public static httpStatus = 503
61
+ public static allowServerErrorStatus = true
62
+
63
+ constructor() {
64
+ super(TemporarilyUnavailable.typeName, 'temporarily-unavailable')
65
+ }
66
+ }
67
+
68
+ class Throttled extends ResilientError {
69
+ public static override typeName = 'ServerApiSpecThrottled'
70
+ public static httpStatus = 429
71
+ public readonly retryAfter = 17.2
72
+
73
+ constructor() {
74
+ super(Throttled.typeName, 'throttled')
75
+ }
76
+ }
77
+
78
+ const declaring = (typeName: string, httpStatus: unknown) => {
79
+ class Declaring extends ResilientError {
80
+ public static override typeName = typeName
81
+ public static httpStatus = httpStatus
82
+
83
+ constructor(message: string = 'error') {
84
+ super(typeName, message)
85
+ }
86
+ }
87
+ return Declaring
88
+ }
89
+
90
+ /**
91
+ * A duplicate module copy of the error family — what a process loading `@owlmeans/error` through
92
+ * several `node_modules` paths holds: the same registered names, none of the imported classes.
93
+ */
94
+ class ForeignResilientError extends Error {
95
+ public static typeName = 'ResilientError'
96
+ public type: string
97
+
98
+ constructor(type: string, message: string) {
99
+ super(message)
100
+ this.type = type
101
+ }
102
+ }
103
+
104
+ class ForeignAuthFailedError extends ForeignResilientError {
105
+ public static override typeName = 'AuthFailedError'
106
+
107
+ constructor() {
108
+ super(ForeignAuthFailedError.typeName, 'api:auth:error')
109
+ }
110
+ }
111
+
112
+ class ForeignAuthorizationError extends ForeignResilientError {
113
+ public static override typeName = 'AuthorizationError'
114
+
115
+ constructor(message: string = 'error') {
116
+ super(ForeignAuthorizationError.typeName, `auth:authorization:${message}`)
117
+ }
118
+ }
119
+
120
+ class ForeignAuthForbidden extends ForeignAuthorizationError {
121
+ public static override typeName = 'ForbiddenAuthorizationError'
122
+
123
+ constructor(message: string = 'error') {
124
+ super(`forbidden:${message}`)
125
+ this.type = ForeignAuthForbidden.typeName
126
+ }
127
+ }
128
+
129
+ /** A subclass whose own name embeds nothing of its parent's, like `EntitlementRefusal`. */
130
+ class ForeignEntitlementRefusal extends ForeignAuthForbidden {
131
+ public static override typeName = 'EntitlementRefusal'
132
+
133
+ constructor() {
134
+ super('entitlement')
135
+ this.type = ForeignEntitlementRefusal.typeName
136
+ }
137
+ }
138
+
139
+ /** A declared refusal no registry of this process knows. */
140
+ class ForeignOutOfCredit extends ForeignResilientError {
141
+ public static override typeName = 'ServerApiSpecForeignOutOfCredit'
142
+ public static httpStatus = 402
143
+
144
+ constructor() {
145
+ super(ForeignOutOfCredit.typeName, 'out-of-credit:foreign')
146
+ }
147
+ }
148
+
149
+ const typed = (type: string) => Object.assign(new Error('typed'), { type })
150
+
151
+ ResilientError.registerErrorClass(OutOfCredit)
152
+ ResilientError.registerErrorClass(OutOfCreditForStory)
153
+ ResilientError.registerErrorClass(Busy)
154
+ ResilientError.registerErrorClass(DeclaringForbidden)
155
+
156
+ let server: FastifyInstance
157
+ let thrown: unknown
158
+
159
+ beforeAll(async () => {
160
+ server = Fastify()
161
+ server.get('/handle', async (_req, reply) => {
162
+ httpErrorHelper.handleError(thrown as Error, reply)
163
+ return reply
164
+ })
165
+ server.get('/handle-development', async (_req, reply) => {
166
+ httpErrorHelper.handleError(thrown as Error, reply, 'development')
167
+ return reply
168
+ })
169
+ server.get('/execute', async (_req, reply) => {
170
+ const response = provideResponse(reply)
171
+ response.reject(thrown as Error)
172
+ payloadHelper.executeResponse(response, reply)
173
+ return reply
174
+ })
175
+ await server.ready()
176
+ })
177
+
178
+ afterAll(async () => {
179
+ await server.close()
180
+ })
181
+
182
+ const answer = async (error: unknown, path: string = '/handle') => {
183
+ thrown = error
184
+ const reply = await server.inject({ method: 'GET', url: path })
185
+ return {
186
+ status: reply.statusCode,
187
+ body: reply.body,
188
+ incidentId: reply.headers[INCIDENT_ID_HEADER.toLowerCase()] as string,
189
+ retryAfter: reply.headers['retry-after'],
190
+ denialKind: reply.headers[DENIAL_KIND_HEADER.toLowerCase()],
191
+ }
192
+ }
193
+
194
+ describe('@owlmeans/server-api — production error exposure', () => {
195
+ test('returns only an incident id in production and correlates it with the header', async () => {
196
+ const response = await answer(new Error('private-marker'))
197
+ expect(response.body).toBe(response.incidentId)
198
+ expect(response.body).not.toContain('private-marker')
199
+ expect(response.body).not.toContain(SEPARATOR)
200
+ expect(response.body).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
201
+ })
202
+
203
+ test('includes stacks only under explicit development exposure and safely serializes SyntaxError', async () => {
204
+ const development = await answer(new Error('development-marker'), '/handle-development')
205
+ const [type, message, stack, incidentId] = development.body.split(SEPARATOR, 4)
206
+ expect(type).toBe(ResilientError.typeName)
207
+ expect(message).toBe('development-marker')
208
+ expect(stack).toContain('development-marker')
209
+ expect(incidentId).toBe(development.incidentId)
210
+
211
+ const syntax = await answer(new SyntaxError('bad wiring'))
212
+ expect(syntax.status).toBe(500)
213
+ expect(syntax.body).toBe(syntax.incidentId)
214
+ expect(syntax.body).not.toContain('bad wiring')
215
+
216
+ expect(httpErrorHelper.errorExposure()).toBe('production')
217
+ expect(httpErrorHelper.errorExposure({ http: { errors: { exposure: 'development' } } })).toBe('development')
218
+ })
219
+ })
220
+
221
+ describe('@owlmeans/server-api — handleError status', () => {
222
+ test('keeps the auth mappings: forbidden 403, unauthenticated 401', async () => {
223
+ expect((await answer(new AuthForbidden('x'))).status).toBe(403)
224
+ expect((await answer(new AccessError())).status).toBe(403)
225
+ expect((await answer(new AuthorizationError('x'))).status).toBe(401)
226
+ expect((await answer(new AuthFailedError())).status).toBe(401)
227
+ expect((await answer(new DeclaringForbidden())).status).toBe(403)
228
+ expect((await answer(new AuthForbidden('permission'))).denialKind).toBe(ACCESS_DENIED_KIND)
229
+ expect((await answer(new AccessError())).denialKind).toBe(ACCESS_DENIED_KIND)
230
+ expect((await answer(new DeclaringForbidden())).denialKind).toBeUndefined()
231
+ expect((await answer(new ForeignEntitlementRefusal())).denialKind).toBeUndefined()
232
+ })
233
+
234
+ test('honours a 4xx a class declares — inherited, redeclared, or after a marshal hop', async () => {
235
+ const refused = await answer(new OutOfCredit('create'))
236
+ expect(refused.status).toBe(402)
237
+ expect(refused.body).toBe(refused.incidentId)
238
+
239
+ expect((await answer(new OutOfCreditForStory())).status).toBe(402)
240
+ expect((await answer(new Busy())).status).toBe(409)
241
+ expect((await answer(ResilientError.marshal(new Busy()))).status).toBe(409)
242
+ expect((await answer(new ForeignOutOfCredit())).status).toBe(402)
243
+ })
244
+
245
+ test('answers 500 for no declaration, or one outside the client-error range', async () => {
246
+ expect((await answer(new ResilientError('Plain', 'plain'))).status).toBe(500)
247
+ expect((await answer(new Error('boom'))).status).toBe(500)
248
+ for (const status of [500, 503, 200, 302, 399, 600, 402.5, '402', null]) {
249
+ const Declaring = declaring(`ServerApiSpecDeclaring${String(status)}`, status)
250
+ expect((await answer(new Declaring())).status).toBe(500)
251
+ }
252
+ for (const type of ['AuthFailedErrorX', 'NotAuthorizationError', 'Forbidden', 'ResilientError']) {
253
+ expect((await answer(typed(type))).status).toBe(500)
254
+ expect((await answer(new ForeignResilientError(type, 'near-miss'))).status).toBe(500)
255
+ }
256
+ })
257
+
258
+ test('an unknown authentication method or identity answers 400, as thrown and after a marshal hop', async () => {
259
+ expect((await answer(new AuthUnknown('email-otp'))).status).toBe(400)
260
+ expect((await answer(ResilientError.marshal(new AuthUnknown('entity')))).status).toBe(400)
261
+ expect((await answer(new AuthUnknown(), '/execute')).status).toBe(400)
262
+ })
263
+
264
+ test('honours an explicitly exposed 5xx without opening arbitrary server statuses', async () => {
265
+ expect((await answer(new TemporarilyUnavailable())).status).toBe(503)
266
+ const Unexposed = declaring('ServerApiSpecUnexposed503', 503)
267
+ expect((await answer(new Unexposed())).status).toBe(500)
268
+ })
269
+
270
+ test('emits Retry-After for a throttled response', async () => {
271
+ const response = await answer(new Throttled())
272
+ expect(response.status).toBe(429)
273
+ expect(response.retryAfter).toBe('18')
274
+ })
275
+
276
+ test('executeResponse answers a rejected response with the same status', async () => {
277
+ expect((await answer(new OutOfCredit('create'), '/execute')).status).toBe(402)
278
+ expect((await answer(new AuthForbidden('x'), '/execute')).status).toBe(403)
279
+ expect((await answer(new ResilientError('Plain', 'plain'), '/execute')).status).toBe(500)
280
+ })
281
+ })
282
+
283
+ describe('@owlmeans/server-api — auth status across a rebuild and duplicate module copies', () => {
284
+ test('an AuthFailedError answers 401 as thrown and after a marshal round trip', async () => {
285
+ expect((await answer(new AuthFailedError())).status).toBe(401)
286
+ expect((await answer(ResilientError.marshal(new AuthFailedError()))).status).toBe(401)
287
+ expect(httpErrorHelper.errorStatus(ResilientError.ensure(ResilientError.marshal(new AuthFailedError())))).toBe(401)
288
+ expect((await answer(new AuthFailedError(), '/execute')).status).toBe(401)
289
+ })
290
+
291
+ test('a class from another module copy answers by its registered type name', async () => {
292
+ // The rebuild alone loses it — the regression this resolution exists for.
293
+ expect(httpErrorHelper.errorStatus(ResilientError.ensure(new ForeignAuthFailedError()))).toBe(500)
294
+ expect((await answer(new ForeignAuthFailedError())).status).toBe(401)
295
+ expect((await answer(new ForeignAuthorizationError())).status).toBe(401)
296
+ expect((await answer(new ForeignAuthForbidden())).status).toBe(403)
297
+ expect((await answer(new ForeignAuthForbidden(), '/execute')).status).toBe(403)
298
+ })
299
+
300
+ test('the family is read off the instance type or the constructor chain, 403 before 401', async () => {
301
+ expect((await answer(typed('AuthFailedError'))).status).toBe(401)
302
+ expect((await answer(typed('ForbiddenAuthorizationError'))).status).toBe(403)
303
+ expect((await answer(typed('AccessAuthFailedError'))).status).toBe(403)
304
+ // Its own type names nothing of the family; only the chain does.
305
+ expect((await answer(new ForeignEntitlementRefusal())).status).toBe(403)
306
+ })
307
+ })
308
+
309
+ describe('@owlmeans/server-api — storage refusals of @owlmeans/resource', () => {
310
+ test('an absent record answers 404 and a taken id or key 409 — as thrown and after a marshal hop', async () => {
311
+ expect((await answer(new UnknownRecordError('shelf/3'))).status).toBe(404)
312
+ expect((await answer(new RecordExists('id-present'))).status).toBe(409)
313
+ expect((await answer(ResilientError.marshal(new UnknownRecordError('shelf/3')))).status).toBe(404)
314
+ expect((await answer(ResilientError.marshal(new RecordExists('isbn')))).status).toBe(409)
315
+ expect((await answer(new UnknownRecordError('shelf/3'), '/execute')).status).toBe(404)
316
+ })
317
+
318
+ test('a storage fault still answers 500', async () => {
319
+ for (const fault of [
320
+ new MisshapedRecord('x'), new RecordUpdateFailed('x'), new UnsupportedArgumentError('ttl'),
321
+ new UnsupportedMethodError('watch'), new MigrationError('x'),
322
+ ]) {
323
+ expect([fault.type, (await answer(fault)).status]).toEqual([fault.type, 500])
324
+ }
325
+ })
326
+
327
+ test('a development body rebuilds into the same class, and the status the client stamps agrees with it', async () => {
328
+ for (const [error, status] of [
329
+ [new UnknownRecordError('shelf/3'), 404], [new RecordExists('isbn'), 409],
330
+ ] as const) {
331
+ const response = await answer(error, '/handle-development')
332
+ expect(response.status).toBe(status)
333
+ // What `@owlmeans/api` does with a non-2xx body carrying the separator.
334
+ const rebuilt = Object.assign(ResilientError.ensure(response.body, true), { responseStatus: response.status })
335
+ expect(rebuilt.constructor).toBe(error.constructor)
336
+ expect(rebuilt.message).toBe(error.message)
337
+ expect(apiStatusHelper.httpStatusOf(rebuilt)).toBe(status)
338
+ }
339
+ const rebuilt = ResilientError.ensure((await answer(new UnknownRecordError('shelf/3'), '/handle-development')).body, true)
340
+ expect((rebuilt as UnknownRecordError).id).toBe('shelf/3')
341
+ })
342
+
343
+ test('a production body is the incident id alone, under the declared status', async () => {
344
+ const response = await answer(new UnknownRecordError('shelf/3'))
345
+ expect(response.status).toBe(404)
346
+ expect(response.body).toBe(response.incidentId)
347
+ expect(response.body).not.toContain('shelf')
348
+ })
349
+ })
@@ -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
+ import { addLogPlugin, memoryPlugin, removeLogPlugin } from '@owlmeans/log'
6
+
7
+ /**
8
+ * A protocol-bound implementation is run with the smallest binding context the transport needs —
9
+ * same pattern as `@owlmeans/server-auth-token`'s own `handlers.spec.ts`.
10
+ */
11
+ const invoke = async (handler: any, context: any, req: any = {}): Promise<any> => {
12
+ const res: any = { resolve: (value: unknown) => { res.value = value }, reject: (e: Error) => { res.error = e } }
13
+ await handler.bind({ ref: { ctx: context } })(req, res)
14
+ if (res.error != null) throw res.error
15
+
16
+ return res.value
17
+ }
18
+
19
+ const protocolFor = (alias: string): EntrypointProtocolDeclaration =>
20
+ ({ alias } as unknown as EntrypointProtocolDeclaration)
21
+
22
+ const context = { alias: 'test-context' } as any
23
+ const api = handlers<any>()
24
+
25
+ describe('@owlmeans/server-api — handlers() wrap-once tolerance', () => {
26
+ test('a plain function is wrapped once and called with the context', async () => {
27
+ const protocol = protocolFor('api.thing.plain')
28
+ const handler = api.request(protocol, async (_req: any, ctx: any) => ({ ok: true, ctx }))
29
+
30
+ const result = await invoke(handler, context, {})
31
+ expect(result).toEqual({ ok: true, ctx: context })
32
+ })
33
+
34
+ test('a handler already bound to the SAME protocol is returned unchanged by body/params/request and still answers', async () => {
35
+ for (const method of ['body', 'params', 'request'] as const) {
36
+ const protocol = protocolFor(`api.thing.${method}`)
37
+ const bound = api.request(protocol, async () => ({ from: method }))
38
+
39
+ const wrapped = (api as any)[method](protocol, bound)
40
+ expect(wrapped).toBe(bound)
41
+
42
+ const result = await invoke(wrapped, context, {})
43
+ expect(result).toEqual({ from: method })
44
+ }
45
+ })
46
+
47
+ test('a handler bound to a DIFFERENT protocol answers with HandlerMisconfiguredError', async () => {
48
+ const protocolA = protocolFor('api.thing.mismatch-a')
49
+ const protocolB = protocolFor('api.thing.mismatch-b')
50
+ const boundToA = api.request(protocolA, async () => ({ from: 'a' }))
51
+
52
+ const wrapped = api.request(protocolB, boundToA as any)
53
+ expect(wrapped).not.toBe(boundToA)
54
+ await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
55
+ })
56
+
57
+ test('a value that is neither a function nor a bound handler answers with HandlerMisconfiguredError', async () => {
58
+ const protocol = protocolFor('api.thing.not-a-function')
59
+ const wrapped = api.request(protocol, { not: 'a function' } as any)
60
+
61
+ await expect(invoke(wrapped, context, {})).rejects.toThrow(HandlerMisconfiguredError)
62
+ })
63
+
64
+ test('the double-wrap warning fires once per alias, not once per call', async () => {
65
+ const protocol = protocolFor('api.thing.warn-once')
66
+ const bound = api.request(protocol, async () => 'ok')
67
+
68
+ const memory = memoryPlugin('handlers-warn')
69
+ addLogPlugin(memory)
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
+ removeLogPlugin('handlers-warn')
79
+ }
80
+
81
+ expect(memory.records.filter(record => record.level === 'warn').length).toBe(1)
82
+ })
83
+ })
@@ -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
+ })
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test'
2
+ import { addLogPlugin, configureLog, memoryPlugin, resetLog } from '@owlmeans/log'
3
+ import { fastifyLogUtils } from '../src/utils/log.js'
4
+
5
+ const fastifyLogger = fastifyLogUtils.logger
6
+
7
+ const memory = memoryPlugin('adapter')
8
+
9
+ beforeEach(() => {
10
+ resetLog()
11
+ addLogPlugin(memory)
12
+ configureLog({ level: 'debug', console: 'native' })
13
+ })
14
+ afterEach(() => { memory.clear(); resetLog() })
15
+
16
+ describe('@owlmeans/server-api — the Fastify logger adapter', () => {
17
+ test('lowers the listening line, keeps the rest', () => {
18
+ const log = fastifyLogger() as unknown as Record<string, (...args: unknown[]) => void>
19
+ log.info('Server listening at http://127.0.0.1:80')
20
+ log.info('something else')
21
+ log.warn({ a: 1 }, 'warned')
22
+ log.error({ err: new Error('boom') }, 'failed')
23
+
24
+ expect(memory.records.map(r => [r.level, r.message])).toEqual([
25
+ ['debug', 'Server listening at http://127.0.0.1:80'],
26
+ ['info', 'something else'],
27
+ ['warn', 'warned'],
28
+ ['error', 'failed'],
29
+ ])
30
+ expect(memory.records[3].error?.message).toBe('boom')
31
+ })
32
+
33
+ test('the controller writes no per-request pair and a missing route without its query', () => {
34
+ const controller = fastifyLogUtils.controller()
35
+ const request = { method: 'GET', url: '/x?token=abc' } as never
36
+ controller.incomingRequest(request, {} as never)
37
+ controller.requestCompleted(null, request, { statusCode: 200 } as never)
38
+ controller.routeNotFound(request, {} as never)
39
+
40
+ expect(memory.records.map(r => [r.level, r.message, r.data])).toEqual([
41
+ ['debug', 'Route not found', { method: 'GET', path: '/x' }],
42
+ ])
43
+ })
44
+
45
+ test('child loggers bind their fields and the level is the process level', () => {
46
+ const base = fastifyLogger()
47
+ const child = (base as unknown as { child: (b: object) => Record<string, (...a: unknown[]) => void> }).child({ reqId: 'r1' })
48
+ child.warn('hello')
49
+ expect(memory.records[0].data).toEqual({ reqId: 'r1' })
50
+ expect(base.level).toBe('debug')
51
+ configureLog({ level: 'warn' })
52
+ expect(base.level).toBe('warn')
53
+ })
54
+
55
+ test('at info level, debug lines are not written', () => {
56
+ configureLog({ level: 'info' })
57
+ const log = fastifyLogger() as unknown as Record<string, (...args: unknown[]) => void>
58
+ log.debug('quiet')
59
+ log.info('shown')
60
+ expect(memory.records.map(r => r.message)).toEqual(['shown'])
61
+ })
62
+ })