sukovanej / effect-http

Declarative HTTP API library for effect-ts
https://sukovanej.github.io/effect-http
MIT License
244 stars 20 forks source link

Cannot implement 302 redirect #614

Closed amosbastian closed 1 month ago

amosbastian commented 1 month ago

Probably me doing something dumb, but couldn't find an example anywhere or anything in the documentation.

How would I implement the equivalent of something like this

return new Response(null, {
  status: 302,
  headers: {
    Location: "/",
    "Set-Cookie": "cookie=abc"
  }
});

with effect-http? I tried doing something like this (just an example):

export const endpoint = Api.post("endpoint", "/endpoint").pipe(
  Api.setRequestBody(Body),
  Api.setResponseStatus(302),
  Api.setResponseHeaders(
    S.Struct({
      "set-cookie": S.String,
      location: S.String,
    }),
  ),
);

const endpointHandler = Handler.make(endpoint, ({ body }) =>
  Effect.gen(function* () {
    return {
      status: 302 as const,
      headers: {
        "set-cookie": "session=123; Path=/; HttpOnly",
        location: "/",
      },
    };
  }).pipe(Effect.withSpan("endpoint")),
);

but this gives these TypeScript errors

No overload matches this call.
  Overload 1 of 2, '(fn: Function<Any, unknown, unknown>, options?: Partial<Options> | undefined): (endpoint: Any) => Handler<Any, unknown, unknown>', gave the following error.
    Argument of type 'ApiEndpoint<"endpoint", ApiRequest<Body, Ignored, Ignored, Ignored, never>, ApiResponse<302, Ignored, { readonly "set-cookie": string; readonly location: string; }, never>, Security<...>>' is not assignable to parameter of type 'Function<Any, unknown, unknown>'.
      Type 'ApiEndpoint<"endpoint", ApiRequest<Body, Ignored, Ignored, Ignored, never>, ApiResponse<302, Ignored, { readonly "set-cookie": string; readonly location: string; }, never>, Security<...>>' provides no match for the signature '(request: { readonly body: any; readonly path: any; readonly query: any; readonly headers: any; }, security: any): Effect<never, unknown, unknown>'.
  Overload 2 of 2, '(endpoint: ApiEndpoint<"endpoint", ApiRequest<Body, Ignored, Ignored, Ignored, never>, ApiResponse<302, Ignored, { readonly "set-cookie": string; readonly location: string; }, never>, Security<...>>, fn: Function<...>, options?: Partial<...> | undefined): Handler<...>', gave the following error.
  Type 'Effect<{ headers: { "set-cookie": string; location: string; }; status: 302; }, SqlError, PgDrizzle>' is not assignable to type 'Effect<never, SqlError, PgDrizzle>' with 'exactOptionalPropertyTypes: true'. Consider adding 'undefined' to the types of the target's properties.
      Type '{ headers: { "set-cookie": string; location: string; }; status: 302; }' is not assignable to type 'never'.ts(2769)

This code was pointed out to me:

const handleUnsucessful = Unify.unify(
  (response: HttpClientResponse.HttpClientResponse) => {
    if (response.status >= 300) {
      return response.json.pipe(
        Effect.orElse(() => response.text),
        Effect.orElseSucceed(() => "No body provided"),
        Effect.flatMap((error) => Effect.fail(ClientError.makeServerSide(error, response.status)))
      )
    }

    return Effect.void
  }
)

and doing

return yield* Effect.fail({
  status: 302 as const,
  headers: {
    "set-cookie": "session=123; Path=/; HttpOnly",
    location: "/",
  },
});

does make the error go away, but then when actually sending a POST request to that endpoint it doesn't work

sukovanej commented 1 month ago

This use-case was indeed purely covered, you'd need to implement it using a RouterBuilder.handleRaw. I just released an update that adds the missing errors and redirections to the HttpError module. And it allows to specify headers and cookies when creating an error. So with the new effect-http version you'll be able to implement it like this.

Be aware that effect-http works with non-2xx responses using the effect error channel, so you need to use Effect.fail(<error>).

import { Cookies, Headers } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Schema } from "@effect/schema"
import { Effect } from "effect"
import { Api, Handler, HttpError, RouterBuilder } from "effect-http"
import { NodeServer } from "effect-http-node"

export const endpoint = Api.post("endpoint", "/endpoint").pipe(
  Api.setResponseStatus(302),
  Api.setResponseHeaders(
    Schema.Struct({
      "set-cookie": Schema.String,
      location: Schema.String
    })
  )
)

const api = Api.make().pipe(
  Api.addEndpoint(endpoint)
)

const endpointHandler = Handler.make(endpoint, () =>
  Effect.fail(HttpError.found(undefined, {
    headers: Headers.fromInput({ location: "/" }),
    cookies: Cookies.fromSetCookie("session=123; Path=/; HttpOnly")
  })))

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle(endpointHandler),
  RouterBuilder.build
)

app.pipe(NodeServer.listen({ port: 3000 }), NodeRuntime.runMain)

Also, I added a Handler.makeRaw which allows to implement the endpoint using an effect as if you were implementing a @effect/platform router.

import { HttpServerResponse } from "@effect/platform"

const endpointHandler = Handler.makeRaw(
  endpoint,
  HttpServerResponse.empty({
    status: 302,
    headers: Headers.fromInput({ location: "/" }),
    cookies: Cookies.fromSetCookie("session=123; Path=/; HttpOnly")
  })
)
amosbastian commented 1 month ago

@sukovanej the above examples seem to get rid of the error, but the actual status code that is returned is still 404 and not 302 and it is not returning the headers correctly.

sukovanej commented 1 month ago

The exact code snippet I sent works for me and curl -X POST localhost:3000/endpoint -v returns

< HTTP/1.1 302 Found
< location: /
< set-cookie: session=123; Path=/; HttpOnly

Are you calling it using a javascript? Fetch does an automatic redirect by default and if you don't have a / endpoint the server will return 404. See https://github.com/sukovanej/effect-http/blob/master/packages/effect-http-node/test/handler.test.ts#L343 how to disable the automatic redirect.

amosbastian commented 1 month ago

The exact code snippet I sent works for me and curl -X POST localhost:3000/endpoint -v returns

< HTTP/1.1 302 Found
< location: /
< set-cookie: session=123; Path=/; HttpOnly

Are you calling it using a javascript? Fetch does an automatic redirect by default and if you don't have a / endpoint the server will return 404. See https://github.com/sukovanej/effect-http/blob/master/packages/effect-http-node/test/handler.test.ts#L343 how to disable the automatic redirect.

I see. I was trying with an effect-http client as well as Postman, and this fixed it for the effect-http client, thanks! 👍