Skip to content

feat(effect): proposal — HttpApiMiddleware can declare query fields#2154

Draft
kitlangton wants to merge 1 commit into
Effect-TS:mainfrom
kitlangton:kit/proposal-middleware-query-fields
Draft

feat(effect): proposal — HttpApiMiddleware can declare query fields#2154
kitlangton wants to merge 1 commit into
Effect-TS:mainfrom
kitlangton:kit/proposal-middleware-query-fields

Conversation

@kitlangton
Copy link
Copy Markdown
Contributor

Status

Draft proposal — looking for design feedback before fleshing out. I have a working runtime implementation plus a passing end-to-end test, but several follow-on design calls (type-level threading, headers symmetry, OpenAPI emission, non-Fields endpoint queries) are deliberately deferred so this PR can be a focused conversation starter rather than a fait accompli. Happy to rework or split per maintainer preference.

Problem

HttpApiMiddleware cannot declare query parameters that propagate to endpoints. Today middleware can declare provides, requires, error, security, and requiredForClient — those cover what middleware adds to the Effect signature (context, errors, auth) but not the request shape the middleware reads. If middleware reads ?directory=… off the URL, that field is invisible to HttpApi's schema validation, so:

  1. Middleware reads a query param off request.url.
  2. Endpoints that use the middleware don't list the param in their query: Schema.Struct({…}).
  3. HttpApi's runtime decoder is strict-by-default — unknown query fields produce 400.
  4. Result: every SDK request that includes the middleware-consumed param 400s, even though it's exactly the request the middleware was designed to handle. If the OpenAPI doc has been hand-patched to advertise the param, doc and runtime silently disagree.

Concrete repro: opencode's WorkspaceRoutingMiddleware reads ?directory and ?workspace on every endpoint in the groups it's applied to. The fix today is to spread a WorkspaceRoutingQueryFields constant into all 9 affected endpoint query schemas — and remember to keep doing it forever (anomalyco/opencode#26581).

Proposed API

class WorkspaceRoutingMiddleware extends HttpApiMiddleware.Service<WorkspaceRoutingMiddleware>()(
  "WorkspaceRouting",
  {
    query: {
      directory: Schema.optional(Schema.String),
      workspace: Schema.optional(Schema.String)
    }
  }
) {}

const Group = HttpApiGroup.make("session")
  .add(HttpApiEndpoint.get("list", "/", { /* no `directory` here */ }))
  .middleware(WorkspaceRoutingMiddleware)
// → GET /session?directory=foo no longer 400s.

query is constrained to Schema.Struct.Fields (the dictionary form), not arbitrary Schema.Top, so the merge is mechanically field-wise.

Implementation

Mirrors the existing error pattern — lazy merge at builder time, no eager mutation at .middleware() call time:

  • HttpApiMiddleware.Service accepts a query option, stored on the static class as self.query (parallel to self.error, self.security).
  • HttpApiEndpoint.make preserves the raw query fields when the user passes Schema.Struct.Fields (so the merge can rebuild from them later).
  • HttpApiEndpoint.getQuerySchema(endpoint) (new) is the parallel of getErrorSchemas(endpoint) — walks endpoint.middlewares, merges field schemas with the endpoint's own fields, and returns the combined transformed schema. Throws on conflicting field names with non-equivalent schemas.
  • HttpApiBuilder.handlerToHttpEffect calls getQuerySchema(endpoint) instead of reading endpoint.query directly.

Total runtime change: ~80 lines net, all in three files.

What's working

  • Middleware-only fields (?directory=foo against an endpoint that didn't declare directory) — accepted, decoded as part of ctx.query.
  • Mixed (?directory=foo&limit=10 where limit is endpoint-declared and directory is middleware-declared) — both decode correctly.
  • Endpoints with no middleware-query — unchanged path, no behavioural difference.
  • Conflicts (two middlewares declare the same field, or middleware and endpoint declare the same field) — throw at materialisation time with a clear message.
  • New end-to-end test in packages/platform-node/test/HttpApi.test.ts covers all three.
  • 44/45 of the existing HttpApi.test.ts tests pass with the change. The 1 failure is a pre-existing toMatchSnapshot issue on main unrelated to this work.

Deferred — explicit open questions

These are intentionally unresolved here so the maintainers can shape them:

  1. Type-level threading — the runtime puts middleware fields into ctx.query, but the endpoint's Query type parameter doesn't include them, so they show up at runtime but not in TS. Should .middleware(M) widen the endpoint's query type to a union of M's fields ∪ endpoint's fields? error already accumulates via Middleware | I plus Errors<Endpoint> — same shape would work for query, but it's invasive.
  2. headers symmetry — the same plumbing trivially extends to headers. Worth landing in this PR, or as a separate one once the query shape is blessed?
  3. OpenAPI emissionOpenApi.fromApi walks endpoint.middlewares for security but not for query. Adding a parallel walk in processParameters is straightforward; deferred only because it's a separate code path with its own concerns. Should be in this PR before merge?
  4. Non-Schema.Struct.Fields endpoint queries — when an endpoint passes a full Schema.Top (not the Fields dict form) and applies middleware with declared query fields, getQuerySchema currently throws because it can't extract raw fields to merge. Three options: keep the throw with a clear error (current), unwrap the AST to extract fields (fragile), or also store the Schema.Top alongside and intersect at the Schema layer. I went with the throw since it's cheap and pushes the user to the cleaner Fields shape.
  5. Body deliberately excluded — there's one body stream; middleware can't decode-then-replay without buffering, and "merge body schemas" is semantically wrong. Documented in the changeset; happy to add a // not supported: body comment somewhere visible if useful.

Test plan

  • pnpm check:tsgo clean
  • pnpm lint-fix clean
  • pnpm docgen (effect package) clean
  • New e2e test in packages/platform-node/test/HttpApi.test.ts passes
  • All other HttpApi.test.ts tests still pass (1 pre-existing snapshot failure on main unrelated)
  • Changeset added (minor)

Why a draft PR rather than an issue

Honestly — happy to convert to an issue if that's the maintainers' preferred shape for this kind of design call. The reason I sent it as a PR is that the API surface and merge mechanism are concrete enough that running code is easier to react to than a prose proposal. If you'd rather discuss before any code lands, say the word and I'll close this and open an issue with the same content.

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.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 10, 2026

🦋 Changeset detected

Latest commit: 6d318ff

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 27 packages
Name Type
effect Major
@effect/opentelemetry Major
@effect/platform-browser Major
@effect/platform-bun Major
@effect/platform-node-shared Major
@effect/platform-node Major
@effect/vitest Major
@effect/ai-anthropic Major
@effect/ai-openai-compat Major
@effect/ai-openai Major
@effect/ai-openrouter Major
@effect/atom-react Major
@effect/atom-solid Major
@effect/atom-vue Major
@effect/sql-clickhouse Major
@effect/sql-d1 Major
@effect/sql-libsql Major
@effect/sql-mssql Major
@effect/sql-mysql2 Major
@effect/sql-pg Major
@effect/sql-pglite Major
@effect/sql-sqlite-bun Major
@effect/sql-sqlite-do Major
@effect/sql-sqlite-node Major
@effect/sql-sqlite-react-native Major
@effect/sql-sqlite-wasm Major
@effect/openapi-generator Major

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant