Endpoints and OpenAPI

Endpoint is the typed description of one operation. Api.bind attaches the function. OpenAPI 3.1 and Swagger UI are projections. The teaching path is Operations are an AST then Docs and HTTP fall out.

OpenAPI is a projection
/shows/{id}:
  get:
    summary: One bill
    parameters: [{ name: id, in: path, schema: integer }]
    responses:
      "200": { schema: Show }
      "404": { schema: NotFound }

The DSL

.in[A], .out[A], and .outError[E] need a BodyCodec. A type that derives Schema, JsonCodec gets JSON. A given BodyCodec[A] replaces that default. .query and .header decode inputs. .outHeader, after .out, adds a response header to Out. The next section is that header. .auth, .summary, and .tag are documentation that also drives decoding.

BoxOffice.seed.flatMap { store =>
  BoxOffice.api(store).routes(Request.get("/shows/1")).flatMap(res => res.body.utf8.map((res.status, _)))
}
(Status(200,OK),{"id":1,"title":"Evening bill","remaining":12})

Headers

A header is a value whose name you pass, or a type that already owns its name.

HeaderCodec is the value, and the spellings match a query. .header[Int]("X-Count") is missing as ParamError.Missing and x as Malformed. The same codec has String, Long, Boolean, Instant for Date, Expires, Last-Modified, and If-Modified-Since, Url for Location (the failure is ParamError.Url, the UrlError kept whole), and Chunk[EntityTag] for If-Match and If-None-Match. .header[Option[Int]]("X-Count") is absent as None. A present value that does not parse stays an error, and toRequest omits None. Content-Length is .header[Long], not its own type.

TypedHeader owns the name, so the call does not repeat it. .header[Host] reads Host. .header[BasicCredentials] and .header[BearerToken] both read Authorization and refuse the other scheme. EntityTag is ETag, RangeSpec is Range. Cookie, Set-Cookie, Content-Encoding, Accept-Encoding, and Transfer-Encoding read every line. Set-Cookie is written one line per cookie, never joined. A repeated single-value header, a second Host, is Malformed.

.outHeader is those two forms on the way out, and it comes after .out. The handler returns the body and the header together. encodeOut writes the header, fromResponse and Client.call read it back, and a missing one is CallFailure.Undecodable with OutputError.Header. A later .out replaces that success, header included. OpenAPI documents a request header on the operation and a response header on the success status only.

A header is not a tool argument, and a response header is not a tool result. OpArgs.promotable is false for an endpoint that declares one, so Mcp.from leaves it on HTTP.

