@effect/platform
v0.69.28
Published
Unified interfaces for common platform-specific services
Downloads
1,318,841
Keywords
Readme
Introduction
Welcome to the documentation for @effect/platform
, a library designed for creating platform-independent abstractions (Node.js, Bun, browsers).
[!WARNING] This documentation focuses on unstable modules. For stable modules, refer to the official website documentation.
HTTP API
Overview
The HttpApi
family of modules provide a declarative way to define HTTP APIs.
You can create an API by combining multiple endpoints, each with its own set of
schemas that define the request and response types.
After you have defined your API, you can use it to implement a server or derive a client that can interact with the server.
Defining an API
To define an API, you need to create a set of endpoints. Each endpoint is defined by a path, a method, and a set of schemas that define the request and response types.
Each set of endpoints is added to an HttpApiGroup
, which can be combined with
other groups to create a complete API.
Your first HttpApiGroup
Let's define a simple CRUD API for managing users. First, we need to make an
HttpApiGroup
that contains our endpoints.
import { HttpApiEndpoint, HttpApiGroup } from "@effect/platform"
import { Schema } from "effect"
// Our domain "User" Schema
class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.String,
createdAt: Schema.DateTimeUtc
}) {}
const usersApi = HttpApiGroup.make("users")
.add(
// each endpoint has a name and a path
HttpApiEndpoint.get("findById", "/users/:id")
// the endpoint can have a Schema for a successful response
.addSuccess(User)
// and here is a Schema for the path parameters
.setPath(
Schema.Struct({
id: Schema.NumberFromString
})
)
)
.add(
HttpApiEndpoint.post("create", "/users")
.addSuccess(User)
// and here is a Schema for the request payload / body
//
// this is a POST request, so the payload is in the body
// but for a GET request, the payload would be in the URL search params
.setPayload(
Schema.Struct({
name: Schema.String
})
)
)
// by default, the endpoint will respond with a 204 No Content
.add(HttpApiEndpoint.del("delete", "/users/:id"))
.add(
HttpApiEndpoint.patch("update", "/users/:id")
.addSuccess(User)
.setPayload(
Schema.Struct({
name: Schema.String
})
)
)
You can also extend the HttpApiGroup
with a class to gain an opaque type.
We will use this API style in the following examples:
class UsersApi extends HttpApiGroup.make("users").add(
HttpApiEndpoint.get("findById", "/users/:id")
// ... same as above
) {}
Creating the top level HttpApi
Once you have defined your groups, you can combine them into a single HttpApi
.
import { HttpApi } from "@effect/platform"
class MyApi extends HttpApi.empty.add(UsersApi) {}
Or with the non-opaque style:
const api = HttpApi.empty.add(usersApi)
Adding OpenApi annotations
You can add OpenApi annotations to your API by using the OpenApi
module.
Let's add a title to our UsersApi
group:
import { OpenApi } from "@effect/platform"
class UsersApi extends HttpApiGroup.make("users")
.add(
HttpApiEndpoint.get("findById", "/users/:id")
// ... same as above
)
// add an OpenApi title & description
// You can set one attribute at a time
.annotate(OpenApi.Title, "Users API")
// or multiple at once
.annotateContext(
OpenApi.annotations({
title: "Users API",
description: "API for managing users"
})
) {}
Now when you generate OpenApi documentation, the title and description will be included.
You can also add OpenApi annotations to the top-level HttpApi
:
class MyApi extends HttpApi.empty
.add(UsersApi)
.annotate(OpenApi.Title, "My API") {}
Adding errors
You can add error responses to your endpoints using the following apis:
HttpApiEndpoint.addError
- add an error response for a single endpointHttpApiGroup.addError
- add an error response for all endpoints in a groupHttpApi.addError
- add an error response for all endpoints in the api
The group & api level errors are useful for adding common error responses that can be used in middleware.
Here is an example of adding a 404 error to the UsersApi
group:
// define the error schemas
class UserNotFound extends Schema.TaggedError<UserNotFound>()(
"UserNotFound",
{}
) {}
class Unauthorized extends Schema.TaggedError<Unauthorized>()(
"Unauthorized",
{}
) {}
class UsersApi extends HttpApiGroup.make("users")
.add(
HttpApiEndpoint.get("findById", "/users/:id")
// here we are adding our error response
.addError(UserNotFound, { status: 404 })
.addSuccess(User)
.setPath(Schema.Struct({ id: Schema.NumberFromString }))
)
// or we could add an error to the group
.addError(Unauthorized, { status: 401 }) {}
It is worth noting that you can add multiple error responses to an endpoint,
just by calling HttpApiEndpoint.addError
multiple times.
Multipart requests
If you need to handle file uploads, you can use the HttpApiSchema.Multipart
api to flag a HttpApiEndpoint
payload schema as a multipart request.
You can then use the schemas from the Multipart
module to define the expected
shape of the multipart request.
import { HttpApiSchema, Multipart } from "@effect/platform"
class UsersApi extends HttpApiGroup.make("users").add(
HttpApiEndpoint.post("upload", "/users/upload").setPayload(
HttpApiSchema.Multipart(
Schema.Struct({
// add a "files" field to the schema
files: Multipart.FilesSchema
})
)
)
) {}
Changing the response encoding
By default, the response is encoded as JSON. You can change the encoding using
the HttpApiSchema.withEncoding
api.
Here is an example of changing the encoding to text/csv:
class UsersApi extends HttpApiGroup.make("users").add(
HttpApiEndpoint.get("csv", "/users/csv").addSuccess(
Schema.String.pipe(
HttpApiSchema.withEncoding({
kind: "Text",
contentType: "text/csv"
})
)
)
) {}
Implementing a server
Now that you have defined your API, you can implement a server that serves the endpoints.
The HttpApiBuilder
module provides all the apis you need to implement your
server.
Implementing a HttpApiGroup
First up, let's implement an UsersApi
group with a single findById
endpoint.
The HttpApiBuilder.group
api takes the HttpApi
definition, the group name,
and a function that adds the handlers required for the group.
Each endpoint is implemented using the HttpApiBuilder.handle
api.
import {
HttpApi,
HttpApiBuilder,
HttpApiEndpoint,
HttpApiGroup
} from "@effect/platform"
import { DateTime, Effect, Layer, Schema } from "effect"
// here is our api definition
class User extends Schema.Class<User>("User")({
id: Schema.Number,
name: Schema.String,
createdAt: Schema.DateTimeUtc
}) {}
class UsersApi extends HttpApiGroup.make("users").add(
HttpApiEndpoint.get("findById", "/users/:id")
.addSuccess(User)
.setPath(
Schema.Struct({
id: Schema.NumberFromString
})
)
) {}
class MyApi extends HttpApi.empty.add(UsersApi) {}
// --------------------------------------------
// Implementation
// --------------------------------------------
// the `HttpApiBuilder.group` api returns a `Layer`
const UsersApiLive: Layer.Layer<HttpApiGroup.ApiGroup<"users">> =
HttpApiBuilder.group(MyApi, "users", (handlers) =>
handlers
// the parameters & payload are passed to the handler function.
.handle("findById", ({ path: { id } }) =>
Effect.succeed(
new User({
id,
name: "John Doe",
createdAt: DateTime.unsafeNow()
})
)
)
)
Using services inside a HttpApiGroup
If you need to use services inside your handlers, you can return an
Effect
from the HttpApiBuilder.group
api.
class UsersRepository extends Context.Tag("UsersRepository")<
UsersRepository,
{
readonly findById: (id: number) => Effect.Effect<User>
}
>() {}
// the dependencies will show up in the resulting `Layer`
const UsersApiLive: Layer.Layer<
HttpApiGroup.ApiGroup<"users">,
never,
UsersRepository
> = HttpApiBuilder.group(MyApi, "users", (handlers) =>
// we can return an Effect that creates our handlers
Effect.gen(function* () {
const repository = yield* UsersRepository
return handlers.handle("findById", ({ path: { id } }) =>
repository.findById(id)
)
})
)
Implementing a HttpApi
Once all your groups are implemented, you can implement the top-level HttpApi
.
This is done using the HttpApiBuilder.api
api, and then using Layer.provide
to add all the group implementations.
const MyApiLive: Layer.Layer<HttpApi.Api> = HttpApiBuilder.api(MyApi).pipe(
Layer.provide(UsersApiLive)
)
Serving the API
Finally, you can serve the API using the HttpApiBuilder.serve
api.
You can also add middleware to the server using the HttpMiddleware
module, or
use some of the middleware Layer's from the HttpApiBuilder
module.
import { HttpMiddleware, HttpServer } from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { createServer } from "node:http"
// use the `HttpApiBuilder.serve` function to register our API with the HTTP
// server
const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
// Add CORS middleware
Layer.provide(HttpApiBuilder.middlewareCors()),
// Provide the API implementation
Layer.provide(MyApiLive),
// Log the address the server is listening on
HttpServer.withLogAddress,
// Provide the HTTP server implementation
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)
// run the server
Layer.launch(HttpLive).pipe(NodeRuntime.runMain)
Serving Swagger documentation
You can add Swagger documentation to your API using the HttpApiSwagger
module.
You just need to provide the HttpApiSwagger.layer
to your server
implementation:
import { HttpApiSwagger } from "@effect/platform"
const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe(
// add the swagger documentation layer
Layer.provide(
HttpApiSwagger.layer({
// "/docs" is the default path for the swagger documentation
path: "/docs"
})
),
Layer.provide(HttpApiBuilder.middlewareCors()),
Layer.provide(MyApiLive),
Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 }))
)
Adding middleware
Defining middleware
The HttpApiMiddleware
module provides a way to add middleware to your API.
You can create a HttpApiMiddleware.Tag
that represents your middleware, which
allows you to set:
failure
- a Schema for any errors that the middleware can returnprovides
- aContext.Tag
that the middleware will providesecurity
-HttpApiSecurity
definitions that the middleware will implementoptional
- a boolean that indicates that if the middleware fails with an expected error, the request should continue. When using optional middleware,provides
&failure
options will not affect the handlers or final error type.
Here is an example of defining a simple logger middleware:
import {
HttpApiEndpoint,
HttpApiGroup,
HttpApiMiddleware
} from "@effect/platform"
import { Schema } from "effect"
class LoggerError extends Schema.TaggedError<LoggerError>()(
"LoggerError",
{}
) {}
// first extend the HttpApiMiddleware.Tag class
class Logger extends HttpApiMiddleware.Tag<Logger>()("Http/Logger", {
// optionally define any errors that the middleware can return
failure: LoggerError
}) {}
// apply the middleware to an `HttpApiGroup`
class UsersApi extends HttpApiGroup.make("users")
.add(
HttpApiEndpoint.get("findById", "/:id")
// apply the middleware to a single endpoint
.middleware(Logger)
)
// or apply the middleware to the group
.middleware(Logger) {}
Defining security middleware
The HttpApiSecurity
module provides a way to add security annotations to your
API.
It offers the following authorization types:
HttpApiSecurity.apiKey
- API key authorization through headers, query parameters, or cookies.HttpApiSecurity.basicAuth
- HTTP Basic authentication.HttpApiSecurity.bearerAuth
- Bearer token authentication.
You can then use these security annotations in combination with HttpApiMiddleware
to define middleware that will protect your endpoints.
import {
HttpApiGroup,
HttpApiEndpoint,
HttpApiMiddleware,
HttpApiSchema,
HttpApiSecurity
} from "@effect/platform"
import { Context, Schema } from "effect"
class User extends Schema.Class<User>("User")({ id: Schema.Number }) {}
class Unauthorized extends Schema.TaggedError<Unauthorized>()(
"Unauthorized",
{},
HttpApiSchema.annotations({ status: 401 })
) {}
class CurrentUser extends Context.Tag("CurrentUser")<CurrentUser, User>() {}
// first extend the HttpApiMiddleware.Tag class
class Authorization extends HttpApiMiddleware.Tag<Authorization>()(
"Authorization",
{
// add your error schema
failure: Unauthorized,
// add the Context.Tag that the middleware will provide
provides: CurrentUser,
// add the security definitions
security: {
// the object key is a custom name for the security definition
myBearer: HttpApiSecurity.bearer
// You can add more security definitions here.
// They will attempt to be resolved in the order they are defined
}
}
) {}
// apply the middleware to an `HttpApiGroup`
class UsersApi extends HttpApiGroup.make("users")
.add(
HttpApiEndpoint.get("findById", "/:id")
// apply the middleware to a single endpoint
.middleware(Authorization)
)
// or apply the middleware to the group
.middleware(Authorization) {}
Implementing HttpApiMiddleware
Once your HttpApiMiddleware
is defined, you can use the
HttpApiMiddleware.Tag
definition to implement your middleware.
By using the Layer
apis, you can create a Layer that implements your
middleware.
Here is an example:
import { HttpApiMiddleware, HttpServerRequest } from "@effect/platform"
import { Effect, Layer } from "effect"
class Logger extends HttpApiMiddleware.Tag<Logger>()("Http/Logger") {}
const LoggerLive = Layer.effect(
Logger,
Effect.gen(function* () {
yield* Effect.log("creating Logger middleware")
// standard middleware is just an Effect, that can access the `HttpRouter`
// context.
return Logger.of(
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
yield* Effect.log(`Request: ${request.method} ${request.url}`)
})
)
})
)
When the Layer
is created, you can then provide it to your group layers:
const UsersApiLive = HttpApiBuilder.group(...).pipe(
Layer.provide(LoggerLive)
)
Implementing HttpApiSecurity
middleware
If you are using HttpApiSecurity
in your middleware, implementing the Layer
looks a bit different.
Here is an example of implementing a HttpApiSecurity.bearer
middleware:
import {
HttpApiMiddleware,
HttpApiSchema,
HttpApiSecurity
} from "@effect/platform"
import { Context, Effect, Layer, Redacted, Schema } from "effect"
class User extends Schema.Class<User>("User")({ id: Schema.Number }) {}
class Unauthorized extends Schema.TaggedError<Unauthorized>()(
"Unauthorized",
{},
HttpApiSchema.annotations({ status: 401 })
) {}
class CurrentUser extends Context.Tag("CurrentUser")<CurrentUser, User>() {}
class Authorization extends HttpApiMiddleware.Tag<Authorization>()(
"Authorization",
{
failure: Unauthorized,
provides: CurrentUser,
security: { myBearer: HttpApiSecurity.bearer }
}
) {}
const AuthorizationLive = Layer.effect(
Authorization,
Effect.gen(function* () {
yield* Effect.log("creating Authorization middleware")
// return the security handlers
return Authorization.of({
myBearer: (bearerToken) =>
Effect.gen(function* () {
yield* Effect.log(
"checking bearer token",
Redacted.value(bearerToken)
)
// return the `User` that will be provided as the `CurrentUser`
return new User({ id: 1 })
})
})
})
)
Setting HttpApiSecurity
cookies
If you need to set the security cookie from within a handler, you can use the
HttpApiBuilder.securitySetCookie
api.
By default, the cookie will be set with the HttpOnly
and Secure
flags.
const security = HttpApiSecurity.apiKey({
in: "cookie",
key: "token"
})
const UsersApiLive = HttpApiBuilder.group(MyApi, "users", (handlers) =>
handlers.handle("login", () =>
// set the security cookie
HttpApiBuilder.securitySetCookie(security, Redacted.make("keep me secret"))
)
)
Deriving a client
Once you have defined your API, you can derive a client that can interact with the server.
The HttpApiClient
module provides all the apis you need to derive a client.
import { HttpApiClient } from "@effect/platform"
Effect.gen(function* () {
const client = yield* HttpApiClient.make(MyApi, {
baseUrl: "http://localhost:3000"
// You can transform the HttpClient to add things like authentication
// transformClient: ....
})
const user = yield* client.users.findById({ path: { id: 1 } })
yield* Effect.log(user)
})
HTTP Client
Overview
The @effect/platform/HttpClient*
modules provide a way to send HTTP requests,
handle responses, and abstract over the differences between platforms.
The HttpClient
interface has a set of methods for sending requests:
.execute
- takes a HttpClientRequest and returns aHttpClientResponse
.{get, del, head, options, patch, post, put}
- convenience methods for creating a request and executing it in one step
To access the HttpClient
, you can use the HttpClient.HttpClient
tag.
This will give you access to a HttpClient
instance.
Example: Retrieving JSON Data (GET)
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Effect } from "effect"
const program = Effect.gen(function* () {
// Access HttpClient
const client = yield* HttpClient.HttpClient
// Create and execute a GET request
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
const json = yield* response.json
console.log(json)
}).pipe(
// Ensure request is aborted if the program is interrupted
Effect.scoped,
// Provide the HttpClient
Effect.provide(FetchHttpClient.layer)
)
Effect.runPromise(program)
/*
Output:
{
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
*/
Example: Retrieving JSON Data with accessor apis (GET)
The HttpClient
module also provides a set of accessor apis that allow you to
easily send requests without first accessing the HttpClient
service.
Below is an example of using the get
accessor api to send a GET request:
(The following examples will continue to use the HttpClient
service approach).
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Effect } from "effect"
const program = HttpClient.get(
"https://jsonplaceholder.typicode.com/posts/1"
).pipe(
Effect.andThen((response) => response.json),
Effect.scoped,
Effect.provide(FetchHttpClient.layer)
)
Effect.runPromise(program)
/*
Output:
{
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
*/
Example: Creating and Executing a Custom Request
Using HttpClientRequest, you can create and then execute a request. This is useful for customizing the request further.
import {
FetchHttpClient,
HttpClient,
HttpClientRequest
} from "@effect/platform"
import { Effect } from "effect"
const program = Effect.gen(function* () {
// Access HttpClient
const client = yield* HttpClient.HttpClient
// Create a GET request
const req = HttpClientRequest.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
// Optionally customize the request
// Execute the request and get the response
const response = yield* client.execute(req)
const json = yield* response.json
console.log(json)
}).pipe(
// Ensure request is aborted if the program is interrupted
Effect.scoped,
// Provide the HttpClient
Effect.provide(FetchHttpClient.layer)
)
Effect.runPromise(program)
/*
Output:
{
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
*/
Understanding Scope
When working with a request, note that there is a Scope
requirement:
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Effect } from "effect"
// const program: Effect<void, HttpClientError, Scope>
const program = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
const json = yield* response.json
console.log(json)
}).pipe(
// Provide the HttpClient implementation without scoping
Effect.provide(FetchHttpClient.layer)
)
A Scope
is required because there is an open connection between the HTTP response and the body processing. For instance, if you have a streaming body, you receive the response before processing the body. This connection is managed within a scope, and using Effect.scoped
controls when it is closed.
Customize a HttpClient
The HttpClient
module allows you to customize the client in various ways. For instance, you can log details of a request before execution using the tapRequest
function.
Example: Tapping
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Console, Effect } from "effect"
const program = Effect.gen(function* () {
const client = (yield* HttpClient.HttpClient).pipe(
// Log the request before fetching
HttpClient.tapRequest(Console.log)
)
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
const json = yield* response.json
console.log(json)
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
Effect.runPromise(program)
/*
Output:
{
_id: '@effect/platform/HttpClientRequest',
method: 'GET',
url: 'https://jsonplaceholder.typicode.com/posts/1',
urlParams: [],
hash: { _id: 'Option', _tag: 'None' },
headers: Object <[Object: null prototype]> {},
body: { _id: '@effect/platform/HttpBody', _tag: 'Empty' }
}
{
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
*/
Operations Summary
| Operation | Description |
| ------------------------ | --------------------------------------------------------------------------------------- |
| get
,post
,put
... | Send a request without first accessing the HttpClient
service. |
| filterOrElse
| Filters the result of a response, or runs an alternative effect if the predicate fails. |
| filterOrFail
| Filters the result of a response, or throws an error if the predicate fails. |
| filterStatus
| Filters responses by HTTP status code. |
| filterStatusOk
| Filters responses that return a 2xx status code. |
| followRedirects
| Follows HTTP redirects up to a specified number of times. |
| mapRequest
| Appends a transformation of the request object before sending it. |
| mapRequestEffect
| Appends an effectful transformation of the request object before sending it. |
| mapRequestInput
| Prepends a transformation of the request object before sending it. |
| mapRequestInputEffect
| Prepends an effectful transformation of the request object before sending it. |
| retry
| Retries the request based on a provided schedule or policy. |
| tap
| Performs an additional effect after a successful request. |
| tapRequest
| Performs an additional effect on the request before sending it. |
| withCookiesRef
| Associates a Ref
of cookies with the client for handling cookies across requests. |
| withTracerDisabledWhen
| Disables tracing for specific requests based on a provided predicate. |
| withTracerPropagation
| Enables or disables tracing propagation for the request. |
Mapping Requests
Note that mapRequest
and mapRequestEffect
add transformations at the end of the request chain, while mapRequestInput
and mapRequestInputEffect
apply transformations at the start:
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Effect } from "effect"
const program = Effect.gen(function* () {
const client = (yield* HttpClient.HttpClient).pipe(
// Append transformation
HttpClient.mapRequest((req) => {
console.log(1)
return req
}),
// Another append transformation
HttpClient.mapRequest((req) => {
console.log(2)
return req
}),
// Prepend transformation, this executes first
HttpClient.mapRequestInput((req) => {
console.log(3)
return req
})
)
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
const json = yield* response.json
console.log(json)
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
Effect.runPromise(program)
/*
Output:
3
1
2
{
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
*/
Persisting Cookies
You can manage cookies across requests using the HttpClient.withCookiesRef
function, which associates a reference to a Cookies
object with the client.
import { Cookies, FetchHttpClient, HttpClient } from "@effect/platform"
import { Effect, Ref } from "effect"
const program = Effect.gen(function* () {
// Create a reference to store cookies
const ref = yield* Ref.make(Cookies.empty)
// Access the HttpClient and associate the cookies reference with it
const client = (yield* HttpClient.HttpClient).pipe(
HttpClient.withCookiesRef(ref)
)
// Make a GET request to the specified URL
yield* client.get("https://www.google.com/")
// Log the keys of the cookies stored in the reference
console.log(Object.keys((yield* ref).cookies))
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
Effect.runPromise(program)
// Output: [ 'SOCS', 'AEC', '__Secure-ENID' ]
RequestInit Options
You can customize the FetchHttpClient
by passing RequestInit
options to configure aspects of the HTTP requests, such as credentials, headers, and more.
In this example, we customize the FetchHttpClient
to include credentials with every request:
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Effect, Layer } from "effect"
const CustomFetchLive = FetchHttpClient.layer.pipe(
Layer.provide(
Layer.succeed(FetchHttpClient.RequestInit, {
credentials: "include"
})
)
)
const program = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
const json = yield* response.json
console.log(json)
}).pipe(Effect.scoped, Effect.provide(CustomFetchLive))
Create a Custom HttpClient
You can create a custom HttpClient
using the HttpClient.make
function. This allows you to simulate or mock server responses within your application.
import { HttpClient, HttpClientResponse } from "@effect/platform"
import { Effect, Layer } from "effect"
const myClient = HttpClient.make((req) =>
Effect.succeed(
HttpClientResponse.fromWeb(
req,
// Simulate a response from a server
new Response(
JSON.stringify({
userId: 1,
id: 1,
title: "title...",
body: "body..."
})
)
)
)
)
const program = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
const json = yield* response.json
console.log(json)
}).pipe(
Effect.scoped,
// Provide the HttpClient
Effect.provide(Layer.succeed(HttpClient.HttpClient, myClient))
)
Effect.runPromise(program)
/*
Output:
{ userId: 1, id: 1, title: 'title...', body: 'body...' }
*/
HttpClientRequest
Overview
You can create a HttpClientRequest
using the following provided constructors:
| Constructor | Description |
| --------------------------- | ------------------------- |
| HttpClientRequest.del
| Create a DELETE request |
| HttpClientRequest.get
| Create a GET request |
| HttpClientRequest.head
| Create a HEAD request |
| HttpClientRequest.options
| Create an OPTIONS request |
| HttpClientRequest.patch
| Create a PATCH request |
| HttpClientRequest.post
| Create a POST request |
| HttpClientRequest.put
| Create a PUT request |
Setting Headers
When making HTTP requests, sometimes you need to include additional information in the request headers. You can set headers using the setHeader
function for a single header or setHeaders
for multiple headers simultaneously.
import { HttpClientRequest } from "@effect/platform"
const req = HttpClientRequest.get("https://api.example.com/data").pipe(
// Setting a single header
HttpClientRequest.setHeader("Authorization", "Bearer your_token_here"),
// Setting multiple headers
HttpClientRequest.setHeaders({
"Content-Type": "application/json; charset=UTF-8",
"Custom-Header": "CustomValue"
})
)
console.log(JSON.stringify(req.headers, null, 2))
/*
Output:
{
"authorization": "Bearer your_token_here",
"content-type": "application/json; charset=UTF-8",
"custom-header": "CustomValue"
}
*/
basicAuth
To include basic authentication in your HTTP request, you can use the basicAuth
method provided by HttpClientRequest
.
import { HttpClientRequest } from "@effect/platform"
const req = HttpClientRequest.get("https://api.example.com/data").pipe(
HttpClientRequest.basicAuth("your_username", "your_password")
)
console.log(JSON.stringify(req.headers, null, 2))
/*
Output:
{
"authorization": "Basic eW91cl91c2VybmFtZTp5b3VyX3Bhc3N3b3Jk"
}
*/
bearerToken
To include a Bearer token in your HTTP request, use the bearerToken
method provided by HttpClientRequest
.
import { HttpClientRequest } from "@effect/platform"
const req = HttpClientRequest.get("https://api.example.com/data").pipe(
HttpClientRequest.bearerToken("your_token")
)
console.log(JSON.stringify(req.headers, null, 2))
/*
Output:
{
"authorization": "Bearer your_token"
}
*/
accept
To specify the media types that are acceptable for the response, use the accept
method provided by HttpClientRequest
.
import { HttpClientRequest } from "@effect/platform"
const req = HttpClientRequest.get("https://api.example.com/data").pipe(
HttpClientRequest.accept("application/xml")
)
console.log(JSON.stringify(req.headers, null, 2))
/*
Output:
{
"accept": "application/xml"
}
*/
acceptJson
To indicate that the client accepts JSON responses, use the acceptJson
method provided by HttpClientRequest
.
import { HttpClientRequest } from "@effect/platform"
const req = HttpClientRequest.get("https://api.example.com/data").pipe(
HttpClientRequest.acceptJson
)
console.log(JSON.stringify(req.headers, null, 2))
/*
Output:
{
"accept": "application/json"
}
*/
GET
Converting the Response
The HttpClientResponse
provides several methods to convert a response into different formats.
Example: Converting to JSON
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const getPostAsJson = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
return yield* response.json
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
getPostAsJson.pipe(
Effect.andThen((post) => Console.log(typeof post, post)),
NodeRuntime.runMain
)
/*
Output:
object {
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
*/
Example: Converting to Text
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const getPostAsText = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
return yield* response.text
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
getPostAsText.pipe(
Effect.andThen((post) => Console.log(typeof post, post)),
NodeRuntime.runMain
)
/*
Output:
string {
userId: 1,
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
body: 'quia et suscipit\n' +
'suscipit recusandae consequuntur expedita et cum\n' +
'reprehenderit molestiae ut ut quas totam\n' +
'nostrum rerum est autem sunt rem eveniet architecto'
}
*/
Methods Summary
| Method | Description |
| --------------- | ------------------------------------- |
| arrayBuffer
| Convert to ArrayBuffer
|
| formData
| Convert to FormData
|
| json
| Convert to JSON |
| stream
| Convert to a Stream
of Uint8Array
|
| text
| Convert to text |
| urlParamsBody
| Convert to UrlParams
|
Decoding Data with Schemas
A common use case when fetching data is to validate the received format. For this purpose, the HttpClientResponse
module is integrated with effect/Schema
.
import {
FetchHttpClient,
HttpClient,
HttpClientResponse
} from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect, Schema } from "effect"
const Post = Schema.Struct({
id: Schema.Number,
title: Schema.String
})
const getPostAndValidate = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/posts/1"
)
return yield* HttpClientResponse.schemaBodyJson(Post)(response)
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
getPostAndValidate.pipe(Effect.andThen(Console.log), NodeRuntime.runMain)
/*
Output:
{
id: 1,
title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit'
}
*/
In this example, we define a schema for a post object with properties id
and title
. Then, we fetch the data and validate it against this schema using HttpClientResponse.schemaBodyJson
. Finally, we log the validated post object.
Note that we use Effect.scoped
after consuming the response. This ensures that any resources associated with the HTTP request are properly cleaned up once we're done processing the response.
Filtering And Error Handling
It's important to note that HttpClient.get
doesn't consider non-200
status codes as errors by default. This design choice allows for flexibility in handling different response scenarios. For instance, you might have a schema union where the status code serves as the discriminator, enabling you to define a schema that encompasses all possible response cases.
You can use HttpClient.filterStatusOk
to ensure only 2xx
responses are treated as successes.
In this example, we attempt to fetch a non-existent page and don't receive any error:
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const getText = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/non-existing-page"
)
return yield* response.text
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
getText.pipe(Effect.andThen(Console.log), NodeRuntime.runMain)
/*
Output:
{}
*/
However, if we use HttpClient.filterStatusOk
, an error is logged:
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const getText = Effect.gen(function* () {
const client = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk)
const response = yield* client.get(
"https://jsonplaceholder.typicode.com/non-existing-page"
)
return yield* response.text
}).pipe(Effect.scoped, Effect.provide(FetchHttpClient.layer))
getText.pipe(Effect.andThen(Console.log), NodeRuntime.runMain)
/*
Output:
[17:37:59.923] ERROR (#0):
ResponseError: StatusCode: non 2xx status code (404 GET https://jsonplaceholder.typicode.com/non-existing-page)
... stack trace ...
*/
POST
To make a POST request, you can use the HttpClientRequest.post
function provided by the HttpClientRequest
module. Here's an example of how to create and send a POST request:
import {
FetchHttpClient,
HttpClient,
HttpClientRequest
} from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const addPost = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return yield* HttpClientRequest.post(
"https://jsonplaceholder.typicode.com/posts"
).pipe(
HttpClientRequest.bodyJson({
title: "foo",
body: "bar",
userId: 1
}),
Effect.flatMap(client.execute),
Effect.flatMap((res) => res.json),
Effect.scoped
)
}).pipe(Effect.provide(FetchHttpClient.layer))
addPost.pipe(Effect.andThen(Console.log), NodeRuntime.runMain)
/*
Output:
{ title: 'foo', body: 'bar', userId: 1, id: 101 }
*/
If you need to send data in a format other than JSON, such as plain text, you can use different APIs provided by HttpClientRequest
.
In the following example, we send the data as text:
import {
FetchHttpClient,
HttpClient,
HttpClientRequest
} from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect } from "effect"
const addPost = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return yield* HttpClientRequest.post(
"https://jsonplaceholder.typicode.com/posts"
).pipe(
HttpClientRequest.bodyText(
JSON.stringify({
title: "foo",
body: "bar",
userId: 1
}),
"application/json; charset=UTF-8"
),
client.execute,
Effect.flatMap((res) => res.json),
Effect.scoped
)
}).pipe(Effect.provide(FetchHttpClient.layer))
addPost.pipe(Effect.andThen(Console.log), NodeRuntime.runMain)
/*
Output:
{ title: 'foo', body: 'bar', userId: 1, id: 101 }
*/
Decoding Data with Schemas
A common use case when fetching data is to validate the received format. For this purpose, the HttpClientResponse
module is integrated with effect/Schema
.
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
HttpClientResponse
} from "@effect/platform"
import { NodeRuntime } from "@effect/platform-node"
import { Console, Effect, Schema } from "effect"
const Post = Schema.Struct({
id: Schema.Number,
title: Schema.String
})
const addPost = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return yield* HttpClientRequest.post(
"https://jsonplaceholder.typicode.com/posts"
).pipe(
HttpClientRequest.bodyText(
JSON.stringify({
title: "foo",
body: "bar",
userId: 1
}),
"application/json; charset=UTF-8"
),
client.execute,
Effect.flatMap(HttpClientResponse.schemaBodyJson(Post)),
Effect.scoped
)
}).pipe(Effect.provide(FetchHttpClient.layer))
addPost.pipe(Effect.andThen(Console.log), NodeRuntime.runMain)
/*
Output:
{ id: 101, title: 'foo' }
*/
Testing
Injecting Fetch
To test HTTP requests, you can inject a mock fetch implementation.
import { FetchHttpClient, HttpClient } from "@effect/platform"
import { Effect, Layer } from "effect"
import * as assert from "node:assert"
// Mock fetch implementation
const FetchTest = Layer.succeed(FetchHttpClient.Fetch, () =>
Promise.resolve(new Response("not found", { status: 404 }))
)
const TestLayer = FetchHttpClient.layer.pipe(Layer.provide(FetchTest))
const program = Effect.gen(function* () {
const client = yield* HttpClient.HttpClient
return yield* client.get("https://www.google.com/").pipe(
Effect.flatMap((res) => res.text),
Effect.scoped
)
})
// Test
Effect.gen(function* () {
const response = yield* program
assert.equal(response, "not found")
}).pipe(Effect.provide(TestLayer), Effect.runPromise)
HTTP Server
Overview
This section provides a simplified explanation of key concepts within the @effect/platform
TypeScript library, focusing on components used to build HTTP servers. Understanding these terms and their relationships helps in structuring and managing server applications effectively.
Core Concepts
HttpApp: This is an
Effect
which results in a valueA
. It can utilizeServerRequest
to produce the outcomeA
. Essentially, anHttpApp
represents an application component that handles HTTP requests and generates responses based on those requests.Default (HttpApp): A special type of
HttpApp
that specifically produces aServerResponse
as its outputA
. This is the most common form of application where each interaction is expected to result in an HTTP response.Server: A construct that takes a
Default
app and converts it into anEffect
. This serves as the execution layer where theDefault
app is operated, handling incoming requests and serving responses.Router: A type of
Default
app where the possible error outcome isRouteNotFound
. Routers are used to direct incoming requests to appropriate handlers based on the request path and method.Handler: Another form of
Default
app, which has access to bothRouteContext
andServerRequest.ParsedSearchParams
. Handlers are specific functions designed to process requests and generate responses.Middleware: Functions that transform a
Default
app into anotherDefault
app. Middleware can be used to modify requests, responses, or handle tasks like logging, authentication, and more. Middleware can be applied in two ways:- On a
Router
usingrouter.use: Handler -> Default
which applies the middleware to specific routes. - On a
Server
usingserver.serve: () -> Layer | Middleware -> Layer
which applies the middleware globally to all routes handled by the server.
- On a
Applying Concepts
These components are designed to work together in a modular and flexible way, allowing developers to build complex server applications with reusable components. Here's how you might typically use these components in a project:
Create Handlers: Define functions that process specific types of requests (e.g., GET, POST) and return responses.
Set Up Routers: Organize handlers into routers, where each router manages a subset of application routes.
Apply Middleware: Enhance routers or entire servers with middleware to add extra functionality like error handling or request logging.
Initialize the Server: Wrap the main router with server functionality, applying any server-wide middleware, and start listening for requests.
Getting Started
Hello world example
In this example, we will create a simple HTTP server that listens on port 3000
. The server will respond with "Hello World!" when a request is made to the root URL (/) and return a 500
error for all other paths.
Node.js Example
import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Layer } from "effect"
import { createServer } from "node:http"
// Define the router with a single route for the root URL
const router = HttpRouter.empty.pipe(
HttpRouter.get("/", HttpServerResponse.text("Hello World"))
)
// Set up the application server with logging
const app = router.pipe(HttpServer.serve(), HttpServer.withLogAddress)
// Specify the port
const port = 3000
// Create a server layer with the specified port
const ServerLive = NodeHttpServer.layer(() => createServer(), { port })
// Run the application
NodeRuntime.runMain(Layer.launch(Layer.provide(app, ServerLive)))
/*
Output:
timestamp=... level=INFO fiber=#0 message="Listening on http://localhost:3000"
*/
[!NOTE] The
HttpServer.withLogAddress
middleware logs the address and port where the server is listening, helping to confirm that the server is running correctly and accessible on the expected endpoint.
Bun Example
import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform"
import { BunHttpServer, BunRuntime } from "@effect/platform-bun"
import { Layer } from "effect"
// Define the router with a single route for the root URL
const router = HttpRouter.empty.pipe(
HttpRouter.get("/", HttpServerResponse.text("Hello World"))
)
// Set up the application server with logging
const app = router.pipe(HttpServer.serve(), HttpServer.withLogAddress)
// Specify the port
const port = 3000
// Create a server layer with the specified port
const ServerLive = BunHttpServer.layer({ port })
// Run the application
BunRuntime.runMain(Layer.launch(Layer.provide(app, ServerLive)))
/*
Output:
timestamp=... level=INFO fiber=#0 message="Listening on http://localhost:3000"
*/
To avoid boilerplate code for the final server setup, we'll use a helper function from the listen.ts
file:
import type { HttpPlatform, HttpServer } from "@effect/platform"
import { NodeHttpServer, NodeRuntime } from "@effect/platform-node"
import { Layer } from "effect"
import { createServer } from "node:http"
export const listen = (
app: Layer.Layer<
never,
never,
HttpPlatform.HttpPlatform | HttpServer.HttpServer
>,
port: number
) =>
NodeRuntime.runMain(
Layer.launch(
Layer.provide(
app,
NodeHttpServer.layer(() => createServer(), { port })
)
)
)
Basic routing
Routing refers to determining how an application responds to a client request to a particular endpoint, which is a URI (or path) and a specific HTTP request method (GET, POST, and so on).
Route definition takes the following structure:
router.pipe(HttpRouter.METHOD(PATH, HANDLER))
Where:
- router is an instance of
Router
(import type { Router } from "@effect/platform/Http/Router"
). - METHOD is an HTTP request method, in lowercase (e.g., get, post, put, del).
- PATH is the path on the server (e.g., "/", "/user").
- HANDLER is the action that gets executed when the route is matched.
The following examples illustrate defining simple routes.
Respond with "Hello World!"
on the homepage:
router.pipe(HttpRouter.get("/", HttpServerResponse.text("Hello World")))
Respond to POST request on the root route (/), the application's home page:
router.pipe(HttpRouter.post("/", HttpServerResponse.text("Got a POST request")))
Respond to a PUT request to the /user
route:
router.pipe(
HttpRouter.put("/user", HttpServerResponse.text("Got a PUT request at /user"))
)
Respond to a DELETE request to the /user
route:
router.pipe(
HttpRouter.del(
"/user",
HttpServerResponse.text("Got a DELETE request at /user")
)
)
Serving static files
To serve static files such as images, CSS files, and JavaScript files, use the HttpServerResponse.file
built-in action.
import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform"
import { listen } from "./listen.js"
const router = HttpRouter.empty.pipe(
HttpRouter.get("/", HttpServerResponse.file("index.html"))
)
const app = router.pipe(HttpServer.serve())
listen(app, 3000)
Create an index.html
file in your project directory:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<title>index.html</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</head>
<body>
index.html
</body>
</html>
Routing
Routing refers to how an application's endpoints (URIs) respond to client requests.
You define routing using methods of the HttpRouter
object that correspond to HTTP methods; for example, HttpRouter.get()
to handle GET requests and HttpRouter.post
to handle POST requests. You can also use HttpRouter.all()
to handle all HTTP methods.
These routing methods specify a Route.Handler
called when the application receives a request to the specified route (endpoint) and HTTP method. In other words, the application “listens” for requests that match the specified route(s) and method(s), and when it detects a match, it calls the specified handler.
The following code is an example of a very basic route.
// respond with "hello world" when a GET request is made to the homepage
HttpRouter.get("/", HttpServerResponse.text("Hello World"))
Route methods
A route method is derived from one of the HTTP methods, and is attached to an instance of the HttpRouter
object.
The following code is an example of routes that are defined for the GET and the POST methods to the root of the app.
// GET method route
HttpRouter.get("/", HttpServerResponse.text("GET request to the homepage"))
// POST method route
HttpRouter.post("/", HttpServerResponse.text("POST request to the homepage"))
HttpRouter
supports methods that correspond to all HTTP request methods: get
, post
, and so on.
There is a special routing method, HttpRouter.all()
, used to load middleware functions at a path for all HTTP request methods. For example, the following handler is executed for requests to the route “/secret” whether using GET, POST, PUT, DELETE.
HttpRouter.all(
"/secret",
HttpServerResponse.empty().pipe(
Effect.tap(Console.log("Accessing the secret section ..."))
)
)
Route paths
Route paths, when combined with a request method, define the endpoints where requests can be made. Route paths can be specified as strings according to the following type:
type PathInput = `/${string}` | "*"
[!NOTE] Query strings are not part of the route path.
Here are some examples of route paths based on strings.
This route path will match requests to the root route, /.
HttpRouter.get("/", HttpServerResponse.text("root"))
This route path will match requests to /user
.
HttpRouter.get("/user", HttpServerResponse.text("user"))
This route path matches requests to any path starting with /user
(e.g., /user
, /users
, etc.)
HttpRouter.get(
"/user*",
Effect.map(HttpServerRequest.HttpServerRequest, (req) =>
HttpServerResponse.text(req.url)
)
)
Route parameters
Route parameters are named URL segments that are used to capture the values specified at their position in the URL. By using a schema the captured values are populated in an object, with the name of the route parameter specified in the path as their respective keys.
Route parameters are named segments in a URL that capture the values specified at those positions. These captured values are stored in an object, with the parameter names used as keys.
For example:
Route path: /users/:userId/books/:bookId
Request URL: http://localhost:3000/users/34/books/8989
params: { "userId": "34", "bookId": "8989" }
To define routes with parameters, include the parameter names in the path and use a schema to validate and parse these parameters, as shown below.
import { HttpRouter, HttpServer, HttpServerResponse } from "@effect/platform"
import { Effect, Schema } from "effect"
import { listen } from "./listen.js"
// Define the schema for route parameters
const Params = Schema.Struct({
userId: Schema.String,
bookId: Schema.String
})
// Create a router with a route that captures parameters
const router = HttpRouter.empty.pipe(
HttpRouter.get(
"/users/:userId/books/:bookId",
HttpRouter.schemaPathParams(Params).pipe(
Effect.flatMap((params) => HttpServerResponse.json(params))
)
)
)
const app = router.pipe(HttpServer.serve())
listen(app, 3000)
Response methods
The methods on HttpServerResponse
object in the following table can send a response to the client, and terminate the request-response cycle. If none of these methods are called from a route handler, the client request will be left hanging.
| Method | Description | | ------------ | ------------------------------ | | empty | Sends an empty response. | | formData | Sends form data. | | html | Sends an HTML response. | | raw | Sends a raw response. | | setBody | Sets the body of the response. | | stream | Sends a streaming response. | | text | Sends a plain text response. |
Router
Use the HttpRouter
object to create modular, mountable route handlers. A Router
instance is a complete middleware and routing system, often referred to as a "mini-app."
The following example shows how to create a router as a module, define some routes, and mount the router module on a path in the main app.
Create a file named birds.ts
in your app directory with the following content:
import { HttpRouter, HttpServerResponse } from "@effect/platform"
export const birds = HttpRouter.empty.pipe(
HttpRouter.get("/", HttpServerResponse.text("Birds home page")),
HttpRouter.get("/about", HttpServerResponse.text("About birds"))
)
In your main application file, load the router module and mount it.
import { HttpRouter, HttpServer } from "@effect/platform"
import { birds } from "./birds.js"
import { listen } from "./listen.js"
// Create the main router and mount the birds router
const router = HttpRouter.empty.pipe(HttpRouter.mount("/birds", birds))
const app = router.pipe(HttpServer.serve())
listen(app, 3000)
When you run this code, your application will be able to handle requests to /birds
and /birds/about
, serving the respective responses defined in the birds
router module.
Writing Middleware
In this section, we'll build a simple "Hello World" application and demonstrate how to add three middleware functions: myLogger
for logging, requestTime
for displaying request timestamps, and validateCookies
for validating incoming cookies.
Example Application
Here is an example of a basic "Hello World" application with middleware.
Middleware myLogger
This middleware logs "LOGGED" whenever a request passes through it.
const myLogger = HttpMiddleware.make((app) =>
Effect.gen(function* () {
console.log("LOGGED")
return yield* app
})
)
To use the middleware, add it to the router using HttpRouter.use()
:
import {
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerResponse
} from "@effect/platform"
import { Effect } from "effect"
import { listen } from "./listen.js"
const myLogger = HttpMiddleware.make((app) =>
Effect.gen(function* () {
console.log("LOGGED")
return yield* app
})
)
const router = HttpRouter.empty.pipe(
HttpRouter.get("/", HttpServerResponse.text("Hello World"))
)
const app = router.pipe(HttpRouter.use(myLogger), HttpServer.serve())
listen(app, 3000)
With this setup, every request to the app will log "LOGGED" to the terminal. Middleware execute in the order they are loaded.
Middleware requestTime
Next, we'll create a middleware that records the timestamp of each HTTP request and provides it via a service called RequestTime
.
class RequestTime extends Context.Tag("RequestTime")<RequestTime, number>() {}
const requestTime = HttpMiddleware.make((app) =>
Effect.gen(function* () {
return yield* app.pipe(Effect.provideService(RequestTime, Date.now()))
})
)
Update the app to use this middleware and display the timestamp in the response:
import {
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerResponse
} from "@effect/platform"
import { Context, Effect } from "effect"
import { listen } from "./listen.js"
class RequestTime extends Context.Tag("RequestTime")<RequestTime, number>() {}
const requestTime = HttpMiddleware.make((app) =>
Effect.gen(function* () {
return yield* app.pipe(Effect.provideService(RequestTime, Date.now()))
})
)
const router = HttpRouter.empty.pipe(
HttpRouter.get(
"/",
Effect.gen(function* () {
const requestTime = yield* RequestTime
const responseText = `Hello World<br/><small>Requested at: ${requestTime}</small>`
return yield* HttpServerResponse.html(responseText)
})
)
)
const app = router.pipe(HttpRouter.use(requestTime), HttpServer.serve())
listen(app, 3000)
Now, when you make a request to the root path, the response will include the timestamp of the request.
Middleware validateCookies
Finally, we'll create a middleware that validates incoming cookies. If the cookies are invalid, it sends a 400 response.
Here's an example that validates cookies using an external service:
class CookieError {
readonly _tag = "CookieError"
}
const externallyValidateCookie = (testCookie: string | undefined) =>
testCookie && testCookie.length > 0
? Effect.succeed(testCookie)
: Effect.fail(new CookieError())
const cookieValidator = HttpMiddleware.make((app) =>
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
yield* externallyValidateCookie(req.cookies.testCookie)
return yield* app
}).pipe(
Effect.catchTag("CookieError", () =>
HttpServerResponse.text("Invalid cookie")
)
)
)
Update the app to use the cookieValidator
middleware:
import {
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse
} from "@effect/platform"
import { Effect } from "effect"
import { listen } from "./listen.js"
class CookieError {
readonly _tag = "CookieError"
}
const externallyValidateCookie = (testCookie: string | undefined) =>
testCookie && testCookie.length > 0
? Effect.succeed(testCookie)
: Effect.fail(new CookieError())
const cookieValidator = HttpMiddleware.make((app) =>
Effect.gen(function* () {
const req = yield* HttpServerRequest.HttpServerRequest
yield* externallyValidateCookie(req.cookies.testCookie)
return yield* app
}).pipe(
Effect.catchTag("CookieError", () =>
HttpServerResponse.text("Invalid cookie")
)
)
)
const router = HttpRouter.empty.pipe(
HttpRouter.get("/", HttpServerResponse.text("Hello World"))
)
const app = router.pipe(HttpRouter.use(cookieValidator), HttpServer.serve())
listen(app, 3000)
Test the middleware with the following commands:
curl -i http://localhost:3000
curl -i http://localhost:3000 --cookie "testCookie=myvalue"
curl -i http://localhost:3000 --cookie "testCookie="
This setup validates the testCookie
and returns "Invalid cookie" if the validation fails, or "Hello World" if it passes.
Applying Middleware in Your Application
Middleware functions are powerful tools that allow you to modify the request-response cycle. Middlewares can be applied at various levels to achieve different scopes of influence:
- Route Level: Apply middleware to individual routes.
- Router Level: Apply middleware to a group of routes within a single router.
- Server Level: Apply middleware across all routes managed by a server.
Applying Middleware at the Route Level
At the route level, middlewares are applied to specific endpoints, allowing for targeted modifications or enhancements such as logging, authentication, or parameter validation for a particular route.
Example
Here's a practical example showing how to apply middleware at the route level:
import {
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerResponse
} from "@effect/platform"
import { Effect } from "effect"
import { listen } from "./listen.js"
// Middleware constructor that logs the name of the middleware
const withMiddleware = (name: string) =>
HttpMiddleware.make((app) =>
Effect.gen(function* () {
console.log(name) // Log the middleware name when the route is accessed
return yield* app // Continue with the original application flow
})
)
const router = HttpRouter.empty.pipe(
// Applying middleware to route "/a"
HttpRouter.get("/a", HttpServerResponse.text("a").pipe(withMiddleware("M1"))),
// Applying middleware to route "/b"
HttpRouter.get("/b", HttpServerResponse.text("b").pipe(withMiddleware("M2")))
)
const app = router.pipe(HttpServer.serve())
listen(app, 3000)
Testing the Middleware
You can test the middleware by making requests to the respective routes and observing the console output:
# Test route /a
curl -i http://localhost:3000/a
# Expected console output: M1
# Test route /b
curl -i http://localhost:3000/b
# Expected console output: M2
Applying Middleware at the Router Level
Applying middleware at the router level is an efficient way to manage common functionalities across multiple routes within your application. Middleware can handle tasks such as logging, authentication, and response modifications before reaching the actual route handlers.
Example
Here's how you can structure and apply middleware across different routers using the @effect/platform
library:
import {
HttpMiddleware,
HttpRouter,
HttpServer,
HttpServerResponse
} from "@effect/platform"
import { Effect } from "effect"
import { listen } from "./listen.js"
// Middleware constructor that logs the name of the middleware
const withMiddleware = (name: string) =>
HttpMiddleware.make((app) =>
Effect.gen(function* () {
console.log(name) // Log the middleware name when a route is accessed
return yield* app // Continue with the original application flow
})
)
// Define Router1 with specific routes
const router1 = HttpRouter.empty.pipe(
HttpRouter.get("/a", HttpServerResponse.text("a")), // Middleware M4, M3, M1 will apply
HttpRouter.get("/b", HttpServerResponse.text("b")), // Middleware M4, M3, M1 will apply
// Apply Middleware at the router level
HttpRouter.use(withMiddleware("M1")),
HttpRouter.get("/c", HttpServerResponse.text("c")) // Middleware M4, M3 will apply
)
// Define Router2 with specific routes
const router2 = HttpRouter.empty.pipe(
HttpRouter.get("/d", HttpServerResponse.text("d")), // Middleware M4, M2 will apply
HttpRouter.get("/e", HttpServerResponse.text("e")), // Middleware M4, M2 will apply
HttpRouter.get("/f", HttpServerResponse.text("f")), // Middleware M4, M2 will apply
// Apply Middleware at the router level
HttpRouter.use(withMiddleware("M2"))
)
// Main router combining