mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 12:56:51 +00:00
Adding Message Doc Json schema files
This commit is contained in:
parent
79c44db3bc
commit
3f371cf551
275
obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala
Normal file
275
obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala
Normal 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) }))
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user