Skip to main content

HTTP4s Integration

Baklava provides seamless integration with http4s server and route documentation in unit tests.

Documenting routes in unit tests​

Spec2​

Most convenient way to use Baklava with Spec2 is to define base class for all tests. This can look like below:

import cats.effect.IO
import cats.effect.unsafe.IORuntime
import cats.effect.testing.specs2.CatsEffect
import org.http4s.{HttpRoutes, Request}
import org.specs2.mutable.SpecificationLike
import org.specs2.specification.core.{AsExecution, Fragment, Fragments}
import pl.iterators.baklava.specs2.BaklavaSpecs2
import pl.iterators.baklava.http4s.BaklavaHttp4s

import com.example.users.UserRoutes

trait BaseRouteSpec
extends CatsEffect
with SpecificationLike
with BaklavaHttp4s[Fragment, Fragments, AsExecution]
with BaklavaSpecs2[HttpRoutes[IO], BaklavaHttp4s.ToEntityMarshaller, BaklavaHttp4s.FromEntityUnmarshaller] {

// Define the routes to test
val allRoutes: HttpRoutes[IO] = ???

// Required implementations for Baklava framework
override implicit val runtime: IORuntime = IORuntime.global

override def strictHeaderCheckDefault: Boolean = false

override def performRequest(routes: HttpRoutes[IO], request: Request[IO]): HttpResponse =
routes.orNotFound.run(request).unsafeRunSync()

override def afterAll(): Unit = ()

}

Then use above as base class for your tests as below:

import io.circe.generic.auto.*
import org.http4s.Method.*
import org.http4s.circe.CirceEntityCodec.*
import org.http4s.dsl.io.*

class GetUsersUserIdRouteSpec extends BaseRouteSpec {

path(path = "/users/{userId}")(
supports(
GET,
pathParameters = p[Long]("userId"),
description = "Get a specific user by ID",
summary = "Retrieve a specific user"
)(
onRequest(pathParameters = (1L))
.respondsWith[User](Ok, description = "Return user with ID 1")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)

response.body.id should beEqualTo(1L)
},
onRequest(pathParameters = (999L))
.respondsWith[ErrorResponse](NotFound, description = "Return 404 for non-existent user")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)

response.body should beEqualTo {
ErrorResponse("User with the specified ID does not exist", "USER_NOT_FOUND")
}
}
)
)

}

MUnit​

Most convenient way to use Baklava with MUnit is to define base class for all tests. This can look like below:

import cats.effect.IO
import cats.effect.unsafe.IORuntime
import munit.CatsEffectSuite
import org.http4s.{HttpRoutes, Request}
import pl.iterators.baklava.http4s.BaklavaHttp4s
import pl.iterators.baklava.munit.{BaklavaMunit, MunitAsExecution}

trait BaseRouteTest
extends CatsEffectSuite
with BaklavaHttp4s[Unit, Unit, MunitAsExecution]
with BaklavaMunit[HttpRoutes[IO], BaklavaHttp4s.ToEntityMarshaller, BaklavaHttp4s.FromEntityUnmarshaller] {

// Define the routes to test
val allRoutes: HttpRoutes[IO] = ???

// Required implementations for Baklava framework
override implicit val runtime: IORuntime = IORuntime.global

override def strictHeaderCheckDefault: Boolean = false

override def performRequest(routes: HttpRoutes[IO], request: Request[IO]): HttpResponse =
routes.orNotFound.run(request).unsafeRunSync()

}

Then use above as base class for your tests as below:

import io.circe.generic.auto.*
import org.http4s.Method.*
import org.http4s.circe.CirceEntityCodec.*
import org.http4s.dsl.io.*

class GetUsersUserIdRouteTest extends BaseRouteTest {

path(path = "/users/{userId}")(
supports(
GET,
pathParameters = p[Long]("userId"),
description = "Get a specific user by ID",
summary = "Retrieve a specific user"
)(
onRequest(pathParameters = (1L))
.respondsWith[User](Ok, description = "Return user with ID 1")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)

assertEquals(response.body.id, 1L)
},
onRequest(pathParameters = (999L))
.respondsWith[ErrorResponse](NotFound, description = "Return 404 for non-existent user")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)

assertEquals(
response.body,
ErrorResponse("User with the specified ID does not exist", "USER_NOT_FOUND")
)
}
)
)

}

ScalaTest​

Most convenient way to use Baklava with ScalaTest is to define base class for all tests. This can look like below:

import cats.effect.IO
import cats.effect.unsafe.IORuntime
import org.http4s.{HttpRoutes, Request}
import org.scalatest.funspec.AnyFunSpec
import org.scalatest.matchers.should.Matchers
import pl.iterators.baklava.http4s.BaklavaHttp4s
import pl.iterators.baklava.scalatest.{BaklavaScalatest, ScalatestAsExecution}

import com.example.users.UserRoutes

trait BaseRouteSpec
extends AnyFunSpec
with Matchers
with BaklavaHttp4s[Unit, Unit, ScalatestAsExecution]
with BaklavaScalatest[HttpRoutes[IO], BaklavaHttp4s.ToEntityMarshaller, BaklavaHttp4s.FromEntityUnmarshaller] {

// Define the routes to test
val allRoutes: HttpRoutes[IO] = UserRoutes.routes

// Required implementations for Baklava framework
override implicit val runtime: IORuntime = IORuntime.global

override def strictHeaderCheckDefault: Boolean = false

override def performRequest(routes: HttpRoutes[IO], request: Request[IO]): HttpResponse =
routes.orNotFound.run(request).unsafeRunSync()

}

Then use above as base class for your tests as below:

import io.circe.generic.auto.*
import org.http4s.Method.*
import org.http4s.circe.CirceEntityCodec.*
import org.http4s.dsl.io.*

