From 6d318ff550653468d585deeca9980cd8730b34a0 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sat, 9 May 2026 20:55:28 -0400 Subject: [PATCH] =?UTF-8?q?feat(effect):=20proposal=20=E2=80=94=20HttpApiM?= =?UTF-8?q?iddleware=20can=20declare=20query=20fields?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets HttpApiMiddleware declare query parameters it reads off the request URL. The framework merges those field schemas into the runtime query schema of every endpoint that applies the middleware, so requests carrying those params no longer 400 just because the endpoint did not list them. Mirrors the existing `error` pattern: lazy walk over `endpoint.middlewares` in `HttpApiEndpoint.getQuerySchema`, no eager mutation at `.middleware()` time. Conflicts (same field name with non-equivalent schemas) throw at HttpApi materialisation time. This PR is intentionally narrow as a design proposal — open questions for type-level threading, `headers` symmetry, OpenAPI emission, and non-Schema.Struct.Fields endpoint queries are listed in the PR description. --- .../proposal-middleware-query-fields.md | 21 ++++++ .../src/unstable/httpapi/HttpApiBuilder.ts | 2 +- .../src/unstable/httpapi/HttpApiEndpoint.ts | 66 ++++++++++++++++++- .../src/unstable/httpapi/HttpApiMiddleware.ts | 24 ++++++- packages/platform-node/test/HttpApi.test.ts | 51 ++++++++++++++ 5 files changed, 161 insertions(+), 3 deletions(-) create mode 100644 .changeset/proposal-middleware-query-fields.md diff --git a/.changeset/proposal-middleware-query-fields.md b/.changeset/proposal-middleware-query-fields.md new file mode 100644 index 0000000000..a92eeebb46 --- /dev/null +++ b/.changeset/proposal-middleware-query-fields.md @@ -0,0 +1,21 @@ +--- +"effect": minor +--- + +**Proposal**: `HttpApiMiddleware.Service` can now declare `query: Schema.Struct.Fields` — query parameters that the middleware reads off the request URL. The framework merges these field schemas into the runtime query schema of every endpoint that applies the middleware, so requests carrying these params do not 400 just because the endpoint did not list them. + +```ts +class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service()( + "WorkspaceRouting", + { + query: { + directory: Schema.optional(Schema.String), + workspace: Schema.optional(Schema.String) + } + } +) {} +``` + +The merge mirrors the existing `error` pattern: lazy walk over `endpoint.middlewares` in `HttpApiEndpoint.getQuerySchema`, no eager mutation at `.middleware()` time. Conflicts (same field name with non-equivalent schemas) throw at HttpApi materialisation time. + +Scope of this PR is intentionally narrow as a design proposal — open questions for maintainer feedback are listed in the PR description (type-level threading, `headers` symmetry, OpenAPI emission, whether endpoint queries that aren't `Schema.Struct.Fields` literals should be supported). diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index 5fcc6fedf3..35b4a4145a 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -584,7 +584,7 @@ function handlerToHttpEffect( const encodeError = Schema.encodeUnknownEffect(makeErrorSchema(endpoint)) const decodeParams = UndefinedOr.map(endpoint.params, Schema.decodeUnknownEffect) const decodeHeaders = UndefinedOr.map(endpoint.headers, Schema.decodeUnknownEffect) - const decodeQuery = UndefinedOr.map(endpoint.query, Schema.decodeUnknownEffect) + const decodeQuery = UndefinedOr.map(HttpApiEndpoint.getQuerySchema(endpoint), Schema.decodeUnknownEffect) const shouldParsePayload = endpoint.payload.size > 0 && !isRaw const payloadBy = shouldParsePayload ? buildPayloadDecoders(endpoint.payload) : undefined diff --git a/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts b/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts index aec699cc65..ab5760aa57 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts @@ -182,6 +182,63 @@ export function getErrorSchemas(endpoint: AnyWithProps): Array { return Array.from(schemas) } +/** + * Build the effective query schema for an endpoint by merging field schemas + * declared by its applied middlewares into the endpoint's own query fields. + * + * Mirrors `getErrorSchemas`: lazy walk over `endpoint.middlewares` at the + * time the schema is needed, no eager mutation at `.middleware()` time. + * + * Conflicts (same field name with non-equivalent schemas) throw at call time. + * + * @internal + */ +export function getQuerySchema(endpoint: AnyWithProps): Schema.Top | undefined { + const middlewareFields: Record = {} + for (const middleware of endpoint.middlewares) { + const key = middleware as any as HttpApiMiddleware.AnyService + if (!key.query) continue + for (const name of Object.keys(key.query)) { + const field = key.query[name] + const existing = middlewareFields[name] + if (existing !== undefined && existing !== field) { + throw new globalThis.Error( + `HttpApiMiddleware: conflicting query field "${name}" declared by multiple middlewares on endpoint "${endpoint.name}".` + ) + } + middlewareFields[name] = field + } + } + if (Object.keys(middlewareFields).length === 0) return endpoint.query + + const endpointFields = (endpoint as AnyWithProps & { readonly queryFields?: Schema.Struct.Fields | undefined }) + .queryFields + const disableCodecs = (endpoint as AnyWithProps & { readonly disableCodecs?: boolean | undefined }).disableCodecs + ?? false + + if (endpointFields === undefined && endpoint.query !== undefined) { + throw new globalThis.Error( + `HttpApiMiddleware: cannot merge middleware-declared query fields into endpoint "${endpoint.name}" because its query schema is not a Schema.Struct.Fields literal. Re-declare the endpoint query as { ... } (a fields object) or remove the middleware-declared query.` + ) + } + + const merged: Record = { ...middlewareFields } + if (endpointFields !== undefined) { + for (const name of Object.keys(endpointFields)) { + const field = endpointFields[name] + const existing = merged[name] + if (existing !== undefined && existing !== field) { + throw new globalThis.Error( + `HttpApiMiddleware: conflicting query field "${name}" declared by both middleware and endpoint "${endpoint.name}".` + ) + } + merged[name] = field + } + } + const struct = Schema.Struct(merged) + return disableCodecs ? struct : Schema.toCodecStringTree(struct) +} + /** * @since 4.0.0 * @category models @@ -837,6 +894,8 @@ function makeProto< readonly error: ReadonlySet readonly annotations: Context.Context readonly middlewares: ReadonlySet> + readonly queryFields?: Schema.Struct.Fields | undefined + readonly disableCodecs?: boolean | undefined }): HttpApiEndpoint< Name, Method, @@ -1021,6 +1080,9 @@ export const make = (method: Method): { > => { const disableCodecs = options?.disableCodecs ?? false const transformStringTree = disableCodecs ? identity : Schema.toCodecStringTree + const queryFields = options?.query !== undefined && !Schema.isSchema(options.query) + ? (options.query as Schema.Struct.Fields) + : undefined return makeProto({ name, path, @@ -1032,7 +1094,9 @@ export const make = (method: Method): { success: getResponse(options?.success, disableCodecs), error: getResponse(options?.error, disableCodecs), annotations: Context.empty(), - middlewares: new Set() + middlewares: new Set(), + queryFields, + disableCodecs }) } diff --git a/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts b/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts index 500cd7d76d..0022964e6b 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts @@ -104,6 +104,7 @@ export interface AnyService extends Context.Key { readonly error: ReadonlySet readonly requiredForClient: boolean readonly "~ClientError": any + readonly query?: Schema.Struct.Fields | undefined } /** @@ -250,13 +251,30 @@ export const Service = < const Id extends string, const Error extends ErrorConstraint = never, const Security extends Record = never, - RequiredForClient extends boolean = false + RequiredForClient extends boolean = false, + const Query extends Schema.Struct.Fields = never >( id: Id, options?: { readonly error?: Error | undefined readonly security?: Security | undefined readonly requiredForClient?: RequiredForClient | undefined + /** + * Query parameters that this middleware reads off the request URL. + * + * Declaring them here means the framework merges these field schemas into + * the runtime query schema of every endpoint that applies this middleware, + * so requests carrying these params do not 400 just because the endpoint + * did not list them. + * + * Conflicts (same field name with non-equivalent schemas) are reported at + * HttpApi materialisation time. Mirrors how `error` is accumulated lazily + * in `getErrorSchemas`. + * + * Currently limited to `Schema.Struct.Fields` so the merge is mechanically + * field-wise. `headers` is a natural follow-up. + */ + readonly query?: Query | undefined } | undefined ) => ServiceClass | undefined readonly error?: ErrorConstraint | undefined readonly requiredForClient?: boolean | undefined + readonly query?: Schema.Struct.Fields | undefined } | undefined ) => { const Err = globalThis.Error as any @@ -291,6 +310,9 @@ export const Service = < self[TypeId] = TypeId self.error = getError(options?.error) self.requiredForClient = options?.requiredForClient ?? false + if (options?.query !== undefined) { + self.query = options.query + } if (options?.security !== undefined) { if (Object.keys(options.security).length === 0) { throw new Error("HttpApiMiddleware.Service: security object must not be empty") diff --git a/packages/platform-node/test/HttpApi.test.ts b/packages/platform-node/test/HttpApi.test.ts index 9b1995082f..93e7e6f3bb 100644 --- a/packages/platform-node/test/HttpApi.test.ts +++ b/packages/platform-node/test/HttpApi.test.ts @@ -631,6 +631,57 @@ describe("HttpApi", () => { }).pipe(Effect.provide(ApiLive)) }) + it.effect("middleware-declared query fields propagate to endpoint runtime decoding", () => { + // Reproduces opencode's WorkspaceRoutingMiddleware case: middleware reads + // ?directory off the URL on every endpoint in the group. The endpoint + // does not list ?directory in its own query schema, so without the + // middleware-query feature this request would 400 with "unexpected + // property" because HttpApi's runtime decoder is strict-by-default. + class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service()( + "Http/WorkspaceRouting", + { + query: { + directory: Schema.optional(Schema.String) + } + } + ) {} + + const Api = HttpApi.make("api").add( + HttpApiGroup.make("group") + .add( + HttpApiEndpoint.get("a", "/a", { + query: { + limit: Schema.optional(Schema.NumberFromString) + }, + success: Schema.String + }) + ) + .middleware(WorkspaceRoutingMiddleware) + ) + const GroupLive = HttpApiBuilder.group( + Api, + "group", + (handlers) => handlers.handle("a", (ctx) => Effect.succeed(`limit=${ctx.query.limit ?? "none"}`)) + ) + const MLive = Layer.succeed( + WorkspaceRoutingMiddleware, + (effect) => effect + ) + const ApiLive = HttpRouter.serve( + HttpApiBuilder.layer(Api).pipe(Layer.provide(GroupLive), Layer.provide(MLive)), + { disableListenLog: true, disableLogger: true } + ).pipe(Layer.provideMerge(NodeHttpServer.layerTest)) + + return Effect.gen(function*() { + // The middleware-only field is accepted; endpoint decode still works. + yield* assertServerText(yield* HttpClient.get("/a?directory=foo"), 200, `"limit=none"`) + // Endpoint's own field still works alongside the middleware field. + yield* assertServerText(yield* HttpClient.get("/a?directory=foo&limit=10"), 200, `"limit=10"`) + // Without the middleware field, endpoint decode is unchanged. + yield* assertServerText(yield* HttpClient.get("/a?limit=10"), 200, `"limit=10"`) + }).pipe(Effect.provide(ApiLive)) + }) + it.effect("missing middleware layer fails with service not found error", () => { class M extends HttpApiMiddleware.Service()("Server/MissingMiddleware") {}