Tapir ย่อมาจาก Typed API descRiptions เป็นไลบรารี Scala ที่ให้คุณอธิบาย HTTP endpoint ในรูปแบบค่าธรรมดา: ทั้งเมธอด, พาธ, อินพุต, เอาต์พุต และกรณีข้อผิดพลาด ล้วนถูกบันทึกไว้ในระบบประเภท (type system) จากคำอธิบายเดียวนี้ Tapir สามารถสร้างเส้นทางเซิร์ฟเวอร์, ไคลเอนต์ และเอกสาร OpenAPI ได้ คุณเขียนข้อตกลง (contract) เพียงครั้งเดียว ที่เหลือจะตามมาเอง
นี่คือสิ่งที่ทำให้ Tapir แตกต่างจากการเขียนเส้นทางโดยตรงในเฟรมเวิร์กเว็บ ตัวจัดการเส้นทางจะผูกการเชื่อมต่อ HTTP เข้ากับตรรกะทางธุรกิจ แต่ Tapir endpoint แยกสิ่งเหล่านี้ออกจากกัน: คำอธิบายเป็นค่าหนึ่ง ตรรกะทางธุรกิจเป็นฟังก์ชันแยกต่างหาก และ Tapir จะเชื่อมโยงทั้งสองเข้าด้วยกัน บทช่วยสอนนี้จะสร้าง API สำหรับงานเล็กๆ เชื่อมต่อกับเซิร์ฟเวอร์ สร้างเอกสารประกอบ และทดสอบ โดยใช้ Scala ตลอดทั้งกระบวนการ
สิ่งที่ Tapir มอบให้คุณ
Tapir สร้างขึ้นจากแนวคิดง่ายๆ ที่ให้ประโยชน์สามประการ
ประการแรกคือความปลอดภัยของประเภท (type safety) อินพุตและเอาต์พุตของ endpoint ถูกกำหนดประเภทไว้ ดังนั้นคอมไพเลอร์จะตรวจสอบว่าตัวจัดการของคุณส่งคืนข้อมูลในรูปแบบที่ถูกต้องหรือไม่ หากเกิดความไม่ตรงกันระหว่างสิ่งที่ endpoint สัญญาไว้กับสิ่งที่ตรรกะผลิตขึ้น นั่นคือข้อผิดพลาดในการคอมไพล์ ไม่ใช่ข้อผิดพลาดในการผลิต
ประการที่สองคือแหล่งความจริงเดียว (single source of truth) เนื่องจาก endpoint เป็นค่า คุณสามารถสร้าง OpenAPI spec, ตัวแปลเซิร์ฟเวอร์ (server interpreter) และไคลเอนต์จากคำอธิบายเดียวกันได้ สิ่งเหล่านี้ไม่สามารถคลาดเคลื่อนกันได้ เพราะมีคำอธิบายเพียงชุดเดียว นี่คือหลักการเดียวกันที่อยู่เบื้องหลัง การทดสอบสัญญา API ซึ่งบังคับใช้โดยคอมไพเลอร์
ประการที่สามคือความเป็นอิสระของเฟรมเวิร์ก (framework independence) คำอธิบาย endpoint ไม่ได้ทราบว่ามันทำงานบน Akka HTTP, Pekko, http4s หรือ Netty คุณเลือกตัวแปลเซิร์ฟเวอร์แยกต่างหาก ดังนั้นคำอธิบายจึงมีอายุการใช้งานนานกว่าการเลือกเฟรมเวิร์กใดๆ
สิ่งสำคัญคือต้องชี้แจงให้ชัดเจนว่า Tapir ไม่ใช่สิ่งใด มันไม่ใช่เฟรมเวิร์กเว็บ; มันไม่ได้จัดการซ็อกเก็ต, พูลเธรด หรือเอนจิ้นการเราต์ มันเป็นชั้นคำอธิบายที่อยู่ด้านบนของเฟรมเวิร์กที่คุณเลือก นอกจากนี้ มันไม่ใช่เครื่องมือสร้างโค้ด (code generator) ในความหมายทั่วไป คุณไม่ได้รันเครื่องมือที่เขียนไฟล์ Scala แล้วคุณนำไปแก้ไข ค่าของ endpoint คือแหล่งที่มา และเซิร์ฟเวอร์, ไคลเอนต์ และเอกสารประกอบถูกคำนวณจากสิ่งเหล่านั้นในระหว่างการคอมไพล์ สิ่งนี้ทำให้คำอธิบายและโค้ดที่รันอยู่ไม่คลาดเคลื่อนกัน ซึ่งเป็นรูปแบบความล้มเหลวที่เกิดขึ้นซ้ำๆ ในเวิร์กโฟลว์ที่ใช้เครื่องมือสร้างโค้ด โดยที่ไฟล์ที่สร้างขึ้นมีการแก้ไขด้วยมือและแหล่งที่มาแตกต่างกันไป
การตั้งค่าโปรเจกต์
เพิ่ม Tapir ไปยัง build.sbt คุณต้องมีโมดูลหลัก, โมดูล OpenAPI, ตัวแปลเซิร์ฟเวอร์ และไลบรารี JSON ตัวอย่างนี้ใช้ Pekko HTTP ซึ่งเป็นผู้สืบทอดที่ยังคงได้รับการดูแลจาก Akka HTTP พร้อมกับ 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"
)
เอกสารประกอบ Tapir ปัจจุบันที่ tapir.softwaremill.com แสดงรายการทุกโมดูลและเวอร์ชันที่ตรงกัน เนื่องจากระบบนิเวศมีการเปลี่ยนแปลงอย่างรวดเร็ว
การกำหนด Typed Endpoint
เริ่มต้นด้วยประเภทโดเมนและ JSON codec ของพวกมัน circe จะสร้าง codec จาก case classs
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 แต่ละ endpoint เป็นค่าที่สร้างขึ้นโดยการเชื่อม combinator เข้ากับ endpoint พื้นฐาน อ่านการเชื่อมต่อนี้เป็นประโยค: นี่คือ GET ไปยัง /tasks/{id} ที่รับอินพุตพาธเป็นจำนวนเต็ม และส่งคืน ApiError พร้อมโค้ด 404 หรือ Task พร้อมโค้ด 200
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]))
พารามิเตอร์ประเภทสี่ตัวบน PublicEndpoint คืออินพุต, เอาต์พุตข้อผิดพลาด, เอาต์พุตความสำเร็จ และความสามารถที่จำเป็น ตอนนี้ยังไม่มีอะไรทำงาน นี่เป็นเพียงคำอธิบายบริสุทธิ์ ซึ่งเป็นเหตุผลที่ค่าเดียวกันสามารถสร้างเซิร์ฟเวอร์, ไคลเอนต์ และเอกสารประกอบได้
combinator ไม่กี่ตัวมีบทบาทสำคัญ in เพิ่มอินพุต ไม่ว่าจะเป็นส่วนของพาธ, พารามิเตอร์การสอบถาม, เฮดเดอร์ หรือบอดี้ out อธิบายการตอบกลับที่สำเร็จ errorOut อธิบายการตอบกลับที่ล้มเหลว และ endpoint เดียวสามารถจำลองกรณีข้อผิดพลาดหลายกรณีโดยการรวมตัวแปรต่างๆ ด้วย oneOf combinator แต่ละตัวจะจำกัดประเภทของ endpoint ดังนั้นเมื่อคำอธิบายเสร็จสมบูรณ์ ลายเซ็นประเภทของมันจะบอกถึงสัญญาได้อย่างแม่นยำ คุณสามารถอ่านลายเซ็นของ getTask และทราบได้ว่ามันรับ Int สามารถล้มเหลวด้วย ApiError และส่งคืน Task โดยไม่ต้องอ่านโค้ดแม้แต่บรรทัดเดียว
นี่คือหัวใจสำคัญของ Tapir ในทางปฏิบัติ คำอธิบาย endpoint คือเอกสารประกอบที่คอมไพเลอร์บังคับใช้ เพื่อนร่วมทีมไม่สามารถตีความสัญญาผิดได้ เพราะสัญญาคือประเภท และโค้ดที่ละเมิดจะไม่สามารถสร้างได้
การเพิ่มตรรกะเซิร์ฟเวอร์
คำอธิบายจะกลายเป็นเส้นทางที่ทำงานได้เมื่อคุณแนบตรรกะด้วย serverLogic ตรรกะคือฟังก์ชันที่ส่งคืน Either[ErrorType, SuccessType] ซึ่งถูกห่อหุ้มในประเภท effect เช่น Future เพื่อให้ตัวอย่างนี้สมบูรณ์ เราจะใช้ที่เก็บข้อมูลในหน่วยความจำแบบง่าย
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))
}
คอมไพเลอร์บังคับใช้สัญญาที่นี่ getTask สัญญาว่าจะเกิดข้อผิดพลาดประเภท ApiError ดังนั้น toRight ต้องให้ ApiError หากส่งคืนประเภทที่ผิด โค้ดจะไม่คอมไพล์ รายละเอียด HTTP เช่น 404, การเข้ารหัส JSON ได้รับการจัดการโดยคำอธิบายแล้ว
แปลง server endpoint ให้เป็นเส้นทางจริงด้วย Pekko interpreter และเริ่มต้นเซิร์ฟเวอร์
import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter import org.apache.pekko.http.scaladsl.Http import org.apache.pekko.actor.typed.ActorSystem import org.apache.pekko.actor.typed.scaladsl.Behaviors 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 ตอบสนองบนพอร์ต 8080 คุณเปลี่ยนสิ่งที่เกี่ยวข้องกับเฟรมเวิร์กในจุดเดียว นั่นคือ interpreter โดยไม่ต้องแตะต้องคำอธิบาย endpoint แม้แต่จุดเดียว
การสร้างเอกสารประกอบ OpenAPI
เนื่องจาก endpoint เป็นค่า เอกสาร OpenAPI จึงเป็นฟังก์ชันของพวกมัน ไม่ต้องมีคำอธิบายประกอบ (annotations) หรือไฟล์สเปคแยกต่างหากให้บำรุงรักษา
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 ที่สมบูรณ์ เพิ่มโมดูล tapir-swagger-ui-bundle และ Tapir จะให้บริการ Swagger UI แบบอินเทอร์แอคทีฟโดยตรงจากเซิร์ฟเวอร์ของคุณ สเปคไม่สามารถโกหกเกี่ยวกับ API ได้ เพราะทั้งสองมาจากค่า endpoint เดียวกัน
ไฟล์ OpenAPI นั้นยังเป็นสะพานเชื่อมไปยังเครื่องมืออื่นๆ อีกด้วย นำเข้ามันเข้าสู่ Apidog แล้วคุณจะได้รับ API reference ที่สามารถเรียกดูได้, คอนโซลสำหรับส่งคำขอเพื่อตรวจสอบด้วยตนเอง และ mock server ที่สร้างขึ้นมาทั้งหมดนี้โดยไม่ต้องออกจากสเปคที่โค้ด Scala ของคุณได้สร้างขึ้นมาแล้ว นี่เป็นวิธีปฏิบัติในการให้เพื่อนร่วมทีมส่วนหน้ามีบางสิ่งบางอย่างไว้ทำงานในขณะที่บริการ Scala กำลังพัฒนา
มีเวิร์กโฟลว์ที่ควรกล่าวถึงที่นี่ Tapir สร้างสเปคจากโค้ด Scala ของคุณ ดังนั้นสเปคจึงเป็นปัจจุบันอยู่เสมอ เครื่องมือจำลอง (mocking tool) ที่ใช้สเปคนั้นจะให้ endpoint ที่ใช้งานได้แก่ส่วนหน้าของคุณก่อนที่เซิร์ฟเวอร์จริงจะถูกปรับใช้ที่ใดก็ตาม สัญญาจะไหลไปในทิศทางเดียว: ประเภท Scala กำหนดมัน, OpenAPI บรรทุกมัน, และ mock กับส่วนหน้าใช้มัน ไม่มีใครเขียนสัญญาด้วยมือซ้ำสองครั้ง ดังนั้นจึงไม่มีใครทำให้สำเนาสองฉบับคลาดเคลื่อนกันได้ สำหรับทีม Scala ที่ส่งมอบ API ที่ทีมอื่นต้องพึ่งพา นี่คือเหตุผลหลักในการเชื่อมโยงการส่งออก OpenAPI เข้ากับการสร้าง (build) ให้ต่อเนื่อง
การทดสอบ API
Tapir endpoint สามารถทดสอบได้สองระดับ
ระดับแรกคือการตรวจสอบตรรกะของ endpoint โดยไม่มี HTTP เลย โดยใช้ stub interpreter คุณสร้าง test backend จาก server endpoint และส่งคำขอผ่านไคลเอนต์ 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 }
}
}
ระดับที่สองคือการทดสอบแบบบูรณาการเต็มรูปแบบ: ผูกเซิร์ฟเวอร์จริงเข้ากับพอร์ตและเรียกใช้ด้วยไคลเอนต์ HTTP ใช้สิ่งนี้อย่างจำกัดเพื่อยืนยันการเชื่อมต่อ และพึ่งพาการทดสอบแบบ stub-based สำหรับการครอบคลุมส่วนใหญ่ เนื่องจากมันทำงานได้เร็วกว่ามาก การแยกระหว่างการตรวจสอบที่รวดเร็วและแยกส่วน กับชั้นบางๆ ของการตรวจสอบจริง สะท้อนให้เห็นถึงแนวปฏิบัติที่ดีในการ ทดสอบอัตโนมัติ โดยทั่วไป
คุณสมบัติที่มีประโยชน์ที่ได้มาจากการออกแบบของ Tapir คือ: เนื่องจากคำอธิบาย endpoint ถูกแชร์ การทดสอบที่สร้างคำขอจาก getTask ไม่สามารถส่งคำขอที่เซิร์ฟเวอร์ที่ใช้งานจริงจะปฏิเสธได้ สัญญาถูกบังคับใช้ทั้งสองฝ่ายด้วยค่าเดียวกัน หากต้องการทำสัญญาแบบ round-trip ผ่านการสำรวจด้วยตนเองและ mock server ให้ ดาวน์โหลด Apidog และนำเข้าไฟล์ OpenAPI ที่ Tapir สร้างขึ้น
คำถามที่พบบ่อย
Tapir ใน Scala คืออะไร?
Tapir ย่อมาจาก Typed API descRiptions เป็นไลบรารี Scala สำหรับอธิบาย HTTP endpoint ในรูปแบบค่าที่ถูกกำหนดประเภทไว้ จากคำอธิบายเดียว มันสามารถสร้างเส้นทางเซิร์ฟเวอร์, ไคลเอนต์ และเอกสาร OpenAPI ได้ ดังนั้นสัญญาจึงถูกกำหนดเพียงครั้งเดียวและคงความสอดคล้องกันทั้งสามส่วน
ฉันจำเป็นต้องใช้ Akka HTTP กับ Tapir หรือไม่?
ไม่ Tapir แยกคำอธิบาย endpoint ออกจากตัวแปลเซิร์ฟเวอร์ คุณสามารถรัน endpoint เดียวกันบน Pekko HTTP, http4s, Netty, Vert.x หรืออื่นๆ โดยเลือกโมดูลตัวแปลที่แตกต่างกัน คำอธิบายจะไม่เปลี่ยนแปลง
Tapir สร้างเอกสารประกอบ OpenAPI ได้อย่างไร?
โมดูล OpenAPI ของ Tapir อ่านค่า endpoint ของคุณและสร้างเอกสาร OpenAPI ที่สมบูรณ์จากสิ่งเหล่านั้น เนื่องจากเอกสารประกอบและเซิร์ฟเวอร์ทั้งสองมาจากคำอธิบายเดียวกัน เอกสารประกอบจึงไม่สามารถคลาดเคลื่อนจาก API จริงได้
ฉันจะทดสอบ Tapir API โดยไม่ต้องเริ่มเซิร์ฟเวอร์ได้อย่างไร?
ใช้ Tapir stub interpreter มันสร้าง sttp test backend จาก server endpoint ของคุณ ดังนั้นคุณสามารถส่งคำขอและยืนยันการตอบสนองได้โดยไม่ต้องผูกพอร์ต การทดสอบเหล่านี้รวดเร็ว และคุณยังคงมีการทดสอบแบบบูรณาการเต็มรูปแบบจำนวนเล็กน้อยเพื่อยืนยันการเชื่อมต่อจริง
ฉันสามารถใช้เอาต์พุตของ Tapir กับเครื่องมือ API อื่นๆ ได้หรือไม่?
ได้ เอกสาร OpenAPI ที่ Tapir สร้างขึ้นเป็นสเปคมาตรฐาน คุณสามารถนำเข้ามันเข้าสู่เครื่องมืออย่าง Apidog เพื่อรับ API reference แบบอินเทอร์แอคทีฟ, คอนโซลสำหรับส่งคำขอ และ mock server ที่สร้างขึ้นมา ซึ่งเป็นประโยชน์สำหรับการแชร์ API กับนักพัฒนาส่วนหน้า
