Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .changeset/proposal-middleware-query-fields.md
Original file line number Diff line number Diff line change
@@ -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<WorkspaceRoutingMiddleware>()(
"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).
2 changes: 1 addition & 1 deletion packages/effect/src/unstable/httpapi/HttpApiBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 65 additions & 1 deletion packages/effect/src/unstable/httpapi/HttpApiEndpoint.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,63 @@ export function getErrorSchemas(endpoint: AnyWithProps): Array<Schema.Top> {
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<string, Schema.Top> = {}
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<string, Schema.Top> = { ...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
Expand Down Expand Up @@ -837,6 +894,8 @@ function makeProto<
readonly error: ReadonlySet<Schema.Top>
readonly annotations: Context.Context<never>
readonly middlewares: ReadonlySet<Context.Key<Middleware, any>>
readonly queryFields?: Schema.Struct.Fields | undefined
readonly disableCodecs?: boolean | undefined
}): HttpApiEndpoint<
Name,
Method,
Expand Down Expand Up @@ -1021,6 +1080,9 @@ export const make = <Method extends HttpMethod>(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,
Expand All @@ -1032,7 +1094,9 @@ export const make = <Method extends HttpMethod>(method: Method): {
success: getResponse(options?.success, disableCodecs),
error: getResponse(options?.error, disableCodecs),
annotations: Context.empty(),
middlewares: new Set()
middlewares: new Set(),
queryFields,
disableCodecs
})
}

Expand Down
24 changes: 23 additions & 1 deletion packages/effect/src/unstable/httpapi/HttpApiMiddleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ export interface AnyService extends Context.Key<any, any> {
readonly error: ReadonlySet<Schema.Top>
readonly requiredForClient: boolean
readonly "~ClientError": any
readonly query?: Schema.Struct.Fields | undefined
}

/**
Expand Down Expand Up @@ -250,13 +251,30 @@ export const Service = <
const Id extends string,
const Error extends ErrorConstraint = never,
const Security extends Record<string, HttpApiSecurity.HttpApiSecurity> = 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<Self, Id, {
requires: "requires" extends keyof Config ? Config["requires"] : never
Expand All @@ -272,6 +290,7 @@ export const Service = <
readonly security?: Record<string, HttpApiSecurity.HttpApiSecurity> | undefined
readonly error?: ErrorConstraint | undefined
readonly requiredForClient?: boolean | undefined
readonly query?: Schema.Struct.Fields | undefined
} | undefined
) => {
const Err = globalThis.Error as any
Expand All @@ -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")
Expand Down
51 changes: 51 additions & 0 deletions packages/platform-node/test/HttpApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<WorkspaceRoutingMiddleware>()(
"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<M>()("Server/MissingMiddleware") {}

Expand Down