@getsoda/core 0.2.1 → 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.
Files changed (137) hide show
  1. package/README.md +103 -0
  2. package/dist/app/app.d.ts +15 -5
  3. package/dist/app/app.d.ts.map +1 -1
  4. package/dist/app/app.js +31 -8
  5. package/dist/app/app.js.map +1 -1
  6. package/dist/app/index.d.ts +1 -1
  7. package/dist/app/index.d.ts.map +1 -1
  8. package/dist/config/config.d.ts +57 -0
  9. package/dist/config/config.d.ts.map +1 -0
  10. package/dist/config/config.js +74 -0
  11. package/dist/config/config.js.map +1 -0
  12. package/dist/config/index.d.ts +9 -0
  13. package/dist/config/index.d.ts.map +1 -0
  14. package/dist/config/index.js +7 -0
  15. package/dist/config/index.js.map +1 -0
  16. package/dist/config/module.d.ts +29 -0
  17. package/dist/config/module.d.ts.map +1 -0
  18. package/dist/config/module.js +28 -0
  19. package/dist/config/module.js.map +1 -0
  20. package/dist/di/errors.d.ts.map +1 -1
  21. package/dist/di/errors.js +2 -0
  22. package/dist/di/errors.js.map +1 -1
  23. package/dist/http/arguments.d.ts +19 -6
  24. package/dist/http/arguments.d.ts.map +1 -1
  25. package/dist/http/arguments.js +7 -0
  26. package/dist/http/arguments.js.map +1 -1
  27. package/dist/http/cookies.d.ts.map +1 -1
  28. package/dist/http/cookies.js +2 -0
  29. package/dist/http/cookies.js.map +1 -1
  30. package/dist/http/decorators.d.ts +18 -6
  31. package/dist/http/decorators.d.ts.map +1 -1
  32. package/dist/http/decorators.js +7 -2
  33. package/dist/http/decorators.js.map +1 -1
  34. package/dist/http/errors.d.ts +3 -1
  35. package/dist/http/errors.d.ts.map +1 -1
  36. package/dist/http/errors.js +2 -0
  37. package/dist/http/errors.js.map +1 -1
  38. package/dist/http/index.d.ts +7 -5
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +3 -2
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/parsers.d.ts +4 -0
  43. package/dist/http/parsers.d.ts.map +1 -1
  44. package/dist/http/parsers.js +9 -5
  45. package/dist/http/parsers.js.map +1 -1
  46. package/dist/http/pipeline.d.ts +4 -5
  47. package/dist/http/pipeline.d.ts.map +1 -1
  48. package/dist/http/pipeline.js +2 -2
  49. package/dist/http/pipeline.js.map +1 -1
  50. package/dist/http/routes.d.ts +27 -2
  51. package/dist/http/routes.d.ts.map +1 -1
  52. package/dist/http/routes.js +34 -20
  53. package/dist/http/routes.js.map +1 -1
  54. package/dist/http/schema.d.ts +2 -0
  55. package/dist/http/schema.d.ts.map +1 -1
  56. package/dist/http/schema.js +2 -1
  57. package/dist/http/schema.js.map +1 -1
  58. package/dist/http/sse.d.ts +39 -0
  59. package/dist/http/sse.d.ts.map +1 -0
  60. package/dist/http/sse.js +89 -0
  61. package/dist/http/sse.js.map +1 -0
  62. package/dist/index.d.ts +2 -0
  63. package/dist/index.d.ts.map +1 -1
  64. package/dist/index.js +2 -0
  65. package/dist/index.js.map +1 -1
  66. package/dist/openapi/decorators.d.ts +41 -0
  67. package/dist/openapi/decorators.d.ts.map +1 -0
  68. package/dist/openapi/decorators.js +29 -0
  69. package/dist/openapi/decorators.js.map +1 -0
  70. package/dist/openapi/document.d.ts +62 -0
  71. package/dist/openapi/document.d.ts.map +1 -0
  72. package/dist/openapi/document.js +135 -0
  73. package/dist/openapi/document.js.map +1 -0
  74. package/dist/openapi/index.d.ts +11 -0
  75. package/dist/openapi/index.d.ts.map +1 -0
  76. package/dist/openapi/index.js +8 -0
  77. package/dist/openapi/index.js.map +1 -0
  78. package/dist/openapi/module.d.ts +26 -0
  79. package/dist/openapi/module.d.ts.map +1 -0
  80. package/dist/openapi/module.js +73 -0
  81. package/dist/openapi/module.js.map +1 -0
  82. package/dist/routing/errors.d.ts.map +1 -1
  83. package/dist/routing/errors.js +2 -0
  84. package/dist/routing/errors.js.map +1 -1
  85. package/dist/testing/index.d.ts +46 -0
  86. package/dist/testing/index.d.ts.map +1 -0
  87. package/dist/testing/index.js +49 -0
  88. package/dist/testing/index.js.map +1 -0
  89. package/dist/utils/errors.d.ts +11 -0
  90. package/dist/utils/errors.d.ts.map +1 -0
  91. package/dist/utils/errors.js +22 -0
  92. package/dist/utils/errors.js.map +1 -0
  93. package/dist/utils/object.d.ts +4 -0
  94. package/dist/utils/object.d.ts.map +1 -1
  95. package/dist/utils/object.js +16 -0
  96. package/dist/utils/object.js.map +1 -1
  97. package/dist/websocket/decorators.d.ts +31 -0
  98. package/dist/websocket/decorators.d.ts.map +1 -0
  99. package/dist/websocket/decorators.js +43 -0
  100. package/dist/websocket/decorators.js.map +1 -0
  101. package/dist/websocket/gateway.d.ts +61 -0
  102. package/dist/websocket/gateway.d.ts.map +1 -0
  103. package/dist/websocket/gateway.js +142 -0
  104. package/dist/websocket/gateway.js.map +1 -0
  105. package/dist/websocket/index.d.ts +7 -0
  106. package/dist/websocket/index.d.ts.map +1 -0
  107. package/dist/websocket/index.js +6 -0
  108. package/dist/websocket/index.js.map +1 -0
  109. package/package.json +12 -1
  110. package/src/app/app.ts +69 -18
  111. package/src/app/index.ts +1 -1
  112. package/src/config/config.ts +90 -0
  113. package/src/config/index.ts +9 -0
  114. package/src/config/module.ts +40 -0
  115. package/src/di/errors.ts +3 -0
  116. package/src/http/arguments.ts +31 -6
  117. package/src/http/cookies.ts +2 -0
  118. package/src/http/decorators.ts +25 -10
  119. package/src/http/errors.ts +7 -0
  120. package/src/http/index.ts +7 -4
  121. package/src/http/parsers.ts +18 -7
  122. package/src/http/pipeline.ts +6 -6
  123. package/src/http/routes.ts +68 -29
  124. package/src/http/schema.ts +2 -1
  125. package/src/http/sse.ts +120 -0
  126. package/src/index.ts +2 -0
  127. package/src/openapi/decorators.ts +50 -0
  128. package/src/openapi/document.ts +221 -0
  129. package/src/openapi/index.ts +11 -0
  130. package/src/openapi/module.ts +95 -0
  131. package/src/routing/errors.ts +3 -0
  132. package/src/testing/index.ts +73 -0
  133. package/src/utils/errors.ts +25 -0
  134. package/src/utils/object.ts +23 -0
  135. package/src/websocket/decorators.ts +55 -0
  136. package/src/websocket/gateway.ts +213 -0
  137. package/src/websocket/index.ts +7 -0
