OpenAPI-first API đź”—
OpenApi4s turns an OpenAPI document into Scala 3 code. With the Sharaf backend it generates Tupson request/response models and controller route stubs. The contract is the source of truth, not a second description of the routes.
This tutorial uses the sbt-openapi4s plugin. It keeps OpenApi4s outside the application's runtime classpath: it is a build-time generator, while the generated sources depend only on Sharaf, Tupson, Validson, and optionally STTP.
1. Write the contract đź”—
Create openapi/greeting.yaml:
openapi: 3.0.3
info:
title: Greeting API
version: 1.0.0
paths:
/greetings/{name}:
get:
operationId: getGreeting
tags: [greetings]
parameters:
- name: name
in: path
required: true
schema:
type: string
minLength: 1
responses:
'200':
description: A greeting
content:
application/json:
schema:
$ref: '#/components/schemas/Greeting'
components:
schemas:
Greeting:
type: object
required: [message]
properties:
message:
type: string
2. Add the generator to sbt đź”—
Add the plugin in project/plugins.sbt:
addSbtPlugin("ba.sake" % "sbt-openapi4s" % "0.1.0")
Enable and configure the plugin in build.sbt:
import ba.sake.openapi4s.OpenApi4sPlugin
import ba.sake.openapi4s.OpenApi4sPlugin.autoImport.*
lazy val canonicalOpenApiFile = file("openapi/greeting.yaml")
lazy val api = project
.in(file("modules/api"))
.enablePlugins(OpenApi4sPlugin)
.settings(
scalaVersion := "3.7.3",
libraryDependencies += "ba.sake" %% "sharaf-undertow" % "0.19.0",
openApi4sPackage := "com.example.greetings",
openApi4sFile := canonicalOpenApiFile,
openApi4sModels := "tupson",
openApi4sFramework := Some("sharaf"),
openApi4sValidation := "validson",
openApi4sVersion := "0.9.0"
)
Generate the contracts whenever the specification changes. The generated sources go below modules/api/src/main/scala/com/example/greetings:
sbt api/openApi4sGenerate
OpenApi4s generation is additive: review and commit the resulting source changes. Do not hand-maintain another model or route definition alongside the canonical document.
OpenApi4s can also generate a typed STTP client from the same contract. Keep that client in its own module, with its own model backend, as shown by the API starter and the sbt-openapi4s documentation.
Deder and Mill đź”—
If your application already uses another build tool, use its integration instead of adding sbt just for code generation:
- deder-plugins provides the Deder OpenApi4s plugin.
- mill-openapi4s provides the Mill OpenApi4s plugin.
All three integrations invoke OpenApi4s with the same model, framework, validation, and client backend choices.
3. Implement generated controller routes đź”—
The command creates models/, controllers/, and clients/ under src/com/example/greetings. The generated controller initially returns 501 Not Implemented; replace that stub with your application behavior while retaining its generated route pattern and request parsing. For example, the generated GreetingsController route can return its generated Greeting model:
package com.example.greetings.controllers
import ba.sake.sharaf.*
import com.example.greetings.models.Greeting
class GreetingsController {
def routes = Routes {
case GET -> Path("greetings", name) =>
Response.withBody(Greeting(s"Hello, $name"))
}
}
Wire it as you would any other Sharaf routes:
import ba.sake.sharaf.Routes
import ba.sake.sharaf.undertow.UndertowSharafServer
import com.example.greetings.controllers.GreetingsController
val routes = Routes.merge(Seq(GreetingsController().routes))
UndertowSharafServer("0.0.0.0", 8080, routes).start()
4. Serve the contract and Swagger UI đź”—
Copy the canonical contract to modules/api/src/main/resources/public/openapi.yaml. UndertowSharafServer serves classpath resources from public, so the document is then available as /openapi.yaml. Add the Swagger UI WebJar to the api project:
libraryDependencies += "org.webjars" % "swagger-ui" % "5.20.1"
Expose a /swagger route whose HTML loads the WebJar assets and points Swagger UI at /openapi.yaml. The API starter's Swagger controller is the maintained copy-and-adapt implementation.
5. Test the contract through HTTP đź”—
Use the generated STTP client in an integration test against a running server. This proves that the generated types, routes, and implementation still agree:
import munit.FunSuite
import sttp.client4.*
import sttp.model.StatusCode
import com.example.greetings.client.clients.GreetingsClient
class GreetingIntegrationTest extends FunSuite {
private val backend = DefaultSyncBackend()
test("the generated client matches the running API") {
val response = GreetingsClient("http://127.0.0.1:8080").getGreeting("Ada").send(backend)
assertEquals(response.code, StatusCode.Ok)
assertEquals(response.body.map(_.message), Right("Hello, Ada"))
}
}
Put this test in a separate integration-test project that depends on the generated client module and starts the API.
Also make a direct request to /openapi.yaml in that suite and assert its openapi version and an expected operationId. Together, those checks catch a missing deployed contract as well as a route or payload that drifted from the generated client.
For a complete runnable reference—including database migrations, Docker, Swagger UI, generated Sharaf server contracts, a generated STTP client, and integration tests—see sharaf-api-starter.