High-level declarative HTTP API for effect-ts.
- ⭐ 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.
- Quickstart
- Request validation
- Headers
- Responses
- Testing the server
- Error handling
- Grouping endpoints
- Incremental adoption into existing express app
- Cookbook
- API on the client side
- Extensions
- Compatibility
Install together with effect
using
pnpm add effect effect-http
Bootstrap a simple API specification.
import * as Schema from "@effect/schema/Schema";
import { Effect, pipe } from "effect";
import * as Http from "effect-http";
const responseSchema = Schema.struct({ name: Schema.string });
const query = Schema.struct({ id: Schema.number });
const api = pipe(
Http.api({ title: "Users API" }),
Http.get("getUser", "/user", {
response: responseSchema,
request: { query: query },
}),
);
Create the server implementation.
const server = pipe(
api,
Http.server,
Http.handle("getUser", ({ query }) => Effect.succeed({ name: "milan" })),
Http.exhaustive,
);
Now, we can generate an object providing the HTTP client interface using Http.client
.
const client = Http.client(api, new URL("http://localhost:3000"));
And spawn the server on port 3000 and call it using the client
.
const callServer = () =>
pipe(
client.getUser({ query: { id: 12 } }),
Effect.flatMap((user) => Effect.logInfo(`Got ${user.name}, nice!`)),
);
pipe(
server,
Http.listen({ port: 3000, onStart: callServer }),
Effect.runPromise,
);
Also, check the auto-generated OpenAPI UI running on localhost:3000/docs. How awesome is that!
Each endpoint can declare expectations on the request format. Specifically,
body
- request bodyquery
- query parametersparams
- path parametersheaders
- request headers
They are specified in the input schemas object (3rd argument of Http.get
, Http.post
, ...).
import * as Schema from "@effect/schema/Schema";
import { pipe } from "effect";
import * as Http from "effect-http";
export const api = pipe(
Http.api({ title: "My api" }),
Http.get("stuff", "/stuff/:param", {
response: Schema.struct({ value: Schema.number }),
request: {
body: Schema.struct({ bodyField: Schema.array(Schema.string) }),
query: Schema.struct({ query: Schema.string }),
params: Schema.struct({ param: Schema.string }),
},
}),
);
(This is a complete standalone code example)
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 * as Schema from "@effect/schema/Schema";
import { pipe } from "effect";
import * as Http from "effect-http";
export const api = pipe(
Http.api({ title: "My api" }),
Http.get("stuff", "/stuff/:param/:another?", {
response: Schema.struct({ value: Schema.number }),
request: {
params: Schema.struct({
param: Schema.string,
another: Schema.optional(Schema.string),
}),
},
}),
);
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 * as Schema from "@effect/schema/Schema";
import * as Http from "effect-http";
const api = pipe(
Http.api(),
Http.get("hello", "/hello", {
response: Schema.string,
request: {
headers: Schema.struct({ "X-Client-Id": Schema.string }),
},
}),
);
(This is a complete standalone code example)
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": "InvalidHeadersError", "details": "x-client-id is missing" }
And as usual, the information about headers will be reflected in the generated OpenAPI UI.
Important note. You might have noticed the details
field of the error response
describes the missing header using lower-case. This is not an error but rather a
consequence of the fact that HTTP headers are case-insensitive and internally effect-http
converts all header names to lower-case to simplify integration with the underlying
http library - express.
Don't worry, this is also encoded into the type information and if you were to implement the handler, both autocompletion and type-checking would hint the lower-cased form of the header name.
type Api = typeof api;
const handleHello = ({
headers: { "x-client-id": clientId },
}: Http.Input<Api, "hello">) => Effect.succeed("all good");
Take a look at examples/headers.ts to see a complete example API implementation with in-memory rate-limiting and client identification using headers.
Response can be specified using a Schema.Schema<I, O>
which automatically
returns status code 200 and includes only default headers.
If you want a response with custom headers and status code, use the full response schema instead. The following example will enforce (both for types and runtime) that returned status, content and headers conform the specified response.
const api = pipe(
Http.api(),
Http.post("hello", "/hello", {
response: {
status: 200,
content: Schema.number,
headers: { "My-Header": Schema.string },
},
}),
);
It is also possible to specify multiple full response schemas.
const api = pipe(
Http.api(),
Http.post("hello", "/hello", {
response: [
{
status: 201,
content: Schema.number,
},
{
status: 200,
content: Schema.number,
headers: { "My-Header": Schema.string },
},
{
status: 204,
headers: { "X-Another": Schema.NumberFromString },
},
],
}),
);
The server implemention is type-checked against the api responses and one of the specified response objects must be returned.
The response object can be generated using a ResponseUtil
. It is
a derived object based on the Api
and operation id and it provides
methods named response<status>
that create the response.
const server = pipe(
Http.server(api),
Http.handle("hello", ({ ResponseUtil }) =>
Effect.succeed(
ResponseUtil.response200({ headers: { "my-header": 12 }, content: 12 }),
),
),
);
Note that one can create the response util object using Http.responseUtil
if needed independently of the server implementation.
const HelloResponseUtil = Http.responseUtil(api, "hello");
const response200 = HelloResponseUtil.response200({
headers: { "my-header": 12 },
content: 12,
});
The derived client for this Api
exposes a hello
method that
returns the following type.
type DerivedTypeOfHelloMethod =
| {
content: number;
status: 201;
}
| {
headers: {
readonly "my-header": number;
};
content: number;
status: 200;
}
| {
headers: {
readonly "x-another": number;
};
status: 204;
};
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 Testing
module offers a
Http.testingClient
combinator that generates a testing client from
the Server. This derived testing client has a similar interface
to the one derived by Http.client
, but with the distinction that
it returns a Response
object and bypasses the network by
directly triggering the handlers defined in the Server.
Now, let's write an example test for the following server.
const api = pipe(
Http.api(),
Http.get("hello", "/hello", {
response: Schema.string,
}),
);
const server = pipe(
api,
Http.server,
Http.handle("hello", ({ query }) => Effect.succeed(`${query.input + 1}`)),
);
The test might look as follows.
test("test /hello endpoint", async () => {
const testingClient = Http.testingClient(server);
const response = await Effect.runPromise(
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.
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.
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.
- 400
Http.invalidQueryError
- query parameters validation failed - 400
Http.invalidParamsError
- path parameters validation failed - 400
Http.invalidBodyError
- request body validation failed - 400
Http.invalidHeadersError
- request headers validation failed - 401
Http.unauthorizedError
- invalid authentication credentials - 403
Http.forbiddenError
- authorization failure - 404
Http.notFoundError
- cannot find the requested resource - 409
Http.conflictError
- request conflicts with the current state of the server - 415
Http.unsupportedMediaTypeError
- unsupported payload format - 429
Http.tooManyRequestsError
- the user has sent too many requests in a given amount of time
- 500
Http.invalidResponseError
- internal server error because of response validation failure - 500
Http.internalServerError
- internal server error - 501
Http.notImplementedError
- functionality to fulfill the request is not supported - 502
Http.badGatewayError
- invalid response from the upstream server - 503
Http.serviceunavailableError
- server is not ready to handle the request - 504
Http.gatewayTimeoutError
- request timeout from the upstream server
Using these errors, Server
runtime can generate human-readable details
in HTTP responses and logs. Also, it can correctly decide what status code
should be returned to the client.
The formatting of details
field of the error JSON object is abstracted using
Http.ValidationErrorFormatter. The
ValidationErrorFormatter
is a function taking a Schema.ParseError
and returning
its string representation. It can be overridden using layers. The following example
will perform a direct JSON serialization of the Schema.ParserError
to construct
the error details.
const myValidationErrorFormatter: Http.ValidationErrorFormatter = (error) =>
JSON.stringify(error);
pipe(
server,
Http.listen({ port: 3000 }),
Effect.provide(
Http.setValidationErrorFormatter(myValidationErrorFormatter),
),
Effect.runPromise,
);
Let's see it in action and implement the mentioned user management API. The API will look as follows.
import * as Schema from "@effect/schema/Schema";
import { Context, Effect, pipe } from "effect";
import * as Http from "effect-http";
const api = pipe(
Http.api({ title: "Users API" }),
Http.post("storeUser", "/users", {
response: Schema.string,
request: {
body: Schema.struct({ name: Schema.string }),
},
}),
);
type Api = typeof api;
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 {
existsByName: (name: string) => Effect.Effect<never, never, boolean>;
store: (user: string) => Effect.Effect<never, never, void>;
}
const UserRepository = Context.Tag<UserRepository>();
const mockUserRepository = UserRepository.of({
existsByName: () => Effect.succeed(true),
store: () => Effect.unit,
});
And finally, we have the actual Server
implementation.
const handleStoreUser = ({ body }: Http.Input<Api, "storeUser">) =>
pipe(
Effect.flatMap(UserRepository, (userRepository) =>
userRepository.existsByName(body.name),
),
Effect.filterOrFail(
(alreadyExists) => !alreadyExists,
() => Http.conflictError(`User "${body.name}" already exists.`),
),
Effect.flatMap(() =>
Effect.flatMap(UserRepository, (repository) =>
repository.store(body.name),
),
),
Effect.map(() => `User "${body.name}" stored.`),
);
const server = pipe(
api,
Http.server,
Http.handle("storeUser", handleStoreUser),
Http.exhaustive,
);
To run the server, we will start the server using Http.listen
and provide
the mockUserRepository
service.
pipe(
server,
Http.listen({ port: 3000 }),
Effect.provideService(UserRepository, mockUserRepository),
Effect.runPromise,
);
Try to run the server and call the POST /user
.
Server
$ pnpm tsx examples/conflict-error-example.ts
16:53:55 (Fiber #0) INFO Server listening on :::3000
16:54:14 (Fiber #8) WARN POST /users failed
ᐉ { "errorTag": "ConflictError", "error": "User "milan" already exists." }
Client (using httpie cli)
$ http localhost:3000/users name="patrik"
HTTP/1.1 409 Conflict
Connection: keep-alive
Content-Length: 68
Content-Type: application/json; charset=utf-8
Date: Sat, 15 Apr 2023 16:36:44 GMT
ETag: W/"44-T++MIpKSqscvfSu9Ed1oobwDDXo"
Keep-Alive: timeout=5
X-Powered-By: Express
{
"details": "User \"patrik\" already exists.",
"error": "ConflictError"
}
To create a new group of endpoints, use Http.apiGroup("group name")
. This combinator
initializes new ApiGroup
object. You can pipe it with combinators like Http.get
,
Http.post
, etc, as if were defining the Api
. Api groups can be combined into an
Api
using a Http.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 * as Schema from "@effect/schema/Schema";
import { Effect, pipe } from "effect";
import * as Http from "effect-http";
const responseSchema = Schema.struct({ name: Schema.string });
const testApi = pipe(
Http.apiGroup("test"),
Http.get("test", "/test", { response: responseSchema }),
);
const userApi = pipe(
Http.apiGroup("Users"),
Http.get("getUser", "/user", { response: responseSchema }),
Http.post("storeUser", "/user", { response: responseSchema }),
Http.put("updateUser", "/user", { response: responseSchema }),
Http.delete("deleteUser", "/user", { response: responseSchema }),
);
const categoriesApi = pipe(
Http.apiGroup("Categories"),
Http.get("getCategory", "/category", { response: responseSchema }),
Http.post("storeCategory", "/category", { response: responseSchema }),
Http.put("updateCategory", "/category", { response: responseSchema }),
Http.delete("deleteCategory", "/category", { response: responseSchema }),
);
const api = pipe(
Http.api(),
Http.addGroup(testApi),
Http.addGroup(userApi),
Http.addGroup(categoriesApi),
);
pipe(api, Http.exampleServer, Http.listen({ port: 3000 }), Effect.runPromise);
(This is a complete standalone code example)
The OpenAPI UI will group endpoints according to the api
and show
corresponding titles for each group.
In effect-http
, calling Http.listen
under the hood performs the conversion of a
Server
onto an Express
app and then it immediately triggers listen()
on the
generated app. This is very convenient because in the userland, you deal with the
app creation using Effect
and don't need to care about details of the underlying
Express web framework.
This hiding might get in a way if you decide to adopt effect-http
into an
existing application. Because of that, the Express app derivation is exposed
as a public API of the effect-http
. The exposed function is Http.express
and it takes a configuration options and Server
object (it's curried) as
inputs and returns the generated Express
app.
As the Express documentation describes, an Express application is essentially a series of middleware function calls. This is perfect because if we decide to integrate an effect api into an express app, we can do so by simply plugging the generated app as an application-level middleware into the express app already in place.
Let's see it in action. Suppose we have the following existing express application.
import express from "express";
const legacyApp = express();
legacyApp.get("/legacy-endpoint", (_, res) => {
res.json({ hello: "world" });
});
app.listen(3000, () => console.log("Listening on 3000"));
Now, we'll create an effect-http
api and server.
import * as Schema from "@effect/schema/Schema";
import { Effect, pipe } from "effect";
import * as Http from "effect-http";
const api = pipe(
Http.api(),
Http.get("newEndpoint", "/new-endpoint", {
response: Schema.struct({ hello: Schema.string }),
}),
);
const server = pipe(
Http.server(api),
Http.handle("newEndpoint", () => Effect.succeed({ hello: "new world" })),
Http.exhaustive,
);
In order to merge these two applications, we use the aforementioned
Http.express
function to convert the server
into an Express app.
We'll receive the Express
app in the success rail of the Effect
so we can map over it and attach the legacy app there. To start
the application, use Http.listenExpress()
which has the same
signature as Http.listen
but instead of Http.Server
it takes
an Express
instance.
pipe(
server,
Http.express({ openapiPath: "/docs-new" }),
Effect.map((app) => {
app.use(legacyApp);
return app;
}),
Effect.flatMap(Http.listenExpress()),
Effect.runPromise,
);
There are some caveats we should be aware of. Middlewares and express in general
are imperative. Middlewares that execute first can end the request-response
cycle. Also, the Server
doesn't have any information about the legacy express
application and the validation, logging and OpenAPI applies only for its
routes. That's why this approach should be generally considered a transition
state. It's in place mainly to allow an incremental rewrite.
If you already manage an OpenAPI in the express app, you can use the
options of Http.express
to either disable the generated OpenAPI completely
or expose it through a different endpoint.
// Disable the generated OpenAPI
Http.express({ openapiEnabled: false });
// Or, expose the new OpenAPI within a different route
Http.express({ openapiPath: "/docs-new" });
In non-trivial applications, it is expected the Server
specification
and handlers are separated. If we define schemas purely for the purpose
of defining the Api
we'd be forced to derive their type definitions
only for the type-safety of Server
handlers. Instead, Http
provides
the Input
type helper which accepts a type of the Api
and operation
id type and produces type covering query, params and body schema type.
import * as Schema from "@effect/schema/Schema";
import { Effect, pipe } from "effect";
import * as Http from "effect-http";
const api = pipe(
Http.api({ title: "My api" }),
Http.get("stuff", "/stuff", {
response: Schema.string,
request: {
query: Schema.struct({ value: Schema.string }),
},
}),
);
type Api = typeof api;
// Notice query has type { readonly value: string; }
const handleStuff = ({ query }: Http.Input<Api, "stuff">) =>
pipe(
Effect.fail(Http.notFoundError("I didnt find it")),
Effect.tap(() => Effect.log(`Received ${query.value}`)),
);
const server = pipe(
api,
Http.server,
Http.handle("stuff", handleStuff),
Http.exhaustive,
);
pipe(server, Http.listen({ port: 3000 }), Effect.runPromise);
(This is a complete standalone code example)
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()
will result in "a positive number"
description in the OpenApi schema. One can also add custom description using the
Schema.description
combinator.
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 (Http.get
,
Http.post
etc) with a 4th argument and set the description
field to the
desired description.
import * as Schema from "@effect/schema/Schema";
import { pipe } from "effect";
import * as Http from "effect-http";
const responseSchema = pipe(
Schema.struct({
name: Schema.string,
id: pipe(Schema.number, Schema.int(), Schema.positive()),
}),
Schema.description("User"),
);
const querySchema = Schema.struct({
id: pipe(Schema.NumberFromString, Schema.description("User id")),
});
const api = pipe(
Http.api({ title: "Users API" }),
Http.get(
"getUser",
"/user",
{
response: responseSchema,
request: { query: querySchema },
},
{ description: "Returns a User by id" },
),
);
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.
Http.client
- client for the real integration with the API.Http.mockClient
- client for testing against the API interface.Http.exampleServer
- server implementation derivation with example responses.
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 Http.exampleServer
combinator to generate a Server
from Api
.
import * as Schema from "@effect/schema/Schema";
import { Effect, pipe } from "effect";
import * as Http from "effect-http";
const responseSchema = Schema.struct({ name: Schema.string });
const api = pipe(
Http.api(),
Http.get("test", "/test", { response: responseSchema }),
);
pipe(api, Http.exampleServer, Http.listen({ port: 3000 }), Effect.runPromise);
(This is a complete standalone code example)
Go to localhost:3000/docs and try calling
endpoints. The exposed HTTP service conforms the api
and will return
only valid example responses.
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 * as Schema from "@effect/schema/Schema";
import { pipe } from "effect";
import * as Http from "effect-http";
const api = pipe(
Http.api(),
Http.get("getValue", "/get-value", { response: Schema.number }),
);
In a real environment, we will probably use the derived client
using Http.client
. 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 = pipe(api, Http.mockClient());
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 = pipe(api, Http.mockClient({ responses: { getValue: 12 } }));
On the client side, headers that have the same value for all request calls
(for example USER-AGENT
) can be configured during a client creation. Such
headers can be completely omitted when an operation requiring these headers
is called. Common headers can be overriden during operation call.
Note that configuring common headers doesn't make them available for all requests. Common header will be applied only if the given endpoint declares it in its schema.
import * as Schema from "@effect/schema/Schema";
import { Effect, pipe } from "effect";
import * as Http from "effect-http";
const api = pipe(
Http.api(),
Http.get("test", "/test", {
response: Schema.struct({ name: Schema.string }),
request: {
headers: Schema.struct({ AUTHORIZATION: Schema.string }),
},
}),
);
const client = Http.client(api, new URL("http://my-url"), {
headers: {
authorization: "Basic aGVsbG8gcGF0cmlrLCBob3cgYXJlIHlvdSB0b2RheQ==",
},
});
// "x-my-header" can be provided to override the default but it's not necessary
pipe(client.test(), Effect.runPromise);
(This is a complete standalone code example)
Warning
The extension API is very experimental.
Extensions allow the Server
to be extended using effects that
are applied for all requests. The extension API is primarly
introduced to allow reusable implementations of authentication,
authorization, access logging, metrics collection, etc.
If you have a particular functionality that needs to be triggered
for every request on the server, extensions might be the way to go.
Extensions are somewhat similar to middlewares. They are represented as a list of functions with additional metadata and they run sequentially during every request handling. Contrary to middlewares, extensions can't arbitrarly interupt the request-response chain, they can only end it by failing. Also, extensions can't perform mutations of the request object. The following extension types are supported.
BeforeHandlerExtension
- runs before every endpoint handler.AfterHandlerExtension
- runs after every sucessful endpoint handler.OnErrorExtension
- runs when the handling fails.
Failure of extension effects is propagated as the endpoint error but success results of extensions are ignored. Therefore, extensions don't allow modifications of inputs and outputs of endpoints.
There are built-in extensions in the effect-http
in the Extensions
module.
Extension | Description | Applied by default |
---|---|---|
accessLogExtension |
access logs for handled requests | ✅ |
errorLogExtension |
failed requests are logged out | ✅ |
uuidLogAnnotationExtension |
generates a UUID for every request and uses it in log annotations | ❌ |
endpointCallsMetricExtension |
measures how many times each endpoint was called in a server.endpoint_calls counter metrics |
❌ |
basicAuthExtension |
authentication / authorization using basic auth | ❌ |
In the following example, uuid-log-annotation
, access-log
and endpoint-calls-metric
extensions are used. Collected metrics
are accesible as raw data through the /metrics endpoint.
import * as Schema from "@effect/schema/Schema";
import { Effect, Metric, pipe } from "effect";
import * as Http from "effect-http";
const api = pipe(
Http.api({ title: "Users API" }),
Http.get(
"getUser",
"/user",
{ response: Schema.string },
{ description: "Returns a User by id" },
),
Http.get("metrics", "/metrics", { response: Schema.any }),
);
const server = pipe(
api,
Http.server,
Http.handle("getUser", () => Effect.succeed("Hello")),
Http.handle("metrics", () => Metric.snapshot()),
Http.prependExtension(Http.uuidLogAnnotationExtension()),
Http.addExtension(Http.endpointCallsMetricExtension()),
Http.exhaustive,
);
pipe(server, Http.listen({ port: 3000 }), Effect.runPromise);
Extensions can be constructed using the following constructors.
Http.afterHandlerExtension
Http.beforeHandlerExtension
Http.onHandlerErrorExtension
The example bellow would fail every request with the authorization error.
const myExtension = Http.beforeHandlerExtension("example-auth", () =>
Effect.fail(Http.unauthorizedError("sorry bro")),
);
const server = pipe(
api,
Http.server,
Http.addExtension(myExtension),
Http.exhaustive,
);
This library is tested against nodejs 20.9.0.