بناء واختبار HTTP API باستخدام Tapir في Scala

INEZA Felin-Michel

INEZA Felin-Michel

22 مايو 2026

بناء واختبار HTTP API باستخدام Tapir في Scala

Apidog للمؤسسات

نشر محلي

SSO & RBAC

متوافق مع SOC 2

استكشاف Apidog Enterprise

يرمز Tapir إلى "Typed API descRiptions" (أوصاف API المكتوبة). إنها مكتبة Scala تتيح لك وصف نقطة نهاية HTTP كقيمة عادية: يتم التقاط أسلوبها ومسارها ومدخلاتها ومخرجاتها وحالات الأخطاء فيها جميعًا في نظام النوع. من هذا الوصف الفردي، يستمد Tapir مسار الخادم، وعميلاً، ومستند OpenAPI. تكتب العقد مرة واحدة ويتبع ذلك الباقي.

هذا ما يجعل Tapir مختلفًا عن كتابة المسارات مباشرة في إطار عمل الويب. فمعالج المسار يربط توصيلات HTTP بالمنطق. أما نقطة نهاية Tapir فتفصل بينهما: الوصف هو قيمة واحدة، ومنطق العمل هو دالة منفصلة، وTapir يربط بينهما. ينشئ هذا البرنامج التعليمي واجهة برمجة تطبيقات مهام صغيرة، ويربطها بخادم، وينشئ وثائق، ويختبرها، مع استخدام Scala الحقيقي في جميع أنحاء العملية.

ما يقدمه لك Tapir

يعتمد Tapir على فكرة بسيطة بثلاثة فوائد.

الأول هو الأمان من حيث النوع (type safety). فمدخلات ومخرجات نقطة النهاية مكتوبة (typed)، لذا يتحقق المترجم من أن المعالج الخاص بك يعيد الشكل الصحيح. إن عدم التطابق بين ما تعد به نقطة النهاية وما ينتجه المنطق هو خطأ تجميع (compile error)، وليس خطأ إنتاجي.

الثاني هو مصدر واحد للحقيقة. نظرًا لأن نقطة النهاية هي قيمة، فإنك تنشئ مواصفات OpenAPI، ومفسر الخادم، وعميلاً من نفس الوصف. لا يمكن أن تتباعد، لأنه يوجد وصف واحد فقط. هذه هي نفس المنهجية وراء اختبار عقود API، التي يفرضها المترجم.

الثالث هو استقلالية الإطار (framework independence). وصف نقطة النهاية لا يعرف ما إذا كان يعمل على Akka HTTP، أو Pekko، أو http4s، أو Netty. أنت تختار مفسر الخادم بشكل منفصل، لذا يتجاوز الوصف أي اختيار للإطار.

من المهم توضيح ما ليس Tapir. إنه ليس إطار عمل ويب؛ فهو لا يتعامل مع المقبس، أو مجمع الخيوط (thread pool)، أو محرك التوجيه. إنه طبقة وصفية تقع فوق إطار عمل تختاره. كما أنه ليس مولد تعليمات برمجية بالمعنى المعتاد. أنت لا تشغل أداة تكتب ملفات Scala ثم تقوم بتحريرها. قيم نقطة النهاية هي المصدر، ويتم حساب الخادم، والعميل، والوثائق منها في وقت التجميع. يحافظ ذلك على عدم خروج الوصف والتعليمات البرمجية قيد التشغيل عن التزامن أبدًا، وهو نمط الفشل المتكرر لسير العمل القائم على المولدات حيث يتم تحرير الملف المُنشأ يدويًا وتختلف المصادر.

إعداد المشروع

أضف 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)

ابدأ بأنواع النطاق (domain types) ومُرمزات JSON الخاصة بها. circe يستمد المُرمزات من فئات الحالة (case classes).

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)

الآن صف نقاط النهاية. كل واحدة هي قيمة مبنية عن طريق ربط المُركبات (combinators) على أساس 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 هي المدخلات، ومخرجات الخطأ، ومخرجات النجاح، والقدرات المطلوبة. لا شيء يعمل هنا حتى الآن. هذه أوصاف بحتة، وهذا هو بالضبط السبب الذي يجعل نفس القيم يمكن أن تنتج خادمًا وعميلًا ووثائق.

عدد قليل من المُركبات يحمل معظم العبء. in يضيف مدخلاً، سواء كان جزءًا من المسار، أو معلمة استعلام (query parameter)، أو رأسًا (header)، أو جسمًا (body). out يصف استجابة النجاح. errorOut يصف استجابة الفشل، ويمكن لنقطة نهاية واحدة أن تصمم عدة حالات خطأ من خلال دمج المتغيرات باستخدام oneOf. كل مُركب يضيق نوع نقطة النهاية، لذا بحلول الوقت الذي يكتمل فيه الوصف، يكون توقيع نوعه بيانًا دقيقًا للعقد. يمكنك قراءة توقيع getTask ومعرفة أنه يأخذ Int، ويمكن أن يفشل مع ApiError، وبخلاف ذلك يعيد Task، دون قراءة سطر واحد من التنفيذ.

هذا هو القلب العملي لـ Tapir. وصف نقطة النهاية هو وثائق يفرضها المترجم. لا يمكن لزميل في الفريق أن يسيء قراءة العقد، لأن العقد هو نوع، والتعليمات البرمجية التي تنتهكه لا يتم تجميعها.

إضافة منطق الخادم

يصبح الوصف مسارًا عاملاً عندما تربط المنطق باستخدام serverLogic. المنطق هو دالة تعيد Either[ErrorType, SuccessType]، ملفوفة في نوع تأثير مثل 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، يتم التعامل معها بالفعل بواسطة الوصف.

