@c9up/ream 0.1.24 → 0.1.26
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/ConfigLoader.d.ts +46 -5
- package/dist/ConfigLoader.d.ts.map +1 -1
- package/dist/ConfigLoader.js +60 -9
- package/dist/ConfigLoader.js.map +1 -1
- package/dist/HttpKernel.d.ts.map +1 -1
- package/dist/HttpKernel.js +46 -6
- package/dist/HttpKernel.js.map +1 -1
- package/dist/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +21 -3
- package/dist/Ignitor.js.map +1 -1
- package/dist/container/Container.d.ts +23 -3
- package/dist/container/Container.d.ts.map +1 -1
- package/dist/container/Container.js +139 -60
- package/dist/container/Container.js.map +1 -1
- package/dist/env/Env.d.ts +29 -4
- package/dist/env/Env.d.ts.map +1 -1
- package/dist/env/Env.js +27 -4
- package/dist/env/Env.js.map +1 -1
- package/dist/env/Secret.d.ts +11 -0
- package/dist/env/Secret.d.ts.map +1 -0
- package/dist/env/Secret.js +36 -0
- package/dist/env/Secret.js.map +1 -0
- package/dist/env/interpolate.d.ts +26 -0
- package/dist/env/interpolate.d.ts.map +1 -0
- package/dist/env/interpolate.js +48 -0
- package/dist/env/interpolate.js.map +1 -0
- package/dist/env/loadEnvFiles.d.ts.map +1 -1
- package/dist/env/loadEnvFiles.js +14 -4
- package/dist/env/loadEnvFiles.js.map +1 -1
- package/dist/env/schema.d.ts +23 -6
- package/dist/env/schema.d.ts.map +1 -1
- package/dist/env/schema.js +67 -33
- package/dist/env/schema.js.map +1 -1
- package/dist/events/Emitter.d.ts +1 -1
- package/dist/events/Emitter.d.ts.map +1 -1
- package/dist/events/Emitter.js +1 -1
- package/dist/events/Emitter.js.map +1 -1
- package/dist/events/EventsProvider.d.ts +2 -2
- package/dist/events/EventsProvider.d.ts.map +1 -1
- package/dist/events/EventsProvider.js +3 -3
- package/dist/events/EventsProvider.js.map +1 -1
- package/dist/graphql/GraphQLEngine.d.ts +1 -1
- package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
- package/dist/graphql/GraphQLEngine.js +2 -2
- package/dist/graphql/GraphQLEngine.js.map +1 -1
- package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
- package/dist/graphql/GraphQLProvider.js +7 -3
- package/dist/graphql/GraphQLProvider.js.map +1 -1
- package/dist/http/Exception.d.ts +63 -1
- package/dist/http/Exception.d.ts.map +1 -1
- package/dist/http/Exception.js +185 -48
- package/dist/http/Exception.js.map +1 -1
- package/dist/http/HttpContext.d.ts +56 -9
- package/dist/http/HttpContext.d.ts.map +1 -1
- package/dist/http/HttpContext.js +56 -15
- package/dist/http/HttpContext.js.map +1 -1
- package/dist/http/RedirectBuilder.d.ts.map +1 -1
- package/dist/http/RedirectBuilder.js +4 -1
- package/dist/http/RedirectBuilder.js.map +1 -1
- package/dist/http/Request.d.ts +141 -10
- package/dist/http/Request.d.ts.map +1 -1
- package/dist/http/Request.js +244 -34
- package/dist/http/Request.js.map +1 -1
- package/dist/http/Response.d.ts +176 -9
- package/dist/http/Response.d.ts.map +1 -1
- package/dist/http/Response.js +431 -19
- package/dist/http/Response.js.map +1 -1
- package/dist/index.d.ts +5 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -3
- package/dist/index.js.map +1 -1
- package/dist/middleware/Pipeline.js +1 -1
- package/dist/middleware/Pipeline.js.map +1 -1
- package/dist/openapi/OpenApiProvider.js +4 -4
- package/dist/openapi/OpenApiProvider.js.map +1 -1
- package/dist/router/Router.d.ts +223 -31
- package/dist/router/Router.d.ts.map +1 -1
- package/dist/router/Router.js +520 -102
- package/dist/router/Router.js.map +1 -1
- package/dist/rpc/RpcProvider.d.ts +1 -0
- package/dist/rpc/RpcProvider.d.ts.map +1 -1
- package/dist/rpc/RpcProvider.js +7 -3
- package/dist/rpc/RpcProvider.js.map +1 -1
- package/dist/rpc/RpcRouter.d.ts +2 -2
- package/dist/rpc/RpcRouter.d.ts.map +1 -1
- package/dist/rpc/RpcRouter.js +5 -4
- package/dist/rpc/RpcRouter.js.map +1 -1
- package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
- package/dist/scheduler/ScheduleProvider.js +7 -15
- package/dist/scheduler/ScheduleProvider.js.map +1 -1
- package/dist/server/Server.d.ts +17 -4
- package/dist/server/Server.d.ts.map +1 -1
- package/dist/server/Server.js +41 -7
- package/dist/server/Server.js.map +1 -1
- package/dist/session/SessionMiddleware.js +3 -3
- package/dist/session/SessionMiddleware.js.map +1 -1
- package/dist/storage/StaticProvider.js +1 -1
- package/dist/storage/StaticProvider.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts +60 -0
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +180 -1
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/dist/testing/TestClient.d.ts +22 -1
- package/dist/testing/TestClient.d.ts.map +1 -1
- package/dist/testing/TestClient.js +40 -0
- package/dist/testing/TestClient.js.map +1 -1
- package/dist/utils/Macroable.d.ts +30 -0
- package/dist/utils/Macroable.d.ts.map +1 -0
- package/dist/utils/Macroable.js +43 -0
- package/dist/utils/Macroable.js.map +1 -0
- package/dist/utils/inflect.d.ts +17 -0
- package/dist/utils/inflect.d.ts.map +1 -0
- package/dist/utils/inflect.js +32 -0
- package/dist/utils/inflect.js.map +1 -0
- package/dist/utils/objectPath.d.ts +57 -0
- package/dist/utils/objectPath.d.ts.map +1 -0
- package/dist/utils/objectPath.js +141 -0
- package/dist/utils/objectPath.js.map +1 -0
- package/events.darwin-arm64.node +0 -0
- package/events.darwin-x64.node +0 -0
- package/events.linux-arm64-gnu.node +0 -0
- package/events.linux-x64-gnu.node +0 -0
- package/events.win32-x64-msvc.node +0 -0
- package/index.darwin-arm64.node +0 -0
- package/index.darwin-x64.node +0 -0
- package/index.linux-arm64-gnu.node +0 -0
- package/index.linux-x64-gnu.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +6 -1
- package/scheduler.darwin-arm64.node +0 -0
- package/scheduler.darwin-x64.node +0 -0
- package/scheduler.linux-arm64-gnu.node +0 -0
- package/scheduler.linux-x64-gnu.node +0 -0
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/ConfigLoader.ts +72 -9
- package/src/HttpKernel.ts +53 -15
- package/src/Ignitor.ts +24 -3
- package/src/container/Container.ts +145 -61
- package/src/env/Env.ts +49 -5
- package/src/env/Secret.ts +41 -0
- package/src/env/interpolate.ts +54 -0
- package/src/env/loadEnvFiles.ts +12 -4
- package/src/env/schema.ts +109 -35
- package/src/events/Emitter.ts +2 -2
- package/src/events/EventsProvider.ts +5 -5
- package/src/graphql/GraphQLEngine.ts +4 -4
- package/src/graphql/GraphQLProvider.ts +7 -3
- package/src/http/Exception.ts +214 -37
- package/src/http/HttpContext.ts +93 -20
- package/src/http/RedirectBuilder.ts +4 -1
- package/src/http/Request.ts +264 -34
- package/src/http/Response.ts +504 -30
- package/src/index.ts +11 -2
- package/src/middleware/Pipeline.ts +1 -1
- package/src/openapi/OpenApiProvider.ts +4 -4
- package/src/router/Router.ts +631 -113
- package/src/rpc/RpcProvider.ts +6 -2
- package/src/rpc/RpcRouter.ts +7 -6
- package/src/scheduler/ScheduleProvider.ts +7 -15
- package/src/server/Server.ts +49 -10
- package/src/session/SessionMiddleware.ts +4 -4
- package/src/storage/StaticProvider.ts +1 -1
- package/src/testing/RequestBuilder.ts +218 -1
- package/src/testing/TestClient.ts +57 -1
- package/src/utils/Macroable.ts +54 -0
- package/src/utils/inflect.ts +29 -0
- package/src/utils/objectPath.ts +142 -0
- package/dist/decorators/Lazy.d.ts +0 -19
- package/dist/decorators/Lazy.d.ts.map +0 -1
- package/dist/decorators/Lazy.js +0 -53
- package/dist/decorators/Lazy.js.map +0 -1
- package/src/decorators/Lazy.ts +0 -57
package/src/rpc/RpcProvider.ts
CHANGED
|
@@ -42,6 +42,7 @@ export interface RpcConfig {
|
|
|
42
42
|
|
|
43
43
|
export class RpcProvider extends Provider {
|
|
44
44
|
readonly rpc: RpcRouter
|
|
45
|
+
#registered = false
|
|
45
46
|
|
|
46
47
|
constructor(app: AppContext, options: RpcProviderOptions = {}) {
|
|
47
48
|
super(app)
|
|
@@ -51,7 +52,9 @@ export class RpcProvider extends Provider {
|
|
|
51
52
|
override register(): void {
|
|
52
53
|
const container = this.app.container
|
|
53
54
|
if (container.has('rpc')) {
|
|
54
|
-
|
|
55
|
+
// Idempotent re-register: tracked with a flag rather than
|
|
56
|
+
// `container.resolve('rpc')` because resolution is now async.
|
|
57
|
+
if (this.#registered) return
|
|
55
58
|
throw new ReamError(
|
|
56
59
|
'RPC_PROVIDER_ALREADY_REGISTERED',
|
|
57
60
|
"Container token 'rpc' is already bound to a different instance",
|
|
@@ -62,12 +65,13 @@ export class RpcProvider extends Provider {
|
|
|
62
65
|
}
|
|
63
66
|
this.rpc.useContainer(container)
|
|
64
67
|
container.singleton('rpc', () => this.rpc)
|
|
68
|
+
this.#registered = true
|
|
65
69
|
}
|
|
66
70
|
|
|
67
71
|
override async boot(): Promise<void> {
|
|
68
72
|
const config = this.app.config.get<RpcConfig>('rpc')
|
|
69
73
|
const path = config?.path ?? '/rpc'
|
|
70
|
-
const router = this.app.container.make<Router>('router')
|
|
74
|
+
const router = await this.app.container.make<Router>('router')
|
|
71
75
|
// RpcRouter.handle() reads the registered methods at request time, so the
|
|
72
76
|
// mount can happen before apps finish registering methods.
|
|
73
77
|
const route = router.post(path, (ctx: HttpContext) => this.rpc.handle(ctx))
|
package/src/rpc/RpcRouter.ts
CHANGED
|
@@ -29,8 +29,8 @@ export type RpcHandler = (ctx: HttpContext, params: unknown) => Promise<unknown>
|
|
|
29
29
|
* the shared `'middleware'` registry, and `validator:<name>` schemas.
|
|
30
30
|
*/
|
|
31
31
|
interface RpcContainer {
|
|
32
|
-
make<T>(target: new (...args: unknown[]) => T): T
|
|
33
|
-
resolve<T>(token: string): T
|
|
32
|
+
make<T>(target: new (...args: unknown[]) => T): Promise<T>
|
|
33
|
+
resolve<T>(token: string): Promise<T>
|
|
34
34
|
has(token: string): boolean
|
|
35
35
|
}
|
|
36
36
|
|
|
@@ -101,7 +101,7 @@ function checkRpcAuthorization(
|
|
|
101
101
|
def: RpcMethodDefinition,
|
|
102
102
|
): { code: number; message: string } | undefined {
|
|
103
103
|
const needsAuth = def.guards.length > 0 || def.roles.length > 0 || def.permissions.length > 0
|
|
104
|
-
if (needsAuth && !ctx.auth?.
|
|
104
|
+
if (needsAuth && !ctx.auth?.isAuthenticated) {
|
|
105
105
|
return { code: -32003, message: 'Unauthorized' }
|
|
106
106
|
}
|
|
107
107
|
if (def.roles.length > 0) {
|
|
@@ -163,7 +163,7 @@ export class RpcRouter {
|
|
|
163
163
|
this.method(`${prefix}.${methodName}`, async (ctx, params) => {
|
|
164
164
|
// Resolve through the container on every call (fresh DI per request,
|
|
165
165
|
// like GraphQLEngine), falling back to a bare `new` when unset.
|
|
166
|
-
const instance = this.#container ? this.#container.make(controller) : new controller()
|
|
166
|
+
const instance = this.#container ? await this.#container.make(controller) : new controller()
|
|
167
167
|
return instance[methodName](ctx, params)
|
|
168
168
|
})
|
|
169
169
|
}
|
|
@@ -238,7 +238,7 @@ export class RpcRouter {
|
|
|
238
238
|
*/
|
|
239
239
|
async #runMiddleware(ctx: HttpContext, names: string[]): Promise<void> {
|
|
240
240
|
const registry = this.#container?.has('middleware')
|
|
241
|
-
? this.#container.resolve<MiddlewareRegistry>('middleware')
|
|
241
|
+
? await this.#container.resolve<MiddlewareRegistry>('middleware')
|
|
242
242
|
: undefined
|
|
243
243
|
if (!registry) {
|
|
244
244
|
throw new Error(
|
|
@@ -285,7 +285,8 @@ export class RpcRouter {
|
|
|
285
285
|
id,
|
|
286
286
|
)
|
|
287
287
|
}
|
|
288
|
-
const
|
|
288
|
+
const validator = await this.#container.resolve<RuntimeValidator>(token)
|
|
289
|
+
const outcome = validator.validate(params)
|
|
289
290
|
if (!outcome.valid) return rpcError(-32602, 'Invalid params', id, outcome.errors)
|
|
290
291
|
effectiveParams = outcome.data ?? params
|
|
291
292
|
}
|
|
@@ -41,6 +41,7 @@ let activeProvider: ScheduleProvider | undefined
|
|
|
41
41
|
export class ScheduleProvider extends Provider {
|
|
42
42
|
readonly scheduler: Scheduler
|
|
43
43
|
#booted = false
|
|
44
|
+
#registered = false
|
|
44
45
|
|
|
45
46
|
constructor(app: AppContext, options: ScheduleProviderOptions = {}) {
|
|
46
47
|
super(app)
|
|
@@ -65,20 +66,10 @@ export class ScheduleProvider extends Provider {
|
|
|
65
66
|
override register(): void {
|
|
66
67
|
const scheduler = this.scheduler
|
|
67
68
|
const container = this.app.container
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
try {
|
|
73
|
-
existing = container.resolve('scheduler')
|
|
74
|
-
} catch {
|
|
75
|
-
existing = undefined
|
|
76
|
-
}
|
|
77
|
-
if (existing !== undefined) {
|
|
78
|
-
if (existing === scheduler) {
|
|
79
|
-
// Same provider re-running its register phase — no-op.
|
|
80
|
-
return
|
|
81
|
-
}
|
|
69
|
+
if (container.has('scheduler')) {
|
|
70
|
+
// Idempotent re-register: tracked with a flag rather than
|
|
71
|
+
// `container.resolve('scheduler')` because resolution is now async.
|
|
72
|
+
if (this.#registered) return
|
|
82
73
|
throw new ReamError(
|
|
83
74
|
'SCHEDULE_PROVIDER_ALREADY_REGISTERED',
|
|
84
75
|
"Container token 'scheduler' is already bound to a different instance",
|
|
@@ -88,6 +79,7 @@ export class ScheduleProvider extends Provider {
|
|
|
88
79
|
)
|
|
89
80
|
}
|
|
90
81
|
container.singleton('scheduler', () => scheduler)
|
|
82
|
+
this.#registered = true
|
|
91
83
|
}
|
|
92
84
|
|
|
93
85
|
override async boot(): Promise<void> {
|
|
@@ -130,7 +122,7 @@ export class ScheduleProvider extends Provider {
|
|
|
130
122
|
this.scheduler.register(taskName, cronExpr, async () => {
|
|
131
123
|
// Resolve from the container on every fire so transient
|
|
132
124
|
// services get a fresh instance each run.
|
|
133
|
-
const instance = this.app.container.make<Record<string, unknown>>(target)
|
|
125
|
+
const instance = await this.app.container.make<Record<string, unknown>>(target)
|
|
134
126
|
const method = instance[methodName]
|
|
135
127
|
if (typeof method !== 'function') {
|
|
136
128
|
throw new ReamError(
|
package/src/server/Server.ts
CHANGED
|
@@ -12,11 +12,12 @@ import type { MiddlewareFunction } from '../middleware/Pipeline.js'
|
|
|
12
12
|
import type { Router } from '../router/Router.js'
|
|
13
13
|
|
|
14
14
|
/**
|
|
15
|
-
* A middleware class with a handle method.
|
|
16
|
-
*
|
|
15
|
+
* A middleware class with a handle method. The optional third argument carries
|
|
16
|
+
* per-route configuration passed through a named-middleware factory (AdonisJS
|
|
17
|
+
* `handle(ctx, next, args)`).
|
|
17
18
|
*/
|
|
18
19
|
export interface MiddlewareClass {
|
|
19
|
-
handle(ctx: HttpContext, next: () => Promise<void
|
|
20
|
+
handle(ctx: HttpContext, next: () => Promise<void>, args?: unknown): Promise<void> | void
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
/** Lazy import returning a module with a default export. */
|
|
@@ -109,7 +110,11 @@ export class Server {
|
|
|
109
110
|
|
|
110
111
|
/**
|
|
111
112
|
* Convert a MiddlewareEntry (lazy import or function) into a MiddlewareFunction.
|
|
112
|
-
*
|
|
113
|
+
* The imported class is cached on first call; the INSTANCE is resolved through
|
|
114
|
+
* the request's IoC container so `@inject()` constructor dependencies are wired
|
|
115
|
+
* (AdonisJS resolves middleware via the container — a plain `new Class()` gives
|
|
116
|
+
* every injected dependency `undefined`). Falls back to `new Class()` only when
|
|
117
|
+
* no container is present (mock contexts in unit tests).
|
|
113
118
|
*/
|
|
114
119
|
export function resolveMiddlewareEntry(entry: MiddlewareEntry): MiddlewareFunction {
|
|
115
120
|
// Direct middleware function (2 params: ctx, next)
|
|
@@ -117,16 +122,50 @@ export function resolveMiddlewareEntry(entry: MiddlewareEntry): MiddlewareFuncti
|
|
|
117
122
|
return entry
|
|
118
123
|
}
|
|
119
124
|
|
|
120
|
-
// Lazy import of a middleware class —
|
|
121
|
-
//
|
|
125
|
+
// Lazy import of a middleware class — cache the CLASS on first invocation,
|
|
126
|
+
// then resolve the instance per request through the container (DI). The
|
|
127
|
+
// container's own singleton bindings still cache instances where desired.
|
|
122
128
|
const lazyImport = entry
|
|
123
|
-
let
|
|
129
|
+
let cachedClass: MiddlewareClassConstructor | undefined
|
|
124
130
|
return async (ctx: HttpContext, next: () => Promise<void>) => {
|
|
125
|
-
if (!
|
|
131
|
+
if (!cachedClass) {
|
|
126
132
|
const mod = await lazyImport()
|
|
127
|
-
|
|
133
|
+
cachedClass = mod.default
|
|
134
|
+
}
|
|
135
|
+
const instance = ctx.containerResolver
|
|
136
|
+
? await ctx.containerResolver.make<MiddlewareClass>(cachedClass)
|
|
137
|
+
: new cachedClass()
|
|
138
|
+
await instance.handle(ctx, next)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Turn a middleware entry into a FACTORY `(args?) => MiddlewareFunction`
|
|
144
|
+
* (AdonisJS named-middleware factories). The returned function bakes `args` in
|
|
145
|
+
* and forwards them to the class's `handle(ctx, next, args)`. A plain function
|
|
146
|
+
* middleware ignores `args` (it has no third parameter). Class resolution +
|
|
147
|
+
* caching mirror {@link resolveMiddlewareEntry} (imported once, instance via DI).
|
|
148
|
+
*/
|
|
149
|
+
export function resolveParametrizedMiddlewareEntry(
|
|
150
|
+
entry: MiddlewareEntry,
|
|
151
|
+
): (args?: unknown) => MiddlewareFunction {
|
|
152
|
+
if (isMiddlewareFunction(entry)) {
|
|
153
|
+
return () => entry
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const lazyImport = entry
|
|
157
|
+
let cachedClass: MiddlewareClassConstructor | undefined
|
|
158
|
+
return (args?: unknown): MiddlewareFunction => {
|
|
159
|
+
return async (ctx: HttpContext, next: () => Promise<void>) => {
|
|
160
|
+
if (!cachedClass) {
|
|
161
|
+
const mod = await lazyImport()
|
|
162
|
+
cachedClass = mod.default
|
|
163
|
+
}
|
|
164
|
+
const instance = ctx.containerResolver
|
|
165
|
+
? await ctx.containerResolver.make<MiddlewareClass>(cachedClass)
|
|
166
|
+
: new cachedClass()
|
|
167
|
+
await instance.handle(ctx, next, args)
|
|
128
168
|
}
|
|
129
|
-
await cached.handle(ctx, next)
|
|
130
169
|
}
|
|
131
170
|
}
|
|
132
171
|
|
|
@@ -56,7 +56,7 @@ export default class SessionMiddleware {
|
|
|
56
56
|
const isProduction = process.env.NODE_ENV === 'production'
|
|
57
57
|
|
|
58
58
|
// Read session ID from cookie — pre-parsed by the Rust HyperServer.
|
|
59
|
-
let sessionId = ctx.request.
|
|
59
|
+
let sessionId = ctx.request.plainCookie(cookieName)
|
|
60
60
|
const hadIncomingCookie = sessionId !== null
|
|
61
61
|
|
|
62
62
|
// Cookie driver: the cookie value IS the session data (encrypted)
|
|
@@ -65,7 +65,7 @@ export default class SessionMiddleware {
|
|
|
65
65
|
sessionId = sessionId ?? generateSessionId()
|
|
66
66
|
const session = new Session(sessionId, data)
|
|
67
67
|
ctx.store.set('session', session)
|
|
68
|
-
|
|
68
|
+
ctx.session = session
|
|
69
69
|
|
|
70
70
|
await next()
|
|
71
71
|
|
|
@@ -85,7 +85,7 @@ export default class SessionMiddleware {
|
|
|
85
85
|
'Switch to a server-side driver (memory/redis) or reduce session data.\n',
|
|
86
86
|
)
|
|
87
87
|
}
|
|
88
|
-
ctx.response.
|
|
88
|
+
ctx.response.plainCookie(cookieName, encoded, {
|
|
89
89
|
maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
|
|
90
90
|
path: '/',
|
|
91
91
|
httpOnly: true,
|
|
@@ -145,7 +145,7 @@ export default class SessionMiddleware {
|
|
|
145
145
|
// defeats HTTP caching, and leaks a Set-Cookie to CDNs. This mirrors
|
|
146
146
|
// the cookie-driver path above (which already omits `isNew`).
|
|
147
147
|
if (session.isDirty() || regenerated || (rolling && hadIncomingCookie)) {
|
|
148
|
-
ctx.response.
|
|
148
|
+
ctx.response.plainCookie(cookieName, session.sessionId, {
|
|
149
149
|
maxAge: this.#config.clearWithBrowser ? undefined : maxAge,
|
|
150
150
|
path: '/',
|
|
151
151
|
httpOnly: true,
|
|
@@ -34,7 +34,7 @@ export class StaticProvider extends Provider {
|
|
|
34
34
|
const config = this.app.config.get<StaticConfig>('static')
|
|
35
35
|
const middleware = this.#override ?? (config?.root ? new StaticMiddleware(config) : undefined)
|
|
36
36
|
if (!middleware) return // not configured — opt-out
|
|
37
|
-
const server = this.app.container.make<Server>('server')
|
|
37
|
+
const server = await this.app.container.make<Server>('server')
|
|
38
38
|
server.use([(ctx, next) => middleware.handle(ctx, next)])
|
|
39
39
|
}
|
|
40
40
|
}
|
|
@@ -38,6 +38,16 @@ class ExpectationError extends Error {
|
|
|
38
38
|
|
|
39
39
|
export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'
|
|
40
40
|
|
|
41
|
+
/** Cookie the signed CSRF token is issued in (blackhole default: `XSRF-TOKEN`). */
|
|
42
|
+
const CSRF_COOKIE_NAME = 'XSRF-TOKEN'
|
|
43
|
+
/** Header the client echoes the token back in (Axios/Angular default). */
|
|
44
|
+
const CSRF_HEADER_NAME = 'x-xsrf-token'
|
|
45
|
+
|
|
46
|
+
/** Primitive accepted as a query-string value (mirrors `.qs()` in japa/api-client). */
|
|
47
|
+
type QueryValue = string | number | boolean
|
|
48
|
+
/** Query-string map — a value or an array of values (repeated key). */
|
|
49
|
+
export type QueryParams = Record<string, QueryValue | ReadonlyArray<QueryValue>>
|
|
50
|
+
|
|
41
51
|
/**
|
|
42
52
|
* Internal low-level sender — matches what the TestClient exposes.
|
|
43
53
|
* Accepting it as an injected callable keeps RequestBuilder framework-agnostic.
|
|
@@ -72,6 +82,7 @@ export class RequestBuilder {
|
|
|
72
82
|
#body: Buffer = Buffer.alloc(0)
|
|
73
83
|
#multipart: MultipartPart[] = []
|
|
74
84
|
#cookies: Record<string, string> = {}
|
|
85
|
+
#query = new URLSearchParams()
|
|
75
86
|
#authStrategy: AuthStrategy | null
|
|
76
87
|
#pendingAuth: AuthSubject | null = null
|
|
77
88
|
#sent: Promise<TestResponse> | null = null
|
|
@@ -159,6 +170,58 @@ export class RequestBuilder {
|
|
|
159
170
|
return this
|
|
160
171
|
}
|
|
161
172
|
|
|
173
|
+
/**
|
|
174
|
+
* Append query-string params — mirrors japa/api-client's `.qs()`. Values are
|
|
175
|
+
* URL-encoded; arrays repeat the key (`?tag=a&tag=b`). Merges with any query
|
|
176
|
+
* string already on the path and with earlier `.qs()` calls.
|
|
177
|
+
*/
|
|
178
|
+
qs(params: QueryParams): this {
|
|
179
|
+
for (const [key, value] of Object.entries(params)) {
|
|
180
|
+
if (Array.isArray(value)) {
|
|
181
|
+
for (const item of value) this.#query.append(key, String(item))
|
|
182
|
+
} else {
|
|
183
|
+
this.#query.append(key, String(value))
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return this
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Pass a bearer token as the `Authorization` header — japa `.bearerToken()`. */
|
|
190
|
+
bearerToken(token: string): this {
|
|
191
|
+
this.#headers.authorization = `Bearer ${token}`
|
|
192
|
+
return this
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Pass HTTP Basic credentials as the `Authorization` header — japa `.basicAuth()`. */
|
|
196
|
+
basicAuth(user: string, password: string): this {
|
|
197
|
+
const encoded = Buffer.from(`${user}:${password}`, 'utf8').toString('base64')
|
|
198
|
+
this.#headers.authorization = `Basic ${encoded}`
|
|
199
|
+
return this
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Satisfy blackhole's signed double-submit CSRF check: echo the `XSRF-TOKEN`
|
|
204
|
+
* cookie back in the `X-XSRF-TOKEN` header (the pair the server compares).
|
|
205
|
+
*
|
|
206
|
+
* With a `token` argument, sets both the cookie and the header — useful when
|
|
207
|
+
* you already hold a signed token. Without one, reads the token from a cookie
|
|
208
|
+
* set earlier on this request (typically extracted from a prior safe GET's
|
|
209
|
+
* `Set-Cookie`); throws if none is present so the mistake is caught at the
|
|
210
|
+
* call site instead of surfacing as a confusing 403.
|
|
211
|
+
*/
|
|
212
|
+
withCsrf(token?: string): this {
|
|
213
|
+
if (token !== undefined) this.#cookies[CSRF_COOKIE_NAME] = token
|
|
214
|
+
const value = this.#cookies[CSRF_COOKIE_NAME]
|
|
215
|
+
if (value === undefined) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`RequestBuilder: withCsrf() found no '${CSRF_COOKIE_NAME}' cookie. Set it first via ` +
|
|
218
|
+
`.cookie('${CSRF_COOKIE_NAME}', token) (extracted from a prior safe GET) or pass the token: withCsrf(token).`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
this.#headers[CSRF_HEADER_NAME] = value
|
|
222
|
+
return this
|
|
223
|
+
}
|
|
224
|
+
|
|
162
225
|
/**
|
|
163
226
|
* Attach auth for a user. Uses the strategy passed to `client`'s `auth`
|
|
164
227
|
* option — Warden's session/JWT/API-key strategy decides the header/cookie
|
|
@@ -250,6 +313,152 @@ export class RequestBuilder {
|
|
|
250
313
|
return this
|
|
251
314
|
}
|
|
252
315
|
|
|
316
|
+
// ─── japa/api-client status shortcuts ──────────────────────
|
|
317
|
+
// Thin aliases over expectStatus() — one per common HTTP status, matching
|
|
318
|
+
// @japa/api-client's assertOk()/assertCreated()/… surface.
|
|
319
|
+
|
|
320
|
+
/** Assert a 200 OK response. */
|
|
321
|
+
assertOk(): Promise<this> {
|
|
322
|
+
return this.expectStatus(200)
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Assert a 201 Created response. */
|
|
326
|
+
assertCreated(): Promise<this> {
|
|
327
|
+
return this.expectStatus(201)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** Assert a 204 No Content response. */
|
|
331
|
+
assertNoContent(): Promise<this> {
|
|
332
|
+
return this.expectStatus(204)
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Assert a 400 Bad Request response. */
|
|
336
|
+
assertBadRequest(): Promise<this> {
|
|
337
|
+
return this.expectStatus(400)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Assert a 401 Unauthorized response. */
|
|
341
|
+
assertUnauthorized(): Promise<this> {
|
|
342
|
+
return this.expectStatus(401)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/** Assert a 403 Forbidden response. */
|
|
346
|
+
assertForbidden(): Promise<this> {
|
|
347
|
+
return this.expectStatus(403)
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Assert a 404 Not Found response. */
|
|
351
|
+
assertNotFound(): Promise<this> {
|
|
352
|
+
return this.expectStatus(404)
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Assert a redirect (3xx) whose `Location` resolves to `path`. Compares the
|
|
357
|
+
* pathname (query/host ignored), mirroring japa's `assertRedirectsTo`.
|
|
358
|
+
*/
|
|
359
|
+
async assertRedirectsTo(path: string): Promise<this> {
|
|
360
|
+
const res = await this.send()
|
|
361
|
+
if (res.status < 300 || res.status >= 400) {
|
|
362
|
+
throw new ExpectationError(
|
|
363
|
+
`Expected a redirect (3xx) to "${path}", got status ${res.status}.`,
|
|
364
|
+
)
|
|
365
|
+
}
|
|
366
|
+
const location = res.headers.location
|
|
367
|
+
if (location === undefined) {
|
|
368
|
+
throw new ExpectationError(
|
|
369
|
+
`Expected a redirect to "${path}", but the response has no Location header.`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
// Location may be absolute or path-only; compare pathnames either way.
|
|
373
|
+
const actual = location.startsWith('http')
|
|
374
|
+
? new URL(location).pathname
|
|
375
|
+
: (location.split('?')[0] ?? location)
|
|
376
|
+
if (actual !== path) {
|
|
377
|
+
throw new ExpectationError(`Expected redirect to "${path}", got "${actual}".`)
|
|
378
|
+
}
|
|
379
|
+
return this
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/** Assert the JSON body contains `subset` (deep partial) — japa `assertBodyContains`. */
|
|
383
|
+
async assertBodyContains(subset: unknown): Promise<this> {
|
|
384
|
+
const actual = await this.#bodyJson()
|
|
385
|
+
if (!partialMatch(actual, subset)) {
|
|
386
|
+
throw new ExpectationError(
|
|
387
|
+
`Expected body to contain subset.\nSubset: ${JSON.stringify(subset)}\nActual: ${capBody(JSON.stringify(actual))}`,
|
|
388
|
+
)
|
|
389
|
+
}
|
|
390
|
+
return this
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/** Assert the JSON body does NOT contain `subset` — japa `assertBodyNotContains`. */
|
|
394
|
+
async assertBodyNotContains(subset: unknown): Promise<this> {
|
|
395
|
+
const actual = await this.#bodyJson()
|
|
396
|
+
if (partialMatch(actual, subset)) {
|
|
397
|
+
throw new ExpectationError(
|
|
398
|
+
`Expected body NOT to contain subset, but it did.\nSubset: ${JSON.stringify(subset)}\nActual: ${capBody(JSON.stringify(actual))}`,
|
|
399
|
+
)
|
|
400
|
+
}
|
|
401
|
+
return this
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Assert a response header is present, and equals `value` when given. Unlike
|
|
406
|
+
* {@link expectHeader}, the value is optional (presence-only check).
|
|
407
|
+
*/
|
|
408
|
+
async assertHeader(name: string, value?: string): Promise<this> {
|
|
409
|
+
if (value !== undefined) return this.expectHeader(name, value)
|
|
410
|
+
const res = await this.send()
|
|
411
|
+
if (res.headers[name.toLowerCase()] === undefined) {
|
|
412
|
+
throw new ExpectationError(
|
|
413
|
+
`Expected header ${name}, not present in: ${Object.keys(res.headers).join(', ')}`,
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
return this
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/** Assert a response header is absent — japa `assertHeaderMissing`. */
|
|
420
|
+
async assertHeaderMissing(name: string): Promise<this> {
|
|
421
|
+
const res = await this.send()
|
|
422
|
+
if (res.headers[name.toLowerCase()] !== undefined) {
|
|
423
|
+
throw new ExpectationError(`Expected header ${name} to be absent, but it was present.`)
|
|
424
|
+
}
|
|
425
|
+
return this
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** Assert the response set a cookie, optionally with a given value — japa `assertCookie`. */
|
|
429
|
+
assertCookie(name: string, value?: string): Promise<this> {
|
|
430
|
+
return this.expectCookie(name, value)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** Assert the response did NOT set a cookie — japa `assertCookieMissing`. */
|
|
434
|
+
async assertCookieMissing(name: string): Promise<this> {
|
|
435
|
+
const res = await this.send()
|
|
436
|
+
const setCookie = res.headers['set-cookie']
|
|
437
|
+
if (setCookie) {
|
|
438
|
+
const present = setCookie
|
|
439
|
+
.split(/,(?=\s*\w+=)/)
|
|
440
|
+
.some((c) => c.trimStart().startsWith(`${name}=`))
|
|
441
|
+
if (present) {
|
|
442
|
+
throw new ExpectationError(
|
|
443
|
+
`Expected cookie ${name} to be absent, but it was set: ${setCookie}`,
|
|
444
|
+
)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return this
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** Send (once) and parse the body as JSON, wrapping parse errors as ExpectationError. */
|
|
451
|
+
async #bodyJson(): Promise<unknown> {
|
|
452
|
+
const res = await this.send()
|
|
453
|
+
try {
|
|
454
|
+
return res.json()
|
|
455
|
+
} catch (err) {
|
|
456
|
+
throw new ExpectationError(
|
|
457
|
+
`Expected JSON body, got non-JSON. Body: ${capBody(res.body)} (parse error: ${err instanceof Error ? err.message : String(err)})`,
|
|
458
|
+
)
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
253
462
|
async #execute(): Promise<TestResponse> {
|
|
254
463
|
// Resolve auth before sending.
|
|
255
464
|
if (this.#pendingAuth !== null) {
|
|
@@ -287,7 +496,15 @@ export class RequestBuilder {
|
|
|
287
496
|
this.#headers.cookie = cookieEntries.map(([k, v]) => `${k}=${v}`).join('; ')
|
|
288
497
|
}
|
|
289
498
|
|
|
290
|
-
|
|
499
|
+
// Merge `.qs()` params into the path, preserving any query string it
|
|
500
|
+
// already carries.
|
|
501
|
+
let path = this.#path
|
|
502
|
+
const qs = this.#query.toString()
|
|
503
|
+
if (qs) {
|
|
504
|
+
path += (path.includes('?') ? '&' : '?') + qs
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
return this.#sender(this.#method, path, {
|
|
291
508
|
headers: this.#headers,
|
|
292
509
|
body: this.#body,
|
|
293
510
|
})
|
|
@@ -51,18 +51,27 @@ export interface TestRequestBuilder extends PromiseLike<TestResponse> {
|
|
|
51
51
|
send(): Promise<TestResponse>
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Named-route manifest — `name → path pattern`, e.g. `{ 'users.show':
|
|
56
|
+
* '/users/:id' }`. Feed `router.namedManifest()` here so `visit()` can resolve
|
|
57
|
+
* named routes without the full Router.
|
|
58
|
+
*/
|
|
59
|
+
export type RouteManifest = Record<string, string>
|
|
60
|
+
|
|
54
61
|
export class TestClient {
|
|
55
62
|
#port = 0
|
|
56
63
|
#headers: Dict = {}
|
|
57
64
|
#bootFn: (port: number) => Promise<{ port: number; close: () => Promise<void> | void }>
|
|
58
65
|
#auth: AuthStrategy | null
|
|
66
|
+
#routes: RouteManifest | null
|
|
59
67
|
|
|
60
68
|
constructor(
|
|
61
69
|
bootFn: (port: number) => Promise<{ port: number; close: () => Promise<void> | void }>,
|
|
62
|
-
options: { auth?: AuthStrategy } = {},
|
|
70
|
+
options: { auth?: AuthStrategy; routes?: RouteManifest } = {},
|
|
63
71
|
) {
|
|
64
72
|
this.#bootFn = bootFn
|
|
65
73
|
this.#auth = options.auth ?? null
|
|
74
|
+
this.#routes = options.routes ?? null
|
|
66
75
|
}
|
|
67
76
|
|
|
68
77
|
#server: { port: number; close: () => Promise<void> | void } | null = null
|
|
@@ -140,6 +149,21 @@ export class TestClient {
|
|
|
140
149
|
return new RequestBuilder(sender, method, path, this.#auth)
|
|
141
150
|
}
|
|
142
151
|
|
|
152
|
+
/**
|
|
153
|
+
* GET a named route — japa/api-client's `.visit()`. Resolves `name` against
|
|
154
|
+
* the `routes` manifest (`router.namedManifest()`), fills `:param`
|
|
155
|
+
* placeholders, and returns the rich chainable builder. Throws a clear error
|
|
156
|
+
* when no manifest was configured or the name is unknown.
|
|
157
|
+
*/
|
|
158
|
+
visit(name: string, params?: Record<string, string>): RequestBuilder {
|
|
159
|
+
if (!this.#routes) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'TestClient: visit() needs a named-route manifest. Pass `routes: router.namedManifest()` in the client options.',
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
return this.fluent('GET', resolveNamedRoute(this.#routes, name, params))
|
|
165
|
+
}
|
|
166
|
+
|
|
143
167
|
/** Build a request with the low-level fluent API. */
|
|
144
168
|
request(method: string, path: string): TestRequestBuilder {
|
|
145
169
|
const headers: Dict = { ...this.#headers }
|
|
@@ -198,9 +222,41 @@ export {
|
|
|
198
222
|
type HttpMethod,
|
|
199
223
|
type HttpSender,
|
|
200
224
|
partialMatch,
|
|
225
|
+
type QueryParams,
|
|
201
226
|
RequestBuilder,
|
|
202
227
|
} from './RequestBuilder.js'
|
|
203
228
|
|
|
229
|
+
/**
|
|
230
|
+
* Resolve a named route against a manifest — the client-side twin of
|
|
231
|
+
* `Router.urlFor`. Fills `:param` placeholders (word-boundary safe), strips
|
|
232
|
+
* unprovided `:optional?` segments, and throws on an unknown name or a missing
|
|
233
|
+
* required param.
|
|
234
|
+
*/
|
|
235
|
+
export function resolveNamedRoute(
|
|
236
|
+
manifest: RouteManifest,
|
|
237
|
+
name: string,
|
|
238
|
+
params?: Record<string, string>,
|
|
239
|
+
): string {
|
|
240
|
+
const pattern = manifest[name]
|
|
241
|
+
if (pattern === undefined) {
|
|
242
|
+
const available = Object.keys(manifest).join(', ') || '(none)'
|
|
243
|
+
throw new Error(`Route '${name}' not found. Available: ${available}`)
|
|
244
|
+
}
|
|
245
|
+
let url = pattern
|
|
246
|
+
if (params) {
|
|
247
|
+
for (const [key, value] of Object.entries(params)) {
|
|
248
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
249
|
+
url = url.replace(new RegExp(`:${escaped}\\??(?![\\w])`, 'g'), encodeURIComponent(value))
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
url = url.replace(/\/:[A-Za-z_][\w]*\?/g, '')
|
|
253
|
+
const missing = url.match(/:[A-Za-z_][\w]*/g)
|
|
254
|
+
if (missing && missing.length > 0) {
|
|
255
|
+
throw new Error(`Cannot generate URL for route '${name}': missing params ${missing.join(', ')}`)
|
|
256
|
+
}
|
|
257
|
+
return url
|
|
258
|
+
}
|
|
259
|
+
|
|
204
260
|
/** Send an HTTP request using raw TCP (no external dependencies). */
|
|
205
261
|
async function sendRequest(
|
|
206
262
|
port: number,
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Macroable — lets a package extend a class at runtime with extra methods
|
|
3
|
+
* (`macro`) and computed properties (`getter`), AdonisJS-style. A framework
|
|
4
|
+
* class (Request, Response, HttpContext, Router, …) extends this so an add-on
|
|
5
|
+
* can attach behaviour without a subclass:
|
|
6
|
+
*
|
|
7
|
+
* Request.macro('wantsJson', function () { return this.accepts(['json']) === 'json' })
|
|
8
|
+
* HttpContext.getter('tenant', function () { return resolveTenant(this) })
|
|
9
|
+
*
|
|
10
|
+
* TypeScript sees only the base class; consumers type their macros with
|
|
11
|
+
* declaration merging (`declare module`) exactly as in AdonisJS.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A macro method. When invoked, `this` is the instance it's attached to —
|
|
16
|
+
* declare it explicitly in your callback (`function (this: Request) { … }`) to
|
|
17
|
+
* type the receiver; the alias itself leaves `this` open so any class fits.
|
|
18
|
+
*/
|
|
19
|
+
export type MacroFn = (...args: unknown[]) => unknown
|
|
20
|
+
|
|
21
|
+
/** A getter implementation — declare `this` in your callback to type the receiver. */
|
|
22
|
+
export type GetterFn = () => unknown
|
|
23
|
+
|
|
24
|
+
export class Macroable {
|
|
25
|
+
/** Attach a method to every instance of this class (AdonisJS `macro`). */
|
|
26
|
+
static macro(name: string, fn: MacroFn): void {
|
|
27
|
+
Object.defineProperty(this.prototype, name, {
|
|
28
|
+
value: fn,
|
|
29
|
+
writable: true,
|
|
30
|
+
configurable: true,
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Attach a computed property to every instance (AdonisJS `getter`). With
|
|
36
|
+
* `singleton`, the value is computed once per instance and cached.
|
|
37
|
+
*/
|
|
38
|
+
static getter(name: string, fn: GetterFn, singleton = false): void {
|
|
39
|
+
if (!singleton) {
|
|
40
|
+
Object.defineProperty(this.prototype, name, { get: fn, configurable: true })
|
|
41
|
+
return
|
|
42
|
+
}
|
|
43
|
+
const cacheKey = Symbol(name)
|
|
44
|
+
Object.defineProperty(this.prototype, name, {
|
|
45
|
+
configurable: true,
|
|
46
|
+
get(this: Record<symbol, unknown>) {
|
|
47
|
+
if (!(cacheKey in this)) {
|
|
48
|
+
Object.defineProperty(this, cacheKey, { value: fn.call(this), configurable: true })
|
|
49
|
+
}
|
|
50
|
+
return this[cacheKey]
|
|
51
|
+
},
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
}
|