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]).