حوّل نقاط نهاية الخادم إلى مسارات فعلية باستخدام مفسر Pekko وابدأ تشغيل الخادم.

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)

واجهة برمجة التطبيقات تستجيب الآن على المنفذ 8080. لقد غيرت اهتمام الإطار في مكان واحد، وهو المفسر، دون لمس وصف نقطة نهاية واحدة.

إنشاء وثائق OpenAPI

نظرًا لأن نقاط النهاية هي قيم، فإن مستند OpenAPI هو دالة لها. لا توجد تعليقات توضيحية، ولا ملف مواصفات منفصل للحفاظ عليه.

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 تفاعلية مباشرة من خادمك. المواصفات لا يمكن أن تكذب بشأن واجهة برمجة التطبيقات، لأن كلاهما يأتي من نفس قيم نقطة النهاية.

ملف OpenAPI هذا هو أيضًا جسرك إلى أدوات أخرى. قم باستيراده إلى Apidog وستحصل على مرجع API قابل للتصفح، ووحدة تحكم للطلبات للتحقق اليدوي، وخادم وهمي مُنشأ، كل ذلك دون مغادرة المواصفات التي أنتجتها تعليمات Scala البرمجية الخاصة بك بالفعل. إنها طريقة عملية لإعطاء زملاء الواجهة الأمامية شيئًا للعمل عليه بينما تتطور خدمة Scala.

هناك سير عمل يستحق الإشارة إليه هنا. ينتج Tapir المواصفات من مصدر Scala الخاص بك، لذا تكون المواصفات دائمًا محدثة. أداة محاكاة تستهلك تلك المواصفات ثم تمنح واجهتك الأمامية نقطة نهاية عاملة قبل نشر الخادم الحقيقي في أي مكان. يتدفق العقد في اتجاه واحد: تحدده أنواع Scala، وتحمله OpenAPI، ويستهلكه Mock والواجهة الأمامية. لا أحد يكتب العقد يدويًا مرتين، لذا لا يمكن لأحد أن يخرج النسختين عن التزامن. لفرق Scala التي توفر واجهات برمجة تطبيقات تعتمد عليها فرق أخرى، هذا هو السبب الرئيسي للحفاظ على تصدير OpenAPI متصلاً بالبناء.

اختبار واجهة برمجة التطبيقات

يمكن اختبار نقاط نهاية Tapir على مستويين.

المستوى الأول يتحقق من منطق نقطة النهاية بدون HTTP على الإطلاق، باستخدام مفسر العنصر الوهمي (stub interpreter). أنت تبني خلفية اختبار من نقاط نهاية الخادم وترسل الطلبات عبر عميل 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 tests) لمعظم تغطيتك لأنها تعمل بشكل أسرع بكثير. الانقسام بين الفحوصات المعزولة السريعة وطبقة رقيقة من الفحوصات الحقيقية يعكس الممارسات الجيدة عبر الاختبار الآلي بشكل عام.

خاصية مفيدة تنبع من تصميم Tapir: نظرًا لأن وصف نقطة النهاية مشترك، فإن الاختبار الذي يبني الطلبات من getTask لا يمكنه إرسال طلب سيرفضه خادم الإنتاج. يتم فرض العقد على كلا الجانبين بنفس القيمة. لتدوير هذا العقد عبر الاستكشاف اليدوي وخادم وهمي، قم بتنزيل Apidog واستورد ملف OpenAPI الذي ينشئه Tapir.

أسئلة متكررة

ما هو Tapir في Scala؟

تابير، اختصار لـ "Typed API descRiptions" (أوصاف API مكتوبة)، هي مكتبة Scala لوصف نقاط نهاية HTTP كقيم مكتوبة. من وصف واحد، تستمد مسار الخادم، والعميل، ومستند OpenAPI، وبالتالي يتم تعريف العقد مرة واحدة ويظل متسقًا عبر الثلاثة.

هل يجب علي استخدام Akka HTTP مع Tapir؟

لا. يفصل Tapir وصف نقطة النهاية عن مفسر الخادم. يمكنك تشغيل نفس نقاط النهاية على Pekko HTTP، أو http4s، أو Netty، أو Vert.x، أو غيرها عن طريق اختيار وحدة مفسر مختلفة. الأوصاف لا تتغير.

كيف ينشئ Tapir وثائق OpenAPI؟

تقرأ وحدة OpenAPI في Tapir قيم نقاط النهاية الخاصة بك وتنتج مستند OpenAPI كاملاً منها. نظرًا لأن الوثائق والخادم كلاهما يستمدان من نفس الأوصاف، فلا يمكن للوثائق أن تخرج عن التزامن مع واجهة برمجة التطبيقات الفعلية.

كيف أختبر واجهة برمجة تطبيقات Tapir دون تشغيل خادم؟

استخدم مفسر العنصر الوهمي (stub interpreter) في Tapir. يبني خلفية اختبار sttp من نقاط نهاية الخادم الخاصة بك، حتى تتمكن من إرسال الطلبات والتأكيد على الاستجابات دون ربط منفذ. هذه الاختبارات سريعة، وتحافظ على عدد قليل من اختبارات التكامل الكامل لتأكيد التوصيلات الحقيقية.

هل يمكنني استخدام مخرجات Tapir مع أدوات API أخرى؟

نعم. مستند OpenAPI الذي ينشئه Tapir هو مواصفات قياسية. يمكنك استيراده إلى أدوات مثل Apidog للحصول على مرجع API تفاعلي، ووحدة تحكم للطلبات، وخادم وهمي مُنشأ، وهو أمر مفيد لمشاركة واجهة برمجة التطبيقات مع مطوري الواجهة الأمامية.

ممارسة تصميم API في Apidog

اكتشف طريقة أسهل لبناء واستخدام واجهات برمجة التطبيقات