Skip to content

Commit 7070476

Browse files
committed
feature/Add JSON Schema generation support and corresponding tests
- Introduced `OBPResourceDocJson` for generating Draft-04 JSON Schema from case classes. - Updated `ResourceDocJson` to utilize Circe JSON types for request and response bodies. - Added unit tests in `OBPResourceDocJsonSpec` to validate schema generation for sample case classes.
1 parent c2b8a33 commit 7070476

3 files changed

Lines changed: 98 additions & 5 deletions

File tree

build.sbt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ ThisBuild / organizationHomepage := Some(url("https://www.openbankproject.com/")
1515
libraryDependencies ++= Seq(
1616
"io.circe" %% "circe-core" % "0.14.10",
1717
"io.circe" %% "circe-parser" % "0.14.10",
18+
// JSON Schema generation
19+
"com.github.andyglow" %% "scala-jsonschema" % "0.7.11",
20+
"com.github.andyglow" %% "scala-jsonschema-circe-json" % "0.7.11",
1821
"org.scalatest" %% "scalatest" % "3.2.19" % Test
1922
)
2023

src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package com.openbankproject.resourcedocs.core.model
22

3-
import io.circe.Json
3+
import io.circe.{Json => CirceJson}
4+
import com.github.andyglow.jsonschema.AsCirce._
5+
import json._
6+
import json.schema.Version.Draft04
47

58
/** JSON-friendly DTO for API resource documentation. This structure avoids framework types and uses primitive-friendly
69
* fields.
@@ -17,6 +20,21 @@ final case class ImplementedByJson(
1720
function: String // The val / partial function that implements the call e.g. "getBanks"
1821
)
1922

23+
object OBPResourceDocJson {
24+
/** Helper to generate Schema JSON from a Case Class type.
25+
*
26+
* @tparam T
27+
* The case class type to generate schema for
28+
* @return
29+
* Circe JSON representing the Draft-04 JSON Schema
30+
*/
31+
def generateSchema[T: Schema]: CirceJson = {
32+
// Instantiate the final class directly
33+
val v = new json.schema.Version.Draft04()
34+
Json.schema[T].asCirce(v)
35+
}
36+
}
37+
2038
/** Extended JSON-friendly DTO for API resource documentation that matches OBP-API structure. This structure includes
2139
* all fields from OBP-API's ResourceDocJson for compatibility. Field names use snake_case to match OBP-API's
2240
* ResourceDocJson structure.
@@ -29,14 +47,16 @@ final case class OBPResourceDocJson(
2947
summary: String,
3048
description: String, // HTML format
3149
description_markdown: String, // Markdown format
32-
example_request_body: Option[Json] = None,
33-
success_response_body: Option[Json] = None,
50+
example_request_body: Option[CirceJson] = None,
51+
success_response_body: Option[CirceJson] = None,
3452

3553
error_response_bodies: List[String] = List.empty,
3654
tags: List[String] = List.empty,
3755

38-
typed_request_body: Option[Json] = None,
39-
typed_success_response_body: Option[Json] = None,
56+
// Schema for request body (Draft-04 JSON Schema)
57+
typed_request_body: Option[CirceJson] = None,
58+
// Schema for success response (Draft-04 JSON Schema)
59+
typed_success_response_body: Option[CirceJson] = None,
4060

4161
roles: Option[List[RoleInfoJson]] = None,
4262
is_featured: Boolean = false,
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
package com.openbankproject.resourcedocs.core.model
2+
3+
import io.circe.{Json => CirceJson}
4+
import org.scalatest.flatspec.AnyFlatSpec
5+
import org.scalatest.matchers.should.Matchers
6+
import json._
7+
8+
case class SampleRequest(id: String, quantity: Int, tags: List[String])
9+
case class SampleResponse(success: Boolean, message: Option[String])
10+
11+
class OBPResourceDocJsonSpec extends AnyFlatSpec with Matchers {
12+
13+
// Define implicits using the macro
14+
implicit val sampleRequestSchema: Schema[SampleRequest] = Json.schema[SampleRequest]
15+
implicit val sampleResponseSchema: Schema[SampleResponse] = Json.schema[SampleResponse]
16+
17+
"OBPResourceDocJson.generateSchema" should "generate correct Draft-04 schema for a case class" in {
18+
val schema: CirceJson = OBPResourceDocJson.generateSchema[SampleRequest]
19+
20+
val schemaObj = schema.asObject.get
21+
22+
// Handle Schema with Definitions (Draft-04 style)
23+
// The root might contain $ref, and properties are in definitions
24+
val properties = if (schemaObj.contains("definitions")) {
25+
val definitions = schemaObj("definitions").flatMap(_.asObject).get
26+
// Assuming only one definition is generated for the simple case class
27+
definitions.values.head.asObject.get("properties").flatMap(_.asObject).get
28+
} else {
29+
schemaObj("properties").flatMap(_.asObject).get
30+
}
31+
32+
properties("id").flatMap(_.asObject).flatMap(_("type").flatMap(_.asString)) should be(Some("string"))
33+
properties("quantity").flatMap(_.asObject).flatMap(_("type").flatMap(_.asString)) should be(Some("integer"))
34+
35+
// Verify array handling
36+
val tagsProp = properties("tags").flatMap(_.asObject).get
37+
tagsProp("type").flatMap(_.asString) should be(Some("array"))
38+
tagsProp("items").flatMap(_.asObject).flatMap(_("type").flatMap(_.asString)) should be(Some("string"))
39+
40+
// Verify required fields
41+
// If using definitions, 'required' is inside the definition
42+
val requiredSource = if (schemaObj.contains("definitions")) {
43+
val definitions = schemaObj("definitions").flatMap(_.asObject).get
44+
definitions.values.head.asObject.get
45+
} else {
46+
schemaObj
47+
}
48+
val required = requiredSource("required").flatMap(_.asArray).getOrElse(Vector.empty).flatMap(_.asString)
49+
required should contain allOf ("id", "quantity", "tags")
50+
}
51+
52+
it should "handle Option types as optional fields in schema" in {
53+
val schema: CirceJson = OBPResourceDocJson.generateSchema[SampleResponse]
54+
55+
val schemaObj = schema.asObject.get
56+
57+
val requiredSource = if (schemaObj.contains("definitions")) {
58+
val definitions = schemaObj("definitions").flatMap(_.asObject).get
59+
definitions.values.head.asObject.get
60+
} else {
61+
schemaObj
62+
}
63+
64+
val required = requiredSource("required").flatMap(_.asArray).map(_.flatMap(_.asString)).getOrElse(Vector.empty)
65+
66+
// 'success' should be required, 'message' should NOT be in required list
67+
required should contain("success")
68+
required should not contain "message"
69+
}
70+
}

0 commit comments

Comments
 (0)