Skip to content

Commit

Permalink
Implement OpenAPIVerifier (#4215)
Browse files Browse the repository at this point in the history
Co-authored-by: adamw <[email protected]>
  • Loading branch information
abdelfetah18 and adamw authored Dec 23, 2024
1 parent 0fc93a0 commit b494b1b
Show file tree
Hide file tree
Showing 4 changed files with 293 additions and 0 deletions.
23 changes: 23 additions & 0 deletions build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ lazy val rawAllAggregates = core.projectRefs ++
pekkoGrpcExamples.projectRefs ++
apispecDocs.projectRefs ++
openapiDocs.projectRefs ++
openapiVerifier.projectRefs ++
asyncapiDocs.projectRefs ++
swaggerUi.projectRefs ++
swaggerUiBundle.projectRefs ++
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -2144,6 +2166,7 @@ lazy val documentation: ProjectMatrix = (projectMatrix in file("generated-doc"))
nettyServerCats,
nettyServerSync,
openapiDocs,
openapiVerifier,
opentelemetryMetrics,
pekkoHttpServer,
picklerJson,
Expand Down
87 changes: 87 additions & 0 deletions doc/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
```
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}

0 comments on commit b494b1b

Please sign in to comment.