From b494b1bf54d0912171f6f0295cbcbcc96defc1d0 Mon Sep 17 00:00:00 2001 From: Abdelfetah <62770167+abdelfetah18@users.noreply.github.com> Date: Mon, 23 Dec 2024 07:20:37 -0600 Subject: [PATCH] Implement OpenAPIVerifier (#4215) Co-authored-by: adamw --- build.sbt | 23 ++++ doc/testing.md | 87 ++++++++++++ .../tapir/docs/openapi/OpenAPIVerifier.scala | 58 ++++++++ .../docs/openapi/OpenApiVerifierTest.scala | 125 ++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 docs/openapi-verifier/src/main/scala/sttp/tapir/docs/openapi/OpenAPIVerifier.scala create mode 100644 docs/openapi-verifier/src/test/scala/sttp/tapir/docs/openapi/OpenApiVerifierTest.scala diff --git a/build.sbt b/build.sbt index 530cf8c0e3..db99719362 100644 --- a/build.sbt +++ b/build.sbt @@ -200,6 +200,7 @@ lazy val rawAllAggregates = core.projectRefs ++ pekkoGrpcExamples.projectRefs ++ apispecDocs.projectRefs ++ openapiDocs.projectRefs ++ + openapiVerifier.projectRefs ++ asyncapiDocs.projectRefs ++ swaggerUi.projectRefs ++ swaggerUiBundle.projectRefs ++ @@ -1110,6 +1111,27 @@ lazy val openapiDocs: ProjectMatrix = (projectMatrix in file("docs/openapi-docs" ) .dependsOn(core, apispecDocs, tests % Test) +lazy val openapiVerifier: ProjectMatrix = (projectMatrix in file("docs/openapi-verifier")) + .settings(commonSettings) + .settings( + name := "tapir-openapi-verifier", + libraryDependencies ++= Seq( + "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % Versions.sttpApispec % Test, + "com.softwaremill.sttp.apispec" %% "openapi-circe" % Versions.sttpApispec, + "io.circe" %% "circe-parser" % Versions.circe, + "io.circe" %% "circe-yaml" % Versions.circeYaml + ) + ) + .jvmPlatform( + scalaVersions = scala2And3Versions, + settings = commonJvmSettings + ) + .jsPlatform( + scalaVersions = scala2And3Versions, + settings = commonJsSettings + ) + .dependsOn(core, openapiDocs, tests % Test) + lazy val openapiDocs3 = openapiDocs.jvm(scala3).dependsOn() lazy val openapiDocs2_13 = openapiDocs.jvm(scala2_13).dependsOn(enumeratum.jvm(scala2_13)) lazy val openapiDocs2_12 = openapiDocs.jvm(scala2_12).dependsOn(enumeratum.jvm(scala2_12)) @@ -2144,6 +2166,7 @@ lazy val documentation: ProjectMatrix = (projectMatrix in file("generated-doc")) nettyServerCats, nettyServerSync, openapiDocs, + openapiVerifier, opentelemetryMetrics, pekkoHttpServer, picklerJson, diff --git a/doc/testing.md b/doc/testing.md index 46b38c581c..d8ef12fa53 100644 --- a/doc/testing.md +++ b/doc/testing.md @@ -375,3 +375,90 @@ Results in: ```scala mdoc result3.toString ``` + +## OpenAPI schema compatibility + +The `OpenAPIVerifier` provides utilities for verifying that client and server endpoints are consistent with an OpenAPI specification. This ensures that endpoints defined in your code correspond to those documented in the OpenAPI schema, and vice versa. + +To use the `OpenAPIVerifier`, add the following dependency: + +```scala +"com.softwaremill.sttp.tapir" %% "tapir-openapi-verifier" % "@VERSION@" +``` + +The `OpenAPIVerifier` supports two key verification scenarios: + +1. **Server Verification**: Ensures that all endpoints defined in the OpenAPI specification are implemented by the server. +2. **Client Verification**: Ensures that the client implementation matches the OpenAPI specification. + +As a result, you get a list of issues that describe the incomapatibilities, or an empty list, if the endpoints and schema are compatible. + +### Example Usage + +#### Server Endpoint Verification + +```scala mdoc:silent +import sttp.tapir.* +import sttp.tapir.docs.openapi.OpenAPIVerifier +import sttp.tapir.json.circe.* + +val clientOpenAPISpecification: String = """ +openapi: 3.0.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + summary: Get users + responses: + "200": + description: A list of users + content: + application/json: + schema: + type: array + items: + type: string +""" + +val serverEndpoints = List( + endpoint.get.in("users").out(jsonBody[List[String]]) +) + +val serverIssues = OpenAPIVerifier.verifyServer(serverEndpoints, clientOpenAPISpecification) +``` + +#### Client Endpoint Verification + +```scala mdoc:silent +import sttp.tapir.* +import sttp.tapir.docs.openapi.OpenAPIVerifier +import sttp.tapir.json.circe.* + +val serverOpenAPISpecification: String = """ +openapi: 3.0.0 +info: + title: Sample API + version: 1.0.0 +paths: + /users: + get: + summary: Get users + responses: + "200": + description: A list of users + content: + application/json: + schema: + type: array + items: + type: string +""".stripMargin + +val clientEndpoints = List( + endpoint.get.in("users").out(jsonBody[List[String]]) +) + +val clientIssues = OpenAPIVerifier.verifyClient(clientEndpoints, serverOpenAPISpecification) +``` diff --git a/docs/openapi-verifier/src/main/scala/sttp/tapir/docs/openapi/OpenAPIVerifier.scala b/docs/openapi-verifier/src/main/scala/sttp/tapir/docs/openapi/OpenAPIVerifier.scala new file mode 100644 index 0000000000..fee67b8e73 --- /dev/null +++ b/docs/openapi-verifier/src/main/scala/sttp/tapir/docs/openapi/OpenAPIVerifier.scala @@ -0,0 +1,58 @@ +package sttp.tapir.docs.openapi + +import sttp.apispec.openapi.OpenAPI +import sttp.apispec.openapi.validation._ +import sttp.tapir._ +import io.circe._ +import io.circe.yaml.parser +import sttp.apispec.openapi.circe.openAPIDecoder + +/** A utility for verifying the compatibility of Tapir endpoints with an OpenAPI specification. + * + * The `OpenAPIVerifier` object provides methods to verify compatibility between endpoints and OpenAPI specifications, or client endpoints + * and server OpenAPI specifications. The compatibility check detects issues such as missing endpoints, parameter mismatches, and schema + * inconsistencies. + */ +object OpenAPIVerifier { + + /** Verifies that the provided client endpoints are compatible with the given server OpenAPI specification. + * + * @param clientEndpoints + * the list of client Tapir endpoints to verify. + * @param serverSpecificationYaml + * the OpenAPI specification provided by the server, in YAML format. + * @return + * a list of `OpenAPICompatibilityIssue` instances detailing the compatibility issues found during verification, or `Nil` if no issues + * were found. + */ + def verifyClient(clientEndpoints: List[AnyEndpoint], serverSpecificationYaml: String): List[OpenAPICompatibilityIssue] = { + val clientOpenAPI = OpenAPIDocsInterpreter().toOpenAPI(clientEndpoints, "OpenAPIVerifier", "1.0") + val serverOpenAPI = readOpenAPIFromString(serverSpecificationYaml) + + OpenAPIComparator(clientOpenAPI, serverOpenAPI).compare() + } + + /** Verifies that the client OpenAPI specification is compatible with the provided server endpoints. + * + * @param serverEndpoints + * the list of server Tapir endpoints to verify. + * @param clientSpecificationYaml + * the OpenAPI specification provided by the client, in YAML format. + * @return + * a list of `OpenAPICompatibilityIssue` instances detailing the compatibility issues found during verification, or `Nil` if no issues + * were found. + */ + def verifyServer(serverEndpoints: List[AnyEndpoint], clientSpecificationYaml: String): List[OpenAPICompatibilityIssue] = { + val serverOpenAPI = OpenAPIDocsInterpreter().toOpenAPI(serverEndpoints, "OpenAPIVerifier", "1.0") + val clientOpenAPI = readOpenAPIFromString(clientSpecificationYaml) + + OpenAPIComparator(clientOpenAPI, serverOpenAPI).compare() + } + + private def readOpenAPIFromString(yamlOpenApiSpec: String): OpenAPI = { + parser.parse(yamlOpenApiSpec).flatMap(_.as[OpenAPI]) match { + case Right(openapi) => openapi + case Left(error) => throw new IllegalArgumentException("Failed to parse OpenAPI YAML specification", error) + } + } +} diff --git a/docs/openapi-verifier/src/test/scala/sttp/tapir/docs/openapi/OpenApiVerifierTest.scala b/docs/openapi-verifier/src/test/scala/sttp/tapir/docs/openapi/OpenApiVerifierTest.scala new file mode 100644 index 0000000000..e26e6dc646 --- /dev/null +++ b/docs/openapi-verifier/src/test/scala/sttp/tapir/docs/openapi/OpenApiVerifierTest.scala @@ -0,0 +1,125 @@ +package sttp.tapir.docs.openapi + +import org.scalatest.funsuite.AnyFunSuite +import sttp.tapir._ +import sttp.tapir.json.circe.jsonBody + +class OpenApiVerifierTest extends AnyFunSuite { + val openAPISpecification: String = + """openapi: 3.0.0 + |info: + | title: Sample API + | description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML. + | version: 0.1.9 + | + |servers: + | - url: http://api.example.com/v1 + | description: Optional server description, e.g. Main (production) server + | - url: http://staging-api.example.com + | description: Optional server description, e.g. Internal staging server for testing + | + |paths: + | /users: + | get: + | summary: Returns a list of users. + | description: Optional extended description in CommonMark or HTML. + | responses: + | "200": # status code + | description: A JSON array of user names + | content: + | application/json: + | schema: + | type: array + | items: + | type: string + | /users/name: + | get: + | summary: Returns a user name. + | description: Retrieves the name of a specific user. + | responses: + | "200": # status code + | description: A plain text user name + | content: + | text/plain: + | schema: + | type: string + """.stripMargin + + test("verifyServer - all client openapi endpoints have corresponding server endpoints") { + val serverEndpoints = List( + endpoint.get + .in("users") + .out(jsonBody[List[String]]), + endpoint.get + .in("users" / "name") + .out(stringBody) + ) + + assert(OpenAPIVerifier.verifyServer(serverEndpoints, openAPISpecification).isEmpty) + } + + test("verifyServer - additional endpoints in server") { + val serverEndpoints = List( + endpoint.get + .in("users") + .out(jsonBody[List[String]]), + endpoint.get + .in("users" / "name") + .out(stringBody), + endpoint.get + .in("extra") + .out(stringBody) + ) + + assert(OpenAPIVerifier.verifyServer(serverEndpoints, openAPISpecification).isEmpty) + } + + test("verifyServer - missing endpoints in server") { + val serverEndpoints = List( + endpoint.get + .in("users") + .out(jsonBody[List[String]]) + ) + + assert(OpenAPIVerifier.verifyServer(serverEndpoints, openAPISpecification).nonEmpty) + } + + test("verifyClient - all server openapi endpoints have corresponding client endpoints") { + val clientEndpoints = List( + endpoint.get + .in("users") + .out(jsonBody[List[String]]), + endpoint.get + .in("users" / "name") + .out(stringBody) + ) + + assert(OpenAPIVerifier.verifyClient(clientEndpoints, openAPISpecification).isEmpty) + } + + test("verifyClient - additional endpoints exist in client") { + val clientEndpoints = List( + endpoint.get + .in("users") + .out(jsonBody[List[String]]), + endpoint.get + .in("users" / "name") + .out(stringBody), + endpoint.get + .in("extra") + .out(stringBody) + ) + + assert(OpenAPIVerifier.verifyClient(clientEndpoints, openAPISpecification).nonEmpty) + } + + test("verifyClient - missing endpoints in client") { + val clientEndpoints = List( + endpoint.get + .in("users") + .out(jsonBody[List[String]]) + ) + + assert(OpenAPIVerifier.verifyClient(clientEndpoints, openAPISpecification).isEmpty) + } +}