class GetUsersUserIdRouteSpec extends BaseRouteSpec {

path(path = "/users/{userId}")(
supports(
GET,
pathParameters = p[Long]("userId"),
description = "Get a specific user by ID",
summary = "Retrieve a specific user"
)(
onRequest(pathParameters = (1L))
.respondsWith[User](Ok, description = "Return user with ID 1")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)

response.body.id shouldBe 1L
},
onRequest(pathParameters = (999L))
.respondsWith[ErrorResponse](NotFound, description = "Return 404 for non-existent user")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)

response.body shouldBe ErrorResponse("User with the specified ID does not exist", "USER_NOT_FOUND")
}
)
)

}

Serving Open API and Swagger UI​

Adding baklava-http4s-routes dependency to your project lets you serve OpenAPI and Swagger UI alongside your application routes. The module is config-less by default — it pulls overrides from BAKLAVA_ROUTES_* environment variables — but accepts an explicit BaklavaRoutesConfig when you want to wire it up from your own config layer (Ciris, PureConfig, plain code, etc.).

import cats.effect.{ExitCode, IO, IOApp}
import cats.syntax.all.*
import com.comcast.ip4s.{ipv4, port}
import org.http4s.HttpRoutes
import org.http4s.ember.server.EmberServerBuilder
import org.http4s.server.Router
import pl.iterators.baklava.http4s.routes.{BaklavaRoutes, BaklavaRoutesConfig}

object Main extends IOApp {
def run(args: List[String]): IO[ExitCode] = {
val apiRoutes: HttpRoutes[IO] = ??? // all your api routes

val docsRoutes: HttpRoutes[IO] = BaklavaRoutes.routes(
BaklavaRoutesConfig(
publicPathPrefix = "/",
apiPublicPathPrefix = "/v1"
)
)

val app = Router("/" -> (apiRoutes <+> docsRoutes)).orNotFound

EmberServerBuilder
.default[IO]
.withHost(ipv4"0.0.0.0")
.withPort(port"8080")
.withHttpApp(app)
.build
.useForever
.as(ExitCode.Success)
}
}

BaklavaRoutes.routes() (no arguments) reads BAKLAVA_ROUTES_* environment variables and falls back to defaults — convenient for containerized deployments. For detailed configuration options check [installation.md#swaggerui-and-routes-configuration].

Unlike the Pekko HTTP module, baklava-http4s-routes does not depend on Typesafe Config. If you prefer HOCON, parse the config in your own code and pass the resulting BaklavaRoutesConfig into routes(...) — the installation guide shows a small adapter you can copy.

Documenting file uploads​

To document a binary upload (e.g. an avatar PNG), declare Content-Type among the request headers and pass the matching value on the onRequest(...) call — the http4s adapter honors that declared value, overriding the content type the EntityEncoder bakes into the request.

import java.nio.charset.StandardCharsets

class PutUsersUserIdAvatarSpec extends BaseRouteSpec {

// Byte-array entity encoders ship with http4s; no extra setup needed.

path(path = "/users/{userId}/avatar")(
supports(
PUT,
pathParameters = p[Long]("userId"),
headers = h[String]("Content-Type"),
description = "Upload or update a user's avatar",
summary = "Upload or update a user's avatar",
tags = List("Users")
)(
onRequest(
pathParameters = 1L,
headers = "image/png",
body = "\u0089PNG\r\n...".getBytes(StandardCharsets.UTF_8)
).respondsWith[EmptyBody](NoContent, description = "User avatar updated successfully")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)
response.status.code shouldBe 204
}
)
)
}

The generator renders this as requestBody.content["image/png"] with a schema: { type: string, format: binary } in OpenAPI, and the test request goes out with Content-Type: image/png on the wire so server routes that pattern-match on it run under the right conditions.

Downloads​

Binary downloads work with respondsWith[Array[Byte]]. http4s ships entity decoders for byte arrays; no extra setup needed. The test stub's response must carry the right Content-Type — whatever the server serves becomes the OpenAPI responseContentType:

supports(
GET,
pathParameters = p[Long]("userId"),
description = "Download the user's avatar as raw image bytes",
tags = List("Users")
)(
onRequest(pathParameters = 1L)
.respondsWith[Array[Byte]](Ok, description = "Avatar bytes")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)
response.status.code shouldBe 200
}
)

Schema[Array[Byte]] is a default on the classpath, so the generated OpenAPI renders responses[code].content["<content-type>"].schema = { type: string, format: binary }.

Documenting multipart/form-data uploads​

Beyond raw binary bodies, Multipart bundles one or more named FilePart / TextPart into a single request. The http4s adapter reassembles them into a native org.http4s.multipart.Multipart[IO] body with a fixed boundary for deterministic test output.

import pl.iterators.baklava.{FilePart, Multipart, TextPart}
import java.nio.charset.StandardCharsets

class PostUsersUserIdPhotoSpec extends BaseRouteSpec {

path(path = "/users/{userId}/photo")(
supports(
POST,
pathParameters = p[Long]("userId"),
description = "Upload a profile photo with a caption",
summary = "Upload photo",
tags = List("Users")
)(
onRequest(
pathParameters = 1L,
body = Multipart(
FilePart("photo", "image/png", "photo.png",
"\u0089PNG\r\n...".getBytes(StandardCharsets.UTF_8)),
TextPart("caption", "profile photo")
)
).respondsWith[EmptyBody](NoContent, description = "Photo uploaded")
.assert { ctx =>
val response = ctx.performRequest(allRoutes)
response.status.code shouldBe 204
}
)
)
}

The generated OpenAPI emits requestBody.content["multipart/form-data"] with a free-form type: object schema (richer per-part schemas can be supplied by replacing the implicit Schema[Multipart]).