Skip to content

Commit b494b1b

Browse files
abdelfetah18adamw
andauthored
Implement OpenAPIVerifier (#4215)
Co-authored-by: adamw <adam@warski.org>
1 parent 0fc93a0 commit b494b1b

File tree

4 files changed

+293
-0
lines changed

4 files changed

+293
-0
lines changed

build.sbt

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ lazy val rawAllAggregates = core.projectRefs ++
200200
pekkoGrpcExamples.projectRefs ++
201201
apispecDocs.projectRefs ++
202202
openapiDocs.projectRefs ++
203+
openapiVerifier.projectRefs ++
203204
asyncapiDocs.projectRefs ++
204205
swaggerUi.projectRefs ++
205206
swaggerUiBundle.projectRefs ++
@@ -1110,6 +1111,27 @@ lazy val openapiDocs: ProjectMatrix = (projectMatrix in file("docs/openapi-docs"
11101111
)
11111112
.dependsOn(core, apispecDocs, tests % Test)
11121113

1114+
lazy val openapiVerifier: ProjectMatrix = (projectMatrix in file("docs/openapi-verifier"))
1115+
.settings(commonSettings)
1116+
.settings(
1117+
name := "tapir-openapi-verifier",
1118+
libraryDependencies ++= Seq(
1119+
"com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % Versions.sttpApispec % Test,
1120+
"com.softwaremill.sttp.apispec" %% "openapi-circe" % Versions.sttpApispec,
1121+
"io.circe" %% "circe-parser" % Versions.circe,
1122+
"io.circe" %% "circe-yaml" % Versions.circeYaml
1123+
)
1124+
)
1125+
.jvmPlatform(
1126+
scalaVersions = scala2And3Versions,
1127+
settings = commonJvmSettings
1128+
)
1129+
.jsPlatform(
1130+
scalaVersions = scala2And3Versions,
1131+
settings = commonJsSettings
1132+
)
1133+
.dependsOn(core, openapiDocs, tests % Test)
1134+
11131135
lazy val openapiDocs3 = openapiDocs.jvm(scala3).dependsOn()
11141136
lazy val openapiDocs2_13 = openapiDocs.jvm(scala2_13).dependsOn(enumeratum.jvm(scala2_13))
11151137
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"))
21442166
nettyServerCats,
21452167
nettyServerSync,
21462168
openapiDocs,
2169+
openapiVerifier,
21472170
opentelemetryMetrics,
21482171
pekkoHttpServer,
21492172
picklerJson,

doc/testing.md

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -375,3 +375,90 @@ Results in:
375375
```scala mdoc
376376
result3.toString
377377
```
378+
379+
## OpenAPI schema compatibility
380+
381+
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.
382+
383+
To use the `OpenAPIVerifier`, add the following dependency:
384+
385+
```scala
386+
"com.softwaremill.sttp.tapir" %% "tapir-openapi-verifier" % "@VERSION@"
387+
```
388+
389+
The `OpenAPIVerifier` supports two key verification scenarios:
390+
391+
1. **Server Verification**: Ensures that all endpoints defined in the OpenAPI specification are implemented by the server.
392+
2. **Client Verification**: Ensures that the client implementation matches the OpenAPI specification.
393+
394+
As a result, you get a list of issues that describe the incomapatibilities, or an empty list, if the endpoints and schema are compatible.
395+
396+
### Example Usage
397+
398+
#### Server Endpoint Verification
399+
400+
```scala mdoc:silent
401+
import sttp.tapir.*
402+
import sttp.tapir.docs.openapi.OpenAPIVerifier
403+
import sttp.tapir.json.circe.*
404+
405+
val clientOpenAPISpecification: String = """
406+
openapi: 3.0.0
407+
info:
408+
title: Sample API
409+
version: 1.0.0
410+
paths:
411+
/users:
412+
get:
413+
summary: Get users
414+
responses:
415+
"200":
416+
description: A list of users
417+
content:
418+
application/json:
419+
schema:
420+
type: array
421+
items:
422+
type: string
423+
"""
424+
425+
val serverEndpoints = List(
426+
endpoint.get.in("users").out(jsonBody[List[String]])
427+
)
428+
429+
val serverIssues = OpenAPIVerifier.verifyServer(serverEndpoints, clientOpenAPISpecification)
430+
```
431+
432+
#### Client Endpoint Verification
433+
434+
```scala mdoc:silent
435+
import sttp.tapir.*
436+
import sttp.tapir.docs.openapi.OpenAPIVerifier
437+
import sttp.tapir.json.circe.*
438+
439+
val serverOpenAPISpecification: String = """
440+
openapi: 3.0.0
441+
info:
442+
title: Sample API
443+
version: 1.0.0
444+
paths:
445+
/users:
446+
get:
447+
summary: Get users
448+
responses:
449+
"200":
450+
description: A list of users
451+
content:
452+
application/json:
453+
schema:
454+
type: array
455+
items:
456+
type: string
457+
""".stripMargin
458+
459+
val clientEndpoints = List(
460+
endpoint.get.in("users").out(jsonBody[List[String]])
461+
)
462+
463+
val clientIssues = OpenAPIVerifier.verifyClient(clientEndpoints, serverOpenAPISpecification)
464+
```
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package sttp.tapir.docs.openapi
2+
3+
import sttp.apispec.openapi.OpenAPI
4+
import sttp.apispec.openapi.validation._
5+
import sttp.tapir._
6+
import io.circe._
7+
import io.circe.yaml.parser
8+
import sttp.apispec.openapi.circe.openAPIDecoder
9+
10+
/** A utility for verifying the compatibility of Tapir endpoints with an OpenAPI specification.
11+
*
12+
* The `OpenAPIVerifier` object provides methods to verify compatibility between endpoints and OpenAPI specifications, or client endpoints
13+
* and server OpenAPI specifications. The compatibility check detects issues such as missing endpoints, parameter mismatches, and schema
14+
* inconsistencies.
15+
*/
16+
object OpenAPIVerifier {
17+
18+
/** Verifies that the provided client endpoints are compatible with the given server OpenAPI specification.
19+
*
20+
* @param clientEndpoints
21+
* the list of client Tapir endpoints to verify.
22+
* @param serverSpecificationYaml
23+
* the OpenAPI specification provided by the server, in YAML format.
24+
* @return
25+
* a list of `OpenAPICompatibilityIssue` instances detailing the compatibility issues found during verification, or `Nil` if no issues
26+
* were found.
27+
*/
28+
def verifyClient(clientEndpoints: List[AnyEndpoint], serverSpecificationYaml: String): List[OpenAPICompatibilityIssue] = {
29+
val clientOpenAPI = OpenAPIDocsInterpreter().toOpenAPI(clientEndpoints, "OpenAPIVerifier", "1.0")
30+
val serverOpenAPI = readOpenAPIFromString(serverSpecificationYaml)
31+
32+
OpenAPIComparator(clientOpenAPI, serverOpenAPI).compare()
33+
}
34+
35+
/** Verifies that the client OpenAPI specification is compatible with the provided server endpoints.
36+
*
37+
* @param serverEndpoints
38+
* the list of server Tapir endpoints to verify.
39+
* @param clientSpecificationYaml
40+
* the OpenAPI specification provided by the client, in YAML format.
41+
* @return
42+
* a list of `OpenAPICompatibilityIssue` instances detailing the compatibility issues found during verification, or `Nil` if no issues
43+
* were found.
44+
*/
45+
def verifyServer(serverEndpoints: List[AnyEndpoint], clientSpecificationYaml: String): List[OpenAPICompatibilityIssue] = {
46+
val serverOpenAPI = OpenAPIDocsInterpreter().toOpenAPI(serverEndpoints, "OpenAPIVerifier", "1.0")
47+
val clientOpenAPI = readOpenAPIFromString(clientSpecificationYaml)
48+
49+
OpenAPIComparator(clientOpenAPI, serverOpenAPI).compare()
50+
}
51+
52+
private def readOpenAPIFromString(yamlOpenApiSpec: String): OpenAPI = {
53+
parser.parse(yamlOpenApiSpec).flatMap(_.as[OpenAPI]) match {
54+
case Right(openapi) => openapi
55+
case Left(error) => throw new IllegalArgumentException("Failed to parse OpenAPI YAML specification", error)
56+
}
57+
}
58+
}
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
package sttp.tapir.docs.openapi
2+
3+
import org.scalatest.funsuite.AnyFunSuite
4+
import sttp.tapir._
5+
import sttp.tapir.json.circe.jsonBody
6+
7+
class OpenApiVerifierTest extends AnyFunSuite {
8+
val openAPISpecification: String =
9+
"""openapi: 3.0.0
10+
|info:
11+
| title: Sample API
12+
| description: Optional multiline or single-line description in [CommonMark](http://commonmark.org/help/) or HTML.
13+
| version: 0.1.9
14+
|
15+
|servers:
16+
| - url: http://api.example.com/v1
17+
| description: Optional server description, e.g. Main (production) server
18+
| - url: http://staging-api.example.com
19+
| description: Optional server description, e.g. Internal staging server for testing
20+
|
21+
|paths:
22+
| /users:
23+
| get:
24+
| summary: Returns a list of users.
25+
| description: Optional extended description in CommonMark or HTML.
26+
| responses:
27+
| "200": # status code
28+
| description: A JSON array of user names
29+
| content:
30+
| application/json:
31+
| schema:
32+
| type: array
33+
| items:
34+
| type: string
35+
| /users/name:
36+
| get:
37+
| summary: Returns a user name.
38+
| description: Retrieves the name of a specific user.
39+
| responses:
40+
| "200": # status code
41+
| description: A plain text user name
42+
| content:
43+
| text/plain:
44+
| schema:
45+
| type: string
46+
""".stripMargin
47+
48+
test("verifyServer - all client openapi endpoints have corresponding server endpoints") {
49+
val serverEndpoints = List(
50+
endpoint.get
51+
.in("users")
52+
.out(jsonBody[List[String]]),
53+
endpoint.get
54+
.in("users" / "name")
55+
.out(stringBody)
56+
)
57+
58+
assert(OpenAPIVerifier.verifyServer(serverEndpoints, openAPISpecification).isEmpty)
59+
}
60+
61+
test("verifyServer - additional endpoints in server") {
62+
val serverEndpoints = List(
63+
endpoint.get
64+
.in("users")
65+
.out(jsonBody[List[String]]),
66+
endpoint.get
67+
.in("users" / "name")
68+
.out(stringBody),
69+
endpoint.get
70+
.in("extra")
71+
.out(stringBody)
72+
)
73+
74+
assert(OpenAPIVerifier.verifyServer(serverEndpoints, openAPISpecification).isEmpty)
75+
}
76+
77+
test("verifyServer - missing endpoints in server") {
78+
val serverEndpoints = List(
79+
endpoint.get
80+
.in("users")
81+
.out(jsonBody[List[String]])
82+
)
83+
84+
assert(OpenAPIVerifier.verifyServer(serverEndpoints, openAPISpecification).nonEmpty)
85+
}
86+
87+
test("verifyClient - all server openapi endpoints have corresponding client endpoints") {
88+
val clientEndpoints = List(
89+
endpoint.get
90+
.in("users")
91+
.out(jsonBody[List[String]]),
92+
endpoint.get
93+
.in("users" / "name")
94+
.out(stringBody)
95+
)
96+
97+
assert(OpenAPIVerifier.verifyClient(clientEndpoints, openAPISpecification).isEmpty)
98+
}
99+
100+
test("verifyClient - additional endpoints exist in client") {
101+
val clientEndpoints = List(
102+
endpoint.get
103+
.in("users")
104+
.out(jsonBody[List[String]]),
105+
endpoint.get
106+
.in("users" / "name")
107+
.out(stringBody),
108+
endpoint.get
109+
.in("extra")
110+
.out(stringBody)
111+
)
112+
113+
assert(OpenAPIVerifier.verifyClient(clientEndpoints, openAPISpecification).nonEmpty)
114+
}
115+
116+
test("verifyClient - missing endpoints in client") {
117+
val clientEndpoints = List(
118+
endpoint.get
119+
.in("users")
120+
.out(jsonBody[List[String]])
121+
)
122+
123+
assert(OpenAPIVerifier.verifyClient(clientEndpoints, openAPISpecification).isEmpty)
124+
}
125+
}

0 commit comments

Comments
 (0)