https://github.com/sukovanej/effect-http
Declarative HTTP API library for effect-ts
https://github.com/sukovanej/effect-http
api effect-ts http openapi swagger typescript
Last synced: 23 days ago
JSON representation
Declarative HTTP API library for effect-ts
- Host: GitHub
- URL: https://github.com/sukovanej/effect-http
- Owner: sukovanej
- License: mit
- Created: 2023-04-11T19:50:10.000Z (about 2 years ago)
- Default Branch: master
- Last Pushed: 2025-03-22T13:19:06.000Z (about 1 month ago)
- Last Synced: 2025-04-12T02:17:47.913Z (23 days ago)
- Topics: api, effect-ts, http, openapi, swagger, typescript
- Language: TypeScript
- Homepage: https://sukovanej.github.io/effect-http
- Size: 3.59 MB
- Stars: 261
- Watchers: 5
- Forks: 21
- Open Issues: 20
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- License: LICENSE
Awesome Lists containing this project
README
# effect-http
> [!WARNING]
>
> [@effect/[email protected]](https://github.com/Effect-TS/effect/releases/tag/%40effect%2Fplatform%400.63.0) introduced a new
> set of `HttpApi` modules that are meant to be an effect official continuation of this project.
>
> Starting today (2024-08-30), the `effect-http` will be receiving only effect updates and critical patches to give
> the current users enough time to migrate. Consider the `effect-http` package to be deprecated in favour of the
> `@effect/platform`.
High-level declarative HTTP library for [Effect-TS](https://github.com/Effect-TS) built on top of
[@effect/platform](https://github.com/Effect-TS/effect/tree/main/packages/platform).- :star: **Client derivation**. Write the api specification once, get the type-safe client with runtime validation for free.
- :rainbow: **OpenAPI derivation**. `/docs` endpoint with OpenAPI UI out of box.
- :battery: **Batteries included server implementation**. Automatic runtime request and response validation.
- :crystal_ball: **Example server derivation**. Automatic derivation of example server implementation.
- :bug: **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
- [Quickstart](#quickstart)
- [Request validation](#request-validation)
- [Example](#example)
- [Optional path parameters](#optional-path-parameters)
- [Headers](#headers)
- [Security](#security)
- [Responses](#responses)
- [Testing the server](#testing-the-server)
- [Error handling](#error-handling)
- [Reporting errors in handlers](#reporting-errors-in-handlers)
- [Example API with conflict API error](#example-api-with-conflict-api-error)
- [Grouping endpoints](#grouping-endpoints)
- [Descriptions in OpenApi](#descriptions-in-openapi)
- [Representations](#representations)
- [API on the client side](#api-on-the-client-side)
- [Example server](#example-server)
- [Mock client](#mock-client)
- [Router handlers](#router-handlers)
- [Compatibility](#compatibility)
- [Scaling up](#scaling-up)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```bash
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```bash
pnpm add effect @effect/platform @effect/platform-node
```The `@effect/platform-node` is needed only for the node version.
Bootstrap a simple API specification.
```typescript
import { Schema } from "effect";
import { Api, QuerySchema } from "effect-http";const UserResponse = Schema.Struct({
name: Schema.String,
id: Schema.Int.pipe(Schema.positive()),
});
const GetUserQuery = Schema.Struct({ id: QuerySchema.Number });const api = Api.make({ title: "Users API" }).pipe(
Api.addEndpoint(
Api.get("getUser", "/user").pipe(
Api.setResponseBody(UserResponse),
Api.setRequestQuery(GetUserQuery)
)
)
);
```Create the app implementation.
```typescript
import { Effect } from "effect";
import { RouterBuilder } from "effect-http";const app = RouterBuilder.make(api).pipe(
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`.
```typescript
import { Client } from "effect-http";const client = Client.make(api, { baseUrl: "http://localhost:3000" });
```Spawn the server on port 3000,
```typescript
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`.
```ts
const response = client.getUser({ query: { id: 12 } }).pipe(
Effect.flatMap((user) => Effect.log(`Got ${user.name}, nice!`)),
);
```[\[Source code\]](./packages/effect-http-node/examples/readme-quickstart.ts)
Also, check the auto-generated OpenAPI UI running on
[localhost:3000/docs](http://localhost:3000/docs/). How awesome is that!
### Request validation
Each endpoint can declare expectations on the request format. Specifically,
- `body` - request body
- `query` - query parameters
- `path` - path parameters
- `headers` - request headersThey are specified in the input schemas object (3rd argument of `Api.get`, `Api.post`, ...).
#### Example
```typescript
import { Schema } from "effect";
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\]](./packages/effect-http-node/examples/request-validation.ts)
#### Optional path parameters
Optional parameter is denoted using a question mark in the path
match pattern. In the request param schema, use `Schema.optional()`.In the following example the last `:another` path parameter can be
ommited on the client side.```typescript
import { Schema } 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 = Api.make({ title: "My api" }).pipe(
Api.addEndpoint(
Api.get("stuff", "/stuff/:param/:another?").pipe(
Api.setResponseBody(Stuff),
Api.setRequestPath(StuffParams)
)
)
);
```[\[Source code\]](./packages/effect-http-node/examples/request-validation-optional-parameter.ts)
### 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.```typescript
import { NodeRuntime } from "@effect/platform-node";
import { Schema } 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 }))
)
)
);ExampleServer.make(api).pipe(
RouterBuilder.build,
NodeServer.listen({ port: 3000 }),
NodeRuntime.runMain
);
```[\[Source code\]](./packages/effect-http-node/examples/readme-headers.ts)
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.```json
{
"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.
**Important!** Use a lowercase form of header names.
### Security
To deal with authentication / authorization, the `effect-http` exposes the `Security` module. `Security.Security`
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.```ts
import { NodeRuntime } from "@effect/platform-node";
import { Effect, Schema } 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\]](./packages/effect-http-node/examples/readme-security-basic.ts)
In the example, we use the `Security.basic()` constructor which produces a new security of type
`Security`. 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` contains an `Effect`.
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.
```ts
const mySecurity = Security.or(
Security.asSome(Security.basic()),
Security.as(Security.unit, Option.none())
);
```[\[Source code\]](./packages/effect-http-node/examples/readme-security.ts)
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.```ts
import { Effect, Layer } from "effect";
import { Security } from "effect-http";interface UserInfo {
email: string;
}class UserStorage extends Effect.Tag("UserStorage")<
UserStorage,
{ getInfo: (user: string) => Effect.Effect }
>() {
static dummy = Layer.succeed(
UserStorage,
UserStorage.of({
getInfo: (_: string) => Effect.succeed({ email: "[email protected]" }),
})
);
}const mySecurity = Security.basic({ description: "My basic auth" }).pipe(
Security.map((creds) => creds.user),
Security.mapEffect((user) => UserStorage.getInfo(user))
);
```In the handler implementation, we obtain the `security` argument typed as `UserInfo`.
```ts
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.```ts
app.pipe(
NodeServer.listen({ port: 3000 }),
Effect.provide(UserStorage.dummy),
NodeRuntime.runMain
);
```[\[Source code\]](./packages/effect-http-node/examples/readme-security-complex.ts)
#### 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.```ts
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.
```ts
import { HttpServer } from "@effect/platform";
import { Effect, pipe, Schema } 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\]](./packages/effect-http-node/examples/readme-security-custom.ts)
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.```ts
import { Schema } from "effect";
import { Api } from "effect-http";const api = Api.make().pipe(
Api.addEndpoint(
Api.get("hello", "/hello").pipe(
Api.setResponseStatus(201),
Api.setResponseBody(Schema.Number),
Api.setResponseHeaders(Schema.Struct({ "x-hello-world": Schema.String }))
)
)
);
```[\[Source code\]](./packages/effect-http-node/examples/custom-response.ts)
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}`.```ts
import { Effect, Schema } 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": Schema.NumberFromString.pipe(
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 = Api.make().pipe(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.```ts
import { Effect } from "effect";
import { Api, RouterBuilder } from "effect-http";const app = RouterBuilder.make(api).pipe(
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.
```ts
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.
```ts
import { it } from "@effect/vitest";
import { NodeTesting } from "effect-http-node";it.scoped("test /hello endpoint", () =>
Effect.gen(function* () {
const response = yield* NodeTesting.make(app, api).pipe(
Effect.flatMap((client) => client.hello({ query: { input: 12 } }))
);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.#### 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.```typescript
import { Context, Effect, pipe, Schema } from "effect";
import { Api, RouterBuilder, HttpError } from "effect-http";
import { NodeServer } from "effect-http-node";const api = Api.make({ title: "Users API" }).pipe(
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.```typescript
interface UserRepository {
userExistsByName: (name: string) => Effect.Effect;
storeUser: (user: string) => Effect.Effect;
}const UserRepository = Context.GenericTag("UserRepository");
const mockUserRepository = UserRepository.of({
userExistsByName: () => Effect.succeed(true),
storeUser: () => Effect.unit,
});const { userExistsByName, storeUser } = Effect.serviceFunctions(UserRepository);
```And finally, we have the actual `HttpApp.HttpApp` implementation.
```typescript
const app = RouterBuilder.make(api).pipe(
RouterBuilder.handle("storeUser", ({ body }) =>
pipe(
userExistsByName(body.name),
Effect.filterOrFail(
(alreadyExists) => !alreadyExists,
() => HttpError.conflict(`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.```typescript
app.pipe(
NodeServer.listen({ port: 3000 }),
Effect.provideService(UserRepository, mockUserRepository),
NodeRuntime.runMain
);
```[\[Source code\]](./packages/effect-http-node/examples/conflict-error-example.ts)
Try to run the server and call the `POST /user`.
_Server_
```bash
$ pnpm tsx examples/conflict-error-example.ts22: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](https://httpie.io/cli))
```bash
$ http localhost:3000/users name="patrik"HTTP/1.1 409 Conflict
Content-Length: 68
Content-Type: application/json; charset=utf-8User "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.```typescript
import { NodeRuntime } from "@effect/platform-node";
import { Effect, Schema } 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 = ApiGroup.make("test", {
description: "Test description",
externalDocs: {
description: "Test external doc",
url: "https://www.google.com/search?q=effect-http",
},
}).pipe(
ApiGroup.addEndpoint(
ApiGroup.get("test", "/test").pipe(Api.setResponseBody(Response))
)
);const userApi = ApiGroup.make("Users", {
description: "All about users",
externalDocs: {
url: "https://www.google.com/search?q=effect-http",
},
}).pipe(
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\]](./packages/effect-http-node/examples/groups.ts)
The OpenAPI UI will group endpoints according to the `api` and show
corresponding titles for each group.
## Descriptions in OpenApi
The OpenAPI logic takes into account
[description](https://effect-ts.github.io/schema/modules/Schema.ts.html#description)
annotations and propagates them into the specification.Some descriptions are provided from the built-in `effect/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.```ts
import { NodeRuntime } from "@effect/platform-node";
import { Effect, Schema } from "effect";
import { Api, QuerySchema, RouterBuilder } from "effect-http";
import { NodeServer } from "effect-http-node";const Response = Schema.Struct({
name: Schema.String,
id: pipe(Schema.Number, Schema.int(), Schema.positive()),
}).pipe(Schema.annotations({ description: "User" }));const Query = Schema.Struct({
id: QuerySchema.Number.pipe(Schema.annotations({ description: "User id" })),
});const api = Api.make({ title: "Users API" }).pipe(
Api.addEndpoint(
Api.get("getUser", "/user", { description: "Returns a User by id" }).pipe(
Api.setResponseBody(Response),
Api.setRequestQuery(Query)
)
)
);const app = RouterBuilder.make(api).pipe(
RouterBuilder.handle("getUser", ({ query }) =>
Effect.succeed({ name: "mike", id: query.id })
),
RouterBuilder.build
);app.pipe(NodeServer.listen({ port: 3000 }), NodeRuntime.runMain);
```[\[Source code\]](./packages/effect-http-node/examples/description.ts)
## 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](https://sukovanej.github.io/effect-http/modules/Representation.ts.html#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.```ts
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.```ts
import { NodeRuntime } from "@effect/platform-node";
import { Effect, Schema } 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\]](./packages/effect-http-node/examples/plain-text.ts)
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.```bash
# 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`.
```typescript
import { NodeRuntime } from "@effect/platform-node";
import { Effect, Schema } 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 = Api.make({
servers: ["http://localhost:3000", { description: "hello", url: "/api/" }],
}).pipe(
Api.addEndpoint(Api.get("test", "/test").pipe(Api.setResponseBody(Response)))
);ExampleServer.make(api).pipe(
RouterBuilder.build,
NodeServer.listen({ port: 3000 }),
NodeRuntime.runMain
);
```[\[Source code\]](./packages/effect-http-node/examples/example-server.ts)
Go to [localhost:3000/docs](http://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.```typescript
import { Schema } from "effect";
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.```typescript
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.```typescript
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.```ts
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.```ts
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`. 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` (where `A` is a description of an endpoint
`ApiEndpoint`). Handlers are combined
into a router using a `RouterBuilder`. You'd implement the handlers
of the article API group as follows.```ts
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.
>
> ```ts
> const getArticleHandler = getArticleEndpoint.pipe(
> Handler.make(() => Effect.succeed(...)
> )
> ```Finally, you merge all the router builders and build the app.
```ts
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.