Endpoints

This algebra, at the top of the hierarchy, provides the base vocabulary to describe HTTP endpoints, requests and responses.

"org.julienrf" %% "endpoints-algebra" % "0.15.0"

API documentation

Endpoint

The algebra introduces the concept of Endpoint[A, B]: an HTTP endpoint whose request carries an information of type A and whose response carries an information of type B. For instance, an endpoint of type Endpoint[Long, Option[User]] has a request containing a Long (for instance, a user id), and a response optionally containing a User.

You can define an endpoint by using the endpoint constructor:

// An endpoint whose requests use the HTTP verb “GET” and the URL
// path “/some-resource”, and whose responses have an entity of
// type “text/plain”
val someResource: Endpoint[Unit, String] =
  endpoint(get(path / "some-resource"), ok(textResponse))

The endpoint constructor takes two parameters, the request description (of type Request[A]) and the response description (of type Response[B]), which are documented in the following sections.

It also takes optional parameters carrying documentation information:

endpoint(
  get(path / "some-resource"),
  ok(textResponse),
  docs = EndpointDocs(description = Some("The contents of some resource"))
)

Request

The Request[A] type models an HTTP request carrying some information of type A. For instance, a Request[Long] value is a request containing a Long value.

A request is defined in terms of an HTTP verb, an URL, an entity and headers:

// A request that uses the verb “GET”, the URL path “/foo”,
// no entity, no documentation, and no headers
request(Get, path / "foo", emptyRequest, None, emptyRequestHeaders)

For convenience, get, post, put and delete methods are provided:

get(path / "foo") // Same as above

The next sections document how to describe URLs, request headers and request entities.

URL

The Url[A] type models an URL carrying some information of type A. For instance, an Url[Long] value is an URL containing a Long value.

An URL is defined by a path and a query string. Here are some self-explanatory examples of URLs:

path // the root path “/”
path / "users" // “/users”
path / "users" / segment[Long]() // “/users/1234”, “/users/5678”, …
path / "assets" / remainingSegments() // “/assets/images/logo.png”
path / "articles" /? qs[Int](
  "page"
) // “/articles?page=2”, “/articles?page=5”, …
// Optional parameter
path / "articles" /? qs[Option[Int]](
  "page"
) // “/articles”, “/articles?page=2”, …
// Repeated parameter
path / "articles" /? qs[List[String]](
  "kinds"
) // “/articles?kinds=garden&kinds=woodworking”, …
// Several parameters
path /? (qs[String]("q") & qs[String]("lang")) // “/?q=foo&lang=en”, …

The examples above show that basic types (e.g., Int, String, etc.) are supported out of the box as query and path parameters. A user-defined type T can be supported either by - defining implicit instances of Segment[T] (for path parameters) or QueryStringParam[T] (for query string parameters), - transforming or refining already supported types by using xmap or xmapPartial (see next section).

Path segments and query string parameters can take additional parameters containing documentation:

// “/users/{id}”
path / "users" / segment[Long]("id", docs = Some("A user id"))

// “/?q=foo&lang=en”, …
val query = qs[String]("q", docs = Some("Query"))
val lang = qs[String]("lang", docs = Some("Language"))
path /? (query & lang)

Transforming and Refining URL Constituents

All the data types involved in a URL description (Path[A], Segment[A], QueryString[A], etc.) have an xmap and an xmapPartial operations, for transforming or refining their carried type.

For instance, consider the following user-defined Location type, containing a longitude and a latitude:

case class Location(longitude: Double, latitude: Double)

The QueryString[Location] type means “a query string that carries a Location”. We can define a value of type QueryString[Location] by transforming a query string that carries the longitude and latitude parameters as follows:

val locationQueryString: QueryString[Location] =
  (qs[Double]("lon") & qs[Double]("lat")).xmap {
    case (lon, lat) => Location(lon, lat)
  } { location => (location.longitude, location.latitude) }

The xmap operation requires the source type and the target type to be equivalent (in the above case, the source type is (Double, Double) and the target type is Location).

In case the target type is smaller than the source type, you can use the xmapPartial operation, which refines the carried type. As an example, here is how you can define a Segment[LocalDate]:

import java.time.LocalDate
import endpoints.{Invalid, Valid}
implicit def localDateSegment(
    implicit string: Segment[String]
): Segment[LocalDate] =
  string.xmapPartial { s =>
    Try(LocalDate.parse(s)) match {
      case Failure(_)    => Invalid(s"Invalid date value '$s'")
      case Success(date) => Valid(date)
    }
  }(_.toString)