{
  val ep     = Endpoint.get("n").header[Int]("X-Count").header[Option[String]]("X-Trace").out[Text]
  val routes = ep.implement((n, trace) => ZIO.succeed(Text(s"$n:${trace.getOrElse("-")}")))
  val sent   = ep.toRequest((4, None), Url.root)
  for
    good     <- routes(Request.get("/n").withHeader("X-Count", "4").withHeader("X-Trace", "ada"))
    goodBody <- good.body.utf8
    bad      <- routes(Request.get("/n").withHeader("X-Count", "x"))
    badBody  <- bad.body.utf8
    bare     <- routes(Request.get("/n").withHeader("X-Count", "4"))
    bareBody <- bare.body.utf8
  yield (sent.header("X-Count"), sent.header("X-Trace"), good.status, goodBody, bad.status, badBody, bareBody)
}
(Some(4),None,Status(200,OK),4:ada,Status(400,Bad Request),header X-Count: expected an int, got 'x',4:-)
{
  val who    = Endpoint.get("who").header[Host].out[Text]
  val token  = Endpoint.get("me").header[BearerToken].out[Text]
  val people = who.implement(host => ZIO.succeed(Text(host.host))) ++
    token.implement(bearer => ZIO.succeed(Text(bearer.token)))
  for
    host       <- people(Request.get("/who").withHeader("Host", "ada.example"))
    hostBody   <- host.body.utf8
    again      <- people(Request.get("/who").addHeader("Host", "a.example").addHeader("Host", "b.example"))
    bearer     <- people(Request.get("/me").withHeader("Authorization", "Bearer tok"))
    bearerBody <- bearer.body.utf8
    basic      <- people(Request.get("/me").withHeader("Authorization", "Basic xxx"))
    basicBody  <- basic.body.utf8
  yield (
    who.doc.headers.headOption.map(h => (h.name, h.required, h.schema)),
    host.status,
    hostBody,
    again.status,
    bearer.status,
    bearerBody,
    basic.status,
    basicBody,
  )
}
(Some((Host,true,Str(None))),Status(200,OK),ada.example,Status(400,Bad Request),Status(200,OK),tok,Status(400,Bad Request),header Authorization: expected a bearer authorization, got 'Basic xxx')
{
  val ep     = Endpoint.get("t").out[Text].outError[String](Status.BadRequest).outHeader[EntityTag]
  val tag    = EntityTag("abc")
  val routes = ep.implement(_ => ZIO.succeed((Text("hi"), tag)))
  for
    res    <- routes(Request.get("/t"))
    back   <- ep.fromResponse(res)
    gone   <- ep.fromResponse(res.removeHeader(HeaderName.ETag)).either
    called <- Client.call(ep)(()).provideLayer(Client.inMemory(routes))
  yield (
    res.header(HeaderName.ETag),
    back,
    gone,
    called,
    ep.doc.responses.exists(r => r.status == Status.Ok && r.headers.exists(_.name == "ETag")),
    ep.doc.responses.exists(r => r.status == Status.BadRequest && r.headers.nonEmpty),
    OpArgs.promotable(ep.doc),
  )
}
(Some("abc"),(hi,EntityTag(abc,false)),Left(Undecodable(Status(200,OK),header ETag: missing)),(hi,EntityTag(abc,false)),true,false,false)

OpenAPI is a projection

BoxOffice.seed.map { store =>
  val json = BoxOffice.api(store).openApi.toJson
  json.contains("/shows/{id}") && json.contains("One bill")
}
true

The live projection is on Docs and HTTP fall out.

Errors

Model an endpoint's errors as a sealed hierarchy: an enum, or a sealed trait with case classes and case objects. derives Schema, JsonCodec on the type is all it needs. .outErrors[E] pins the type, then takes one ErrorCase per case:

enum SeatingError derives Schema, JsonCodec:
  case SoldOut(showId: Int)
  case NoBlock(showId: Int, size: Int)

Endpoint
  .post("parties")
  .in[Party]
  .out[PartySeated](Status.Created)
  .outErrors[SeatingError](
    ErrorCase[SeatingError.SoldOut](Status.Conflict),
    ErrorCase[SeatingError.NoBlock](Status.UnprocessableContent),
  )

The list is checked against the type at compile time. A missing case, a case listed twice, or a type that is not a case of E does not compile, and the error names the case. Cases may share a status. A nested sealed trait counts as one case covering all of its leaves.

The default body is JsonCodec[E]'s encoding: {"SoldOut":{"showId":1}} for a case with fields, {"Closed":{}} for a singleton next to cases with fields, and a bare "Red" when every case is a singleton. A given BodyCodec[E] replaces that body. The statuses stay. OpenAPI documents each status with that case's schema. Client.call reads the status back to the typed case as CallFailure.Domain(case), and MCP returns that JSON body as isError. Derived Schema follows zio-json's default sum encoding; @jsonDiscriminator, @jsonHint, or a custom JsonCodecConfiguration change the wire shape without changing the Schema.

