@getsoda/core 0.2.2 → 0.3.0
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 +80 -0
- package/dist/app/app.d.ts +15 -5
- package/dist/app/app.d.ts.map +1 -1
- package/dist/app/app.js +31 -8
- package/dist/app/app.js.map +1 -1
- package/dist/app/index.d.ts +1 -1
- package/dist/app/index.d.ts.map +1 -1
- package/dist/config/config.d.ts.map +1 -1
- package/dist/config/config.js +2 -0
- package/dist/config/config.js.map +1 -1
- package/dist/di/errors.d.ts.map +1 -1
- package/dist/di/errors.js +2 -0
- package/dist/di/errors.js.map +1 -1
- package/dist/http/arguments.d.ts +19 -6
- package/dist/http/arguments.d.ts.map +1 -1
- package/dist/http/arguments.js +7 -0
- package/dist/http/arguments.js.map +1 -1
- package/dist/http/cookies.d.ts.map +1 -1
- package/dist/http/cookies.js +2 -0
- package/dist/http/cookies.js.map +1 -1
- package/dist/http/decorators.d.ts +12 -5
- package/dist/http/decorators.d.ts.map +1 -1
- package/dist/http/decorators.js +7 -2
- package/dist/http/decorators.js.map +1 -1
- package/dist/http/errors.d.ts +3 -1
- package/dist/http/errors.d.ts.map +1 -1
- package/dist/http/errors.js +2 -0
- package/dist/http/errors.js.map +1 -1
- package/dist/http/index.d.ts +6 -4
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +3 -2
- package/dist/http/index.js.map +1 -1
- package/dist/http/parsers.d.ts +4 -0
- package/dist/http/parsers.d.ts.map +1 -1
- package/dist/http/parsers.js +9 -5
- package/dist/http/parsers.js.map +1 -1
- package/dist/http/pipeline.d.ts +4 -5
- package/dist/http/pipeline.d.ts.map +1 -1
- package/dist/http/pipeline.js +2 -2
- package/dist/http/pipeline.js.map +1 -1
- package/dist/http/routes.d.ts +27 -2
- package/dist/http/routes.d.ts.map +1 -1
- package/dist/http/routes.js +34 -20
- package/dist/http/routes.js.map +1 -1
- package/dist/http/sse.d.ts +39 -0
- package/dist/http/sse.d.ts.map +1 -0
- package/dist/http/sse.js +89 -0
- package/dist/http/sse.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/openapi/decorators.d.ts +41 -0
- package/dist/openapi/decorators.d.ts.map +1 -0
- package/dist/openapi/decorators.js +29 -0
- package/dist/openapi/decorators.js.map +1 -0
- package/dist/openapi/document.d.ts +62 -0
- package/dist/openapi/document.d.ts.map +1 -0
- package/dist/openapi/document.js +135 -0
- package/dist/openapi/document.js.map +1 -0
- package/dist/openapi/index.d.ts +11 -0
- package/dist/openapi/index.d.ts.map +1 -0
- package/dist/openapi/index.js +8 -0
- package/dist/openapi/index.js.map +1 -0
- package/dist/openapi/module.d.ts +26 -0
- package/dist/openapi/module.d.ts.map +1 -0
- package/dist/openapi/module.js +73 -0
- package/dist/openapi/module.js.map +1 -0
- package/dist/routing/errors.d.ts.map +1 -1
- package/dist/routing/errors.js +2 -0
- package/dist/routing/errors.js.map +1 -1
- package/dist/testing/index.d.ts +46 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +49 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/utils/errors.d.ts +11 -0
- package/dist/utils/errors.d.ts.map +1 -0
- package/dist/utils/errors.js +22 -0
- package/dist/utils/errors.js.map +1 -0
- package/dist/utils/object.d.ts +4 -0
- package/dist/utils/object.d.ts.map +1 -1
- package/dist/utils/object.js +16 -0
- package/dist/utils/object.js.map +1 -1
- package/dist/websocket/decorators.d.ts +31 -0
- package/dist/websocket/decorators.d.ts.map +1 -0
- package/dist/websocket/decorators.js +43 -0
- package/dist/websocket/decorators.js.map +1 -0
- package/dist/websocket/gateway.d.ts +61 -0
- package/dist/websocket/gateway.d.ts.map +1 -0
- package/dist/websocket/gateway.js +142 -0
- package/dist/websocket/gateway.js.map +1 -0
- package/dist/websocket/index.d.ts +7 -0
- package/dist/websocket/index.d.ts.map +1 -0
- package/dist/websocket/index.js +6 -0
- package/dist/websocket/index.js.map +1 -0
- package/package.json +12 -1
- package/src/app/app.ts +69 -18
- package/src/app/index.ts +1 -1
- package/src/config/config.ts +2 -0
- package/src/di/errors.ts +3 -0
- package/src/http/arguments.ts +31 -6
- package/src/http/cookies.ts +2 -0
- package/src/http/decorators.ts +16 -8
- package/src/http/errors.ts +7 -0
- package/src/http/index.ts +6 -4
- package/src/http/parsers.ts +18 -7
- package/src/http/pipeline.ts +6 -6
- package/src/http/routes.ts +68 -29
- package/src/http/sse.ts +120 -0
- package/src/index.ts +1 -0
- package/src/openapi/decorators.ts +50 -0
- package/src/openapi/document.ts +221 -0
- package/src/openapi/index.ts +11 -0
- package/src/openapi/module.ts +95 -0
- package/src/routing/errors.ts +3 -0
- package/src/testing/index.ts +73 -0
- package/src/utils/errors.ts +25 -0
- package/src/utils/object.ts +23 -0
- package/src/websocket/decorators.ts +55 -0
- package/src/websocket/gateway.ts +213 -0
- package/src/websocket/index.ts +7 -0
package/src/http/routes.ts
CHANGED
|
@@ -11,9 +11,11 @@
|
|
|
11
11
|
* ```
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
+
import { InjectionToken } from '../di/index.ts'
|
|
14
15
|
import type { Constructor, Container, ControllerEntry, ModuleClass } from '../di/index.ts'
|
|
15
16
|
import type { RouteDefinition } from '../routing/index.ts'
|
|
16
17
|
import { parsePath } from '../routing/path.ts'
|
|
18
|
+
import { methodNames } from '../utils/object.ts'
|
|
17
19
|
import { isThenable } from '../utils/promise.ts'
|
|
18
20
|
import type { Argument } from './arguments.ts'
|
|
19
21
|
import type { Context, RouteInfo } from './context.ts'
|
|
@@ -25,6 +27,10 @@ import { ControllerError } from './errors.ts'
|
|
|
25
27
|
import { createEnhancerResolver, createPipeline } from './pipeline.ts'
|
|
26
28
|
import type { EnhancerResolver } from './pipeline.ts'
|
|
27
29
|
import { createResponder } from './response.ts'
|
|
30
|
+
import { createSseResponder } from './sse.ts'
|
|
31
|
+
import { getGatewayPath } from '../websocket/decorators.ts'
|
|
32
|
+
import { buildGateway } from '../websocket/gateway.ts'
|
|
33
|
+
import type { BuiltGateway } from '../websocket/gateway.ts'
|
|
28
34
|
|
|
29
35
|
/**
|
|
30
36
|
* Options of `controllerRoutes`.
|
|
@@ -62,29 +68,6 @@ const joinPaths = (...parts: readonly string[]): string => {
|
|
|
62
68
|
return path || '/'
|
|
63
69
|
}
|
|
64
70
|
|
|
65
|
-
/**
|
|
66
|
-
* Public method names along the prototype chain, most derived first, without duplicates.
|
|
67
|
-
*/
|
|
68
|
-
const methodNames = (instance: object): string[] => {
|
|
69
|
-
const names: string[] = []
|
|
70
|
-
const seen = new Set<string>(['constructor'])
|
|
71
|
-
|
|
72
|
-
for (
|
|
73
|
-
let prototype: object | null = Object.getPrototypeOf(instance);
|
|
74
|
-
prototype !== null && prototype !== Object.prototype;
|
|
75
|
-
prototype = Object.getPrototypeOf(prototype)
|
|
76
|
-
) {
|
|
77
|
-
for (const name of Object.getOwnPropertyNames(prototype)) {
|
|
78
|
-
if (!seen.has(name)) {
|
|
79
|
-
seen.add(name)
|
|
80
|
-
names.push(name)
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return names
|
|
86
|
-
}
|
|
87
|
-
|
|
88
71
|
/** Every `param()` must name a parameter of the full path, controller prefix included. */
|
|
89
72
|
const assertParamsExist = (args: readonly Argument<unknown>[], path: string, name: string): void => {
|
|
90
73
|
const names = new Set<string>()
|
|
@@ -157,6 +140,9 @@ const resolveAll = <T, S>(
|
|
|
157
140
|
class RoutesBuilder {
|
|
158
141
|
readonly routes: RouteDefinition[] = []
|
|
159
142
|
readonly controllers: MappedController[] = []
|
|
143
|
+
readonly gateways: BuiltGateway[] = []
|
|
144
|
+
/** Routes of the controller being added. */
|
|
145
|
+
#mapped: MappedRoute[] = []
|
|
160
146
|
/** Guards, interceptors and filters created from classes, in creation order. */
|
|
161
147
|
readonly enhancers: unknown[] = []
|
|
162
148
|
readonly #options: ControllerRoutesOptions
|
|
@@ -174,6 +160,14 @@ class RoutesBuilder {
|
|
|
174
160
|
}
|
|
175
161
|
|
|
176
162
|
addController(entry: ControllerEntry): void {
|
|
163
|
+
const gatewayPath = getGatewayPath(entry.controller)
|
|
164
|
+
|
|
165
|
+
if (gatewayPath !== undefined) {
|
|
166
|
+
this.#addGateway(entry, gatewayPath)
|
|
167
|
+
|
|
168
|
+
return
|
|
169
|
+
}
|
|
170
|
+
|
|
177
171
|
const controllerPath = getControllerPath(entry.controller)
|
|
178
172
|
|
|
179
173
|
if (controllerPath === undefined) {
|
|
@@ -185,14 +179,32 @@ class RoutesBuilder {
|
|
|
185
179
|
|
|
186
180
|
const basePath = joinPaths(this.#options.prefix ?? '', controllerPath)
|
|
187
181
|
|
|
188
|
-
|
|
182
|
+
this.#mapped = []
|
|
189
183
|
|
|
190
184
|
// The most derived function: an override without route decorators has no metadata.
|
|
191
185
|
for (const name of methodNames(entry.instance as object)) {
|
|
192
186
|
this.#addMethod(entry, basePath, name)
|
|
193
187
|
}
|
|
194
188
|
|
|
195
|
-
this.controllers.push({ controller: entry.controller, path: basePath, routes: this
|
|
189
|
+
this.controllers.push({ controller: entry.controller, path: basePath, routes: this.#mapped })
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** A gateway: its handshake gets the guards and filters of the class and the global ones. */
|
|
193
|
+
#addGateway(entry: ControllerEntry, gatewayPath: string): void {
|
|
194
|
+
const local = this.#resolverOf(entry)
|
|
195
|
+
const controller = getClassEnhancers(entry.controller)
|
|
196
|
+
const options = this.#options
|
|
197
|
+
|
|
198
|
+
this.gateways.push(
|
|
199
|
+
buildGateway({
|
|
200
|
+
entry,
|
|
201
|
+
path: joinPaths(options.prefix ?? '', gatewayPath),
|
|
202
|
+
guards: resolveAll([options.guards ?? [], controller.guards, []], this.#global.guard, local.guard),
|
|
203
|
+
filters: resolveAll([options.filters ?? [], controller.filters, []], this.#global.filter, local.filter).reverse(),
|
|
204
|
+
metadata: controller.metadata,
|
|
205
|
+
onError: this.#onError,
|
|
206
|
+
})
|
|
207
|
+
)
|
|
196
208
|
}
|
|
197
209
|
|
|
198
210
|
#resolverOf(entry: ControllerEntry): EnhancerResolver {
|
|
@@ -238,6 +250,9 @@ class RoutesBuilder {
|
|
|
238
250
|
assertParamsExist(route.args, path, `${entry.controller.name}.${name}`)
|
|
239
251
|
}
|
|
240
252
|
|
|
253
|
+
const status = statusOf(metadata, route)
|
|
254
|
+
|
|
255
|
+
this.#mapped.push({ info, args: route.args, status, sse: route.sse })
|
|
241
256
|
this.routes.push({
|
|
242
257
|
method: route.method,
|
|
243
258
|
path,
|
|
@@ -248,7 +263,7 @@ class RoutesBuilder {
|
|
|
248
263
|
interceptors,
|
|
249
264
|
filters,
|
|
250
265
|
invoke: createInvoker(instance, method, route.args),
|
|
251
|
-
respond:
|
|
266
|
+
respond: route.sse ? createSseResponder(metadata.headers, this.#onError) : createResponder(status, metadata.headers),
|
|
252
267
|
onError: this.#onError,
|
|
253
268
|
}),
|
|
254
269
|
})
|
|
@@ -256,14 +271,36 @@ class RoutesBuilder {
|
|
|
256
271
|
}
|
|
257
272
|
}
|
|
258
273
|
|
|
259
|
-
/** A
|
|
274
|
+
/** A route as declared: what startup logs and API descriptions (OpenAPI) show. */
|
|
275
|
+
export interface MappedRoute {
|
|
276
|
+
/** Controller, method name, HTTP method, full path and custom metadata. */
|
|
277
|
+
readonly info: RouteInfo
|
|
278
|
+
/** Declared arguments; `undefined` when the method receives the context. */
|
|
279
|
+
readonly args: readonly Argument<unknown>[] | undefined
|
|
280
|
+
/** Status of the responses built from returned values. */
|
|
281
|
+
readonly status: number
|
|
282
|
+
/** Declared with `@Sse()`. */
|
|
283
|
+
readonly sse: boolean
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** A controller and the routes made from it. */
|
|
260
287
|
export interface MappedController {
|
|
261
288
|
readonly controller: Constructor<unknown>
|
|
262
289
|
/** Path of the controller, with the global prefix. */
|
|
263
290
|
readonly path: string
|
|
264
|
-
readonly routes: readonly
|
|
291
|
+
readonly routes: readonly MappedRoute[]
|
|
265
292
|
}
|
|
266
293
|
|
|
294
|
+
/**
|
|
295
|
+
* The routes of the application, grouped by controller: `inject(ROUTES)`.
|
|
296
|
+
* Filled once the routes are built, so read it when serving, not in a constructor.
|
|
297
|
+
*/
|
|
298
|
+
export interface RouteRegistry {
|
|
299
|
+
readonly controllers: readonly MappedController[]
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export const ROUTES = new InjectionToken<RouteRegistry>('ROUTES')
|
|
303
|
+
|
|
267
304
|
/**
|
|
268
305
|
* Routes of every controller, and the guards, interceptors and filters created
|
|
269
306
|
* for them from classes (whose lifecycle hooks the application runs).
|
|
@@ -272,6 +309,8 @@ export interface BuiltRoutes {
|
|
|
272
309
|
readonly routes: RouteDefinition[]
|
|
273
310
|
/** In module order. */
|
|
274
311
|
readonly controllers: readonly MappedController[]
|
|
312
|
+
/** WebSocket gateways, in module order. */
|
|
313
|
+
readonly gateways: readonly BuiltGateway[]
|
|
275
314
|
/** In creation order. */
|
|
276
315
|
readonly enhancers: readonly unknown[]
|
|
277
316
|
}
|
|
@@ -286,7 +325,7 @@ export const buildRoutes = (container: Container, options: ControllerRoutesOptio
|
|
|
286
325
|
builder.addController(entry)
|
|
287
326
|
}
|
|
288
327
|
|
|
289
|
-
return { routes: builder.routes, controllers: builder.controllers, enhancers: builder.enhancers }
|
|
328
|
+
return { routes: builder.routes, controllers: builder.controllers, gateways: builder.gateways, enhancers: builder.enhancers }
|
|
290
329
|
}
|
|
291
330
|
|
|
292
331
|
/**
|
package/src/http/sse.ts
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Server-sent events: a route decorated with `@Sse()` returns an async
|
|
4
|
+
* iterable of messages, streamed as `text/event-stream`, as Nest's `@Sse()`
|
|
5
|
+
* streams an observable.
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* @Sse('ticks')
|
|
9
|
+
* async *ticks(): AsyncGenerator<SseMessage> {
|
|
10
|
+
* for (let i = 0; ; i++) {
|
|
11
|
+
* yield { data: { tick: i } }
|
|
12
|
+
* await Bun.sleep(1000)
|
|
13
|
+
* }
|
|
14
|
+
* }
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* When the client disconnects, the iterator is returned: a generator runs its
|
|
18
|
+
* `finally` blocks. An error thrown while streaming ends the stream and is logged.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Context } from './context.ts'
|
|
22
|
+
|
|
23
|
+
/** One event. Fields are those of the `EventSource` protocol. */
|
|
24
|
+
export interface SseMessage {
|
|
25
|
+
/** A string is sent as is, anything else as JSON. */
|
|
26
|
+
readonly data: unknown
|
|
27
|
+
/** Event name, listened to with `source.addEventListener(event, ...)`. */
|
|
28
|
+
readonly event?: string
|
|
29
|
+
/** Sent back by the browser as `Last-Event-ID` when it reconnects. */
|
|
30
|
+
readonly id?: string
|
|
31
|
+
/** Milliseconds the browser waits before reconnecting. */
|
|
32
|
+
readonly retry?: number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Headers of Nest's SSE responses: no caching, no buffering by nginx. */
|
|
36
|
+
const SSE_HEADERS: readonly [string, string][] = [
|
|
37
|
+
['content-type', 'text/event-stream'],
|
|
38
|
+
['cache-control', 'private, no-cache, no-store, must-revalidate, max-age=0, no-transform'],
|
|
39
|
+
['x-accel-buffering', 'no'],
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
const NEWLINE = /\r\n|\r|\n/
|
|
43
|
+
const encoder = new TextEncoder()
|
|
44
|
+
|
|
45
|
+
const assertSingleLine = (name: string, value: string): void => {
|
|
46
|
+
if (NEWLINE.test(value)) {
|
|
47
|
+
throw new TypeError(`SSE ${name} must not contain line breaks: ${JSON.stringify(value)}`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A message in the event stream format, one `data:` line per line of data. */
|
|
52
|
+
export const formatSseMessage = (message: SseMessage): string => {
|
|
53
|
+
const data = typeof message.data === 'string' ? message.data : JSON.stringify(message.data)
|
|
54
|
+
let text = ''
|
|
55
|
+
|
|
56
|
+
if (message.event !== undefined) {
|
|
57
|
+
assertSingleLine('event', message.event)
|
|
58
|
+
text += `event: ${message.event}\n`
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (message.id !== undefined) {
|
|
62
|
+
assertSingleLine('id', message.id)
|
|
63
|
+
text += `id: ${message.id}\n`
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (message.retry !== undefined) {
|
|
67
|
+
text += `retry: ${Math.trunc(message.retry)}\n`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
for (const line of (data ?? '').split(NEWLINE)) {
|
|
71
|
+
text += `data: ${line}\n`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return `${text}\n`
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const isAsyncIterable = (value: unknown): value is AsyncIterable<SseMessage> =>
|
|
78
|
+
typeof value === 'object' && value !== null && Symbol.asyncIterator in value
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Creates the function streaming what an `@Sse()` route returns. `headers`
|
|
82
|
+
* come from `@Header()`; errors while streaming go to `onError`.
|
|
83
|
+
*/
|
|
84
|
+
export const createSseResponder = (
|
|
85
|
+
headers: readonly [string, string][],
|
|
86
|
+
onError: (error: unknown, req: Request) => void
|
|
87
|
+
): ((value: unknown, ctx: Context) => Response) => {
|
|
88
|
+
const init: ResponseInit = { status: 200, headers: [...SSE_HEADERS, ...headers] }
|
|
89
|
+
|
|
90
|
+
return (value, ctx) => {
|
|
91
|
+
if (!isAsyncIterable(value)) {
|
|
92
|
+
throw new TypeError(`${ctx.route.controller.name}.${ctx.route.handler} is an @Sse() route: it must return an async iterable`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const iterator = value[Symbol.asyncIterator]()
|
|
96
|
+
|
|
97
|
+
const body = new ReadableStream<Uint8Array>({
|
|
98
|
+
async pull(controller) {
|
|
99
|
+
try {
|
|
100
|
+
const next = await iterator.next()
|
|
101
|
+
|
|
102
|
+
if (next.done === true) {
|
|
103
|
+
controller.close()
|
|
104
|
+
} else {
|
|
105
|
+
controller.enqueue(encoder.encode(formatSseMessage(next.value)))
|
|
106
|
+
}
|
|
107
|
+
} catch (error) {
|
|
108
|
+
onError(error, ctx.req)
|
|
109
|
+
controller.close()
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
// The client went away: let the source clean up.
|
|
113
|
+
async cancel() {
|
|
114
|
+
await iterator.return?.()
|
|
115
|
+
},
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
return new Response(body, init)
|
|
119
|
+
}
|
|
120
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Decorators describing routes in the OpenAPI document, as `@nestjs/swagger` does.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createDecorator } from '../http/enhancers.ts'
|
|
7
|
+
import type { ClassOrMethodDecorator } from '../http/enhancers.ts'
|
|
8
|
+
import type { StandardSchemaV1 } from '../http/schema.ts'
|
|
9
|
+
|
|
10
|
+
export interface ApiOperationOptions {
|
|
11
|
+
readonly summary?: string
|
|
12
|
+
readonly description?: string
|
|
13
|
+
readonly deprecated?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ApiResponseOptions {
|
|
17
|
+
readonly description: string
|
|
18
|
+
/** Schema of the JSON body; needs a library implementing Standard JSON Schema, such as zod. */
|
|
19
|
+
readonly schema?: StandardSchemaV1
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const operation = createDecorator<ApiOperationOptions>('ApiOperation')
|
|
23
|
+
const tags = createDecorator<readonly string[]>('ApiTags')
|
|
24
|
+
const responses = createDecorator<Readonly<Record<number, ApiResponseOptions>>>('ApiResponses')
|
|
25
|
+
const bearer = createDecorator<boolean>('ApiBearerAuth')
|
|
26
|
+
const exclude = createDecorator<boolean>('ApiExclude')
|
|
27
|
+
|
|
28
|
+
/** Summary, description and deprecation of an operation. */
|
|
29
|
+
export const ApiOperation = (options: ApiOperationOptions): ClassOrMethodDecorator => operation(options)
|
|
30
|
+
|
|
31
|
+
/** Tags grouping operations, on a controller or a method (which wins). */
|
|
32
|
+
export const ApiTags = (...names: string[]): ClassOrMethodDecorator => tags(names)
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Responses of an operation, by status. Without it, the operation has the
|
|
36
|
+
* status of its route (`200`, `201` for `POST`, or `@HttpCode()`).
|
|
37
|
+
*
|
|
38
|
+
* ```ts
|
|
39
|
+
* @ApiResponses({ 200: { description: 'The user', schema: User }, 404: { description: 'No such user' } })
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
export const ApiResponses = (byStatus: Readonly<Record<number, ApiResponseOptions>>): ClassOrMethodDecorator => responses(byStatus)
|
|
43
|
+
|
|
44
|
+
/** Operations need `Authorization: Bearer <token>`, as `JwtGuard` reads. */
|
|
45
|
+
export const ApiBearerAuth = (): ClassOrMethodDecorator => bearer(true)
|
|
46
|
+
|
|
47
|
+
/** Leaves a controller or a method out of the document. */
|
|
48
|
+
export const ApiExclude = (): ClassOrMethodDecorator => exclude(true)
|
|
49
|
+
|
|
50
|
+
export const KEYS = { operation: operation.key, tags: tags.key, responses: responses.key, bearer: bearer.key, exclude: exclude.key } as const
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* Building an OpenAPI 3.1 document from the routes of an application.
|
|
4
|
+
*
|
|
5
|
+
* Schemas come from what validates requests: argument parsers (`int()`,
|
|
6
|
+
* `uuid()`...) and Standard Schemas implementing Standard JSON Schema, as zod
|
|
7
|
+
* does. Paths with optional parameters give one path per form, as OpenAPI has
|
|
8
|
+
* no optional path parameters; `@All()` routes are left out, OpenAPI has no
|
|
9
|
+
* such method.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import type { Argument } from '../http/arguments.ts'
|
|
13
|
+
import type { Parser } from '../http/parsers.ts'
|
|
14
|
+
import type { MappedController, MappedRoute } from '../http/routes.ts'
|
|
15
|
+
import { isStandardSchema } from '../http/schema.ts'
|
|
16
|
+
import type { StandardSchemaV1 } from '../http/schema.ts'
|
|
17
|
+
import { expandOptional, parsePath } from '../routing/path.ts'
|
|
18
|
+
import type { Segment } from '../routing/path.ts'
|
|
19
|
+
import { KEYS } from './decorators.ts'
|
|
20
|
+
import type { ApiOperationOptions, ApiResponseOptions } from './decorators.ts'
|
|
21
|
+
|
|
22
|
+
export type JsonSchema = Readonly<Record<string, unknown>>
|
|
23
|
+
|
|
24
|
+
export interface OpenApiOptions {
|
|
25
|
+
readonly info: { readonly title: string; readonly version: string; readonly description?: string }
|
|
26
|
+
readonly servers?: readonly { readonly url: string; readonly description?: string }[]
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface OpenApiParameter {
|
|
30
|
+
readonly name: string
|
|
31
|
+
readonly in: 'path' | 'query' | 'header' | 'cookie'
|
|
32
|
+
readonly required: boolean
|
|
33
|
+
readonly schema: JsonSchema
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface OpenApiOperation {
|
|
37
|
+
readonly operationId: string
|
|
38
|
+
readonly summary?: string
|
|
39
|
+
readonly description?: string
|
|
40
|
+
readonly deprecated?: boolean
|
|
41
|
+
readonly tags?: readonly string[]
|
|
42
|
+
readonly parameters?: readonly OpenApiParameter[]
|
|
43
|
+
readonly requestBody?: { readonly required: boolean; readonly content: Readonly<Record<string, { readonly schema: JsonSchema }>> }
|
|
44
|
+
readonly responses: Readonly<Record<string, { readonly description: string; readonly content?: Readonly<Record<string, { readonly schema: JsonSchema }>> }>>
|
|
45
|
+
readonly security?: readonly Readonly<Record<string, readonly string[]>>[]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface OpenApiDocument {
|
|
49
|
+
readonly openapi: '3.1.0'
|
|
50
|
+
readonly info: OpenApiOptions['info']
|
|
51
|
+
readonly servers?: OpenApiOptions['servers']
|
|
52
|
+
readonly paths: Readonly<Record<string, Readonly<Record<string, OpenApiOperation>>>>
|
|
53
|
+
readonly components?: { readonly securitySchemes: Readonly<Record<string, JsonSchema>> }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const METHODS = new Set(['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS', 'HEAD', 'PATCH', 'TRACE'])
|
|
57
|
+
const IN = { query: 'query', header: 'header', cookie: 'cookie' } as const
|
|
58
|
+
const STRING: JsonSchema = { type: 'string' }
|
|
59
|
+
const FORM_TYPES = ['multipart/form-data', 'application/x-www-form-urlencoded']
|
|
60
|
+
|
|
61
|
+
/** Standard JSON Schema: the part of `~standard` a schema library may add. */
|
|
62
|
+
interface WithJsonSchema {
|
|
63
|
+
readonly '~standard': { readonly jsonSchema?: { readonly input: (options: { readonly target: string }) => Record<string, unknown> } }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** JSON Schema of what a parser or schema accepts, `{}` when it cannot tell. */
|
|
67
|
+
const toJsonSchema = (source: Argument<unknown>['source']): JsonSchema => {
|
|
68
|
+
if (source === undefined) {
|
|
69
|
+
return STRING
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isStandardSchema(source)) {
|
|
73
|
+
return (source as Parser<unknown>).jsonSchema ?? STRING
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const converter = (source as WithJsonSchema)['~standard'].jsonSchema
|
|
77
|
+
|
|
78
|
+
if (converter === undefined) {
|
|
79
|
+
return {}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const { $schema: _, ...schema } = converter.input({ target: 'draft-2020-12' })
|
|
83
|
+
|
|
84
|
+
return schema
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A value may be missing when its parser says so, or its schema accepts `undefined`. */
|
|
88
|
+
const isOptional = (source: Argument<unknown>['source']): boolean => {
|
|
89
|
+
if (source === undefined) {
|
|
90
|
+
return true
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (!isStandardSchema(source)) {
|
|
94
|
+
return (source as Parser<unknown>).optional === true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const result = (source as StandardSchemaV1)['~standard'].validate(undefined)
|
|
98
|
+
|
|
99
|
+
return !(result instanceof Promise) && result.issues === undefined
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const toOpenApiPath = (segments: readonly Segment[]): string =>
|
|
103
|
+
`/${segments
|
|
104
|
+
.map((segment) =>
|
|
105
|
+
segment.type === 'static' ? decodeURIComponent(segment.value) : `{${segment.type === 'param' ? segment.name : (segment.name ?? 'wildcard')}}`
|
|
106
|
+
)
|
|
107
|
+
.join('/')}`
|
|
108
|
+
|
|
109
|
+
const pathParameters = (segments: readonly Segment[], args: readonly Argument<unknown>[]): OpenApiParameter[] => {
|
|
110
|
+
const parameters: OpenApiParameter[] = []
|
|
111
|
+
|
|
112
|
+
for (const segment of segments) {
|
|
113
|
+
if (segment.type === 'static') {
|
|
114
|
+
continue
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const name = segment.type === 'param' ? segment.name : (segment.name ?? 'wildcard')
|
|
118
|
+
const declared = args.find((arg) => arg.kind === 'param' && arg.name === name)
|
|
119
|
+
const pattern = segment.type === 'param' && segment.pattern !== undefined ? { pattern: `^(?:${segment.pattern})$` } : {}
|
|
120
|
+
|
|
121
|
+
parameters.push({ name, in: 'path', required: true, schema: { ...toJsonSchema(declared?.source), ...pattern } })
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return parameters
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const requestBody = (args: readonly Argument<unknown>[]): OpenApiOperation['requestBody'] => {
|
|
128
|
+
const body = args.find((arg) => arg.kind === 'body')
|
|
129
|
+
|
|
130
|
+
if (body === undefined) {
|
|
131
|
+
return undefined
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const schema = body.source === undefined ? {} : toJsonSchema(body.source)
|
|
135
|
+
const types = body.multipart === true ? FORM_TYPES : ['application/json']
|
|
136
|
+
|
|
137
|
+
return { required: !isOptional(body.source), content: Object.fromEntries(types.map((type) => [type, { schema }])) }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const responsesOf = (route: MappedRoute): OpenApiOperation['responses'] => {
|
|
141
|
+
const declared = route.info.metadata.get(KEYS.responses) as Readonly<Record<number, ApiResponseOptions>> | undefined
|
|
142
|
+
|
|
143
|
+
if (declared === undefined) {
|
|
144
|
+
return route.sse
|
|
145
|
+
? { 200: { description: 'Event stream', content: { 'text/event-stream': { schema: STRING } } } }
|
|
146
|
+
: { [route.status]: { description: 'Success' } }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return Object.fromEntries(
|
|
150
|
+
Object.entries(declared).map(([status, response]) => [
|
|
151
|
+
status,
|
|
152
|
+
response.schema === undefined
|
|
153
|
+
? { description: response.description }
|
|
154
|
+
: { description: response.description, content: { 'application/json': { schema: toJsonSchema(response.schema) } } },
|
|
155
|
+
])
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const operationOf = (route: MappedRoute, segments: readonly Segment[], suffix: string): OpenApiOperation => {
|
|
160
|
+
const { info } = route
|
|
161
|
+
const args = route.args ?? []
|
|
162
|
+
const parameters = pathParameters(segments, args)
|
|
163
|
+
|
|
164
|
+
for (const arg of args) {
|
|
165
|
+
if (arg.kind === 'query' || arg.kind === 'header' || arg.kind === 'cookie') {
|
|
166
|
+
parameters.push({ name: arg.name, in: IN[arg.kind], required: !isOptional(arg.source), schema: toJsonSchema(arg.source) })
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const options = (info.metadata.get(KEYS.operation) as ApiOperationOptions | undefined) ?? {}
|
|
171
|
+
const tags = info.metadata.get(KEYS.tags) as readonly string[] | undefined
|
|
172
|
+
const body = requestBody(args)
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
operationId: `${info.controller.name}_${info.handler}${suffix}`,
|
|
176
|
+
...options,
|
|
177
|
+
...(tags === undefined ? {} : { tags }),
|
|
178
|
+
...(parameters.length === 0 ? {} : { parameters }),
|
|
179
|
+
...(body === undefined ? {} : { requestBody: body }),
|
|
180
|
+
responses: responsesOf(route),
|
|
181
|
+
...(info.metadata.get(KEYS.bearer) === true ? { security: [{ bearer: [] }] } : {}),
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Adds the operations of `route` to `paths`, one per form of its path; `false` when it is left out. */
|
|
186
|
+
const addRoute = (paths: Record<string, Record<string, OpenApiOperation>>, route: MappedRoute): boolean => {
|
|
187
|
+
const { info } = route
|
|
188
|
+
|
|
189
|
+
if (!METHODS.has(info.method) || info.metadata.get(KEYS.exclude) === true) {
|
|
190
|
+
return false
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
for (const [index, segments] of expandOptional(info.path, parsePath(info.path)).entries()) {
|
|
194
|
+
const path = toOpenApiPath(segments)
|
|
195
|
+
|
|
196
|
+
paths[path] ??= {}
|
|
197
|
+
paths[path][info.method.toLowerCase()] = operationOf(route, segments, index === 0 ? '' : `_${index}`)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return true
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The OpenAPI document of `controllers`, as `inject(ROUTES).controllers` lists them. */
|
|
204
|
+
export const createOpenApiDocument = (controllers: readonly MappedController[], options: OpenApiOptions): OpenApiDocument => {
|
|
205
|
+
const paths: Record<string, Record<string, OpenApiOperation>> = {}
|
|
206
|
+
let bearer = false
|
|
207
|
+
|
|
208
|
+
for (const controller of controllers) {
|
|
209
|
+
for (const route of controller.routes) {
|
|
210
|
+
bearer = (addRoute(paths, route) && route.info.metadata.get(KEYS.bearer) === true) || bearer
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
openapi: '3.1.0',
|
|
216
|
+
info: options.info,
|
|
217
|
+
...(options.servers === undefined ? {} : { servers: options.servers }),
|
|
218
|
+
paths,
|
|
219
|
+
...(bearer ? { components: { securitySchemes: { bearer: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' } } } } : {}),
|
|
220
|
+
}
|
|
221
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* OpenAPI documents of an application, and a Swagger UI page, as `@nestjs/swagger`.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { ApiBearerAuth, ApiExclude, ApiOperation, ApiResponses, ApiTags } from './decorators.ts'
|
|
7
|
+
export type { ApiOperationOptions, ApiResponseOptions } from './decorators.ts'
|
|
8
|
+
export { createOpenApiDocument } from './document.ts'
|
|
9
|
+
export type { JsonSchema, OpenApiDocument, OpenApiOperation, OpenApiOptions, OpenApiParameter } from './document.ts'
|
|
10
|
+
export { OpenApiModule } from './module.ts'
|
|
11
|
+
export type { OpenApiModuleOptions } from './module.ts'
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module
|
|
3
|
+
* `OpenApiModule`: serves the OpenAPI document of the application, and a
|
|
4
|
+
* Swagger UI page, as `SwaggerModule.setup()` of `@nestjs/swagger` does.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { inject, Module } from '../di/index.ts'
|
|
8
|
+
import type { DynamicModule } from '../di/index.ts'
|
|
9
|
+
import { Controller, Get } from '../http/decorators.ts'
|
|
10
|
+
import { ROUTES } from '../http/routes.ts'
|
|
11
|
+
import { ApiExclude } from './decorators.ts'
|
|
12
|
+
import { createOpenApiDocument } from './document.ts'
|
|
13
|
+
import type { OpenApiDocument, OpenApiOptions } from './document.ts'
|
|
14
|
+
|
|
15
|
+
export interface OpenApiModuleOptions extends OpenApiOptions {
|
|
16
|
+
/** Path of the JSON document. Defaults to `/openapi.json`. */
|
|
17
|
+
readonly path?: string
|
|
18
|
+
/** Path of a Swagger UI page reading the document, such as `/docs`. None by default. */
|
|
19
|
+
readonly ui?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Swagger UI, from a CDN at a fixed version. */
|
|
23
|
+
const SWAGGER_UI = 'https://cdn.jsdelivr.net/npm/swagger-ui-dist@5.34.0'
|
|
24
|
+
|
|
25
|
+
const page = (title: string, documentUrl: string): string => `<!doctype html>
|
|
26
|
+
<html lang="en">
|
|
27
|
+
<head>
|
|
28
|
+
<meta charset="utf-8">
|
|
29
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
30
|
+
<title>${Bun.escapeHTML(title)}</title>
|
|
31
|
+
<link rel="stylesheet" href="${SWAGGER_UI}/swagger-ui.css">
|
|
32
|
+
</head>
|
|
33
|
+
<body>
|
|
34
|
+
<div id="docs"></div>
|
|
35
|
+
<script src="${SWAGGER_UI}/swagger-ui-bundle.js" crossorigin></script>
|
|
36
|
+
<script>SwaggerUIBundle({ url: ${JSON.stringify(documentUrl)}, dom_id: '#docs' })</script>
|
|
37
|
+
</body>
|
|
38
|
+
</html>
|
|
39
|
+
`
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* ```ts
|
|
43
|
+
* @Module({ imports: [OpenApiModule.forRoot({ info: { title: 'Shop', version: '1.0.0' }, ui: '/docs' })] })
|
|
44
|
+
* class AppModule {}
|
|
45
|
+
* ```
|
|
46
|
+
*
|
|
47
|
+
* The document is built on the first request, from every route of the
|
|
48
|
+
* application (these two excepted), and kept.
|
|
49
|
+
*/
|
|
50
|
+
@Module({})
|
|
51
|
+
export class OpenApiModule {
|
|
52
|
+
static forRoot(options: OpenApiModuleOptions): DynamicModule {
|
|
53
|
+
const documentPath = options.path ?? '/openapi.json'
|
|
54
|
+
|
|
55
|
+
@Controller()
|
|
56
|
+
@ApiExclude()
|
|
57
|
+
class OpenApiController {
|
|
58
|
+
readonly #routes = inject(ROUTES)
|
|
59
|
+
#document: OpenApiDocument | undefined
|
|
60
|
+
|
|
61
|
+
@Get(documentPath)
|
|
62
|
+
document(): OpenApiDocument {
|
|
63
|
+
this.#document ??= createOpenApiDocument(this.#routes.controllers, options)
|
|
64
|
+
|
|
65
|
+
return this.#document
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const controllers: (new () => unknown)[] = [OpenApiController]
|
|
70
|
+
|
|
71
|
+
if (options.ui !== undefined) {
|
|
72
|
+
const ui = options.ui
|
|
73
|
+
|
|
74
|
+
@Controller()
|
|
75
|
+
@ApiExclude()
|
|
76
|
+
class OpenApiUiController {
|
|
77
|
+
readonly #routes = inject(ROUTES)
|
|
78
|
+
|
|
79
|
+
@Get(ui)
|
|
80
|
+
page(): Response {
|
|
81
|
+
// The document's own path, global prefix included.
|
|
82
|
+
const documentUrl = this.#routes.controllers.find((mapped) => mapped.controller === OpenApiController)?.routes[0]?.info.path
|
|
83
|
+
|
|
84
|
+
return new Response(page(options.info.title, documentUrl ?? documentPath), {
|
|
85
|
+
headers: { 'content-type': 'text/html;charset=utf-8' },
|
|
86
|
+
})
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
controllers.push(OpenApiUiController)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return { module: OpenApiModule, controllers }
|
|
94
|
+
}
|
|
95
|
+
}
|