Adding Message Doc Json schema files

This commit is contained in:
simonredfern 2026-01-20 10:07:08 +01:00
parent 79c44db3bc
commit 3f371cf551
2 changed files with 494 additions and 0 deletions

View File

@ -0,0 +1,275 @@
package code.api.util
import code.api.util.APIUtil.MessageDoc
import com.openbankproject.commons.util.ReflectUtils
import net.liftweb.json.JsonAST._
import net.liftweb.json.JsonDSL._
import scala.reflect.runtime.universe._
/**
* Utility for generating JSON Schema from Scala case classes
* Used by the message-docs JSON Schema endpoint to provide machine-readable schemas
* for adapter code generation in any language.
*/
object JsonSchemaGenerator {
/**
* Convert a list of MessageDoc to a complete JSON Schema document
*/
def messageDocsToJsonSchema(messageDocs: List[MessageDoc], connectorName: String): JObject = {
val allDefinitions = scala.collection.mutable.Map[String, JObject]()
val messages = messageDocs.map { messageDoc =>
val outboundType = ReflectUtils.getType(messageDoc.exampleOutboundMessage)
val inboundType = ReflectUtils.getType(messageDoc.exampleInboundMessage)
// Collect all nested type definitions
collectDefinitions(outboundType, allDefinitions)
collectDefinitions(inboundType, allDefinitions)
("process" -> messageDoc.process) ~
("description" -> messageDoc.description) ~
("message_format" -> messageDoc.messageFormat) ~
("outbound_topic" -> messageDoc.outboundTopic) ~
("inbound_topic" -> messageDoc.inboundTopic) ~
("outbound_schema" -> (
("$schema" -> "http://json-schema.org/draft-07/schema#") ~
typeToJsonSchema(outboundType)
)) ~
("inbound_schema" -> (
("$schema" -> "http://json-schema.org/draft-07/schema#") ~
typeToJsonSchema(inboundType)
)) ~
("adapter_implementation" -> messageDoc.adapterImplementation.map { impl =>
("group" -> impl.group) ~
("suggested_order" -> JInt(BigInt(impl.suggestedOrder)))
})
}
("$schema" -> "http://json-schema.org/draft-07/schema#") ~
("title" -> s"$connectorName Message Schemas") ~
("description" -> s"JSON Schema definitions for $connectorName connector messages") ~
("type" -> "object") ~
("properties" -> (
("messages" -> (
("type" -> "array") ~
("items" -> messages)
))
)) ~
("definitions" -> JObject(allDefinitions.toList.map { case (name, schema) => JField(name, schema) }))
}
/**
* Convert a Scala Type to JSON Schema
*/
private def typeToJsonSchema(tpe: Type): JObject = {
tpe match {
case t if t =:= typeOf[String] =>
("type" -> "string")
case t if t =:= typeOf[Int] =>
("type" -> "integer") ~ ("format" -> "int32")
case t if t =:= typeOf[Long] =>
("type" -> "integer") ~ ("format" -> "int64")
case t if t =:= typeOf[Double] =>
("type" -> "number") ~ ("format" -> "double")
case t if t =:= typeOf[Float] =>
("type" -> "number") ~ ("format" -> "float")
case t if t =:= typeOf[BigDecimal] || t =:= typeOf[scala.math.BigDecimal] =>
("type" -> "number")
case t if t =:= typeOf[Boolean] =>
("type" -> "boolean")
case t if t =:= typeOf[java.util.Date] =>
("type" -> "string") ~ ("format" -> "date-time")
case t if t <:< typeOf[Option[_]] =>
val innerType = t.typeArgs.head
typeToJsonSchema(innerType)
case t if t <:< typeOf[List[_]] || t <:< typeOf[Seq[_]] || t <:< typeOf[scala.collection.immutable.List[_]] =>
val itemType = t.typeArgs.head
("type" -> "array") ~ ("items" -> typeToJsonSchema(itemType))
case t if t <:< typeOf[Map[_, _]] =>
("type" -> "object") ~ ("additionalProperties" -> typeToJsonSchema(t.typeArgs.last))
case t if isEnumType(t) =>
val enumValues = getEnumValues(t)
("type" -> "string") ~ ("enum" -> JArray(enumValues.map(JString(_))))
case t if isCaseClass(t) =>
val typeName = getTypeName(t)
("$ref" -> s"#/definitions/$typeName")
case _ =>
// Fallback for unknown types
("type" -> "object")
}
}
/**
* Collect all type definitions recursively
*/
private def collectDefinitions(tpe: Type, definitions: scala.collection.mutable.Map[String, JObject]): Unit = {
if (!isCaseClass(tpe) || isPrimitiveOrKnown(tpe)) return
val typeName = getTypeName(tpe)
if (definitions.contains(typeName)) return
val schema = caseClassToJsonSchema(tpe, definitions)
definitions += (typeName -> schema)
}
/**
* Convert a case class to JSON Schema definition
*/
private def caseClassToJsonSchema(tpe: Type, definitions: scala.collection.mutable.Map[String, JObject]): JObject = {
try {
val constructor = ReflectUtils.getPrimaryConstructor(tpe)
val params = constructor.paramLists.flatten
val properties = params.map { param =>
val paramName = param.name.toString
val paramType = param.typeSignature
// Recursively collect nested definitions
if (isCaseClass(paramType) && !isPrimitiveOrKnown(paramType)) {
collectDefinitions(paramType, definitions)
}
// Handle List/Seq inner types
if (paramType <:< typeOf[List[_]] || paramType <:< typeOf[Seq[_]]) {
val innerType = paramType.typeArgs.headOption.getOrElse(typeOf[Any])
if (isCaseClass(innerType) && !isPrimitiveOrKnown(innerType)) {
collectDefinitions(innerType, definitions)
}
}
// Handle Option inner types
if (paramType <:< typeOf[Option[_]]) {
val innerType = paramType.typeArgs.headOption.getOrElse(typeOf[Any])
if (isCaseClass(innerType) && !isPrimitiveOrKnown(innerType)) {
collectDefinitions(innerType, definitions)
}
}
val propertySchema = typeToJsonSchema(paramType)
// Add description from annotations if available
val description = getFieldDescription(param)
val schemaWithDesc = if (description.nonEmpty) {
propertySchema ~ ("description" -> description)
} else {
propertySchema
}
JField(paramName, schemaWithDesc)
}
// Determine required fields (non-Option types)
val requiredFields = params
.filterNot(p => p.typeSignature <:< typeOf[Option[_]])
.map(_.name.toString)
val baseSchema = ("type" -> "object") ~ ("properties" -> JObject(properties))
if (requiredFields.nonEmpty) {
baseSchema ~ ("required" -> JArray(requiredFields.map(JString(_))))
} else {
baseSchema
}
} catch {
case e: Exception =>
// Fallback for types we can't introspect
("type" -> "object") ~ ("description" -> s"Schema generation failed: ${e.getMessage}")
}
}
/**
* Get readable type name for schema definitions
*/
private def getTypeName(tpe: Type): String = {
val fullName = tpe.typeSymbol.fullName
// Remove package prefix, keep only class name
val simpleName = fullName.split("\\.").last
// Handle nested types
simpleName.replace("$", "")
}
/**
* Check if type is a case class
*/
private def isCaseClass(tpe: Type): Boolean = {
tpe.typeSymbol.isClass && tpe.typeSymbol.asClass.isCaseClass
}
/**
* Check if type is an enum
*/
private def isEnumType(tpe: Type): Boolean = {
// Check for common enum patterns in OBP
val typeName = tpe.typeSymbol.fullName
typeName.contains("enums.") ||
(tpe.baseClasses.exists(_.fullName.contains("Enumeration")) && tpe.typeSymbol.isModuleClass)
}
/**
* Get enum values if type is an enum
*/
private def getEnumValues(tpe: Type): List[String] = {
try {
// Try to get enum values through reflection
// This is a simplified version - might need enhancement for complex enums
List.empty[String] // Placeholder - enum extraction can be complex
} catch {
case _: Exception => List.empty[String]
}
}
/**
* Check if type is primitive or commonly known type that shouldn't be expanded
*/
private def isPrimitiveOrKnown(tpe: Type): Boolean = {
tpe =:= typeOf[String] ||
tpe =:= typeOf[Int] ||
tpe =:= typeOf[Long] ||
tpe =:= typeOf[Double] ||
tpe =:= typeOf[Float] ||
tpe =:= typeOf[Boolean] ||
tpe =:= typeOf[BigDecimal] ||
tpe =:= typeOf[java.util.Date] ||
tpe <:< typeOf[Option[_]] ||
tpe <:< typeOf[List[_]] ||
tpe <:< typeOf[Seq[_]] ||
tpe <:< typeOf[Map[_, _]]
}
/**
* Extract field description from annotations or scaladoc (simplified)
*/
private def getFieldDescription(param: Symbol): String = {
// This is a placeholder - extracting scaladoc is complex
// Could be enhanced to read annotations or scaladoc comments
""
}
/**
* Generate a simplified single-message JSON Schema (for testing)
*/
def generateSchemaForType[T: TypeTag]: JObject = {
val tpe = typeOf[T]
val definitions = scala.collection.mutable.Map[String, JObject]()
collectDefinitions(tpe, definitions)
("$schema" -> "http://json-schema.org/draft-07/schema#") ~
typeToJsonSchema(tpe) ~
("definitions" -> JObject(definitions.toList.map { case (name, schema) => JField(name, schema) }))
}
}

