DEV Community

Cover image for สร้างและทดสอบ HTTP API ด้วย Tapir ใน Scala
Thanawat Wongchai
Thanawat Wongchai

Posted on • Originally published at apidog.com

สร้างและทดสอบ HTTP API ด้วย Tapir ใน Scala

Tapir ย่อมาจาก Typed API descRiptions เป็นไลบรารี Scala สำหรับอธิบาย HTTP endpoint เป็นค่าที่มี type ชัดเจน ตั้งแต่ method, path, input, output ไปจนถึง error case จากคำอธิบายเดียวกัน คุณสามารถสร้าง server route, client และเอกสาร OpenAPI ได้ แนวคิดหลักคือเขียน API contract ครั้งเดียว แล้วให้ Tapir ใช้ contract เดียวกันตลอดทั้งระบบ

ลองใช้ Apidog วันนี้

ต่างจากการเขียน route โดยตรงใน web framework ตรงที่ Tapir แยก “คำอธิบาย endpoint” ออกจาก “business logic” อย่างชัดเจน endpoint เป็นค่า ส่วน logic เป็นฟังก์ชัน จากนั้น server interpreter จะเชื่อมทั้งสองเข้าด้วยกัน บทความนี้จะแสดงวิธีสร้าง Task API ด้วย Scala, ต่อเข้ากับ Pekko HTTP, สร้าง OpenAPI และทดสอบ endpoint แบบไม่ต้องเริ่ม server จริง

สิ่งที่ Tapir มอบให้คุณ

Tapir เหมาะเมื่อคุณต้องการให้ API contract เป็นศูนย์กลางของระบบ โดยมีข้อดีหลัก 3 อย่าง

  1. Type safety

    input และ output ของ endpoint ถูกกำหนดเป็น type ดังนั้น compiler จะตรวจสอบว่า handler คืนค่าตรงกับ contract หรือไม่ ถ้า endpoint บอกว่าจะคืน Task แต่ logic คืน type อื่น โค้ดจะไม่ compile

  2. Single source of truth

    endpoint เป็นค่าเดียวที่ใช้สร้าง server, client และ OpenAPI spec ได้ จึงลดโอกาสที่เอกสารกับ implementation จะไม่ตรงกัน แนวคิดนี้ใกล้เคียงกับ การทดสอบสัญญา API แต่ถูกบังคับด้วย type system

  3. Framework 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"
)
Enter fullscreen mode Exit fullscreen mode

ตรวจสอบเวอร์ชันล่าสุดและ 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)
Enter fullscreen mode Exit fullscreen mode

จากนั้นกำหนด 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]))
Enter fullscreen mode Exit fullscreen mode

อ่าน 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]
Enter fullscreen mode Exit fullscreen mode

หมายความว่า 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))
  }
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode

ตอนนี้ API ทำงานที่ localhost:8080

ลองเรียกด้วย curl:

curl http://localhost:8080/tasks
Enter fullscreen mode Exit fullscreen mode
curl http://localhost:8080/tasks/1
Enter fullscreen mode Exit fullscreen mode
curl -X POST http://localhost:8080/tasks \
  -H "Content-Type: application/json" \
  -d '{"title":"Ship Tapir API"}'
Enter fullscreen mode Exit fullscreen mode

หากต้องการเปลี่ยนจาก 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)
Enter fullscreen mode Exit fullscreen mode

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 ที่แนะนำคือ:

  1. กำหนด endpoint ด้วย Tapir
  2. สร้าง OpenAPI จาก endpoint
  3. export spec เป็นส่วนหนึ่งของ build หรือ CI
  4. นำ spec ไปใช้กับ API tool, mock server หรือ frontend contract
  5. ทดสอบ 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
      }
  }
}
Enter fullscreen mode Exit fullscreen mode

ข้อดีของแนวทางนี้:

  • ไม่ต้อง 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 คุณสามารถวางโครงสร้างการทำงานแบบนี้:

  1. นิยาม domain model และ JSON codec
  2. นิยาม endpoint ด้วย Tapir
  3. แนบ serverLogic
  4. เลือก server interpreter เช่น Pekko HTTP
  5. สร้าง OpenAPI จาก endpoint เดียวกัน
  6. ใช้ stub interpreter สำหรับ unit/integration-light test
  7. 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)