diff --git a/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala new file mode 100644 index 000000000..781c25ae7 --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/JsonSchemaGenerator.scala @@ -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) })) + } +} \ No newline at end of file diff --git a/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala new file mode 100644 index 000000000..fd8c99f71 --- /dev/null +++ b/obp-api/src/test/scala/code/api/v6_0_0/MessageDocsJsonSchemaTest.scala @@ -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 + } + } + } +} \ No newline at end of file