The first function passed to the xmapPartial operation returns a Validated[LocalDate] value. Returning an Invalid value means that there is no representation of the source type in the target type.

Request Headers

The type RequestHeaders[A] models request headers carrying some information of type A. For instance, a value of type RequestHeaders[Credentials] describes request headers containing credentials.

Please refer to the API documentation for details about constructors and operations for the type RequestHeaders.

Request Entity

The type RequestEntity[A] models a request entity carrying some information of type A. For instance, a value of type RequestEntity[Command] describes a request entity containing a command.

The Endpoints algebra provides a few RequestEntity constructors and operations, which can be extended to support more content types. For instance, the JsonEntities algebra adds support for requests with JSON entities.

Response

The Response[A] type models an HTTP response carrying some information of type A. For instance, a Response[User] value describes an HTTP response containing a user: client interpreters decode a User from the response entity, server interpreters encode a User as a response entity, and documentation interpreters render the serialization schema of a User.

Constructing Responses

A response is defined in terms of a status, headers and an entity. Here is an example of a simple OK response with no entity and no headers:

// An HTTP response with status code 200 (Ok) and no entity
val nothing: Response[Unit] = ok(emptyResponse)

There is a more general response constructor taking the status as parameter:

// An HTTP response with status code 200 (Ok) and a text entity
val aTextResponse: Response[String] = response(OK, textResponse)

Additional documentation about the response can be passed as an extra parameter:

ok(
  emptyResponse,
  docs = Some("A response with an OK status code and no entity")
)

Response Headers

The type ResponseHeaders[A] models response headers carrying some information of type A. For instance, a value of type ResponseHeaders[Origin] describes response headers containing an origin (e.g., an Access-Control-Allow-Origin header).

Refer to the API documentation for details about constructors and operations for the type ResponseHeaders.

Response Entity

The type ResponseEntity[A] models a response entity carrying some information of type A. For instance, a value of type ResponseEntity[Event] describes a response entity containing an event.

The Endpoints algebra provides a few ResponseEntity constructors and operations, which can be extended to support more content-types. For instance, the JsonEntities algebra adds support for responses with JSON entities.

Transforming Responses

Responses have methods provided by the ResponseSyntax and the InvariantFunctorSyntax implicit classes, whose usage is illustrated in the remaining of this section.

The orNotFound operation is useful to handle resources that may not be found:

val getUser: Endpoint[Long, Option[User]] =
  endpoint(
    get(path / "user" / segment[Long]("id")),
    ok(jsonResponse[User]).orNotFound()
  )

In this example, servers can produce a Not Found (404) response by returning None, and an OK (200) response containing a user by returning a Some[User] value. Conversely, clients interpret a Not Found response as a None value, and an OK response (with a valid user entity) as a Some[User] value.

More generally, you can describe an alternative between two possible responses by using the orElse operation:

val maybeUserResponse: Response[Either[Unit, User]] =
  response(NotImplemented, emptyResponse).orElse(ok(jsonResponse[User]))

In this example, servers can produce a Not Implemented (501) response by returning Left(()), and an OK (200) response containing a user by returning Right(user). Conversely, clients interpret a Not Implemented response as a Left(()) value, and an OK response (with a valid user entity) as a Right(user) value.

You can also transform the type produced by the alternative responses into a more convenient type to work with, by using the xmap operation. For instance, here is how to transform a Response[Either[Unit, User]] into a Response[Option[User]]:

val maybeUserResponse: Response[Option[User]] =
  response(NotImplemented, emptyResponse)
    .orElse(ok(jsonResponse[User]))
    .xmap {
      case Left(())    => None
      case Right(user) => Some(user)
    }(_.toRight(()))

Error Responses

endpoints server interpreters handle two kinds of errors:

  • when the server is unable to decode an incoming request (because, for instance, a query parameter is missing, or the request entity has the wrong format). In this case it is a “client error” ;
  • when the provided business logic throws an exception, or the server is unable to serialize the result into a proper HTTP response. In this case it is a “server error”.

By default, client errors are reported as an Invalid value, serialized into a Bad Request (400) response, as a JSON array containing string messages. You can change the provided serialization format by overriding the clientErrorsResponseEntity operation.

Similarly, by default server errors are reported as a Throwable value, serialized into an Internal Server Error (500) response, as a JSON array containing string messages. You can change the provided serialization format by overriding the serverErrorResponseEntity operation.

Next Step

See how you can describe endpoints with JSON entities.