@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.
Files changed (172) hide show
  1. package/dist/ConfigLoader.d.ts +46 -5
  2. package/dist/ConfigLoader.d.ts.map +1 -1
  3. package/dist/ConfigLoader.js +60 -9
  4. package/dist/ConfigLoader.js.map +1 -1
  5. package/dist/HttpKernel.d.ts.map +1 -1
  6. package/dist/HttpKernel.js +46 -6
  7. package/dist/HttpKernel.js.map +1 -1
  8. package/dist/Ignitor.d.ts.map +1 -1
  9. package/dist/Ignitor.js +21 -3
  10. package/dist/Ignitor.js.map +1 -1
  11. package/dist/container/Container.d.ts +23 -3
  12. package/dist/container/Container.d.ts.map +1 -1
  13. package/dist/container/Container.js +139 -60
  14. package/dist/container/Container.js.map +1 -1
  15. package/dist/env/Env.d.ts +29 -4
  16. package/dist/env/Env.d.ts.map +1 -1
  17. package/dist/env/Env.js +27 -4
  18. package/dist/env/Env.js.map +1 -1
  19. package/dist/env/Secret.d.ts +11 -0
  20. package/dist/env/Secret.d.ts.map +1 -0
  21. package/dist/env/Secret.js +36 -0
  22. package/dist/env/Secret.js.map +1 -0
  23. package/dist/env/interpolate.d.ts +26 -0
  24. package/dist/env/interpolate.d.ts.map +1 -0
  25. package/dist/env/interpolate.js +48 -0
  26. package/dist/env/interpolate.js.map +1 -0
  27. package/dist/env/loadEnvFiles.d.ts.map +1 -1
  28. package/dist/env/loadEnvFiles.js +14 -4
  29. package/dist/env/loadEnvFiles.js.map +1 -1
  30. package/dist/env/schema.d.ts +23 -6
  31. package/dist/env/schema.d.ts.map +1 -1
  32. package/dist/env/schema.js +67 -33
  33. package/dist/env/schema.js.map +1 -1
  34. package/dist/events/Emitter.d.ts +1 -1
  35. package/dist/events/Emitter.d.ts.map +1 -1
  36. package/dist/events/Emitter.js +1 -1
  37. package/dist/events/Emitter.js.map +1 -1
  38. package/dist/events/EventsProvider.d.ts +2 -2
  39. package/dist/events/EventsProvider.d.ts.map +1 -1
  40. package/dist/events/EventsProvider.js +3 -3
  41. package/dist/events/EventsProvider.js.map +1 -1
  42. package/dist/graphql/GraphQLEngine.d.ts +1 -1
  43. package/dist/graphql/GraphQLEngine.d.ts.map +1 -1
  44. package/dist/graphql/GraphQLEngine.js +2 -2
  45. package/dist/graphql/GraphQLEngine.js.map +1 -1
  46. package/dist/graphql/GraphQLProvider.d.ts.map +1 -1
  47. package/dist/graphql/GraphQLProvider.js +7 -3
  48. package/dist/graphql/GraphQLProvider.js.map +1 -1
  49. package/dist/http/Exception.d.ts +63 -1
  50. package/dist/http/Exception.d.ts.map +1 -1
  51. package/dist/http/Exception.js +185 -48
  52. package/dist/http/Exception.js.map +1 -1
  53. package/dist/http/HttpContext.d.ts +56 -9
  54. package/dist/http/HttpContext.d.ts.map +1 -1
  55. package/dist/http/HttpContext.js +56 -15
  56. package/dist/http/HttpContext.js.map +1 -1
  57. package/dist/http/RedirectBuilder.d.ts.map +1 -1
  58. package/dist/http/RedirectBuilder.js +4 -1
  59. package/dist/http/RedirectBuilder.js.map +1 -1
  60. package/dist/http/Request.d.ts +141 -10
  61. package/dist/http/Request.d.ts.map +1 -1
  62. package/dist/http/Request.js +244 -34
  63. package/dist/http/Request.js.map +1 -1
  64. package/dist/http/Response.d.ts +176 -9
  65. package/dist/http/Response.d.ts.map +1 -1
  66. package/dist/http/Response.js +431 -19
  67. package/dist/http/Response.js.map +1 -1
  68. package/dist/index.d.ts +5 -4
  69. package/dist/index.d.ts.map +1 -1
  70. package/dist/index.js +4 -3
  71. package/dist/index.js.map +1 -1
  72. package/dist/middleware/Pipeline.js +1 -1
  73. package/dist/middleware/Pipeline.js.map +1 -1
  74. package/dist/openapi/OpenApiProvider.js +4 -4
  75. package/dist/openapi/OpenApiProvider.js.map +1 -1
  76. package/dist/router/Router.d.ts +223 -31
  77. package/dist/router/Router.d.ts.map +1 -1
  78. package/dist/router/Router.js +520 -102
  79. package/dist/router/Router.js.map +1 -1
  80. package/dist/rpc/RpcProvider.d.ts +1 -0
  81. package/dist/rpc/RpcProvider.d.ts.map +1 -1
  82. package/dist/rpc/RpcProvider.js +7 -3
  83. package/dist/rpc/RpcProvider.js.map +1 -1
  84. package/dist/rpc/RpcRouter.d.ts +2 -2
  85. package/dist/rpc/RpcRouter.d.ts.map +1 -1
  86. package/dist/rpc/RpcRouter.js +5 -4
  87. package/dist/rpc/RpcRouter.js.map +1 -1
  88. package/dist/scheduler/ScheduleProvider.d.ts.map +1 -1
  89. package/dist/scheduler/ScheduleProvider.js +7 -15
  90. package/dist/scheduler/ScheduleProvider.js.map +1 -1
  91. package/dist/server/Server.d.ts +17 -4
  92. package/dist/server/Server.d.ts.map +1 -1
  93. package/dist/server/Server.js +41 -7
  94. package/dist/server/Server.js.map +1 -1
  95. package/dist/session/SessionMiddleware.js +3 -3
  96. package/dist/session/SessionMiddleware.js.map +1 -1
  97. package/dist/storage/StaticProvider.js +1 -1
  98. package/dist/storage/StaticProvider.js.map +1 -1
  99. package/dist/testing/RequestBuilder.d.ts +60 -0
  100. package/dist/testing/RequestBuilder.d.ts.map +1 -1
  101. package/dist/testing/RequestBuilder.js +180 -1
  102. package/dist/testing/RequestBuilder.js.map +1 -1
  103. package/dist/testing/TestClient.d.ts +22 -1
  104. package/dist/testing/TestClient.d.ts.map +1 -1
  105. package/dist/testing/TestClient.js +40 -0
  106. package/dist/testing/TestClient.js.map +1 -1
  107. package/dist/utils/Macroable.d.ts +30 -0
  108. package/dist/utils/Macroable.d.ts.map +1 -0
  109. package/dist/utils/Macroable.js +43 -0
  110. package/dist/utils/Macroable.js.map +1 -0
  111. package/dist/utils/inflect.d.ts +17 -0
  112. package/dist/utils/inflect.d.ts.map +1 -0
  113. package/dist/utils/inflect.js +32 -0
  114. package/dist/utils/inflect.js.map +1 -0
  115. package/dist/utils/objectPath.d.ts +57 -0
  116. package/dist/utils/objectPath.d.ts.map +1 -0
  117. package/dist/utils/objectPath.js +141 -0
  118. package/dist/utils/objectPath.js.map +1 -0
  119. package/events.darwin-arm64.node +0 -0
  120. package/events.darwin-x64.node +0 -0
  121. package/events.linux-arm64-gnu.node +0 -0
  122. package/events.linux-x64-gnu.node +0 -0
  123. package/events.win32-x64-msvc.node +0 -0
  124. package/index.darwin-arm64.node +0 -0
  125. package/index.darwin-x64.node +0 -0
  126. package/index.linux-arm64-gnu.node +0 -0
  127. package/index.linux-x64-gnu.node +0 -0
  128. package/index.win32-x64-msvc.node +0 -0
  129. package/package.json +6 -1
  130. package/scheduler.darwin-arm64.node +0 -0
  131. package/scheduler.darwin-x64.node +0 -0
  132. package/scheduler.linux-arm64-gnu.node +0 -0
  133. package/scheduler.linux-x64-gnu.node +0 -0
  134. package/scheduler.win32-x64-msvc.node +0 -0
  135. package/src/ConfigLoader.ts +72 -9
  136. package/src/HttpKernel.ts +53 -15
  137. package/src/Ignitor.ts +24 -3
  138. package/src/container/Container.ts +145 -61
  139. package/src/env/Env.ts +49 -5
  140. package/src/env/Secret.ts +41 -0
  141. package/src/env/interpolate.ts +54 -0
  142. package/src/env/loadEnvFiles.ts +12 -4
  143. package/src/env/schema.ts +109 -35
  144. package/src/events/Emitter.ts +2 -2
  145. package/src/events/EventsProvider.ts +5 -5
  146. package/src/graphql/GraphQLEngine.ts +4 -4
  147. package/src/graphql/GraphQLProvider.ts +7 -3
  148. package/src/http/Exception.ts +214 -37
  149. package/src/http/HttpContext.ts +93 -20
  150. package/src/http/RedirectBuilder.ts +4 -1
  151. package/src/http/Request.ts +264 -34
  152. package/src/http/Response.ts +504 -30
  153. package/src/index.ts +11 -2
  154. package/src/middleware/Pipeline.ts +1 -1
  155. package/src/openapi/OpenApiProvider.ts +4 -4
  156. package/src/router/Router.ts +631 -113
  157. package/src/rpc/RpcProvider.ts +6 -2
  158. package/src/rpc/RpcRouter.ts +7 -6
  159. package/src/scheduler/ScheduleProvider.ts +7 -15
  160. package/src/server/Server.ts +49 -10
  161. package/src/session/SessionMiddleware.ts +4 -4
  162. package/src/storage/StaticProvider.ts +1 -1
  163. package/src/testing/RequestBuilder.ts +218 -1
  164. package/src/testing/TestClient.ts +57 -1
  165. package/src/utils/Macroable.ts +54 -0
  166. package/src/utils/inflect.ts +29 -0
  167. package/src/utils/objectPath.ts +142 -0
  168. package/dist/decorators/Lazy.d.ts +0 -19
  169. package/dist/decorators/Lazy.d.ts.map +0 -1
  170. package/dist/decorators/Lazy.js +0 -53
  171. package/dist/decorators/Lazy.js.map +0 -1
  172. package/src/decorators/Lazy.ts +0 -57
@@ -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
- if (container.resolve('rpc') === this.rpc) return // re-register is idempotent
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))
@@ -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?.authenticated) {
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 outcome = this.#container.resolve<RuntimeValidator>(token).validate(params)
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
- // Probe for an existing binding. The `make` / `resolve` API
69
- // throws when unbound; use it to distinguish "same instance"
70
- // from "another provider already claimed the token".
71
- let existing: unknown
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(
@@ -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
- * This is the pattern used by AdonisJS middleware.
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>): Promise<void> | 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
- * Lazy imports are resolved on first call and cached.
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 — resolve + cache on first invocation
121
- // After isMiddlewareFunction returns false, entry is narrowed to LazyImport<MiddlewareClassConstructor>
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 cached: MiddlewareClass | undefined
129
+ let cachedClass: MiddlewareClassConstructor | undefined
124
130
  return async (ctx: HttpContext, next: () => Promise<void>) => {
125
- if (!cached) {
131
+ if (!cachedClass) {
126
132
  const mod = await lazyImport()
127
- cached = new mod.default()
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.cookie(cookieName)
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
- ctx.session = session
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.cookie(cookieName, encoded, {
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.cookie(cookieName, session.sessionId, {
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
- return this.#sender(this.#method, this.#path, {
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
+ }