Code Monkey home page Code Monkey logo

effect-http's Introduction

effect-http

download badge

High-level declarative HTTP library for Effect-TS built on top of @effect/platform.

  • โญ Client derivation. Write the api specification once, get the type-safe client with runtime validation for free.
  • ๐ŸŒˆ OpenAPI derivation. /docs endpoint with OpenAPI UI out of box.
  • ๐Ÿ”‹ Batteries included server implementation. Automatic runtime request and response validation.
  • ๐Ÿ”ฎ Example server derivation. Automatic derivation of example server implementation.
  • ๐Ÿ› Mock client derivation. Test safely against a specified API.

Under development. Please note that currently any release might introduce breaking changes and the internals and the public API are still evolving and changing.

Note

This is an unofficial community package. You might benefit from checking the @effect/platform and @effect/rpc packages as they are the official Effect packages. The effect-http package strongly relies on @effect/platform, and knowledge of it can be beneficial for understanding what the effect-http does under the hood.

Quickstart

Install

  • effect-http - platform-agnostic, this one is enough if you intend to use it in browser only
  • effect-http-node - if you're planning to run a HTTP server on a node
pnpm add effect-http effect-http-node

Note that effect, @effect/platform and @effect/platform-node are requested as peer dependencies. You very probably have them already. If not, install them using

pnpm add effect @effect/platform @effect/platform-node

The @effect/platform-node is needed only for the node version.

Bootstrap a simple API specification.

import { Schema } from "@effect/schema";
import { Api } from "effect-http";

const UserResponse = Schema.Struct({
  name: Schema.String,
  id: pipe(Schema.Number, Schema.int(), Schema.positive())
})
const GetUserQuery = Schema.Struct({ id: Schema.NumberFromString })

const api = pipe(
  Api.make({ title: "Users API" }),
  Api.addEndpoint(
    pipe(
      Api.get("getUser", "/user"),
      Api.setResponseBody(UserResponse),
      Api.setRequestQuery(GetUserQuery)
    )
  )
)

Create the app implementation.

import { Effect, pipe } from "effect";
import { RouterBuilder } from "effect-http";

const app = pipe(
  RouterBuilder.make(api),
  RouterBuilder.handle("getUser", ({ query }) => Effect.succeed({ name: "milan", id: query.id })),
  RouterBuilder.build
)

Now, we can generate an object providing the HTTP client interface using Client.make.

import { Client } from "effect-http";

const client = Client.make(api, { baseUrl: "http://localhost:3000" });

Spawn the server on port 3000,

import { NodeRuntime } from "@effect/platform-node"
import { NodeServer } from "effect-http-node";

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

and call it using the client.

const response = pipe(
  client.getUser({ query: { id: 12 } }),
  Effect.flatMap((user) => Effect.log(`Got ${user.name}, nice!`)),
  Effect.scoped,
);

[Source code]

Also, check the auto-generated OpenAPI UI running on localhost:3000/docs. How awesome is that!

open api ui

Request validation

Each endpoint can declare expectations on the request format. Specifically,

  • body - request body
  • query - query parameters
  • path - path parameters
  • headers - request headers

They are specified in the input schemas object (3rd argument of Api.get, Api.post, ...).

Example

import { Schema } from "@effect/schema";
import { Api } from "effect-http";

const Stuff = Schema.Struct({ value: Schema.Number })
const StuffRequest = Schema.Struct({ field: Schema.Array(Schema.String) })
const StuffQuery = Schema.Struct({ value: Schema.String })
const StuffPath = Schema.Struct({ param: Schema.String })

export const api = Api.make({ title: "My api" }).pipe(
  Api.addEndpoint(
    Api.post("stuff", "/stuff/:param").pipe(
      Api.setRequestBody(StuffRequest),
      Api.setRequestQuery(StuffQuery),
      Api.setRequestPath(StuffPath),
      Api.setResponseBody(Stuff)
    )
  )
)

[Source code]

Optional path parameters

Optional parameter is denoted using a question mark in the path match pattern. In the request param schema, use Schema.optional(<schema>).

In the following example the last :another path parameter can be ommited on the client side.

import { Schema } from "@effect/schema"
import { pipe } from "effect"
import { Api } from "effect-http"

const Stuff = Schema.Struct({ value: Schema.Number })
const StuffParams = Schema.Struct({
  param: Schema.String,
  another: Schema.optional(Schema.String)
})

export const api = pipe(
  Api.make({ title: "My api" }),
  Api.addEndpoint(
    pipe(
      Api.get("stuff", "/stuff/:param/:another?"),
      Api.setResponseBody(Stuff),
      Api.setRequestPath(StuffParams)
    )
  )
)

[Source code]

Headers

Request headers are part of input schemas along with the request body or query parameters. Their schema is specified similarly to query parameters and path parameters, i.e. using a mapping from header names onto their schemas. The example below shows an API with a single endpoint /hello which expects a header X-Client-Id to be present.

import { NodeRuntime } from "@effect/platform-node"
import { Schema } from "@effect/schema"
import { pipe } from "effect"
import { Api, ExampleServer, RouterBuilder } from "effect-http"
import { NodeServer } from "effect-http-node"

const api = Api.make().pipe(
  Api.addEndpoint(
    Api.get("hello", "/hello").pipe(
      Api.setResponseBody(Schema.String),
      Api.setRequestHeaders(Schema.Struct({ "x-client-id": Schema.String }))
    )
  )
)

pipe(
  ExampleServer.make(api),
  RouterBuilder.build,
  NodeServer.listen({ port: 3000 }),
  NodeRuntime.runMain
)

[Source code]

Server implementation deals with the validation the usual way. For example, if we try to call the endpoint without the header we will get the following error response.

{
  "error": "Request validation error",
  "location": "headers",
  "message": "x-client-id is missing"
}

And as usual, the information about headers will be reflected in the generated OpenAPI UI.

example-headers-openapi-ui

Important! Use a lowercase form of header names.

Security

To deal with authentication / authorization, the effect-http exposes the Security module. Security.Security<A, E, R> is a structure capturing information how to document the security mechanism within the OpenAPI and how to parse the incomming server request to produce a value A available for the endpoint handler.

To to secure an endpoint, use the Api.setSecurity combinator. Let's see an example of a secured endpoint using the basic auth.

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

const api = Api.make().pipe(
  Api.addEndpoint(
    Api.post("mySecuredEndpoint", "/my-secured-endpoint").pipe(
      Api.setResponseBody(Schema.String),
      Api.setSecurity(Security.basic())
    )
  )
)

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle(
    "mySecuredEndpoint",
    (_, security) => Effect.succeed(`Accessed as ${security.user}`)
  ),
  RouterBuilder.build
)

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

[Source code]

In the example, we use the Security.basic() constructor which produces a new security of type Security<BasicCredentials, never, never>. In the second argument of our handler function, we receive the value of BasicCredentials if the request contains a valid authorization header with the basic auth credentials.

In case the request doesn't include valid authorization, the client will get a 401 Unauthorized response with a JSON body containing the error message.

Optional security

Implementation-wise, the Security<A, E, R> contains an Effect<A, E | HttpError, R | ServerRequest>. Therefore, we can combine multiple security mechanisms similarly as if we were combining effects.

For instance, we could make the authentication optional using the Security.or combinator.

const mySecurity = Security.or(
  Security.asSome(Security.basic()),
  Security.as(Security.unit, Option.none())
)

[Source code]

The Security.asSome, Security.as and Security.unit behave the same way as their Effect counterparts.

Constructing more complex security

The following example show-cases how to construct a security mechanism that validates the basic auth credentials and then fetches the user information from the UserStorage service.

import { Effect, Layer, pipe } from "effect"
import { Security } from "effect-http"

interface UserInfo {
  email: string
}

class UserStorage extends Effect.Tag("UserStorage")<
  UserStorage,
  { getInfo: (user: string) => Effect.Effect<UserInfo> }
>() {
  static dummy = Layer.succeed(
    UserStorage,
    UserStorage.of({
      getInfo: (_: string) => Effect.succeed({ email: "[email protected]" })
    })
  )
}

const mySecurity = pipe(
  Security.basic({ description: "My basic auth" }),
  Security.map((creds) => creds.user),
  Security.mapEffect((user) => UserStorage.getInfo(user))
)

In the handler implementation, we obtain the security argument typed as UserInfo.

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle(
    "endpoint",
    (_, security) => Effect.succeed(`Logged as ${security.email}`)
  ),
  RouterBuilder.build,
  Middlewares.errorLog
)

