Skip to content

KhraksMamtsov/effect-http

 
 

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

effect-http

download badge

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

  • Client derivation. Write the api specification once, get the type-safe client with runtime validation for free.
  • 🌈 OpenAPI derivation. /docs endpoint with OpenAPI UI out of box.
  • 🔋 Batteries included server implementation. Automatic runtime request and response validation.
  • 🔮 Example server derivation. Automatic derivation of example server implementation.
  • 🐛 Mock client derivation. Test safely against a specified API.

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

Note

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

Quickstart

Install

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

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

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

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

Bootstrap a simple API specification.

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

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

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

Create the app implementation.

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

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

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

import { Client } from "effect-http";

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

Spawn the server on port 3000,

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

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

and call it using the client.

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

[Source code]

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

open api ui

Request validation

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

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

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

Example

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

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

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

[Source code]

Optional path parameters

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

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

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

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

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

[Source code]

Headers

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

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

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

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

[Source code]

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

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

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

example-headers-openapi-ui

Important! Use a lowercase form of header names.

Security

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

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

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

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

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

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

[Source code]

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

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

Optional security

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

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

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

[Source code]

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

Constructing more complex security

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

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

interface UserInfo {
  email: string
}

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

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

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

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

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

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

[Source code]

Security on the client side

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

import { Client } from 'effect-http';

const client = Client.make(api)

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

Custom security

A primitive security is constructed using Security.make function.

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

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

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

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

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

[Source code]

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

Note

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

Responses

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

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

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

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

[Source code]

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

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

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

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

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

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

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

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

Testing the server

You need to install effect-http-node.

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

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

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

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

The test might look as follows.

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

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

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

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

Error handling

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

Reporting errors in handlers

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

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

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

Example API with conflict API error

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

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

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

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

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

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

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

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

And finally, we have the actual HttpApp.HttpApp implementation.

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

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

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

[Source code]

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

Server

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

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

Client (using httpie cli)

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

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

User "patrik" already exists.

Grouping endpoints

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

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

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

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

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

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

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

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

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

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

[Source code]

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

example-generated-open-api-ui

Descriptions in OpenApi

The OpenAPI logic takes into account description annotations and propagates them into the specification.

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

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

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

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

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

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

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

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

[Source code]

Representations

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

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

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

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

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

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

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

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

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

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

[Source code]

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

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

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

API on the client side

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

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

Example server

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

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

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

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

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

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

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

[Source code]

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

Mock client

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

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

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

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

const client = MockClient.make(api);

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

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

Scaling up

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

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

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

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

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

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

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

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

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

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

Note

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

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

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

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

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

Compatibility

This library is tested against nodejs 21.5.0.

About

Declarative HTTP API library for effect-ts

Resources

License

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages

  • TypeScript 98.6%
  • JavaScript 1.2%
  • Other 0.2%