diff --git a/.changeset/httpapi-request-parse-options.md b/.changeset/httpapi-request-parse-options.md new file mode 100644 index 00000000000..1208853dcc1 --- /dev/null +++ b/.changeset/httpapi-request-parse-options.md @@ -0,0 +1,15 @@ +--- +"effect": patch +--- + +Add `parseOptions` to `HttpApiBuilder` handler options, so the parse options used when decoding request parts can be configured. This makes it possible to collect every decoding error instead of only the first one. + +```ts +HttpApiBuilder.group( + Api, + "users", + (handlers) => handlers.handle("create", handler, { parseOptions: { errors: "all" } }) +) +``` + +The option applies to `params`, `headers`, `query`, and `payload` decoding, both in `HttpApiBuilder.group` and `HttpApiBuilder.endpoint`. diff --git a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts index f949bbf2105..d59a935f52a 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiBuilder.ts @@ -226,6 +226,7 @@ type HandlerRequirements< interface HandlerOptions { readonly uninterruptible?: boolean | undefined + readonly parseOptions?: SchemaAST.ParseOptions | undefined } /** @internal */ @@ -234,6 +235,7 @@ export interface HandlerRuntime { readonly handler: HttpApiEndpoint.Handler readonly isRaw: boolean readonly uninterruptible: boolean + readonly parseOptions: SchemaAST.ParseOptions | undefined } type HandleAllEntry = @@ -324,6 +326,14 @@ export interface Handlers< /** * Add the implementation for an unhandled `HttpApiEndpoint` to a `Handlers` group. + * + * **Details** + * + * Pass `parseOptions` to control how the request parts (`params`, `headers`, + * `query`, and `payload`) are decoded with their schemas. Use it to collect + * every decoding error instead of only the first one, for example + * `{ errors: "all" }`. The `uninterruptible` option controls whether the + * handler fiber is uninterruptible. */ handle< Identifier extends keyof EndpointsByIdentifier, @@ -335,7 +345,7 @@ export interface Handlers< HttpApiEndpoint.MiddlewareError, R1 >, - options?: { readonly uninterruptible?: boolean | undefined } | undefined + options?: HandlerOptions | undefined ): Handlers< R | HandlerRequirements, EndpointsByIdentifier, @@ -344,6 +354,11 @@ export interface Handlers< /** * Add implementations for unhandled `HttpApiEndpoint`s in a `Handlers` group. + * + * **Details** + * + * Each entry is either the handler itself or an object with `handler` and + * `options`, accepting the same options as `handle`. */ handleAll>>( handlers: @@ -358,6 +373,14 @@ export interface Handlers< /** * Add the implementation for an unhandled `HttpApiEndpoint` to a `Handlers` group. * This version opts out of automatic payload decoding and provides the raw request. + * + * **Details** + * + * Pass `parseOptions` to control how the request parts that are still decoded + * automatically (`params`, `headers`, and `query`) are decoded with their + * schemas. The payload is left untouched, so parsing it stays the handler's + * responsibility. The `uninterruptible` option controls whether the handler + * fiber is uninterruptible. */ handleRaw< Identifier extends keyof EndpointsByIdentifier, @@ -369,7 +392,7 @@ export interface Handlers< HttpApiEndpoint.MiddlewareError, R1 >, - options?: { readonly uninterruptible?: boolean | undefined } | undefined + options?: HandlerOptions | undefined ): Handlers< R | HandlerRequirements, EndpointsByIdentifier, @@ -481,6 +504,11 @@ type EndpointReturn< * Builds the server-side HTTP effect for a single endpoint in an API group using * the endpoint metadata, middleware, codecs, and supplied handler. * + * **Details** + * + * Pass `parseOptions` to control how the request parts (`params`, `headers`, + * `query`, and `payload`) are decoded with their schemas. + * * @category handlers * @since 4.0.0 */ @@ -503,7 +531,8 @@ export const endpoint = < never, R > - > + >, + options?: { readonly parseOptions?: SchemaAST.ParseOptions | undefined } | undefined ): EndpointReturn => Effect.contextWith((context: Context.Context) => { const group = api.groups[groupIdentifier] as unknown as HttpApiGroup.Top @@ -513,7 +542,8 @@ export const endpoint = < endpoint, Context.omit(Scope.Scope)(context), handler as any, - false + false, + options?.parseOptions )) }) @@ -653,7 +683,8 @@ const registerHandler = ( endpoint, handler, isRaw, - uninterruptible: options?.uninterruptible ?? false + uninterruptible: options?.uninterruptible ?? false, + parseOptions: options?.parseOptions }) return self } @@ -667,7 +698,7 @@ const HandlersProto = { this: Handlers, identifier: string, handler: HttpApiEndpoint.Handler, - options?: { readonly uninterruptible?: boolean | undefined } | undefined + options?: HandlerOptions | undefined ) { return registerHandler(this, identifier, handler, false, options) }, @@ -692,7 +723,7 @@ const HandlersProto = { this: Handlers, identifier: string, handler: HttpApiEndpoint.Handler, - options?: { readonly uninterruptible?: boolean | undefined } | undefined + options?: HandlerOptions | undefined ) { return registerHandler(this, identifier, handler, true, options) } @@ -721,11 +752,12 @@ type PayloadDecoder = } function buildPayloadDecoders( - payloadMap: HttpApiEndpoint.PayloadMap + payloadMap: HttpApiEndpoint.PayloadMap, + parseOptions: SchemaAST.ParseOptions | undefined ): Map { const result = new Map() payloadMap.forEach(({ encoding, schemas }, contentType) => { - const decode = Schema.decodeUnknownEffect(Schema.Union(schemas)) + const decode = Schema.decodeUnknownEffect(Schema.Union(schemas), parseOptions) if (encoding._tag === "Multipart") { result.set(contentType, { _tag: "Multipart", mode: encoding.mode, limits: encoding.limits, decode }) } else { @@ -804,21 +836,28 @@ function handlerToHttpEffect( endpoint: HttpApiEndpoint.Top, context: Context.Context, handler: HttpApiEndpoint.Handler, - isRaw: boolean + isRaw: boolean, + parseOptions: SchemaAST.ParseOptions | undefined ) { const encodeSuccess = Schema.encodeUnknownEffect(makeSuccessSchema(endpoint)) const encodeError = Schema.encodeUnknownEffect(makeErrorSchema(endpoint)) - const decodeParams = UndefinedOr.map(endpoint.params, Schema.decodeUnknownEffect) - const decodeHeaders = UndefinedOr.map(endpoint.headers, Schema.decodeUnknownEffect) + const decodeParams = UndefinedOr.map( + endpoint.params, + (schema) => Schema.decodeUnknownEffect(schema, parseOptions) + ) + const decodeHeaders = UndefinedOr.map( + endpoint.headers, + (schema) => Schema.decodeUnknownEffect(schema, parseOptions) + ) const decodeQuery = UndefinedOr.map( endpoint.query, - (schema) => Schema.decodeUnknownEffect(Schema.toCodecArrayFromSingle(schema)) + (schema) => Schema.decodeUnknownEffect(Schema.toCodecArrayFromSingle(schema), parseOptions) ) const encodeStream = makeStreamEncoder(endpoint) const encodeWithHeaders = makeWithHeadersEncoder(endpoint) const shouldParsePayload = endpoint.payload.size > 0 && !isRaw - const payloadBy = shouldParsePayload ? buildPayloadDecoders(endpoint.payload) : undefined + const payloadBy = shouldParsePayload ? buildPayloadDecoders(endpoint.payload, parseOptions) : undefined return applyMiddleware( group, @@ -894,7 +933,14 @@ export function handlerToRoute( return HttpRouter.route( endpoint.method, HttpApiPath.toRouterPath(endpoint.path, endpoint.params) as HttpRouter.PathInput, - handlerToHttpEffect(group, endpoint, context, handler.handler, handler.isRaw), + handlerToHttpEffect( + group, + endpoint, + context, + handler.handler, + handler.isRaw, + handler.parseOptions + ), { uninterruptible: handler.uninterruptible } ) } diff --git a/packages/effect/src/unstable/httpapi/HttpApiTest.ts b/packages/effect/src/unstable/httpapi/HttpApiTest.ts index c8f507b35fd..299cf852e57 100644 --- a/packages/effect/src/unstable/httpapi/HttpApiTest.ts +++ b/packages/effect/src/unstable/httpapi/HttpApiTest.ts @@ -78,7 +78,8 @@ export const groups = Effect.fnUntraced(function*< endpoint: endpoint as any, handler: () => Effect.die(new Error(`Unhandled endpoint: ${endpointIdentifier}`)), isRaw: false, - uninterruptible: false + uninterruptible: false, + parseOptions: undefined } handlers.set(endpointIdentifier, handler) routes.push(HttpApiBuilder.handlerToRoute(group as any, handler, context)) diff --git a/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts index 54f327534c1..3e32e6aa143 100644 --- a/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts +++ b/packages/effect/test/unstable/httpapi/HttpApiBuilder.test.ts @@ -11,11 +11,13 @@ import { SchemaTransformation, Stream } from "effect" -import { Etag, HttpEffect, HttpPlatform, HttpServerResponse } from "effect/unstable/http" +import type * as SchemaAST from "effect/SchemaAST" +import { Etag, HttpEffect, HttpPlatform, HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http" import { HttpApi, HttpApiBuilder, HttpApiEndpoint, + HttpApiError, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, @@ -1556,3 +1558,209 @@ it.layer(TestServices)("HttpApiBuilder middleware error responses", (it) => { assert.deepStrictEqual(error, { retryAfter: 30n }) })) }) + +it.layer(TestServices)("HttpApiBuilder request parse options", (it) => { + const Payload = Schema.Struct({ + firstName: Schema.String, + lastName: Schema.String + }) + const Query = Schema.Struct({ + firstName: Schema.String, + lastName: Schema.String + }) + + // Captures the decoding error message the way user middleware would, mirroring + // the reproduction in https://github.com/Effect-TS/effect/issues/8266. + const makeInspector = () => { + const messages: Array = [] + + class Inspect extends HttpApiMiddleware.Service()("Inspect") {} + + const layer = Layer.succeed(Inspect)((effect) => + effect.pipe( + Effect.tapError((error) => + HttpApiError.HttpApiSchemaError.is(error) + ? Effect.sync(() => { + messages.push(error.cause.message) + }) + : Effect.void + ) + ) + ) + + return { Inspect, layer, messages } + } + + const serve = ( + appLayer: Layer.Layer, + request: Request + ) => + Effect.gen(function*() { + const { dispose, handler } = HttpRouter.toWebHandler(appLayer, { disableLogger: true }) + yield* Effect.addFinalizer(() => Effect.promise(() => dispose())) + return yield* Effect.promise(() => handler(request)) + }) + + const postEmptyPayload = () => + new Request("http://localhost/users", { + method: "POST", + headers: { "content-type": "application/json" }, + body: "{}" + }) + + const payloadApp = (parseOptions?: SchemaAST.ParseOptions) => { + const { Inspect, layer, messages } = makeInspector() + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("users").add( + HttpApiEndpoint.post("create", "/users", { payload: Payload }).middleware(Inspect) + ) + ) + + return { + appLayer: HttpApiBuilder.layer(Api).pipe( + Layer.provide( + HttpApiBuilder.group( + Api, + "users", + (handlers) => handlers.handle("create", () => Effect.void, { parseOptions }) + ) + ), + Layer.provide(layer), + Layer.provide(HttpServer.layerServices) + ), + messages + } + } + + it.effect("reports only the first payload error by default", () => + Effect.gen(function*() { + const { appLayer, messages } = payloadApp() + const response = yield* serve(appLayer, postEmptyPayload()) + + assert.strictEqual(response.status, 400) + assert.strictEqual(messages.length, 1) + assert.ok(messages[0].includes("firstName")) + assert.ok(!messages[0].includes("lastName")) + })) + + it.effect("collects every payload error when errors is all", () => + Effect.gen(function*() { + const { appLayer, messages } = payloadApp({ errors: "all" }) + const response = yield* serve(appLayer, postEmptyPayload()) + + assert.strictEqual(response.status, 400) + assert.strictEqual(messages.length, 1) + assert.ok(messages[0].includes("firstName")) + assert.ok(messages[0].includes("lastName")) + })) + + it.effect("applies parse options to query decoding", () => + Effect.gen(function*() { + const makeQueryApp = (parseOptions?: SchemaAST.ParseOptions) => { + const { Inspect, layer, messages } = makeInspector() + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("users").add( + HttpApiEndpoint.get("find", "/users", { query: Query }).middleware(Inspect) + ) + ) + + return { + appLayer: HttpApiBuilder.layer(Api).pipe( + Layer.provide( + HttpApiBuilder.group( + Api, + "users", + (handlers) => handlers.handle("find", () => Effect.void, { parseOptions }) + ) + ), + Layer.provide(layer), + Layer.provide(HttpServer.layerServices) + ), + messages + } + } + + const defaultApp = makeQueryApp() + assert.strictEqual( + (yield* serve(defaultApp.appLayer, new Request("http://localhost/users"))).status, + 400 + ) + assert.strictEqual(defaultApp.messages.length, 1) + assert.ok(!defaultApp.messages[0].includes("lastName")) + + const allApp = makeQueryApp({ errors: "all" }) + assert.strictEqual( + (yield* serve(allApp.appLayer, new Request("http://localhost/users"))).status, + 400 + ) + assert.strictEqual(allApp.messages.length, 1) + assert.ok(allApp.messages[0].includes("firstName")) + assert.ok(allApp.messages[0].includes("lastName")) + })) + + it.effect("does not decode the payload for handleRaw", () => + Effect.gen(function*() { + const { Inspect, layer } = makeInspector() + // `handleRaw` opts out of payload decoding, so a payload that cannot be + // decoded must still reach the handler untouched. + const state: { sawPayloadKey: boolean } = { sawPayloadKey: true } + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("users").add( + HttpApiEndpoint.post("create", "/users", { payload: Payload }).middleware(Inspect) + ) + ) + const appLayer = HttpApiBuilder.layer(Api).pipe( + Layer.provide( + HttpApiBuilder.group( + Api, + "users", + (handlers) => + handlers.handleRaw("create", (request) => + Effect.sync(() => { + state.sawPayloadKey = "payload" in request + return undefined as any + })) + ) + ), + Layer.provide(layer), + Layer.provide(HttpServer.layerServices) + ) + + yield* serve(appLayer, postEmptyPayload()) + + assert.isFalse(state.sawPayloadKey) + })) + + it.effect("applies parse options through HttpApiBuilder.endpoint", () => + Effect.gen(function*() { + const { Inspect, layer, messages } = makeInspector() + const Api = HttpApi.make("Api").add( + HttpApiGroup.make("users").add( + HttpApiEndpoint.post("create", "/users", { payload: Payload }).middleware(Inspect) + ) + ) + + const appLayer = Layer.unwrap( + Effect.gen(function*() { + const route = yield* HttpApiBuilder.endpoint( + Api, + "users", + "create", + () => Effect.void, + { parseOptions: { errors: "all" } } + ) + return HttpRouter.add("POST", "/users", route) + }) + ).pipe( + Layer.provide(layer), + Layer.provide(HttpServer.layerServices) + ) + + const response = yield* serve(appLayer, postEmptyPayload()) + + assert.strictEqual(response.status, 400) + assert.strictEqual(messages.length, 1) + assert.ok(messages[0].includes("firstName")) + assert.ok(messages[0].includes("lastName")) + })) +})