And finally, because we made use of the effect context, we are forced to provide the UserStorage when running the server.

app.pipe(
  NodeServer.listen({ port: 3000 }),
  Effect.provide(UserStorage.dummy),
  NodeRuntime.runMain
)

[Source code]

Security on the client side

Each endpoint method accepts an optional second argument of type (request: ClientRequest) => ClientRequest used to map internally produced HttpClient.request.ClientRequest. We can provide the header mapping to set the appropriate header. Additionally, the Client module exposes Client.setBasic and Client.setBearer combinators that produce setter functions configuring the Authorization header.

import { Client } from 'effect-http';

const client = Client.make(api)

client.endpoint({}, Client.setBasic("user", "pass"))

Custom security

A primitive security is constructed using Security.make function.

It accepts a handler effect which is expected to access the ServerRequest and possibly fail with a HttpError.

If we want to document the authorization mechanism in the OpenAPI, we must also provide the second argument of the Security.make which is a mapping of the auth identifier and actual security scheme spec.

Here is an example of a security validating a X-API-KEY header.

import { HttpServer } from "@effect/platform"
import { Schema } from "@effect/schema"
import { Effect, pipe } from "effect"
import { Security, HttpError } from "effect-http"

const customSecurity = Security.make(
  pipe(
    HttpServer.request.schemaHeaders(Schema.Struct({ "x-api-key": Schema.String })),
    Effect.mapError(() => HttpError.unauthorizedError("Expected valid X-API-KEY header")),
    Effect.map((headers) => headers["x-api-key"])
  ),
  { "myApiKey": { name: "x-api-key", type: "apiKey", in: "header", description: "My API key" } }
)

[Source code]

If the client doesn't provide the X-API-KEY header, the server will respond with 401 Unauthorized status and the given message.

Note

In this particular case, you can use Security.apiKey({ key: "X-API-KEY", in: "header" }) instead of a custom security.

Responses

Every new endpoint has default response with status code 200 with ignored response and headers.

If you want to customize the default response, use the Api.setResponseStatus, Api.setResponseBody or Api.setResponseHeaders combinators. The following example shows how to enforce (both for types and runtime) that returned status, body and headers conform the specified response.

import { Schema } from "@effect/schema"
import { pipe } from "effect"
import { Api } from "effect-http"

const api = pipe(
  Api.make(),
  Api.addEndpoint(
    pipe(
      Api.get("hello", "/hello"),
      Api.setResponseStatus(201),
      Api.setResponseBody(Schema.Number),
      Api.setResponseHeaders(Schema.Struct({ "x-hello-world": Schema.String }))
    )
  )
)

[Source code]

It is also possible to specify multiple response schemas. Use the Api.addResponse combinator to another possible response of an endpoint. The Api.addResponse accepts either an ApiResponse object created using ApiResponse.make or a plain object of form { status; headers; body}.

import { Schema } from "@effect/schema"
import { Effect, pipe } from "effect"
import { Api, ApiResponse, RouterBuilder } from "effect-http"

const helloEndpoint = Api.post("hello", "/hello").pipe(
  Api.setResponseBody(Schema.Number),
  Api.setResponseHeaders(Schema.Struct({
    "my-header": pipe(
      Schema.NumberFromString,
      Schema.annotations({ description: "My header" })
    )
  })),
  Api.addResponse(ApiResponse.make(201, Schema.Number)),
  Api.addResponse({ status: 204, headers: Schema.Struct({ "x-another": Schema.NumberFromString }) })
)

const api = pipe(
  Api.make(),
  Api.addEndpoint(helloEndpoint)
)

The server implemention is type-checked against the api responses and one of the specified response objects must be returned.

Note: the status needs to be as const because without it Typescript will infere the number type.

import { Effect, pipe } from "effect"
import { Api, RouterBuilder } from "effect-http"

const app = pipe(
  RouterBuilder.make(api),
  RouterBuilder.handle("hello", () => Effect.succeed({ body: 12, headers: { "my-header": 69 }, status: 201 as const })),
  RouterBuilder.build
)

Testing the server

You need to install effect-http-node.

While most of your tests should focus on the functionality independent of HTTP exposure, it can be beneficial to perform integration or contract tests for your endpoints. The NodeTesting module offers a NodeTesting.make combinator that generates a testing client from the Server. This derived testing client has a similar interface to the one derived by Client.make.

Now, let's write an example test for the following server.

const api = Api.api().pipe(
  Api.get("hello", "/hello", {
    response: Schema.String,
  }),
);

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle("hello", ({ query }) =>
    Effect.succeed(`${query.input + 1}`),
  ),
  RouterBUilder.build,
);

The test might look as follows.

import { NodeTesting } from 'effect-http-node';

test("test /hello endpoint", async () => {
  const response = await NodeTesting.make(app, api).pipe(
    Effect.flatMap((client) => client.hello({ query: { input: 12 } })),
    Effect.scoped,
    Effect.runPromise,
  );

  expect(response).toEqual("13");
});

In comparison to the Client we need to run our endpoint handlers in place. Therefore, in case your server uses DI services, you need to provide them in the test code. This contract is type safe and you'll be notified by the type-checker if the Effect isn't invoked with all the required services.

Error handling

Validation of query parameters, path parameters, body and even responses is handled for you out of box. By default, failed validation will be reported to clients in the response body. On the server side, you get warn logs with the same information.

Reporting errors in handlers

On top of the automatic input and output validation, handlers can fail for variety of different reasons.

Suppose we're creating user management API. When persisting a new user, we want to guarantee we don't attempt to persist a user with an already taken name. If the user name check fails, the API should return 409 CONFLICT error because the client is attempting to trigger an operatin conflicting with the current state of the server. For these cases, effect-http provides error types and corresponding creational functions we can use in the error rail of the handler effect.

4xx
  • 400 HttpError.badRequest - client make an invalid request
  • 401 HttpError.unauthorizedError - invalid authentication credentials
  • 403 HttpError.forbiddenError - authorization failure
  • 404 HttpError.notFoundError - cannot find the requested resource
  • 409 HttpError.conflictError - request conflicts with the current state of the server
  • 415 HttpError.unsupportedMediaTypeError - unsupported payload format
  • 429 HttpError.tooManyRequestsError - the user has sent too many requests in a given amount of time
5xx
  • 500 HttpError.internalHttpError - internal server error
  • 501 HttpError.notImplementedError - functionality to fulfill the request is not supported
  • 502 HttpError.badGatewayError - invalid response from the upstream server
  • 503 HttpError.serviceunavailableError - server is not ready to handle the request
  • 504 HttpError.gatewayTimeoutError - request timeout from the upstream server

Example API with conflict API error

Let's see it in action and implement the mentioned user management API. The API will look as follows.

import { Schema } from "@effect/schema";
import { Context, Effect, pipe } from "effect";
import { Api, RouterBuilder, HttpError } from "effect-http";
import { NodeServer } from "effect-http-node";

const api = pipe(
  Api.make({ title: "Users API" }),
  Api.addEndpoint(
    Api.post("storeUser", "/users").pipe(
      Api.setResponseBody(Schema.String),
      Api.setRequestBody(Schema.Struct({ name: Schema.String }))
    )
  )
)

Now, let's implement a UserRepository interface abstracting the interaction with our user storage. I'm also providing a mock implementation which will always return the user already exists. We will plug the mock user repository into our server so we can see the failure behavior.

interface UserRepository {
  userExistsByName: (name: string) => Effect.Effect<boolean>;
  storeUser: (user: string) => Effect.Effect<void>;
}

const UserRepository = Context.GenericTag<UserRepository>("UserRepository");

const mockUserRepository = UserRepository.of({
  userExistsByName: () => Effect.succeed(true),
  storeUser: () => Effect.unit,
});

const { userExistsByName, storeUser } = Effect.serviceFunctions(UserRepository);

And finally, we have the actual App implementation.

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle("storeUser", ({ body }) =>
    pipe(
      userExistsByName(body.name),
      Effect.filterOrFail(
        (alreadyExists) => !alreadyExists,
        () => HttpError.conflictError(`User "${body.name}" already exists.`),
      ),
      Effect.andThen(storeUser(body.name)),
      Effect.map(() => `User "${body.name}" stored.`),
    )),
  RouterBuilder.build,
);

To run the server, we will start the server using NodeServer.listen and provide the mockUserRepository service.

app.pipe(
  NodeServer.listen({ port: 3000 }),
  Effect.provideService(UserRepository, mockUserRepository),
  NodeRuntime.runMain
);

