@c9up/ream 0.2.17 → 0.2.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.
- package/dist/HttpKernel.d.ts +10 -0
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +61 -5
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +65 -15
- package/dist/Ignitor.js.map +1 -1
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +174 -45
- package/dist/container/Container.js.map +1 -1
- package/dist/container/types.d.ts +7 -0
- package/dist/container/types.d.ts.map +1 -1
- package/dist/dev/hmr.d.ts +37 -0
- package/dist/dev/hmr.d.ts.map +1 -0
- package/dist/dev/hmr.js +72 -0
- package/dist/dev/hmr.js.map +1 -0
- package/dist/graphql/GraphQLProvider.d.ts +14 -3
- package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
- package/dist/graphql/GraphQLProvider.js +14 -3
- package/dist/graphql/GraphQLProvider.js.map +1 -1
- package/dist/hot.d.ts +29 -0
- package/dist/hot.d.ts.map +1 -0
- package/dist/hot.js +135 -0
- package/dist/hot.js.map +1 -0
- package/dist/http/Exception.d.ts +2 -2
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +3 -3
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/Request.d.ts +11 -0
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +40 -0
- package/dist/http/Request.js.map +1 -1
- package/dist/http/RequestValidator.d.ts +80 -0
- package/dist/http/RequestValidator.d.ts.map +1 -0
- package/dist/http/RequestValidator.js +93 -0
- package/dist/http/RequestValidator.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.d.ts +2 -2
- package/dist/middleware/Pipeline.js +2 -2
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/migrations/MigrationRegistry.d.ts +16 -0
- package/dist/migrations/MigrationRegistry.d.ts.map +1 -1
- package/dist/migrations/MigrationRegistry.js +24 -0
- package/dist/migrations/MigrationRegistry.js.map +1 -1
- package/dist/openapi/OpenApiGenerator.d.ts +16 -0
- package/dist/openapi/OpenApiGenerator.d.ts.map +1 -1
- package/dist/openapi/OpenApiGenerator.js +76 -4
- package/dist/openapi/OpenApiGenerator.js.map +1 -1
- package/dist/openapi/OpenApiProvider.d.ts +16 -5
- package/dist/openapi/OpenApiProvider.d.ts.map +1 -1
- package/dist/openapi/OpenApiProvider.js +21 -6
- package/dist/openapi/OpenApiProvider.js.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +13 -2
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +13 -2
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/storage/StaticProvider.d.ts +12 -2
- package/dist/storage/StaticProvider.d.ts.map +1 -1
- package/dist/storage/StaticProvider.js +12 -2
- package/dist/storage/StaticProvider.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +14 -2
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/HttpKernel.ts +87 -5
- package/src/Ignitor.ts +70 -16
- package/src/container/Container.ts +178 -48
- package/src/container/types.ts +11 -0
- package/src/dev/hmr.ts +83 -0
- package/src/graphql/GraphQLProvider.ts +14 -3
- package/src/hot.ts +165 -0
- package/src/http/Exception.ts +4 -4
- package/src/http/Request.ts +54 -0
- package/src/http/RequestValidator.ts +143 -0
- package/src/index.ts +8 -1
- package/src/middleware/Pipeline.ts +4 -4
- package/src/migrations/MigrationRegistry.ts +23 -0
- package/src/openapi/OpenApiGenerator.ts +84 -4
- package/src/openapi/OpenApiProvider.ts +21 -6
- package/src/rpc/RpcProvider.ts +13 -2
- package/src/storage/StaticProvider.ts +12 -2
package/src/http/Request.ts
CHANGED
|
@@ -13,6 +13,12 @@ import type { SignedUrl } from '../security/SignedUrl.js'
|
|
|
13
13
|
import type { Dict } from '../types/helpers.js'
|
|
14
14
|
import { Macroable } from '../utils/Macroable.js'
|
|
15
15
|
import { getPath, omitPaths, pickPaths } from '../utils/objectPath.js'
|
|
16
|
+
import type {
|
|
17
|
+
RequestAwareValidator,
|
|
18
|
+
RequestValidationOptions,
|
|
19
|
+
ValidatableContext,
|
|
20
|
+
} from './RequestValidator.js'
|
|
21
|
+
import { RequestValidator } from './RequestValidator.js'
|
|
16
22
|
import { unpackCookieValue } from './Response.js'
|
|
17
23
|
|
|
18
24
|
export interface RawRequest {
|
|
@@ -789,6 +795,44 @@ export class Request extends Macroable {
|
|
|
789
795
|
return { ...this.#merged }
|
|
790
796
|
}
|
|
791
797
|
|
|
798
|
+
/**
|
|
799
|
+
* The context a validation hook is handed.
|
|
800
|
+
*
|
|
801
|
+
* The whole context when this request has one, because that is where a hook
|
|
802
|
+
* finds what it needs — `ctx.i18n` for a translated message. A Request built
|
|
803
|
+
* by hand for a test has none, and stands in for itself.
|
|
804
|
+
*/
|
|
805
|
+
#validationContext(): ValidatableContext {
|
|
806
|
+
const ctx = this.ctx
|
|
807
|
+
// Handed back as it is, never spread: a context carries accessors
|
|
808
|
+
// (`ctx.i18n` is one), and a spread would freeze each into a plain value
|
|
809
|
+
// read at this instant instead of when the hook asks for it.
|
|
810
|
+
if (ctx !== undefined && carriesRequest(ctx)) return ctx
|
|
811
|
+
return { request: this }
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* Validate this request, throwing `E_VALIDATION_ERROR` on failure.
|
|
816
|
+
*
|
|
817
|
+
* The entry point that lets a schema stay ignorant of the request: the
|
|
818
|
+
* per-request messages provider and error reporter are filled in here, from
|
|
819
|
+
* hooks a package installed at boot. See {@link RequestValidator}.
|
|
820
|
+
*/
|
|
821
|
+
validateUsing<T>(
|
|
822
|
+
validator: RequestAwareValidator<T>,
|
|
823
|
+
options?: RequestValidationOptions,
|
|
824
|
+
): Promise<T> {
|
|
825
|
+
return new RequestValidator(this.#validationContext()).validateUsing(validator, options)
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Validate this request, answering `[error, null]` or `[null, data]`. */
|
|
829
|
+
tryValidateUsing<T>(
|
|
830
|
+
validator: RequestAwareValidator<T>,
|
|
831
|
+
options?: RequestValidationOptions,
|
|
832
|
+
): Promise<[unknown, null] | [null, T]> {
|
|
833
|
+
return new RequestValidator(this.#validationContext()).tryValidateUsing(validator, options)
|
|
834
|
+
}
|
|
835
|
+
|
|
792
836
|
/**
|
|
793
837
|
* The original request input, captured once and never mutated (AdonisJS
|
|
794
838
|
* `request.original`) — the basis for flash "old input" on validation errors.
|
|
@@ -1174,3 +1218,13 @@ function languageMatches(entry: string, offered: string): boolean {
|
|
|
1174
1218
|
if (primary && primary === o.split('-')[0]) return true
|
|
1175
1219
|
return false
|
|
1176
1220
|
}
|
|
1221
|
+
|
|
1222
|
+
/**
|
|
1223
|
+
* Whether an object is a context wrapped around a real request.
|
|
1224
|
+
*
|
|
1225
|
+
* `Request.ctx` is deliberately typed `object` — the request must not depend
|
|
1226
|
+
* on the context's shape — so this is where that opacity is paid back.
|
|
1227
|
+
*/
|
|
1228
|
+
function carriesRequest(value: object): value is ValidatableContext {
|
|
1229
|
+
return 'request' in value && value.request instanceof Request
|
|
1230
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-request validation.
|
|
3
|
+
*
|
|
4
|
+
* A validator compiled when its module loaded knows nothing about the request
|
|
5
|
+
* about to run through it — yet the two things it most often needs are per
|
|
6
|
+
* request: which language to answer in, and where the errors go. The two
|
|
7
|
+
* static hooks below are that seam. An i18n package installs one at boot:
|
|
8
|
+
*
|
|
9
|
+
* ```ts
|
|
10
|
+
* RequestValidator.messagesProvider = (ctx) => ctx.i18n.createMessagesProvider()
|
|
11
|
+
* ```
|
|
12
|
+
*
|
|
13
|
+
* and every `request.validateUsing(...)` call picks it up, without a single
|
|
14
|
+
* schema mentioning translation. Nothing else in the framework reads them, so
|
|
15
|
+
* a validator called directly keeps whatever provider it was built with.
|
|
16
|
+
*
|
|
17
|
+
* Structural on purpose: ream never imports a validation package. Anything
|
|
18
|
+
* with a `validate(data, options)` fits, which is also what makes this
|
|
19
|
+
* testable without one.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { HttpContext } from './HttpContext.ts'
|
|
23
|
+
|
|
24
|
+
/** What a validator has to expose to be usable here. */
|
|
25
|
+
export interface RequestAwareValidator<T> {
|
|
26
|
+
validate(data: Record<string, unknown>, options?: RequestValidationOptions): Promise<T>
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Options forwarded to the validator, plus the data override. */
|
|
30
|
+
export interface RequestValidationOptions {
|
|
31
|
+
/** Validate this instead of the request's own data. */
|
|
32
|
+
data?: Record<string, unknown>
|
|
33
|
+
/** Per-call messages provider — beats the static hook. */
|
|
34
|
+
messagesProvider?: unknown
|
|
35
|
+
/** Per-call error reporter factory — beats the static hook. */
|
|
36
|
+
errorReporter?: unknown
|
|
37
|
+
/** Arbitrary metadata a rule can read off the field context. */
|
|
38
|
+
meta?: Record<string, unknown>
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The slice this class reads. A real {@link HttpContext} satisfies it, and so
|
|
43
|
+
* does a bare `{ request }` pair — which is what a Request built by hand, with
|
|
44
|
+
* no context around it, can still offer.
|
|
45
|
+
*/
|
|
46
|
+
export interface ValidatableContext {
|
|
47
|
+
request: {
|
|
48
|
+
all(): Record<string, unknown>
|
|
49
|
+
params(): Readonly<Record<string, string | string[]>>
|
|
50
|
+
headers(): Readonly<Record<string, unknown>>
|
|
51
|
+
cookiesList(): Record<string, unknown>
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* What a hook is handed. The full context when there is one — an i18n package
|
|
57
|
+
* reads `ctx.i18n` off it — and the narrow shape otherwise, which is why this
|
|
58
|
+
* is a union rather than just `HttpContext`.
|
|
59
|
+
*/
|
|
60
|
+
export type ValidationHookContext = HttpContext | ValidatableContext
|
|
61
|
+
|
|
62
|
+
export class RequestValidator {
|
|
63
|
+
readonly #ctx: ValidatableContext
|
|
64
|
+
|
|
65
|
+
constructor(ctx: ValidatableContext) {
|
|
66
|
+
this.#ctx = ctx
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Picks the error reporter for a request. Assigned once, at boot, by
|
|
71
|
+
* whatever package owns error formatting.
|
|
72
|
+
*/
|
|
73
|
+
static errorReporter?: (ctx: ValidationHookContext) => unknown
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Picks the messages provider for a request. Assigned once, at boot, by
|
|
77
|
+
* whatever package owns translations.
|
|
78
|
+
*/
|
|
79
|
+
static messagesProvider?: (ctx: ValidationHookContext) => unknown
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Everything a rule might address, in one object.
|
|
83
|
+
*
|
|
84
|
+
* `params`, `headers` and `cookies` are nested under their own keys rather
|
|
85
|
+
* than merged: a form field named `params` is ordinary, and flattening them
|
|
86
|
+
* would let a request rename its own route parameters.
|
|
87
|
+
*/
|
|
88
|
+
#requestData(): Record<string, unknown> {
|
|
89
|
+
const { request } = this.#ctx
|
|
90
|
+
return {
|
|
91
|
+
...request.all(),
|
|
92
|
+
params: request.params(),
|
|
93
|
+
headers: request.headers(),
|
|
94
|
+
cookies: request.cookiesList(),
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Fill in the request-scoped reporter and provider, without overwriting
|
|
100
|
+
* anything the caller passed — an explicit option is a deliberate choice
|
|
101
|
+
* for this one call and must outrank a process-wide default.
|
|
102
|
+
*/
|
|
103
|
+
#processOptions(options?: RequestValidationOptions): RequestValidationOptions {
|
|
104
|
+
const resolved: RequestValidationOptions = { ...options }
|
|
105
|
+
if (RequestValidator.errorReporter && resolved.errorReporter === undefined) {
|
|
106
|
+
const reporter = RequestValidator.errorReporter(this.#ctx)
|
|
107
|
+
// The validator expects a FACTORY, so a fresh reporter is built per run
|
|
108
|
+
// rather than one instance accumulating across requests.
|
|
109
|
+
resolved.errorReporter = () => reporter
|
|
110
|
+
}
|
|
111
|
+
if (RequestValidator.messagesProvider && resolved.messagesProvider === undefined) {
|
|
112
|
+
resolved.messagesProvider = RequestValidator.messagesProvider(this.#ctx)
|
|
113
|
+
}
|
|
114
|
+
return resolved
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Validate the request, throwing `E_VALIDATION_ERROR` on failure. */
|
|
118
|
+
validateUsing<T>(
|
|
119
|
+
validator: RequestAwareValidator<T>,
|
|
120
|
+
options?: RequestValidationOptions,
|
|
121
|
+
): Promise<T> {
|
|
122
|
+
const resolved = this.#processOptions(options)
|
|
123
|
+
return validator.validate(resolved.data ?? this.#requestData(), resolved)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Validate the request, returning `[error, null]` or `[null, data]`.
|
|
128
|
+
*
|
|
129
|
+
* The tuple form exists so a handler can answer a failure itself instead of
|
|
130
|
+
* letting the exception reach the error handler — a wizard step that
|
|
131
|
+
* re-renders the form, say.
|
|
132
|
+
*/
|
|
133
|
+
async tryValidateUsing<T>(
|
|
134
|
+
validator: RequestAwareValidator<T>,
|
|
135
|
+
options?: RequestValidationOptions,
|
|
136
|
+
): Promise<[unknown, null] | [null, T]> {
|
|
137
|
+
try {
|
|
138
|
+
return [null, await this.validateUsing(validator, options)]
|
|
139
|
+
} catch (error) {
|
|
140
|
+
return [error, null]
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -60,6 +60,13 @@ export { HttpContext } from './http/HttpContext.js'
|
|
|
60
60
|
export { RedirectBuilder } from './http/RedirectBuilder.js'
|
|
61
61
|
export type { RawRequest } from './http/Request.js'
|
|
62
62
|
export { Request } from './http/Request.js'
|
|
63
|
+
export type {
|
|
64
|
+
RequestAwareValidator,
|
|
65
|
+
RequestValidationOptions,
|
|
66
|
+
ValidatableContext,
|
|
67
|
+
ValidationHookContext,
|
|
68
|
+
} from './http/RequestValidator.js'
|
|
69
|
+
export { RequestValidator } from './http/RequestValidator.js'
|
|
63
70
|
export { Response } from './http/Response.js'
|
|
64
71
|
export type { SseStreamOptions } from './http/SseStream.js'
|
|
65
72
|
export { SseStream } from './http/SseStream.js'
|
|
@@ -378,4 +385,4 @@ export {
|
|
|
378
385
|
StatsTracker,
|
|
379
386
|
} from './scheduler/index.js'
|
|
380
387
|
|
|
381
|
-
export const VERSION = '0.2.
|
|
388
|
+
export const VERSION = '0.2.18'
|
|
@@ -32,7 +32,7 @@ export interface RuntimeValidationResult {
|
|
|
32
32
|
* Structural contract for a route validator. ream stays decoupled from the
|
|
33
33
|
* validation engine — any object exposing a result-returning check works.
|
|
34
34
|
*
|
|
35
|
-
* Three spellings are accepted because `@c9up/rune`
|
|
35
|
+
* Three spellings are accepted because `@c9up/rune` offers all three: `validate()`
|
|
36
36
|
* is async and throws, `validateResult()` is synchronous and never throws, and
|
|
37
37
|
* `validateResultAsync()` is the async result-based form.
|
|
38
38
|
*
|
|
@@ -46,7 +46,7 @@ export interface RuntimeValidator {
|
|
|
46
46
|
validateResultAsync?(data: unknown): Promise<RuntimeValidationResult>
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
-
/** A thrown validation failure, as raised by rune
|
|
49
|
+
/** A thrown validation failure, as raised by rune's `validate()`. */
|
|
50
50
|
interface ThrownValidationFailure {
|
|
51
51
|
messages: unknown[]
|
|
52
52
|
}
|
|
@@ -66,7 +66,7 @@ function isValidationResult(value: unknown): value is RuntimeValidationResult {
|
|
|
66
66
|
* result object.
|
|
67
67
|
*
|
|
68
68
|
* Order matters: the async result form first (it is the only one that can run
|
|
69
|
-
* `unique` / `exists`), then the synchronous one, then
|
|
69
|
+
* `unique` / `exists`), then the synchronous one, then the throwing
|
|
70
70
|
* `validate()` — awaited, and with its `E_VALIDATION_ERROR` translated back
|
|
71
71
|
* into a result so callers keep a single shape to handle.
|
|
72
72
|
*/
|
|
@@ -85,7 +85,7 @@ export async function runValidator(
|
|
|
85
85
|
if (typeof validator.validate === 'function') {
|
|
86
86
|
try {
|
|
87
87
|
const outcome = await validator.validate(data)
|
|
88
|
-
// A result-returning `validate()` (not the
|
|
88
|
+
// A result-returning `validate()` (not the throwing contract) is honoured
|
|
89
89
|
// as-is; the throwing contract resolves to the validated payload.
|
|
90
90
|
return isValidationResult(outcome) ? outcome : { valid: true, errors: [], data: outcome }
|
|
91
91
|
} catch (err) {
|
|
@@ -27,6 +27,29 @@ export class MigrationRegistry {
|
|
|
27
27
|
return this
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* Remove a store's runner — only while it is still the one that registered.
|
|
32
|
+
*
|
|
33
|
+
* `register` refuses a duplicate name, deliberately: two providers claiming
|
|
34
|
+
* one name means one of them migrates nothing while the run reports success.
|
|
35
|
+
* But nothing ever removed a source, so a provider that shut down left its
|
|
36
|
+
* name taken and its runner holding a connection it had already closed. A
|
|
37
|
+
* second boot in the same process — a hot reload, a test that restarts the
|
|
38
|
+
* app — then failed on "already registered", and the CLI would have driven a
|
|
39
|
+
* runner pointing at a dead pool.
|
|
40
|
+
*
|
|
41
|
+
* `source` is the ownership guard. Two applications can share a process, and
|
|
42
|
+
* the one shutting down must not unregister the survivor's runner that
|
|
43
|
+
* happens to carry the same name.
|
|
44
|
+
*/
|
|
45
|
+
unregister(name: string, source?: RegisteredMigrationSource): boolean {
|
|
46
|
+
const current = this.#sources.get(name)
|
|
47
|
+
if (current === undefined) return false
|
|
48
|
+
if (source !== undefined && current !== source) return false
|
|
49
|
+
this.#sources.delete(name)
|
|
50
|
+
return true
|
|
51
|
+
}
|
|
52
|
+
|
|
30
53
|
/** Every registered store, in registration order. */
|
|
31
54
|
all(): RegisteredMigrationSource[] {
|
|
32
55
|
return [...this.#sources.values()]
|
|
@@ -56,6 +56,16 @@ export class OpenApiGenerator {
|
|
|
56
56
|
#router: Router
|
|
57
57
|
#config: OpenApiConfig
|
|
58
58
|
#runeSchemas: Map<string, Record<string, unknown>>
|
|
59
|
+
/**
|
|
60
|
+
* Schemas a validator produced itself, already in JSON Schema form.
|
|
61
|
+
*
|
|
62
|
+
* Kept apart from {@link #runeSchemas}, which holds rune FIELD MAPS that
|
|
63
|
+
* {@link #runeToJsonSchema} still has to convert. One map for both would
|
|
64
|
+
* mean guessing which kind a value is, and guessing wrong sends a finished
|
|
65
|
+
* schema back through the converter, which reads its `type` and `properties`
|
|
66
|
+
* keys as if they were field names.
|
|
67
|
+
*/
|
|
68
|
+
#jsonSchemas = new Map<string, Record<string, unknown>>()
|
|
59
69
|
|
|
60
70
|
constructor(
|
|
61
71
|
router: Router,
|
|
@@ -72,6 +82,30 @@ export class OpenApiGenerator {
|
|
|
72
82
|
this.#runeSchemas.set(name, definition)
|
|
73
83
|
}
|
|
74
84
|
|
|
85
|
+
/**
|
|
86
|
+
* Fill the schema map from the validators the routes already name.
|
|
87
|
+
*
|
|
88
|
+
* `route.validate('createUser')` records a container token, and the
|
|
89
|
+
* validator behind it can describe itself. Without this step every one of
|
|
90
|
+
* those routes documented its body as a bare `{ type: 'object' }` unless the
|
|
91
|
+
* app ALSO called `registerSchema` by hand with a definition it had to keep
|
|
92
|
+
* in step with the schema — two descriptions of one payload, and nothing
|
|
93
|
+
* checking they agreed.
|
|
94
|
+
*
|
|
95
|
+
* Resolution happens once, here, because `generate()` is synchronous and the
|
|
96
|
+
* container is not. A validator that cannot be resolved, or that cannot
|
|
97
|
+
* describe itself, is skipped: a missing section of documentation is a
|
|
98
|
+
* smaller problem than a boot that fails over one.
|
|
99
|
+
*/
|
|
100
|
+
async hydrateSchemas(resolve: (token: string) => Promise<unknown>): Promise<void> {
|
|
101
|
+
const names = new Set(this.#getRoutes().flatMap((route) => route.validators))
|
|
102
|
+
for (const name of names) {
|
|
103
|
+
if (this.#runeSchemas.has(name) || this.#jsonSchemas.has(name)) continue
|
|
104
|
+
const definition = await describeValidator(resolve, name)
|
|
105
|
+
if (definition !== undefined) this.#jsonSchemas.set(name, definition)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
75
109
|
/** Generate the complete OpenAPI 3.1 spec. */
|
|
76
110
|
generate(): OpenApiSpec {
|
|
77
111
|
const spec: OpenApiSpec = {
|
|
@@ -171,13 +205,13 @@ export class OpenApiGenerator {
|
|
|
171
205
|
|
|
172
206
|
if (route.validators.length > 0 && ['post', 'put', 'patch'].includes(method)) {
|
|
173
207
|
const [validatorName] = route.validators
|
|
174
|
-
const
|
|
175
|
-
if (
|
|
208
|
+
const body = validatorName === undefined ? undefined : this.#bodySchema(validatorName)
|
|
209
|
+
if (body && validatorName !== undefined) {
|
|
176
210
|
operation.requestBody = {
|
|
177
211
|
required: true,
|
|
178
|
-
content: { 'application/json': { schema:
|
|
212
|
+
content: { 'application/json': { schema: body } },
|
|
179
213
|
}
|
|
180
|
-
schemas[validatorName] =
|
|
214
|
+
schemas[validatorName] = body
|
|
181
215
|
} else {
|
|
182
216
|
operation.requestBody = {
|
|
183
217
|
required: true,
|
|
@@ -211,6 +245,17 @@ export class OpenApiGenerator {
|
|
|
211
245
|
return `${route.method} ${route.path}`
|
|
212
246
|
}
|
|
213
247
|
|
|
248
|
+
/**
|
|
249
|
+
* The JSON Schema for a named validator: what it described about itself
|
|
250
|
+
* first, then a hand-registered field map converted here.
|
|
251
|
+
*/
|
|
252
|
+
#bodySchema(name: string): Record<string, unknown> | undefined {
|
|
253
|
+
const described = this.#jsonSchemas.get(name)
|
|
254
|
+
if (described !== undefined) return described
|
|
255
|
+
const fields = this.#runeSchemas.get(name)
|
|
256
|
+
return fields === undefined ? undefined : this.#runeToJsonSchema(fields)
|
|
257
|
+
}
|
|
258
|
+
|
|
214
259
|
/** Convert a Rune schema (Record<string, RuleChain>) to JSON Schema. */
|
|
215
260
|
#runeToJsonSchema(schema: Record<string, unknown>): Record<string, unknown> {
|
|
216
261
|
const properties: Record<string, unknown> = {}
|
|
@@ -254,3 +299,38 @@ export class OpenApiGenerator {
|
|
|
254
299
|
return this.#router.getRoutes()
|
|
255
300
|
}
|
|
256
301
|
}
|
|
302
|
+
|
|
303
|
+
/** A validator that can describe its own shape as JSON Schema. */
|
|
304
|
+
interface SelfDescribingValidator {
|
|
305
|
+
toJSONSchema(): Record<string, unknown>
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function describesItself(value: unknown): value is SelfDescribingValidator {
|
|
309
|
+
return (
|
|
310
|
+
typeof value === 'object' &&
|
|
311
|
+
value !== null &&
|
|
312
|
+
'toJSONSchema' in value &&
|
|
313
|
+
typeof value.toJSONSchema === 'function'
|
|
314
|
+
)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** Resolve one validator and ask it for its schema, or answer undefined. */
|
|
318
|
+
async function describeValidator(
|
|
319
|
+
resolve: (token: string) => Promise<unknown>,
|
|
320
|
+
name: string,
|
|
321
|
+
): Promise<Record<string, unknown> | undefined> {
|
|
322
|
+
let validator: unknown
|
|
323
|
+
try {
|
|
324
|
+
validator = await resolve(`validator:${name}`)
|
|
325
|
+
} catch {
|
|
326
|
+
// An unregistered validator is already a hard error at request time; the
|
|
327
|
+
// documentation is not the place to raise it a second time.
|
|
328
|
+
return undefined
|
|
329
|
+
}
|
|
330
|
+
if (!describesItself(validator)) return undefined
|
|
331
|
+
try {
|
|
332
|
+
return validator.toJSONSchema()
|
|
333
|
+
} catch {
|
|
334
|
+
return undefined
|
|
335
|
+
}
|
|
336
|
+
}
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `OpenApiProvider` — serves an auto-generated OpenAPI 3.1 spec + Swagger UI.
|
|
3
3
|
*
|
|
4
|
-
* `
|
|
4
|
+
* `start()` mounts a global middleware that, on the first GET to `/api-docs`
|
|
5
5
|
* (JSON) or `/docs` (Swagger UI), lazily generates the spec from the router's
|
|
6
|
-
* registered routes — so every route registered
|
|
7
|
-
* (the spec must NOT be generated eagerly
|
|
8
|
-
* registered their routes). Opt-out via
|
|
6
|
+
* registered routes — so every route registered while the application starts
|
|
7
|
+
* up is included (the spec must NOT be generated eagerly, before the other
|
|
8
|
+
* providers and the preloads have registered their routes). Opt-out via
|
|
9
|
+
* `config.openapi.enabled = false`.
|
|
9
10
|
*
|
|
10
11
|
* @implements Story 24.4
|
|
11
12
|
*/
|
|
@@ -43,7 +44,17 @@ export class OpenApiProvider extends Provider {
|
|
|
43
44
|
this.#override = options.generator
|
|
44
45
|
}
|
|
45
46
|
|
|
46
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Routes and server middleware go in `start`, not `boot`.
|
|
49
|
+
*
|
|
50
|
+
* Upstream documents that phase for exactly this, and the order is what makes
|
|
51
|
+
* it matter: providers boot, then providers START, then the preloads run —
|
|
52
|
+
* and the preloads are where an application writes its own routes and kernel.
|
|
53
|
+
* Mounted in `boot`, a framework route landed ahead of every application
|
|
54
|
+
* route, so an overlapping path was answered by the framework rather than by
|
|
55
|
+
* the app that meant to override it.
|
|
56
|
+
*/
|
|
57
|
+
override async start(): Promise<void> {
|
|
47
58
|
const config = this.app.config.get<OpenApiDocsConfig>('openapi') ?? {}
|
|
48
59
|
if (config.enabled === false) return
|
|
49
60
|
|
|
@@ -59,10 +70,14 @@ export class OpenApiProvider extends Provider {
|
|
|
59
70
|
|
|
60
71
|
async #buildGenerator(config: OpenApiDocsConfig): Promise<OpenApiGenerator> {
|
|
61
72
|
const router = await this.app.container.make<Router>('router')
|
|
62
|
-
|
|
73
|
+
const generator = new OpenApiGenerator(router, {
|
|
63
74
|
title: config.title ?? 'API',
|
|
64
75
|
version: config.version ?? '1.0.0',
|
|
65
76
|
})
|
|
77
|
+
// The routes already name their validators; ask each one to describe
|
|
78
|
+
// itself instead of making the app repeat the shape by hand.
|
|
79
|
+
await generator.hydrateSchemas((token) => this.app.container.make<unknown>(token))
|
|
80
|
+
return generator
|
|
66
81
|
}
|
|
67
82
|
}
|
|
68
83
|
|
package/src/rpc/RpcProvider.ts
CHANGED
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
* rpc.method('task.validate', handler).guard('jwt')
|
|
10
10
|
* rpc.namespace('user', UserController)
|
|
11
11
|
*
|
|
12
|
-
* - `
|
|
12
|
+
* - `start()` — mount `POST <config.rpc.path ?? '/rpc'>` on the core router,
|
|
13
13
|
* dispatching every request body (single or batch) to `RpcRouter.handle()`.
|
|
14
|
+
* In `start`, which is the phase upstream documents for routes.
|
|
14
15
|
*
|
|
15
16
|
* @implements Story 24.1, 24.2
|
|
16
17
|
*/
|
|
@@ -68,7 +69,17 @@ export class RpcProvider extends Provider {
|
|
|
68
69
|
this.#registered = true
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Routes and server middleware go in `start`, not `boot`.
|
|
74
|
+
*
|
|
75
|
+
* Upstream documents that phase for exactly this, and the order is what makes
|
|
76
|
+
* it matter: providers boot, then providers START, then the preloads run —
|
|
77
|
+
* and the preloads are where an application writes its own routes and kernel.
|
|
78
|
+
* Mounted in `boot`, a framework route landed ahead of every application
|
|
79
|
+
* route, so an overlapping path was answered by the framework rather than by
|
|
80
|
+
* the app that meant to override it.
|
|
81
|
+
*/
|
|
82
|
+
override async start(): Promise<void> {
|
|
72
83
|
const config = this.app.config.get<RpcConfig>('rpc')
|
|
73
84
|
const path = config?.path ?? '/rpc'
|
|
74
85
|
const router = await this.app.container.make<Router>('router')
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* `StaticProvider` — serves static files from a directory.
|
|
3
3
|
*
|
|
4
4
|
* Opt-in: does nothing unless `config.static.root` is set (or a middleware is
|
|
5
|
-
* injected for tests). When configured, `
|
|
5
|
+
* injected for tests). When configured, `start()` mounts a global
|
|
6
6
|
* `StaticMiddleware` (path-traversal + symlink-escape guarded) that serves
|
|
7
7
|
* matching requests and falls through (`next()`) otherwise.
|
|
8
8
|
*
|
|
@@ -30,7 +30,17 @@ export class StaticProvider extends Provider {
|
|
|
30
30
|
this.#override = options.middleware
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
/**
|
|
34
|
+
* Routes and server middleware go in `start`, not `boot`.
|
|
35
|
+
*
|
|
36
|
+
* Upstream documents that phase for exactly this, and the order is what makes
|
|
37
|
+
* it matter: providers boot, then providers START, then the preloads run —
|
|
38
|
+
* and the preloads are where an application writes its own routes and kernel.
|
|
39
|
+
* Mounted in `boot`, a framework route landed ahead of every application
|
|
40
|
+
* route, so an overlapping path was answered by the framework rather than by
|
|
41
|
+
* the app that meant to override it.
|
|
42
|
+
*/
|
|
43
|
+
override async start(): Promise<void> {
|
|
34
44
|
const config = this.app.config.get<StaticConfig>('static')
|
|
35
45
|
const middleware = this.#override ?? (config?.root ? new StaticMiddleware(config) : undefined)
|
|
36
46
|
if (!middleware) return // not configured — opt-out
|