sukovanej / effect-http

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

Optional API routes #488

Closed jrovira-kumori closed 3 months ago

jrovira-kumori commented 3 months ago

I am opening this issue to ask if it is possible to register endpoints conditionally. I have been unsuccessfully looking around for a way to do this and I am beginning to think it is not possible.

Referenced code can be found @ jrovira-kumori/http-effect-optional-endpoint.

Attempt 1

Trying to add a ternary condition when defining the API with process.env.SOME_FEATURE_FLAG ? ... : e => e produces a runtime error when running with npm run serve.

Details ```text Error: Operation id optional not found at getRemainingEndpoint (http-effect-repro/node_modules/effect-http/dist/cjs/internal/router-builder.js:77:11) at http-effect-repro/node_modules/effect-http/dist/cjs/internal/router-builder.js:82:20 at pipe (http-effect-repro/node_modules/effect/dist/cjs/Function.js:352:17) at Object. (http-effect-repro/build/index.js:34:34) at Module._compile (node:internal/modules/cjs/loader:1376:14) at Module._extensions..js (node:internal/modules/cjs/loader:1435:10) at Module.load (node:internal/modules/cjs/loader:1207:32) at Module._load (node:internal/modules/cjs/loader:1023:12) at Function.executeUserEntryPoint [as runMain] (node:internal/modules/run_main:135:12) at node:internal/main/run_main_module:28:49 ``` ```ts const api = pipe( Api.make(), Api.addEndpoint(pipe(Api.get("info", "/info"), Api.setResponseBody(S.string))), process.env.SOME_FEATURE_FLAG ? Api.addEndpoint(pipe(Api.get("optional", "/optional"), Api.setResponseBody(S.string))) : e => e, ); const server = pipe( RouterBuilder.make(api), RouterBuilder.handle("info", () => Effect.succeed("info")), RouterBuilder.handle("optional", () => Effect.succeed("optional")), RouterBuilder.build, ) pipe( server, NodeServer.listen({ port: 8080 }), NodeRuntime.runMain ) ```

Attempt 2

Also adding a ternary condition when defining the API with process.env.SOME_FEATURE_FLAG ? ... : e => e produces a compilation error building with npm run build.

Details ```text index.ts:19:5 - error TS2345: Argument of type '(builder: RouterBuilder) => Default' is not assignable to parameter of type '(c: RouterBuilder, Empty>>) => Default<...>'. Types of parameters 'builder' and 'c' are incompatible. Type 'RouterBuilder, Empty>>' is not assignable to type 'RouterBuilder'. Type 'ApiEndpoint<"optional", Default, ApiResponse<200, string, Ignored, never>, Empty>' is not assignable to type 'never'. 19 RouterBuilder.build, ~~~~~~~~~~~~~~~~~~~ index.ts:24:5 - error TS2345: Argument of type '(router: Default) => Effect, Server | Platform | Generator | NodeContext>, SwaggerFiles>>' is not assignable to parameter of type '(a: unknown) => Effect'. Types of parameters 'router' and 'a' are incompatible. Type 'unknown' is not assignable to type 'Default'. 24 NodeServer.listen({ port: 8080 }), ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ``` ```ts const api = pipe( Api.make(), Api.addEndpoint(pipe(Api.get("info", "/info"), Api.setResponseBody(S.string))), process.env.SOME_FEATURE_FLAG ? Api.addEndpoint(pipe(Api.get("optional", "/optional"), Api.setResponseBody(S.string))) : e => e, ); const server = pipe( RouterBuilder.make(api), RouterBuilder.handle("info", () => Effect.succeed("info")), process.env.SOME_FEATURE_FLAG ? RouterBuilder.handle("optional", () => Effect.succeed("optional")) : e => e, RouterBuilder.build, ) pipe( server, NodeServer.listen({ port: 8080 }), NodeRuntime.runMain ) ```
sukovanej commented 3 months ago

Hey, interesting use-case. I don't think this is something we'd like to support because the aim for this lib is to be as type safe as possible. For this use-case with feature flags, I'd create the selected endpoints the usual way and deal with the allowance in the endpoint handlers. That way you can add a documentation to the OpenAPI explaining the endpoint is behind a feature flag and in case the feature flag is off, you can return meaningful info about in the HTTP response.

Tho, if you really need something like this, you can fallback to the raw @effect/platform. Be aware that this way, the /another endpoint won't be part of the Api spec, so while the endpoint will be available it won't be documented in the OpenAPI and it won't be part of generated mock client, example server, client, etc.

import { HttpServer } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Effect, identity } from "effect"
import { Api, RouterBuilder } from "effect-http"
import { NodeServer } from "effect-http-node"

export const api = Api.make().pipe(
  Api.addEndpoint(
    Api.post("test", "/test")
  )
)

const myFlag = false

const addGetAnother = HttpServer.router.get("/another", HttpServer.response.json("hello there"))

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle("test", () => Effect.unit),
  RouterBuilder.mapRouter(myFlag ? addGetAnother : identity),
  RouterBuilder.build
)

app.pipe(NodeServer.listen({ port: 3000 }), NodeRuntime.runMain)
jrovira-kumori commented 3 months ago

I see. Thank you for the quick response!

jrovira-kumori commented 3 months ago

@sukovanej I have been trying to implement a handle function similar to RouterBuilder.handle() so I can handle the case when the flag is not defined without having to modify the original function. Perhaps short-circuiting with a 404 or any other arbitrary behaviour.

It escapes me how to specify the appropriate types for this middleware-like function and I cannot find anything similar in the docs. Any pointers would be greatly appreciated.

sukovanej commented 3 months ago

RouterBuilder.build returns HttpServer.app.Default<R | SwaggerFiles, E> so from the point you build your application you can do whatever you like using the @effect/platform primitives. I'm sorry this is not properly documented. I think the official platform package will have its place in the official effect docs at some point, so I won't probably cover these parts in this project but I'll try to document how the effect platform is used so that the usage of e.g. middlewares is more obvious - note taken 👍 .

Anyway, you can do something like this.

import { HttpServer } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Effect } from "effect"
import { Api, RouterBuilder } from "effect-http"
import { NodeServer } from "effect-http-node"

export const api = Api.make().pipe(
  Api.addEndpoint(Api.post("test", "/test")),
  Api.addEndpoint(Api.get("another", "/another"))
)

const disablePaths = (disabledPaths: ReadonlyArray<string>) =>
  HttpServer.middleware.make((app) =>
    Effect.gen(function*(_) {
      const request = yield* _(HttpServer.request.ServerRequest)

      if (disabledPaths.includes(request.url)) {
        return yield* _(HttpServer.response.text("path disabled", { status: 404 }))
      }

      return yield* _(app)
    })
  )

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle("test", () => Effect.unit),
  RouterBuilder.handle("another", () => Effect.unit),
  RouterBuilder.build,
  disablePaths(["/another"])
)

app.pipe(NodeServer.listen({ port: 3000 }), NodeRuntime.runMain)
jrovira-kumori commented 3 months ago

Thank you very much! I was looking at the @effect/platform docs without success. This will work great.