[Source code]

Try to run the server and call the POST /user.

Server

$ pnpm tsx examples/conflict-error-example.ts

22:06:00 (Fiber #0) DEBUG Static swagger UI files loaded (1.7MB)
22:06:00 (Fiber #0) INFO  Listening on :::3000
22:06:01 (Fiber #8) WARN  POST /users client error 409

Client (using httpie cli)

$ http localhost:3000/users name="patrik"

HTTP/1.1 409 Conflict
Content-Length: 68
Content-Type: application/json; charset=utf-8

User "patrik" already exists.

Grouping endpoints

To create a new group of endpoints, use ApiGroup.apiGroup("group name"). This combinator initializes new ApiGroup object. You can pipe it with combinators like ApiGroup.addEndpoint, followed by ApiGroup.get, Api.post, etc, as if were defining the Api. Api groups can be combined into an Api using a Api.addGroup combinator which merges endpoints from the group into the api in the type-safe manner while preserving group names for each endpoint.

This enables separability of concers for big APIs and provides information for generation of tags for the OpenAPI specification.

import { NodeRuntime } from "@effect/platform-node"
import { Schema } from "@effect/schema"
import { Effect, pipe } from "effect"
import { Api, ApiGroup, ExampleServer, RouterBuilder } from "effect-http"

import { NodeServer } from "effect-http-node"

const Response = Schema.Struct({ name: Schema.String })

const testApi = pipe(
  ApiGroup.make("test", {
    description: "Test description",
    externalDocs: {
      description: "Test external doc",
      url: "https://www.google.com/search?q=effect-http"
    }
  }),
  ApiGroup.addEndpoint(
    ApiGroup.get("test", "/test").pipe(Api.setResponseBody(Response))
  )
)

const userApi = pipe(
  ApiGroup.make("Users", {
    description: "All about users",
    externalDocs: {
      url: "https://www.google.com/search?q=effect-http"
    }
  }),
  ApiGroup.addEndpoint(
    ApiGroup.get("getUser", "/user").pipe(Api.setResponseBody(Response))
  ),
  ApiGroup.addEndpoint(
    ApiGroup.post("storeUser", "/user").pipe(Api.setResponseBody(Response))
  ),
  ApiGroup.addEndpoint(
    ApiGroup.put("updateUser", "/user").pipe(Api.setResponseBody(Response))
  ),
  ApiGroup.addEndpoint(
    ApiGroup.delete("deleteUser", "/user").pipe(Api.setResponseBody(Response))
  )
)

const categoriesApi = ApiGroup.make("Categories").pipe(
  ApiGroup.addEndpoint(
    ApiGroup.get("getCategory", "/category").pipe(Api.setResponseBody(Response))
  ),
  ApiGroup.addEndpoint(
    ApiGroup.post("storeCategory", "/category").pipe(Api.setResponseBody(Response))
  ),
  ApiGroup.addEndpoint(
    ApiGroup.put("updateCategory", "/category").pipe(Api.setResponseBody(Response))
  ),
  ApiGroup.addEndpoint(
    ApiGroup.delete("deleteCategory", "/category").pipe(Api.setResponseBody(Response))
  )
)

const api = Api.make().pipe(
  Api.addGroup(testApi),
  Api.addGroup(userApi),
  Api.addGroup(categoriesApi)
)

ExampleServer.make(api).pipe(
  RouterBuilder.build,
  NodeServer.listen({ port: 3000 }),
  NodeRuntime.runMain
)

[Source code]

The OpenAPI UI will group endpoints according to the api and show corresponding titles for each group.

example-generated-open-api-ui

Descriptions in OpenApi

The schema-openapi library which is used for OpenApi derivation from the Schema takes into account description annotations and propagates them into the specification.

Some descriptions are provided from the built-in @effect/schema/Schema combinators. For example, the usage of Schema.Int.pipe(Schema.positive()) will result in "a positive number" description in the OpenApi schema. One can also add custom description using Schema.annotations({ description: ... }).

On top of types descriptions which are included in the schema field, effect-http also checks top-level schema descriptions and uses them for the parent object which uses the schema. In the following example, the "User" description for the response schema is used both as the schema description but also for the response itself. The same holds for the id query paremeter.

For an operation-level description, call the API endpoint method (Api.get, Api.post etc) with a 4th argument and set the description field to the desired description.

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

const Response = pipe(
  Schema.Struct({
    name: Schema.String,
    id: pipe(Schema.Number, Schema.int(), Schema.positive())
  }),
  Schema.annotations({ description: "User" })
)
const Query = Schema.Struct({
  id: pipe(Schema.NumberFromString, Schema.annotations({ description: "User id" }))
})

const api = pipe(
  Api.make({ title: "Users API" }),
  Api.addEndpoint(
    Api.get("getUser", "/user", { description: "Returns a User by id" }).pipe(
      Api.setResponseBody(Response),
      Api.setRequestQuery(Query)
    )
  )
)

const app = pipe(
  RouterBuilder.make(api),
  RouterBuilder.handle("getUser", ({ query }) => Effect.succeed({ name: "mike", id: query.id })),
  RouterBuilder.build
)

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

[Source code]

Representations

By default, the effect-http client and server will attempt the serialize/deserialize messages as JSONs. This means that whenever you return something from a handler, the internal logic will serialize it as a JSON onto a string and send the response along with content-type: application/json header.

This behaviour is a result of a default Representation.json. The default representation of the content can be changed using Api.setResponseRepresentations combinator.

For example, the following API specification states that the response of /test endpoint will be always a string represent as a plain text. Therefore, the HTTP message will contain content-type: text/plain header.

export const api2 = Api.make().pipe(
  Api.addEndpoint(
    Api.get("myHandler", "/test").pipe(
      Api.setResponseBody(Schema.String),
      Api.setResponseRepresentations([Representation.plainText])
    )
  )
)

The representations is a list and if it contains multiple possible representations of the data it internal server logic will respect incomming Accept header to decide which representation to use.

The following example uses plainText and json representations. The order of representations is respected by the logic that decides which representation should be used, and if there is no representation matching the incomming Accept media type, it will choose the first representation in the list.

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

export const api = Api.make({ title: "Example API" }).pipe(
  Api.addEndpoint(
    Api.get("root", "/").pipe(
      Api.setResponseBody(Schema.Unknown),
      Api.setResponseRepresentations([Representation.plainText, Representation.json])
    )
  )
)

export const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle("root", () => Effect.succeed({ content: { hello: "world" }, status: 200 as const })),
  RouterBuilder.build
)

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

[Source code]

Try running the server above and call the root path with different Accept headers. You should see the response content-type reflecting the incomming Accept header.

# JSON
curl localhost:3000/ -H 'accept: application/json' -v

# Plain text
curl localhost:3000/ -H 'accept: text/plain' -v

API on the client side

While effect-http is intended to be primarly used on the server-side, i.e. by developers providing the HTTP service, it is possible to use it also to model, use and test against someone else's API. Out of box, you can make us of the following combinators.

  • Client - client for the real integration with the API.
  • MockClient - client for testing against the API interface.
  • ExampleServer - server implementation derivation with example responses.

Example server

effect-http has the ability to generate an example server implementation based on the Api specification. This can be helpful in the following and probably many more cases.

  • You're in a process of designing an API and you want to have something to share with other people and have a discussion over before the actual implementation starts.
  • You develop a fullstack application with frontend first approach you want to test the integration with a backend you haven't implemeted yet.
  • You integrate a 3rd party HTTP API and you want to have an ability to perform integration tests without the need to connect to a real running HTTP service.

Use ExampleServer.make combinator to generate a RouterBuilder from an Api.

import { NodeRuntime } from "@effect/platform-node"
import { Schema } from "@effect/schema"
import { Effect, pipe } from "effect"
import { Api, ExampleServer, RouterBuilder } from "effect-http"
import { NodeServer } from "effect-http-node"

const Response = Schema.Struct({
  name: Schema.String,
  value: Schema.Number
})

const api = pipe(
  Api.make({ servers: ["http://localhost:3000", { description: "hello", url: "/api/" }] }),
  Api.addEndpoint(
    Api.get("test", "/test").pipe(Api.setResponseBody(Response))
  )
)

pipe(
  ExampleServer.make(api),
  RouterBuilder.build,
  NodeServer.listen({ port: 3000 }),
  NodeRuntime.runMain
)

[Source code]

Go to localhost:3000/docs and try calling endpoints. The exposed HTTP service conforms the api and will return only valid example responses.

Mock client

To performed quick tests against the API interface, effect-http has the ability to generate a mock client which will return example or specified responses. Suppose we are integrating a hypothetical API with /get-value endpoint returning a number. We can model such API as follows.

import { Schema } from "@effect/schema";
import { Api } from "effect-http";

const api = Api.make().pipe(
  Api.addEndpoint(
    Api.get("getValue", "/value").pipe(
      Api.setResponseBody(Schema.Number)
    )
  )
)

In a real environment, we will probably use the derived client using Client.make. But for tests, we probably want a dummy client which will return values conforming the API. For such a use-case, we can derive a mock client.

const client = MockClient.make(api);

Calling getValue on the client will perform the same client-side validation as would be done by the real client. But it will return an example response instead of calling the API. It is also possible to enforce the value to be returned in a type-safe manner using the option argument. The following client will always return number 12 when calling the getValue operation.

const client = MockClient.make(api, { responses: { getValue: 12 } });

Scaling up

For bigger applications, you might want to separate the logic of endpoints or groups of endpoints into separate modules. This section shows how to do that. Firstly, it is possible to declare endpoints independently of the Api or ApiGroup the're part of. Suppose we are creating a CMS system with articles, users, categories, etc. The API group responsible for management of articles would schematically look as follows.

import { Api, ApiGroup } from 'effect-http';

export const getArticleEndpoint = Api.get("getArticle", "/article").pipe(
  Api.setResponseBody(Response)
)
export const storeArticleEndpoint = Api.post("storeArticle", "/article").pipe(
  Api.setResponseBody(Response)
)
export const updateArticleEndpoint = Api.put("updateArticle", "/article").pipe(
  Api.setResponseBody(Response)
)
export const deleteArticleEndpoint = Api.delete("deleteArticle", "/article").pipe(
  Api.setResponseBody(Response)
)

export const articleApi = ApiGroup.make("Articles").pipe(
  ApiGroup.addEndpoint(getArticleEndpoint),
  ApiGroup.addEndpoint(storeArticleEndpoint),
  ApiGroup.addEndpoint(updateArticleEndpoint),
  ApiGroup.addEndpoint(deleteArticleEndpoint)
)

Similarly, we'd define the API group for user management, categories and others. Let's combine these groups into our API definition.

export const api = Api.make().pipe(
  Api.addGroup(articleApi),
  Api.addGroup(userApi),
  Api.addGroup(categoryApi),
  // ...
)

Each one of deleteUserEndpoint, storeUserEndpoint, ..., is an object of type ApiEndpoint<Id, Request, Response, Security>. They are a full type and runtime declarations of your endpoints. You can use these objects to implement the handlers for these endpoints. Produced handlers are objects of type Handler<A, E, R> (where A is a description of an endpoint ApiEndpoint<Id, Request, Response, Security>). Handlers are combined into a router using a RouterBuilder. You'd implement the handlers of the article API group as follows.

import { Handler, RouterBuilder } from 'effect-http';
import { api, getArticleEndpoint, storeArticleEndpoint, updateArticleEndpoint, deleteArticleEndpoint } from 'path-to-your-api';

const getArticleHandler = Handler.make(getArticleEndpoint, () => Effect.succeed(...))
const storeArticleHandler = Handler.make(storeArticleEndpoint, () => Effect.succeed(...))
const updateArticleHandler = Handler.make(updateArticleEndpoint, () => Effect.succeed(...))
const deleteArticleHandler = Handler.make(deleteArticleEndpoint, () => Effect.succeed(...))

export const articleRouterBuilder = RouterBuilder.make(api).pipe(
  RouterBuilder.handle(getArticleHandler),
  RouterBuilder.handle(storeArticleHandler),
  RouterBuilder.handle(updateArticleHandler),
  RouterBuilder.handle(deleteArticleHandler),
)

Note

The Handler.make function has both data-first and data-last overloads. If you prefer the pipe style, you can also do the following.

const getArticleHandler = getArticleEndpoint.pipe(
  Handler.make(() => Effect.succeed(...)
)

Finally, you merge all the router builders and build the app.

import { RouterBuilder } from 'effect-http';
import { userRouterBuilder } from 'path-to-your-router-builder';

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.merge(userRouterBuilder),
  RouterBuilder.merge(articleRouterBuilder),
  RouterBuilder.merge(categoryRouterBuilder),
  // ...
  RouterBuilder.build
)

Compatibility

This library is tested against nodejs 21.5.0.

effect-http's People

Contributors

almaju avatar fubhy avatar github-actions[bot] avatar guillempuche avatar khraksmamtsov avatar kightlingerh avatar renovate[bot] avatar ssijak avatar sukovanej avatar vecerek avatar wewelll avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar

effect-http's Issues

Improve type error reporting

When a handler raises a non expected error we get unknown comparison, it should instead mention that the error can't be accepted. I believe this can be done by adapting the signature of the handle function

Screenshot 2024-05-05 at 15 49 05

Docs not loading when using Router.mountApp('/api', server)

It seems like the the hard-coding of /docs as the base path for the swagger router is preventing mounting an application using Router.mountApp('/api', server) from platform. To be more specific, the docs .html loads fine, but all of the assets fail to load at /docs/{filename} when I'd really want for them to load from /api/docs/{filename}

I'm attempting to create a full-stack implementation of the "realworld" application, and the specification requires the API portion to run at /api/*, while the client rendering happens at /*.

I think just adding some kind of base path configuration would be totally sufficient, or maybe there's a way to adjust it with SwaggerFiles already?

browser-only / client-derivation?

Hey there. I love the proposal here, thanks and kudos.

Are there any plans to have a proper browser-only/client-derivation version of this?
It seems to me that the lib depends on @effect/platform (and @effect/platform-node) which forces us to go through hoops and loops to polyfill stream on a vite build.

The documentation is pretty clear that

[...] effect-http is intended to be primarly used on the server-side

but here I am, wishing for a way to use this as a browser http client.

I'll take no for an answer alright; mostly wondering if you have any comments on how hard it would be to extract (or better, isolate) that portion of the functionality.

Cheers.

Array of query parameters

Single query parameter must be treated as single element array for appropriate schema.

export const GetManyUsersRequest = {
  query: Schema.struct({
    id: Schema.array(IdUserSchema)
  }),
};

Return 400 for GET /users?id=123
But 200 for GET /users?id=123&id=456

I have tried Schema<string[], string> for 123 -> [123] but it breaks swagger UI - it renders string input instead of several inputs for array of strings

Expose openapi.json spec

It would be nice to expose /openapi.json endpoint that returns the OpenAPI spec, as then it could be used to create http clients in other languages, or it could be used in postman, etc.

Adding a cli

Having a cli might be handy(I'd be happy to create a PR if there's interest. I don't suspect that it would be too much work):

effect-http.config.ts

import { Router } from "src/router.ts"

export default createConfig({
    router: Router
})

Dump openapi def to file:
npx effect-http codegen openapi -o api.yml

Start test server:
npx effect-http serve -p 8000

Migrate to @effect/platform/Http

It would be awesome if this lib migrated to the @effect/platform html client+server api now that that has seen some progress. :)

Feature Request: Optional Security Scheme

I've been continuing to work on a Realworld example application for Typed. Some endpoints will make slightly different queries to the database given if there is a current user or not. For example, if a current user is available, articles return whether or not the user favorited the article, or if they follow the author of the article.

The one thing I've stumbled across so far is that I can't define a route which has been the ability to specify optional security schemes.

I'm not as familiar with the internal of how everything comes together, but I could kind of see at least a few different APIs that could make sense. I'd love to hear your thoughts as well

Follow a little more closely to the OpenAPI Spec

I think we'd just need to allow the ability for all of the SecurityScheme fields to be optional

export const jwtToken: SecurityScheme<*> = {
  type: "http",
  options: {
    scheme: "bearer"
  },
  schema: ...
}

Api.get(..., ..., ..., { security: {  jwtToken, none: {}  }  })

I suspect staying close to that of the OpenAPI may help with generating the appropriate output, and will probably do better when your endpoint has more than 1 security scheme using an OR style relationship.

Variation

Maybe we be a bit more explicit as well, as it's hard to type an empty object in TS, and would be easier to create a discriminated union type across

Api.get(..., ..., ..., { security: {  jwtToken, none: { type: "none" }  }  })

Extend the current SecuritySchema

In this variant, I'd see adding an optional: boolean flag to the existing SecurityScheme type. I guess I'd intuit that with optional:true, your Schema would be wrapped in something like sort of nullish/optional type.

I don't know this is great when it comes to generation, because I think it'd require de-duplicating multiple optional: true schemes to map to a single "empty" entry in the OpenAPI specification. I'd also assume building and validating that schema would be more costly at runtime as well.

Ancillary thought

I don't have a real use-case for this stuff, but I think that the current definitions of security are more limited than that of OpenAPI itself. As I hinted, hopefully correctly, in the first variant, today we can we can only define OR style relationships between security schemes, but in the specification its also possible to specify AND style relationships. I don't see this as necessary for my own use cases, but I suspect it'll be asked for at some point.

I could see this happening a few different ways in itself, but I feel like just adding the ability for non-empty arrays as values in the security record would open the way up to AND relationships.

It'd all culminate to something like the following

Api.get(........., { security: {  one: [ A, B ], two: [ A, C ], three: D, none: {}  } })

In a case where there is an AND style relationship, I would generally expect the same shape to be available when writing your handler functions

RouterBuilder.handle('opId', ({...}, { one: [aType, bType] }) => ...) 

A slight variation of this would be to accept a record instead of tuples, and I'd expect similar rules to apply in that case as well.

Api.get(........., { security: {  one: {a: A, b: B } , two: { a: A, c: C }, three: D, none: {}  } })

RouterBuilder.handle('opId', ({...}, { one: {a: aType, b: bType } }) => ...) 

Feature Request: Client should return all expected status codes

At present a Client derived from an API loses all type-safety in regards to any non-2xx status codes which are returned directly when making use of the Client's methods.

The way to recover access errors are via ClientError. The server-side ClientError's, which do have a status, are typed as unknown and must be re-validated manually to ensure type-safety on the client-side by hand.

In an ideal world I think these would be strongly typed by default by using the "success" channel of Effect to return all "expected", as defined by your API, instead of only 2xx status codes. This allows for type-safe transformation of all "expected" status codes into domain-specific values and/or failures to correspond with these other expected conditions.

Prior Discussion: #515

Add fn to export openapi spec

It would be great if there was a fn to export the generated OpenApiSpec. The schema-openapi library could then potentially be extended to render it to a yaml string.

esm vs. commonjs

Hello! Thank you for this library.

I am receiving the following typescript error on 0.12.1:

TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("effect-http")' call instead. ย ย To convert this file to an ECMAScript module, change its file extension to '.mts', or add the field "type": "module" to 'package.json'.

I was not seeing this error in past versions.

Is the library no longer commonjs compatible?

[QUESTION]: Adding Groups endpoints and Build Routes in a modular way

Issue Description:

Currently, there's a challenge regarding the modularity and scalability of routes within the presentation layer of the application, particularly when employing DDD (Domain-Driven Design). The goal is to enhance the architecture to better separate concerns and facilitate maintainability and extensibility.

To address this issue, we aim to refactor the code to achieve a more modular approach, allowing for greater flexibility and abstraction in handling routes.

there's a way to build the routes in a way to be modular with a native technique with Effect or there is a bit limitation for now of effect-http to do that?
this is the structure of the folders
๐Ÿ“ฆsrc
โ”ฃ ๐Ÿ“‚data-access
โ”ƒ โ”— ๐Ÿ“œuser.repository.ts
โ”ฃ ๐Ÿ“‚domain
โ”ƒ โ”ฃ ๐Ÿ“‚user
โ”ƒ โ”ƒ โ”— ๐Ÿ“œuser.model.ts
โ”ƒ โ”— ๐Ÿ“œmodels.ts
โ”ฃ ๐Ÿ“‚entry-points
โ”ƒ โ”ฃ ๐Ÿ“œroutes.ts
โ”ƒ โ”— ๐Ÿ“œserver.ts
โ”ฃ ๐Ÿ“‚presentation
โ”ƒ โ”— ๐Ÿ“‚user
โ”ƒ โ”ƒ โ”— ๐Ÿ“œuser.route.ts
โ”ฃ ๐Ÿ“‚test
โ”ƒ โ”— ๐Ÿ“œ.gitkeep
โ”— ๐Ÿ“œenv.ts

in order to be scalable and preserve the modularity i tried this way per example using DDD, having three layers, first the presentation layer, the domain layer and the data-layer, the main issue is in the presentation layer i need to pull up the implementation of the route away from the main pipe to run the effect

src/entry-points/server.ts

import { findAllUsers } from "@api-gateway-app/presentation/user/user.route";
import { NodeRuntime } from "@effect/platform-node";
import { Effect, Layer, LogLevel, Logger, pipe } from "effect";
import { RouterBuilder } from "effect-http";
import { NodeServer } from "effect-http-node";
import { PrettyLogger } from "effect-log";
import { AppRouter } from "./routes";

export const debugLogger = pipe(
  PrettyLogger.layer(),
  Layer.merge(Logger.minimumLogLevel(LogLevel.All))
)

pipe(
  RouterBuilder.make(AppRouter, { docsPath: '/api', parseOptions: { errors: "all" } }),
  RouterBuilder.handle("findAllUsers", ({ query }) => findAllUsers(query)), // ==== i want to move this handle away from 
  // the pipe ( the best i can do for now is this passing the callback with the implementation but isn't the goal )
  RouterBuilder.build,
  Effect.provide(debugLogger),
  NodeServer.listen({ port: 3001 }),
  NodeRuntime.runMain
)

src/entry-points/routes.ts

// this implementation of definition of the AppRouter is Ok because from here we 
// can define from each scope int presentation layer in this case is user but can be 
// added others scopes / collections
import { userApi } from "@api-gateway-app/presentation/user/user.route";
import { Api } from "effect-http";

export const AppRouter = Api.make({
  title: "API Gateway",
  servers: [{ url: "http://localhost:3001" }],
}).pipe(Api.addGroup(userApi));

import { Criteria, getPaginatedResponse } from "@api-gateway-app/domain/models";
import { User } from "@api-gateway-app/domain/user/user.model";
import { pipe } from "effect";
import { Effect } from "effect";
import { Api, ApiGroup, Security  } from "effect-http";

// this is perfect because in the presentation layer we define the interface of the endpoint
export const userApi = pipe(
  ApiGroup.make("Users", {
    description: "All about Users",
  }),
  ApiGroup.addEndpoint(
    ApiGroup.get("findAllUsers", "/api/users").pipe(
      Api.setRequestQuery(Criteria),
      Api.setResponseBody(getPaginatedResponse(User)),
      Api.setSecurity(
        Security.bearer({ name: "mySecurity", description: "test" })
      ),
    )
  )
);

// this is my concern and the ugly part (its working though) that only can
// export the callback of the implementation but no the actual implementation definition, 
// this should be place the RouterBuilder.handle()
export const findAllUsers = (query: Criteria) => Effect.succeed({
  data: [{ name: "mike", id: JSON.stringify(query) }],
  cursor: "x",
  hasMore: false,
});

there's a way to achieve this ? i'm new on Effect and I am very interested in learn and contribute on examples with TDD, DDD and monorepos with Effect

Thank you in advance!

Best regards,

Content-Type header seems to be fixed to application/json

Hello,
I want to set content-type header to text/plain in a response to a get request. I tried your example but without Schema.struct it does not compile. The code below compiles but the response has still application/json. Could you please tell me how to set content-type header correctly?

import {Effect} from "effect";
import {NodeServer} from "effect-http";
import {PrettyLogger} from "effect-log";
import {RouterBuilder} from "effect-http";
import {Api} from "effect-http";
import * as Schema from "@effect/schema/Schema";

export const api = Api.api({title: "Example API"}).pipe(
  Api.get("root", "/", {
    response: {
      status: 200,
      content: Schema.string,
      headers: Schema.struct({"Content-Type": Schema.string}),
    },
  }),
);

export const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle("root", () =>
    Effect.succeed({
      content: "Hello World!",
      status: 200 as const,
      headers: {"content-type": "text/plain"},
    }),
  ),
  RouterBuilder.build,
)

const program = app.pipe(
  NodeServer.listen({port: 3000}),
  Effect.provide(PrettyLogger.layer()),
);

Effect.runPromise(program);
{
"dependencies": {
    "@effect/schema": "^0.48.3",
    "effect": "2.0.0-next.55",
    "effect-http": "^0.39.0",
    "effect-log": "^0.23.1"
  },
  "devDependencies": {
    "prettier": "^3.1.0",
    "tsx": "^4.1.3",
    "typescript": "^5.2.2"
  }
}

[IDEA] Integration with Express

I am currently working on an Express based codebase and I am trying to switch to effect-http in a non-breaking way.
I wrote a small adapter to wrap Express and be able to do that, I thought this could be of interest.
Not sure this deserves its own package but it could be useful as an example somewhere?

This is what it looks like:

const expressApp = express();

const app = pipe(
  Api.make({ title: 'Users API' }),
  Api.addEndpoint(...),
  RouterBuilder.make,
  RouterBuilder.handle(...),
  RouterBuilder.mapRouter(Middleware.from(expressApp).apply), // <-- where the magic happens
  RouterBuilder.buildPartial,
);

The implementation:

// request.ts
import { HttpServer } from '@effect/platform';
import { NodeHttpServer } from '@effect/platform-node';
import { Data, Effect } from 'effect';
import { IncomingMessage } from 'http';

export class Request extends Data.Class<IncomingMessage> {
  static ask() {
    return Effect.gen(function* (_) {
      return Request.from(yield* _(HttpServer.request.ServerRequest));
    });
  }

  static from(req: HttpServer.request.ServerRequest) {
    return new Request(NodeHttpServer.request.toIncomingMessage(req));
  }
}
// response.ts
import { HttpServer } from '@effect/platform';
import { Data, Effect } from 'effect';
import { EventEmitter } from 'events';
import { Response as ExpressResponse } from 'express';
import httpMocks from 'node-mocks-http';

export class Response extends Data.Class<
  httpMocks.MockResponse<ExpressResponse>
> {
  static make() {
    return new Response(
      httpMocks.createResponse({
        eventEmitter: EventEmitter,
      }),
    );
  }

  resolve = () =>
    Effect.async<void, Error>((resolve) => {
      this.on('end', () => {
        resolve(Effect.void);
        this.on('error', (e) => resolve(Effect.fail(e)));
      });
    });

  into = () =>
    HttpServer.response.text(this._getData(), {
      contentType: 'application/json',
      headers: HttpServer.headers.fromInput(
        this.getHeaders() as HttpServer.headers.Input,
      ),
      status: this._getStatusCode(),
      statusText: this._getStatusMessage(),
    });
}
// middleware.ts
import { HttpServer } from '@effect/platform';
import { Data, Effect, pipe } from 'effect';
import { Express } from 'express';

import { Request } from './request';
import { Response } from './response';

export class Middleware extends Data.Class<{ express: Express }> {
  static from(express: Express) {
    return new Middleware({ express });
  }

  apply = <E, R>(
    router: HttpServer.router.Router<E, R>,
  ): HttpServer.router.Router<E | Error, R> =>
    HttpServer.router.all(
      router,
      '*',
      Effect.gen(this, function* (_) {
        const request = yield* _(Request.ask());
        return yield* _(this.request(request));
      }),
    );

  request = (request: Request) =>
    Effect.gen(this, function* (_) {
      const response = Response.make();
      this.express(request, response);
      yield* _(response.resolve());
      return response.into();
    });
}

Define Default Headers in `Api.post`.

Would be great to be able to define default headers in Api.post. This would help with external api usage where you often have a Bearer token in env and don't want to pass it into the request each time you want something from the api.

Client includes server code

Given that to create a client you need the API spec and the api spec includes handlers you'll end up including server code in the client

Optional API routes

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
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.<anonymous> (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
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
index.ts:19:5 - error TS2345: Argument of type '<R, E>(builder: RouterBuilder<R, E, never>) => Default<R | SwaggerFiles, E>' is not assignable to parameter of type '(c: RouterBuilder<never, never, ApiEndpoint<"optional", Default, ApiResponse<200, string, Ignored, never>, Empty>>) => Default<...>'.
  Types of parameters 'builder' and 'c' are incompatible.
    Type 'RouterBuilder<never, never, ApiEndpoint<"optional", Default, ApiResponse<200, string, Ignored, never>, Empty>>' is not assignable to type 'RouterBuilder<never, never, never>'.
      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 '<R, E>(router: Default<R, E>) => Effect<never, ServeError, Exclude<Exclude<Exclude<R, ServerRequest | Scope>, Server | Platform | Generator | NodeContext>, SwaggerFiles>>' is not assignable to parameter of type '(a: unknown) => Effect<never, ServeError, unknown>'.
  Types of parameters 'router' and 'a' are incompatible.
    Type 'unknown' is not assignable to type 'Default<unknown, unknown>'.

24     NodeServer.listen({ port: 8080 }),
       ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
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
)

CORS header not returned in OPTIONS call

I've got an afterHandlerExtension defined as follows

const CorsExtension = Http.afterHandlerExtension("corsHeader", (_req, resp) => {
  resp.headers.set("Access-Control-Allow-Origin", "*")
  return Effect.succeed(resp)
})
...

export const ApiServer = pipe(
  Api,
  Http.server,
  Http.addExtension(CorsExtension),
  ...add handlers
  Http.exhaustive
)

If I make a call to a defined endpoint directly, then I can see the Access-Control-Allow-Origin header on the response. However, when a request is made from our front end and the browser initially sends the OPTIONS request, the header is not present on the response and we get a CORS Missing Allow Origin error.

Am I configuring this incorrectly?

Cannot configure fetch options in Client

I'm working on an application where the user is authenticated with an "http-only" cookie.

To make correct API calls, I'd like to configure the fetch options of my client with { credentials: 'include' }.

It seems that it's not possible at the moment, fetch options are empty :

const httpClient = PlatformClient.fetch();

Url prefix an api group

Could the url of an entire ApiGroup be prefixed somehow?

Currently I have quite a few routes that look like:

GET /events
GET /events/:id
PATCH /events/:id
// etc.

It would be convenient to be able to set the /events prefix in one place only.

Add option to return all schema validation errors

Hello! I was wondering if you would be interested in supporting an option to return all schema errors. By default schema returns the first one. All errors must be opted into with parse(Type)(x, {errors: "all").

Places I could find parse is called:

const parseQuery = Schema.parse(getSchema(schemas.request.query));

const parseContent = Schema.parse(createResponseSchema(responseSchema));

Access logging

Hi,

I'm trying to understand how the logging works and what's currently available.

I was trying to search in examples but didn't find a solution to get these in the log:

  • entries for 404s (atm incorrect requests are not showing up in the log, which might be a feature or a bug ๐Ÿ˜… )
  • remote IP address
  • HTTP status code of the response

Are these supported or something that needs changes?

Thanks ๐Ÿ™‡

Handling multiple response types

I am trying to create a handler that, per API definition, can respond with different status codes and body types. Standard use-case I think. I'm not sure if I overlooked something in the examples or tests but this simple implementation throws a TS error: Type '400' is not assignable to type '201'.

import { Effect, pipe } from "effect";
import { Schema } from "@effect/schema";
import { Api, RouterBuilder } from "effect-http";
import { NodeRuntime } from "@effect/platform-node";
import { NodeServer } from "effect-http-node";

const api = pipe(
  Api.make(),
  Api.addEndpoint(
    pipe(
      Api.get("test", "/test"),
      Api.setResponse({ status: 200, body: Schema.String }),
      Api.addResponse({ status: 201, body: Schema.Number }),
      Api.addResponse({ status: 400, body: Schema.String }),
      Api.addResponse({ status: 422, body: Schema.String })
    )
  )
);

const app = pipe(
  RouterBuilder.make(api),
  RouterBuilder.handle("test", () => {
    if (Math.random() > 0.5) {
      return Effect.succeed({ status: 200 as const, body: "Hello" });
    } else {
      return Effect.succeed({ status: 400 as const, body: "Bad request" });
    }
  }),
  RouterBuilder.build
);

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

What is the proper way to handle APIs with several possible response types?

Using `MockClient.make` crashes trying to encode request schema

I'm using effect-http to model an external Api that I'm calling using Client.make (which is a really cool feature!) and it works great. But now I want to generate a mock client to write some tests with, so I've got

const AnimalAdapterClientTest = Layer.succeed(AnimalAdapterClient, MockClient.make(animalAdapterApi))

And I get an error at startup:

/Users/bste/ghq/gitlab.com/ezyvet/application/hypr/launchpad/node_modules/.pnpm/@[email protected][email protected][email protected]/node_modules/@effect/schema/src/Parser.ts:273
  switch (ast._tag) {
              ^
TypeError: Cannot read properties of undefined (reading '_tag')

I've had a dig through the code and it looks like in effect-http/internal/utils when we check if the request params schemas is IgnoredSchemaId the test returns false so we try to call Schema.encode.

Printing the requestSchemas to the console from the utils file, I can see params is set to Symbol(effect-http/ignore-schema-id)

{
  query: SchemaImpl {
    ast: {
      _tag: 'TypeLiteral',
      propertySignatures: [Array],
      indexSignatures: [],
      annotations: {}
    },
    [Symbol(@effect/schema/Schema)]: { From: [Function: From], To: [Function: To] }
  },
  params: Symbol(effect-http/ignore-schema-id),
  body: Symbol(effect-http/ignore-schema-id),
  headers: SchemaImpl {
    ast: {
      _tag: 'TypeLiteral',
      propertySignatures: [Array],
      indexSignatures: [],
      annotations: {}
    },
    [Symbol(@effect/schema/Schema)]: { From: [Function: From], To: [Function: To] }
  }
}

Logging out the whole imported Api object shows that IgnoredSchemaId looks fine:

[Object: null prototype] {
  default: {
    ApiGroupTypeId: Symbol(effect-http/Api/ApiGroupTypeId),
    ApiTypeId: Symbol(effect-http/Api/ApiTypeId),
    FormData: SchemaImpl {
      ast: [Object],
      [Symbol(@effect/schema/Schema)]: [Object]
    },
    IgnoredSchemaId: Symbol(effect-http/ignore-schema-id),
    ...
  }
}

but if I print out the field with console.log(Api__namespace.IgnoredSchemaId) then I get undefined.
I'm not too sure what to check next.

Versions of packages are:
effect: 2.0.0-next.60
effect-http: ^0.45.1
@effect/platform-node: ^0.37.6
@effect/schema: ^0.53.1

Create separate lib for Security ("Auth"?) module.

The Security lib might work better as a separate (transport layer independent) library that works not only with with effect-http but also with pure @effect/platform http, rpc, and other transport layers. That's how I would like to use it anyway. :)

Handle EADDRINUSE

As is, the library does not handle the case of a port already being in use, instead it just errors (presumably after a timeout) when it realises it's not listening:

timestamp=2024-05-05T16:54:44.745Z level=ERROR fiber=#0 cause="Error [ERR_SERVER_NOT_RUNNING]: Server is not running.

This provides almost no context for the actual error, and it's very hard to debug. I assume this is default platform behaviour, but I think this library is suited to handle this error too.

Typo in Readme

"Some descriptions are provided from the built-in @effect/schema/Schema combinators. For example, the usage of Schema.int() will result in "a positive number" description in the OpenApi schema. One can also add custom description using the Schema.description combinator."

I hope this isn't the case. Lol

form-data content-type support?

Hello,

I've recently upgraded to a newer version of this package (0.46.2) and am now running into issues with the server failing to process requests with FormData content-types.

The requests fail prior to reaching our registered handler for the given endpoint with the following error. It seems like there may be a bug with how form-data requests are decoded/parsed prior to being passed on to the endpoint's registered handler:

{
  "_tag": "RequestError",
  "request": {
    "_tag": "ServerRequest",
    "method": "POST",
    "url": "/coi_request",
    "originalUrl": "/coi_request",
    "headers": {
      "authorization": "--redacted--",
      "user-agent": "PostmanRuntime/7.36.0",
      "accept": "*/*",
      "cache-control": "no-cache",
      "postman-token": "0b7ca553-40ef-48aa-94b1-866d638875ec",
      "host": "localhost:8080",
      "accept-encoding": "gzip, deflate, br",
      "connection": "keep-alive",
      "content-type": "multipart/form-data; boundary=--------------------------707399609773474252081923",
      "content-length": "318"
    }
  },
  "reason": "Decode",
  "error": {}
}

Are there any known limitations to using FormData content-types/are you aware of any breaking changes to FormData content-type handling since the big re-write post v0.32.0?

Likewise, is there any way I can register a handler that bypasses the default request payload decoding behavior and handle type validations manually within the endpoint's handler?

Thanks for any help you can provide.

Replace Express with Hattip for server

While I really like the way that servers are defined in this library, the use of express is somewhat annoying. Especially for people interested in using this with edge/lambda functions. Hattip is probably a better target for this sense it abstracts away the request handler and is compatible with other frameworks besides express(e.g. aws lambda, etc.). Also, express is also a bit dated at this point and Hattip uses more modern js apis.

Swagger Example generation for Dates in Schema

Hey,
I've noticed that when using a Schema.Date in a struct the example in Swagger doesn't produce usable date-strings: instead they produce "string".

Is it possible to get it to work like with S.UUID ?

Reproduction

const testSchema = S.struct({
  id: S.UUID,
  date: S.Date
})

This is what Swagger shows with the given Schema set as requestBody:

grafik

Which of course leads to an error when trying to execute in Swagger:

grafik

Question

Is it possible to get the current date as an iso string for the swagger documentation by setting some annotations in Schema for example? Or is this not possible at the moment?


Packages:
"@effect/platform": "^0.48.27",
"@effect/platform-node": "^0.45.29",
"effect": "^2.4.18",
"effect-http": "^0.60.7",
"effect-http-node": "^0.8.8",

Issues resolving OpenAPI components with subset of `Schema.optional` API

Hello again ๐Ÿ‘‹

I think I've encountered a small bug somewhere within the OpenAPI integration being unable to create "Components" with schemas containing identifiers when some of its property signatures are optional, but only a subset of them.

I think it has to do with the optionalToRequired transformation that takes place internally to Schema, but I'm not too positive after looking only a bit briefly.

import * as S from '@effect/schema/Schema';
import { Api, OpenApi } from 'effect-http';

const schema = S.struct({
  // Does NOT work
  // a: S.optional(S.Date, { nullable: true, exact: true, as: 'Option' }),
  // b: S.optional(S.Date, { nullable: true, default: () => new Date(), }),
  // c: S.optional(S.Date, { exact: true, default: () => new Date() }),
  // d: S.optional(S.Date, { exact: true, as: 'Option' }),
  // e: S.optional(S.Date, { nullable: true, as: 'Option' }),
  // f: S.optional(S.Date, { as: 'Option' }),
  // Does Work
  // y: S.optional(S.Date, { exact: true }),
  // z: S.optional(S.Date),
}).pipe(S.identifier('Foo'));

const api = Api.api({ title: 'Example' }).pipe(
  Api.get('getFoo', '/:foo', {
    request: {
      params: S.struct({
        foo: S.string,
      }),
    },
    response: schema,
  })
);

console.log(OpenApi.make(api));

Services in Security implementation is leaked into the client

When using a service inside a custom Security, it becomes a requirement of the client endpoint although it should be only used on the server side.

Look below how the effect has the type: Effect.Effect<string, ClientError<number>, UserStorage>

import { Schema } from '@effect/schema';
import { Effect, Layer, pipe } from 'effect';
import {
  Api,
  ApiEndpoint,
  Client,
  Middlewares,
  RouterBuilder,
  Security,
} from 'effect-http';

interface UserInfo {
  email: string;
}

class UserStorage extends Effect.Tag('UserStorage')<
  UserStorage,
  { getInfo: (user: string) => Effect.Effect<UserInfo> }
>() {
  static dummy = Layer.succeed(
    UserStorage,
    UserStorage.of({
      getInfo: (_: string) => Effect.succeed({ email: '[email protected]' }),
    }),
  );
}

const mySecurity = pipe(
  Security.basic({ description: 'My basic auth' }),
  Security.map((creds) => creds.user),
  Security.mapEffect((user) => UserStorage.getInfo(user)),
);

const api = Api.make().pipe(
  Api.addEndpoint(
    Api.post('endpoint', '/my-secured-endpoint').pipe(
      Api.setResponseBody(Schema.String),
      Api.setSecurity(mySecurity),
    ),
  ),
);

const app = RouterBuilder.make(api).pipe(
  RouterBuilder.handle('endpoint', (_, security) =>
    Effect.succeed(`Logged in`),
  ),
  RouterBuilder.build,
  Middlewares.errorLog,
);

const client = Client.make(api);

// BAD: Effect.Effect<string, ClientError<number>, UserStorage>
const effect = client.endpoint({});

I have written a quick type helper as a workaround:

type ApiClient<T> =
  T extends Api.Api<infer Endpoints> ? Api.Api<MapEndpoints<Endpoints>> : never;

type MapEndpoints<T> =
  T extends ApiEndpoint.ApiEndpoint<
    infer Endpoint,
    infer Req,
    infer Resp,
    Security.Security<infer A, infer E, unknown>
  >
    ? ApiEndpoint.ApiEndpoint<
        Endpoint,
        Req,
        Resp,
        Security.Security<A, E, never>
      >
    : never;

const client = Client.make(api as ApiClient<typeof api>);

// GOOD: Effect.Effect<string, ClientError<number>, never>
const effect = client.endpoint({});

I will try to do a PR when I find the time.

Common headers server side

Hi, really enjoying exploring the lib!
I was wondering if there is any way to add in common response headers to the server?
E.g adding in CORS headers at the moment looks like it needs a header schema needs to be added to each response and then instantiated in each handler.
I though this could be achieved by creating an afterHandler extension but I'm not too sure how to modify the response object.

Allow route handlers to be overridden

It might be useful to allow already handled routes to be overridden. Especially when you are already using ExampleServer for testing and want to progressively add real implementations.

I came across this when I was trying to use the Example server and also return Metric.snapshot. E.g.

pipe(
  ExampleServer.make(Api.api),
  RouterBuilder.handle("metrics", () => Metric.snapshot) // error
)

Optional query parameters

Hi @sukovanej ๐Ÿ‘‹ First of all, this library looks super promising! I'm very happy to have started fiddling with it ๐Ÿ™‚

I'm trying to migrate my own fp-ts-based HTTP clients to use effect-http. However, I've hit a wall since I'm unable to express optional query parameters. I was hoping the following (or something similar) would work:

import * as schema from "@effect/schema/Schema";
import { pipe } from "effect";
import * as http from "effect-http";

export const api = pipe(
  http.api(),
  http.get("userById", "/api/users/:userId", {
    response: schema.struct({ name: schema.string }),
    params: { userId: schema.string },
    // @ts-expect-error Type 'Schema<string | undefined, string | undefined>' is not assignable to type 'Schema<string, any>'.
    query: {
      include_deleted: schema.union(schema.string, schema.undefined),
    },
    headers: {
      authorization: schema.string,
    },
  })
);

Looking more at the REAMDE, I haven't seen any mention of optional query parameters or request headers. Is there a specific reason for the lack of optional inputs, or would this be an acceptable feature request?

External API

What is the best way to use this with an external api eg stripe or twilio?

Testing client appears to expect only JSON payloads

We're seeing some issues with the testing client and a multipart form data payload. I found this which makes it look like all requests with a body are transformed to json with application/json headers.

body !== undefined ? JSON.stringify(body) : undefined;

Is the testing client still a work in progress or is it ok to assume it has parity with the server?

Initial feature set

For tracking / TODO list purpose.

Enable incremental adoption into existing express apps

  • Express app derivation.
  • (?) Add api for raw express handlers.
  • Add ability to extend OpenApi schema manually.

Minimal feature set

  • Provide a way to plug in an express middleware.
    • TODO: instead of support of middleware, document the express app creation and document it properly
  • (?) Handling of application shutdown.
  • Response headers.
  • Mock client derivation.

Embeddable examples similar to Hono?

I have an already existing Astro server, but I'm looking for a way to have type safety cross boundaries (RPC style).
This library seems like a good fit to build such foundation, but as I mentioned, I need to embed it inside my existing ASTRO backend.
Hono has a similar feature that allows to embed it within other servers. As far as I know, all I need is a function capable of dealing with a request (perform some routing too) and returns a response. Is this possible? Do you have any examples ?

error due to missing peerDep of isomorphoc-ws when using yarn 4 in pnp mode

Hi, first thanks for this great project! I'm hoping to replace https://github.com/BitGo/api-ts with it

however, running a simple http server example from Readme.md I get:

Error: isomorphic-ws tried to access ws (a peer dependency) but it isn't provided by its ancestors; this makes the require call ambiguous and unsound.

Required package: ws
Required by: isomorphic-ws

Installing ws in my project doesn't help. I believe ws should be added to peerDeps of effect-http in order to fix this

btw. it would be great if this was not required for simple http server as I guess it doesn't actually use the websocket lib

Accessing raw request content in handlers

Hi, thanks for this amazing library. It's effective to use with effect-ts ;)

To perform a signature validation, I need to access the raw string payload of the request, before it gets parsed as JSON. This only needs to be done on a particular route, so I can't do the check across the application. Just wondering if there is a way to access the raw request from the handler?

Dependency Dashboard

This issue lists Renovate updates and detected dependencies. Read the Dependency Dashboard docs to learn more.

Open

These updates have all been created already. Click a checkbox below to force a retry/rebase of any.

Detected dependencies

github-actions
.github/actions/setup/action.yml
  • pnpm/action-setup v3
  • actions/setup-node v4
.github/workflows/build.yml
  • actions/checkout v4
  • changesets/action v1
.github/workflows/check.yml
  • actions/checkout v4
.github/workflows/pages.yml
  • actions/checkout v4
  • actions/jekyll-build-pages v1
  • actions/upload-pages-artifact v2
  • actions/deploy-pages v2
.github/workflows/test.yml
  • actions/checkout v4
  • actions/checkout v4
  • oven-sh/setup-bun v1
npm
package.json
  • @babel/cli ^7.24.6
  • @babel/core ^7.24.6
  • @babel/plugin-transform-export-namespace-from ^7.24.6
  • @babel/plugin-transform-modules-commonjs ^7.24.6
  • @changesets/changelog-github ^0.5.0
  • @changesets/cli ^2.27.3
  • @effect/build-utils ^0.7.6
  • @effect/docgen ^0.4.3
  • @effect/dtslint ^0.1.0
  • @effect/eslint-plugin ^0.1.2
  • @effect/language-service ^0.1.0
  • @effect/vitest ^0.5.8
  • @types/node ^20.14.1
  • @types/swagger-ui-dist ^3.30.4
  • @typescript-eslint/eslint-plugin ^7.12.0
  • @typescript-eslint/parser ^7.12.0
  • @vitest/coverage-v8 ^1.6.0
  • babel-plugin-annotate-pure-calls ^0.4.0
  • eslint ^8.57.0
  • eslint-import-resolver-typescript ^3.6.1
  • eslint-plugin-codegen 0.28.0
  • eslint-plugin-deprecation ^3.0.0
  • eslint-plugin-import ^2.29.1
  • eslint-plugin-simple-import-sort ^12.1.0
  • eslint-plugin-sort-destructure-keys ^2.0.0
  • glob ^10.4.1
  • madge ^7.0.0
  • prettier ^3.3.0
  • rimraf ^5.0.7
  • tsx ^4.11.2
  • typescript ^5.4.5
  • vitest ^1.6.0
  • pnpm 9.1.1
packages/effect-http-error/package.json
  • @effect/platform ^0.55.5
  • @effect/schema ^0.67.18
  • effect ^3.2.8
  • @effect/platform ^0.55.0
  • @effect/schema ^0.67.0
  • effect ^3.2.0
  • pnpm 9.1.1
packages/effect-http-node/package.json
  • swagger-ui-dist ^5.17.14
  • @effect/platform ^0.55.5
  • @effect/platform-bun ^0.36.5
  • @effect/platform-node ^0.51.5
  • @effect/schema ^0.67.18
  • @types/node ^20.14.1
  • effect ^3.2.8
  • effect-log ^0.31.3
  • @effect/platform ^0.55.0
  • @effect/platform-node ^0.51.0
  • @effect/schema ^0.67.0
  • effect ^3.2.0
  • pnpm 9.1.1
packages/effect-http-security/package.json
  • @effect/platform ^0.55.5
  • @effect/schema ^0.67.18
  • effect ^3.2.8
  • @effect/platform ^0.55.0
  • @effect/schema ^0.67.0
  • effect ^3.2.0
  • pnpm 9.1.1
packages/effect-http/package.json
  • schema-openapi ^0.38.2
  • @effect/platform ^0.55.5
  • @effect/schema ^0.67.18
  • effect ^3.2.8
  • @effect/platform ^0.55.0
  • @effect/schema ^0.67.0
  • effect ^3.2.0
  • pnpm 9.1.1

  • Check this box to trigger a request for Renovate to run again on this repository

Type error with Http.get

Hi! I just ran across this library and it looks great! I was playing around with the examples though and was having some type issues though. Specifically with the Http.get function. I get "Expected 0 arguments, but got 3". Currently I'm using version 0.1.0 but I've tested it with earlier versions.

https://codesandbox.io/p/sandbox/winter-field-30ikt5?file=%2Fsrc%2Findex.ts&selection=%5B%7B%22endColumn%22%3A1%2C%22endLineNumber%22%3A9%2C%22startColumn%22%3A1%2C%22startLineNumber%22%3A9%7D%5D

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.