Tapir ย่อมาจาก Typed API descRiptions เป็นไลบรารี Scala สำหรับอธิบาย HTTP endpoint เป็นค่าที่มี type ชัดเจน ตั้งแต่ method, path, input, output ไปจนถึง error case จากคำอธิบายเดียวกัน คุณสามารถสร้าง server route, client และเอกสาร OpenAPI ได้ แนวคิดหลักคือเขียน API contract ครั้งเดียว แล้วให้ Tapir ใช้ contract เดียวกันตลอดทั้งระบบ
ต่างจากการเขียน route โดยตรงใน web framework ตรงที่ Tapir แยก “คำอธิบาย endpoint” ออกจาก “business logic” อย่างชัดเจน endpoint เป็นค่า ส่วน logic เป็นฟังก์ชัน จากนั้น server interpreter จะเชื่อมทั้งสองเข้าด้วยกัน บทความนี้จะแสดงวิธีสร้าง Task API ด้วย Scala, ต่อเข้ากับ Pekko HTTP, สร้าง OpenAPI และทดสอบ endpoint แบบไม่ต้องเริ่ม server จริง
สิ่งที่ Tapir มอบให้คุณ
Tapir เหมาะเมื่อคุณต้องการให้ API contract เป็นศูนย์กลางของระบบ โดยมีข้อดีหลัก 3 อย่าง
Type safety
input และ output ของ endpoint ถูกกำหนดเป็น type ดังนั้น compiler จะตรวจสอบว่า handler คืนค่าตรงกับ contract หรือไม่ ถ้า endpoint บอกว่าจะคืนTaskแต่ logic คืน type อื่น โค้ดจะไม่ compileSingle source of truth
endpoint เป็นค่าเดียวที่ใช้สร้าง server, client และ OpenAPI spec ได้ จึงลดโอกาสที่เอกสารกับ implementation จะไม่ตรงกัน แนวคิดนี้ใกล้เคียงกับ การทดสอบสัญญา API แต่ถูกบังคับด้วย type systemFramework independence
endpoint description ไม่ผูกกับ Akka HTTP, Pekko HTTP, http4s หรือ Netty คุณเลือก server interpreter แยกต่างหากได้ ทำให้ contract อยู่ได้นานกว่าการตัดสินใจเลือก framework
สิ่งที่ควรเข้าใจคือ Tapir ไม่ใช่ web framework และไม่ใช่ code generator แบบที่สร้างไฟล์ Scala ให้คุณแก้เอง Tapir เป็น description layer เหนือ framework ที่คุณเลือก โดย endpoint value คือ source of truth และสิ่งอื่น ๆ เช่น route, client, OpenAPI จะถูกคำนวณจาก value นั้น
การตั้งค่าโปรเจกต์
เพิ่ม dependencies ใน build.sbt ตัวอย่างนี้ใช้ Pekko HTTP เป็น server interpreter และ circe สำหรับ JSON
val tapirVersion = "1.11.7"
libraryDependencies ++= Seq(
"com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion,
"com.softwaremill.sttp.tapir" %% "tapir-pekko-http-server" % tapirVersion,
"com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion,
"com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % tapirVersion,
"com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.11.3"
)
ตรวจสอบเวอร์ชันล่าสุดและ module อื่น ๆ ได้จาก tapir.softwaremill.com
การกำหนด typed endpoint
เริ่มจาก domain model และ JSON codec โดยใช้ circe auto derivation
import io.circe.generic.auto._
import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.generic.auto._
case class Task(id: Int, title: String, done: Boolean)
case class NewTask(title: String)
case class ApiError(message: String)
จากนั้นกำหนด endpoint เป็นค่า โดยประกอบจาก combinator ของ Tapir
val getTask: PublicEndpoint[Int, ApiError, Task, Any] =
endpoint.get
.in("tasks" / path[Int]("id"))
.errorOut(statusCode(sttp.model.StatusCode.NotFound).and(jsonBody[ApiError]))
.out(jsonBody[Task])
val listTasks: PublicEndpoint[Unit, Unit, List[Task], Any] =
endpoint.get
.in("tasks")
.out(jsonBody[List[Task]])
val createTask: PublicEndpoint[NewTask, Unit, Task, Any] =
endpoint.post
.in("tasks")
.in(jsonBody[NewTask])
.out(statusCode(sttp.model.StatusCode.Created).and(jsonBody[Task]))
อ่าน getTask ได้แบบนี้:
- method:
GET - path:
/tasks/{id} - input: path parameter ชื่อ
idเป็นInt - error output: HTTP
404พร้อม JSON body เป็นApiError - success output: HTTP
200พร้อม JSON body เป็นTask
PublicEndpoint[I, E, O, R] มี type parameter 4 ตัว:
-
I: input -
E: error output -
O: success output -
R: required capabilities
ตัวอย่างเช่น:
PublicEndpoint[Int, ApiError, Task, Any]
หมายความว่า endpoint นี้รับ Int, อาจ fail ด้วย ApiError, และ success ด้วย Task
combinator ที่ใช้บ่อยคือ:
-
in(...)เพิ่ม input เช่น path, query, header หรือ body -
out(...)กำหนด success response -
errorOut(...)กำหนด error response -
oneOf(...)ใช้จำลอง error หรือ response หลายรูปแบบ
จุดสำคัญคือ endpoint เหล่านี้ยังไม่รันอะไรเลย มันเป็นเพียง contract ที่ compiler เข้าใจ
การเพิ่ม server logic
เมื่อต้องการให้ endpoint ทำงานจริง ให้แนบ logic ด้วย serverLogic โดย logic ต้องคืนค่าเป็น Either[ErrorType, SuccessType] ภายใน effect เช่น Future
ตัวอย่างนี้ใช้ in-memory store:
import scala.concurrent.Future
import scala.collection.concurrent.TrieMap
import sttp.tapir.server.ServerEndpoint
val store = TrieMap[Int, Task](
1 -> Task(1, "Write the Tapir tutorial", done = false)
)
val getTaskServer: ServerEndpoint[Any, Future] =
getTask.serverLogic { id =>
Future.successful(
store.get(id).toRight(ApiError(s"No task with id $id"))
)
}
val listTasksServer: ServerEndpoint[Any, Future] =
listTasks.serverLogic { _ =>
Future.successful(Right(store.values.toList))
}
val createTaskServer: ServerEndpoint[Any, Future] =
createTask.serverLogic { newTask =>
val id = store.size + 1
val task = Task(id, newTask.title, done = false)
store.put(id, task)
Future.successful(Right(task))
}
compiler จะบังคับให้ logic ตรงกับ endpoint contract เช่น getTask กำหนด error เป็น ApiError ดังนั้นฝั่งซ้ายของ Either ต้องเป็น ApiError เท่านั้น ถ้าคืน type อื่นจะ compile ไม่ผ่าน
รันด้วย Pekko HTTP
แปลง ServerEndpoint ให้เป็น route จริงด้วย Pekko HTTP interpreter
import org.apache.pekko.actor.typed.ActorSystem
import org.apache.pekko.actor.typed.scaladsl.Behaviors
import org.apache.pekko.http.scaladsl.Http
import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter
implicit val system: ActorSystem[Any] =
ActorSystem(Behaviors.empty, "task-api")
import system.executionContext
val routes =
PekkoHttpServerInterpreter().toRoute(
List(getTaskServer, listTasksServer, createTaskServer)
)
Http()
.newServerAt("localhost", 8080)
.bind(routes)
ตอนนี้ API ทำงานที่ localhost:8080
ลองเรียกด้วย curl:
curl http://localhost:8080/tasks
curl http://localhost:8080/tasks/1
curl -X POST http://localhost:8080/tasks \
-H "Content-Type: application/json" \
-d '{"title":"Ship Tapir API"}'
หากต้องการเปลี่ยนจาก Pekko HTTP ไปเป็น interpreter อื่น คุณเปลี่ยนเฉพาะส่วน interpreter โดยไม่ต้องแก้ endpoint description
การสร้างเอกสาร OpenAPI
เพราะ endpoint เป็นค่า OpenAPI spec จึงสร้างจาก endpoint เหล่านั้นได้โดยตรง ไม่ต้องเขียน annotation หรือดูแลไฟล์ spec แยกเอง
import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter
import sttp.apispec.openapi.circe.yaml._
val docs = OpenAPIDocsInterpreter().toOpenAPI(
List(getTask, listTasks, createTask),
"Task API",
"1.0"
)
println(docs.toYaml)
docs.toYaml จะสร้างเอกสาร OpenAPI จาก endpoint ที่ใช้งานจริง
ถ้าต้องการ Swagger UI ให้เพิ่ม module tapir-swagger-ui-bundle แล้ว serve UI จาก server เดียวกันได้ สเปคและ route จะไม่คลาดเคลื่อนกัน เพราะทั้งคู่สร้างจาก endpoint value เดียวกัน
OpenAPI ที่ได้ยังใช้ต่อกับเครื่องมืออื่นได้ เช่น นำเข้าไปที่ Apidog เพื่อดู API reference, ส่ง request จาก console และสร้าง mock server จาก spec เดียวกับที่ Scala สร้างออกมา เหมาะสำหรับทีม frontend ที่ต้องเริ่มทำงานก่อน backend deploy จริง
workflow ที่แนะนำคือ:
- กำหนด endpoint ด้วย Tapir
- สร้าง OpenAPI จาก endpoint
- export spec เป็นส่วนหนึ่งของ build หรือ CI
- นำ spec ไปใช้กับ API tool, mock server หรือ frontend contract
- ทดสอบ implementation เทียบกับ contract เดียวกัน
ทิศทางของ contract จะชัดเจน: Scala type → OpenAPI → mock/client/frontend
การทดสอบ API
Tapir endpoint ทดสอบได้ 2 ระดับ
1. ทดสอบ logic โดยไม่ต้องเปิด server
ใช้ Tapir stub interpreter เพื่อสร้าง test backend จาก server endpoint แล้วส่ง request ด้วย sttp
import org.scalatest.flatspec.AsyncFlatSpec
import org.scalatest.matchers.should.Matchers
import sttp.client3._
import sttp.client3.testing.SttpBackendStub
import sttp.tapir.server.stub.TapirStubInterpreter
import sttp.client3.circe._
import io.circe.generic.auto._
class TaskApiSpec extends AsyncFlatSpec with Matchers {
val backend = TapirStubInterpreter(SttpBackendStub.asynchronousFuture)
.whenServerEndpointRunLogic(getTaskServer)
.whenServerEndpointRunLogic(createTaskServer)
.backend()
"GET /tasks/1" should "return the seeded task" in {
basicRequest
.get(uri"http://test.com/tasks/1")
.response(asJson[Task])
.send(backend)
.map { resp =>
resp.code.code shouldBe 200
resp.body.map(_.title) shouldBe Right("Write the Tapir tutorial")
}
}
"GET /tasks/999" should "return a 404 with an error body" in {
basicRequest
.get(uri"http://test.com/tasks/999")
.response(asJson[ApiError])
.send(backend)
.map { resp =>
resp.code.code shouldBe 404
}
}
}
ข้อดีของแนวทางนี้:
- ไม่ต้อง bind port
- รันเร็ว
- ทดสอบ logic ผ่าน HTTP abstraction
- ยังใช้ contract เดียวกับ server จริง
2. ทดสอบ integration แบบเปิด server จริง
ใช้ integration test จำนวนไม่มากเพื่อยืนยันว่า server binding, routing และ HTTP stack ทำงานจริง ส่วน coverage หลักควรอยู่ที่ stub-based test เพราะเร็วกว่า แนวทางนี้สอดคล้องกับการ ทดสอบอัตโนมัติ ที่ดี: test ส่วนใหญ่ควรรันเร็ว และมี test หนักเฉพาะจุดที่จำเป็น
หากต้องการตรวจ contract แบบ manual หรือทำ mock server จาก OpenAPI ให้ ดาวน์โหลด Apidog แล้วนำเข้าไฟล์ OpenAPI ที่ Tapir สร้างขึ้น
สรุป workflow สำหรับใช้งานจริง
สำหรับโปรเจกต์ Scala API คุณสามารถวางโครงสร้างการทำงานแบบนี้:
- นิยาม domain model และ JSON codec
- นิยาม endpoint ด้วย Tapir
- แนบ
serverLogic - เลือก server interpreter เช่น Pekko HTTP
- สร้าง OpenAPI จาก endpoint เดียวกัน
- ใช้ stub interpreter สำหรับ unit/integration-light test
- export OpenAPI ไปยัง API tool หรือ mock server
ผลลัพธ์คือ API contract อยู่ใน Scala type system และถูกใช้ซ้ำทั้ง server, test และ documentation
คำถามที่พบบ่อย
Tapir ใน Scala คืออะไร?
Tapir ย่อมาจาก Typed API descRiptions เป็นไลบรารี Scala สำหรับอธิบาย HTTP endpoint เป็นค่าที่มี type ชัดเจน จากคำอธิบายเดียวกัน สามารถสร้าง server route, client และ OpenAPI documentation ได้
ฉันจำเป็นต้องใช้ Akka HTTP กับ Tapir หรือไม่?
ไม่จำเป็น Tapir แยก endpoint description ออกจาก server interpreter คุณสามารถใช้ Pekko HTTP, http4s, Netty, Vert.x หรือ interpreter อื่นได้โดยไม่ต้องเปลี่ยน endpoint contract
Tapir สร้าง OpenAPI ได้อย่างไร?
Tapir OpenAPI module อ่าน endpoint value แล้วสร้าง OpenAPI spec จาก metadata, input, output และ error output ที่นิยามไว้ใน endpoint เพราะ server และ docs มาจากค่าเดียวกัน เอกสารจึงไม่ควรคลาดเคลื่อนจาก implementation
ฉันจะทดสอบ Tapir API โดยไม่ต้องเริ่ม server ได้อย่างไร?
ใช้ Tapir stub interpreter เพื่อสร้าง sttp test backend จาก ServerEndpoint จากนั้นส่ง request ด้วย sttp เหมือนเรียก HTTP จริง แต่ไม่ต้อง bind port ทำให้ test เร็วและเหมาะกับการรันใน CI
ฉันสามารถใช้ OpenAPI จาก Tapir กับเครื่องมือ API อื่นได้หรือไม่?
ได้ OpenAPI ที่ Tapir สร้างเป็นสเปคมาตรฐาน คุณสามารถนำเข้าไปยังเครื่องมืออย่าง Apidog เพื่อดู API reference, ส่ง request, ตรวจ contract และสร้าง mock server สำหรับทีม frontend ได้
Top comments (0)