View File

@ -0,0 +1,219 @@
package code.api.v6_0_0
import code.api.util.APIUtil.OAuth._
import code.api.util.ApiRole
import code.api.util.ErrorMessages.InvalidConnector
import code.api.v6_0_0.OBPAPI6_0_0.Implementations6_0_0
import com.github.dwickern.macros.NameOf.nameOf
import com.openbankproject.commons.util.ApiVersion
import net.liftweb.json._
import org.scalatest.Tag
class MessageDocsJsonSchemaTest extends V600ServerSetup {
override def beforeAll(): Unit = {
super.beforeAll()
}
override def afterAll(): Unit = {
super.afterAll()
}
/**
* Test tags (for grouping tests)
*/
object VersionOfApi extends Tag(ApiVersion.v6_0_0.toString)
object ApiEndpoint1 extends Tag(nameOf(Implementations6_0_0.getMessageDocsJsonSchema))
feature("Get Message Docs as JSON Schema - v6.0.0") {
scenario("We get JSON Schema for rabbitmq_vOct2024 connector", ApiEndpoint1, VersionOfApi) {
When("We make a request to get message docs as JSON Schema")
val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET
val response = makeGetRequest(request)
Then("We should get a 200 OK response")
response.code should equal(200)
And("Response should be valid JSON")
val json = response.body.extract[JValue]
json should not be null
And("Response should have JSON Schema structure")
val schemaVersion = (json \ "$schema").extractOpt[String]
schemaVersion shouldBe defined
schemaVersion.get should include("json-schema.org")
And("Response should have a title")
val title = (json \ "title").extractOpt[String]
title shouldBe defined
title.get should include("rabbitmq_vOct2024")
And("Response should have definitions")
val definitions = (json \ "definitions").extractOpt[JObject]
definitions shouldBe defined
And("Response should have properties with messages array")
val properties = (json \ "properties").extractOpt[JObject]
properties shouldBe defined
val messagesProperty = (json \ "properties" \ "messages").extractOpt[JObject]
messagesProperty shouldBe defined
val messagesType = (json \ "properties" \ "messages" \ "type").extractOpt[String]
messagesType shouldBe Some("array")
And("Each message should have required structure")
val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]]
messages should not be empty
// Check first message has expected fields
val firstMessage = messages.head
(firstMessage \ "process").extractOpt[String] shouldBe defined
(firstMessage \ "description").extractOpt[String] shouldBe defined
(firstMessage \ "message_format").extractOpt[String] shouldBe defined
(firstMessage \ "outbound_schema").extractOpt[JObject] shouldBe defined
(firstMessage \ "inbound_schema").extractOpt[JObject] shouldBe defined
And("Outbound schema should be valid JSON Schema")
val outboundSchema = (firstMessage \ "outbound_schema").extract[JObject]
val outboundSchemaVersion = (outboundSchema \ "$schema").extractOpt[String]
outboundSchemaVersion shouldBe defined
val outboundType = (outboundSchema \ "type").extractOpt[String]
outboundType shouldBe Some("object")
val outboundProperties = (outboundSchema \ "properties").extractOpt[JObject]
outboundProperties shouldBe defined
And("Inbound schema should be valid JSON Schema")
val inboundSchema = (firstMessage \ "inbound_schema").extract[JObject]
val inboundSchemaVersion = (inboundSchema \ "$schema").extractOpt[String]
inboundSchemaVersion shouldBe defined
val inboundType = (inboundSchema \ "type").extractOpt[String]
inboundType shouldBe Some("object")
val inboundProperties = (inboundSchema \ "properties").extractOpt[JObject]
inboundProperties shouldBe defined
}
scenario("We get JSON Schema for rest_vMar2019 connector", ApiEndpoint1, VersionOfApi) {
When("We make a request to get message docs as JSON Schema")
val request = (v6_0_0_Request / "message-docs" / "rest_vMar2019" / "json-schema").GET
val response = makeGetRequest(request)
Then("We should get a 200 OK response")
response.code should equal(200)
And("Response should be valid JSON Schema")
val json = response.body.extract[JValue]
val schemaVersion = (json \ "$schema").extractOpt[String]
schemaVersion shouldBe defined
}
scenario("We get JSON Schema for akka_vDec2018 connector", ApiEndpoint1, VersionOfApi) {
When("We make a request to get message docs as JSON Schema")
val request = (v6_0_0_Request / "message-docs" / "akka_vDec2018" / "json-schema").GET
val response = makeGetRequest(request)
Then("We should get a 200 OK response")
response.code should equal(200)
And("Response should be valid JSON Schema")
val json = response.body.extract[JValue]
val schemaVersion = (json \ "$schema").extractOpt[String]
schemaVersion shouldBe defined
}
scenario("We try to get JSON Schema for invalid connector", ApiEndpoint1, VersionOfApi) {
When("We make a request with invalid connector name")
val request = (v6_0_0_Request / "message-docs" / "invalid_connector" / "json-schema").GET
val response = makeGetRequest(request)
Then("We should get a 400 Bad Request response")
response.code should equal(400)
And("Error message should mention invalid connector")
val errorMessage = (response.body \ "message").extractOpt[String]
errorMessage shouldBe defined
errorMessage.get should include("InvalidConnector")
}
scenario("We verify schema includes nested type definitions", ApiEndpoint1, VersionOfApi) {
When("We make a request to get message docs as JSON Schema")
val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET
val response = makeGetRequest(request)
Then("We should get a 200 OK response")
response.code should equal(200)
And("Definitions should include common types")
val json = response.body.extract[JValue]
val definitions = (json \ "definitions").extract[JObject]
// Should have definitions for common adapter types
val definitionNames = definitions.obj.map(_.name)
definitionNames should not be empty
// Common types that should be present in RabbitMQ schemas
// (exact names depend on the case classes, but there should be several)
definitionNames.length should be > 5
And("Each definition should have proper schema structure")
definitions.obj.foreach { case JField(name, schema) =>
val schemaObj = schema.asInstanceOf[JObject]
val schemaType = (schemaObj \ "type").extractOpt[String]
schemaType shouldBe Some("object")
val properties = (schemaObj \ "properties").extractOpt[JObject]
properties shouldBe defined
}
}
scenario("We verify schema marks required fields correctly", ApiEndpoint1, VersionOfApi) {
When("We make a request to get message docs as JSON Schema")
val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET
val response = makeGetRequest(request)
Then("We should get a 200 OK response")
response.code should equal(200)
And("Schemas should indicate required fields")
val json = response.body.extract[JValue]
val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]]
messages.foreach { message =>
val outboundSchema = (message \ "outbound_schema").extract[JObject]
val inboundSchema = (message \ "inbound_schema").extract[JObject]
// Check if required fields are present (they may or may not be required depending on the case class)
val outboundRequired = (outboundSchema \ "required").extractOpt[List[String]]
val inboundRequired = (inboundSchema \ "required").extractOpt[List[String]]
// At minimum, the structure should be present
outboundSchema should not be null
inboundSchema should not be null
}
}
scenario("We verify process names match connector method names", ApiEndpoint1, VersionOfApi) {
When("We make a request to get message docs as JSON Schema")
val request = (v6_0_0_Request / "message-docs" / "rabbitmq_vOct2024" / "json-schema").GET
val response = makeGetRequest(request)
Then("We should get a 200 OK response")
response.code should equal(200)
And("Process names should follow obp.methodName pattern")
val json = response.body.extract[JValue]
val messages = (json \ "properties" \ "messages" \ "items").extract[List[JValue]]
messages.foreach { message =>
val process = (message \ "process").extract[String]
process should startWith("obp.")
process.length should be > 4
}
}
}
}