BoxOffice.seed.flatMap { store =>
  val routes              = BoxOffice.api(store).routes
  def seat(party: String) = routes(Request.post("/parties", Body.json(party)))
  for
    soldOut     <- seat("""{"showId":1,"size":13}""")
    soldOutBody <- soldOut.body.utf8
    noBlock     <- seat("""{"showId":3,"size":2}""")
    noBlockBody <- noBlock.body.utf8
  yield (soldOut.status, soldOutBody, noBlock.status, noBlockBody)
}
(Status(409,Conflict),{"SoldOut":{"showId":1}},Status(422,Unprocessable Content),{"NoBlock":{"showId":3,"size":2}})

For a single error type that needs no per-case status, .outError[E](Status.NotFound) sends every error with one status. OpArgs is how MCP flattens path, query, and JSON body into tool arguments; non-JSON bodies are not promotable.

BoxOffice.seed.flatMap { store =>
  BoxOffice.api(store).routes(Request.get("/shows/99")).map(_.status)
}
Status(404,Not Found)

One description, both directions

An endpoint reads a request into In and writes In back into a request. Every builder records both halves, so toRequest is total and a typed client needs nothing but the endpoint value.

Inputs are flat. Each builder adds its value to the end, and Unit (a literal segment) adds nothing, so a path id, a query, and a JSON body are an (Int, Option[String], Seat), never nested pairs. .as[B] names that tuple as the case class with the same fields, both ways; mapIn takes any other pair of functions:

final case class Lookup(id: Int, tag: Option[String])

Endpoint
  .get("items" / int("id"))
  .query[Option[String]]("tag")
  .as[Lookup]

The output side is the same. .out[O] reads and writes O with its BodyCodec. .outHeader, after .out, appends a response header to that Out. A domain type is JSON. Text, Html, Javascript, Css, Svg, Octet, Form, and EventStream are the other bodies, and a given BodyCodec[A] is any body you want. .outEmpty reads nothing. MCP arguments are a third view: OpArgs.arguments turns a request into tool arguments and OpArgs.request turns them back. The suite checks both directions as laws over generated inputs, including path values like a/b and 50%, which travel as one encoded segment and never change the route.

{
  final case class Lookup(id: Int, tag: Option[String])
  val ep = Endpoint.get("items" / int("id")).query[Option[String]]("tag").as[Lookup]
  ep.toRequest(Lookup(7, Some("new")), Url.root).url.render
}
/items/7?tag=new
{
  val ep     = Endpoint.get("items" / int("id")).query[Option[String]]("tag").out[String]
  val routes = ep.implement((id, tag) => zio.ZIO.succeed(s"$id:${tag.getOrElse("-")}"))
  val req    = ep.toRequest((7, Some("a/b")), Url.root)
  for
    args  <- zio.ZIO.fromEither(OpArgs.arguments(ep.doc, req))
    again <- zio.ZIO.fromEither(OpArgs.request(ep.doc, args))
    out   <- routes(again).flatMap(ep.fromResponse)
  yield (req.url.render, args.toJson, out)
}
(/items/7?tag=a%2Fb,{"id":7,"tag":"a/b"},7:a/b)

A body that streams is not read whole. Client.call on an out[EventStream] endpoint does not compile; Client.subscribe streams its events and holds the connection for as long as you read.

Client.call(Endpoint.get("ticks").out[EventStream])(())
zio.stream.ZStream[Any, Throwable, heddle.sse.ServerSentEvent] is a stream. Read it with Client.subscribe, not Client.call..
I found:

    heddle.client.Client.Strict.given_Strict_A[
      zio.stream.ZStream[Any, Throwable, heddle.sse.ServerSentEvent]](
      /* missing */
        summon[
          scala.util.NotGiven[
            zio.stream.ZStream[Any, Throwable, heddle.sse.ServerSentEvent] <:<
            zio.stream.ZStream[?, ?, ?]]
        ]
    )

But no implicit values were found that match type scala.util.NotGiven[
  zio.stream.ZStream[Any, Throwable, heddle.sse.ServerSentEvent] <:<
  zio.stream.ZStream[?, ?, ?]].
  Client.call(Endpoint.get("ticks").out[EventStream])(())