@@ -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
@@ -32,7 +32,9 @@
32
32
  */
33
33
 
34
34
  export * from './app/index.ts'
35
+ export * from './config/index.ts'
35
36
  export * from './di/index.ts'
36
37
  export * from './http/index.ts'
37
38
  export * from './logger/index.ts'
38
39
  export * from './routing/index.ts'
40
+ export * from './websocket/index.ts'
@@ -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
+ }
@@ -3,6 +3,8 @@
3
3
  * Errors raised by the route compiler.
4
4
  */
5
5
 
6
+ import { hideFrameworkFrames } from '../utils/errors.ts'
7
+
6
8
  /**
7
9
  * Reason of a `RouteError`.
8
10
  *
@@ -26,5 +28,6 @@ export class RouteError extends Error {
26
28
  message: string
27
29
  ) {
28
30
  super(message)
31
+ hideFrameworkFrames(this)
29
32
  }
30
33
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * @module
3
+ * Testing an application over HTTP, as Nest's `Test.createTestingModule()`
4
+ * with supertest does, on a real Bun server bound to a free port.
5
+ *
6
+ * ```ts
7
+ * import { createTestApp } from '@getsoda/core/testing'
8
+ *
9
+ * const app = await createTestApp(AppModule, {
10
+ * overrides: [provide(UsersRepository, { useValue: new MemoryUsersRepository() })],
11
+ * })
12
+ *
13
+ * afterAll(() => app.close())
14
+ *
15
+ * test('creates a user', async () => {
16
+ * const response = await app.request('/users', { method: 'POST', json: { name: 'Bo' } })
17
+ *
18
+ * expect(response.status).toBe(201)
19
+ * })
20
+ * ```
21
+ *
22
+ * A single service needs no server: `createContainer(Module, { overrides })`.
23
+ */
24
+
25
+ import { createApp } from '../app/app.ts'
26
+ import type { App, AppOptions } from '../app/app.ts'
27
+ import type { ModuleImport, Token } from '../di/index.ts'
28
+
29
+ export interface TestRequestInit extends RequestInit {
30
+ /** Sent as the JSON body, with `Content-Type: application/json`. */
31
+ readonly json?: unknown
32
+ }
33
+
34
+ /** A started application, closed by `close()` or at the end of an `await using` block. */
35
+ export interface TestApp extends AsyncDisposable {
36
+ readonly app: App
37
+ /** Where the application listens, on a free port of localhost. */
38
+ readonly url: URL
39
+ /** `app.container.get()`: a provider as the root module sees it. */
40
+ get<T>(token: Token<T>): T
41
+ /** Sends a request to `path`, relative to the application. */
42
+ request(path: string, init?: TestRequestInit): Promise<Response>
43
+ close(): Promise<void>
44
+ }
45
+
46
+ /**
47
+ * Creates `root` and serves it on a free port. Options are those of
48
+ * `createApp`: `overrides` replace providers; the logger is silent unless one is given.
49
+ */
50
+ export const createTestApp = async (root: ModuleImport, options: AppOptions = {}): Promise<TestApp> => {
51
+ const app = await createApp(root, { logger: false, ...options })
52
+ const server = await app.listen({ port: 0, hostname: '127.0.0.1' })
53
+ const close = () => app.close()
54
+
55
+ return {
56
+ app,
57
+ url: server.url,
58
+ get: (token) => app.container.get(token),
59
+ request: (path, { json, ...init } = {}) => {
60
+ if (json === undefined) {
61
+ return fetch(new URL(path, server.url), init)
62
+ }
63
+
64
+ const headers = new Headers(init.headers)
65
+
66
+ headers.set('content-type', 'application/json')
67
+
68
+ return fetch(new URL(path, server.url), { ...init, headers, body: JSON.stringify(json) })
69
+ },
70
+ close,
71
+ [Symbol.asyncDispose]: close,
72
+ }
73
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @module
3
+ * Error utilities.
4
+ */
5
+
6
+ import { fileURLToPath } from 'node:url'
7
+
8
+ /** Where the framework's own files are: `src/` from sources, `dist/` once built. */
9
+ const FRAMEWORK = fileURLToPath(new URL('../', import.meta.url))
10
+
11
+ /**
12
+ * Removes the frames of the framework from the stack of an error about the
13
+ * application's code (a missing provider, an invalid route): the runtime then
14
+ * points at the application, not at the framework, and the message says the rest.
15
+ */
16
+ export const hideFrameworkFrames = (error: Error): void => {
17
+ if (error.stack === undefined) {
18
+ return
19
+ }
20
+
21
+ error.stack = error.stack
22
+ .split('\n')
23
+ .filter((line) => !(line.trimStart().startsWith('at ') && line.includes(FRAMEWORK)))
24
+ .join('\n')
25
+ }
@@ -12,3 +12,26 @@
12
12
  * ```
13
13
  */
14
14
  export const createNullObject = <T extends object>(): T => Object.create(null) as T
15
+
16
+ /**
17
+ * Public method names along the prototype chain, most derived first, without duplicates.
18
+ */
19
+ export const methodNames = (instance: object): string[] => {
20
+ const names: string[] = []
21
+ const seen = new Set<string>(['constructor'])
22
+
23
+ for (
24
+ let prototype: object | null = Object.getPrototypeOf(instance);
25
+ prototype !== null && prototype !== Object.prototype;
26
+ prototype = Object.getPrototypeOf(prototype)
27
+ ) {
28
+ for (const name of Object.getOwnPropertyNames(prototype)) {
29
+ if (!seen.has(name)) {
30
+ seen.add(name)
31
+ names.push(name)
32
+ }
33
+ }
34
+ }
35
+
36
+ return names
37
+ }