mirror of
https://github.com/OpenBankProject/OBP-API.git
synced 2026-02-06 13:46:49 +00:00
410 lines
14 KiB
Scala
410 lines
14 KiB
Scala
|
|
#!/usr/bin/env scala
|
||
|
|
|
||
|
|
/**
|
||
|
|
* OpenAPI 3.1 Exporter for OBP API v6.0.0
|
||
|
|
*
|
||
|
|
* This script extracts API documentation from the OBP API v6.0.0 codebase
|
||
|
|
* and converts it to OpenAPI 3.1 format.
|
||
|
|
*
|
||
|
|
* Usage:
|
||
|
|
* scala OpenAPI31Exporter.scala [output_file]
|
||
|
|
*
|
||
|
|
* If no output file is specified, it writes to stdout.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import scala.io.Source
|
||
|
|
import scala.util.matching.Regex
|
||
|
|
import java.io.{File, PrintWriter}
|
||
|
|
import scala.collection.mutable.ListBuffer
|
||
|
|
import java.time.LocalDateTime
|
||
|
|
import java.time.format.DateTimeFormatter
|
||
|
|
|
||
|
|
case class ApiEndpoint(
|
||
|
|
name: String,
|
||
|
|
method: String,
|
||
|
|
path: String,
|
||
|
|
summary: String,
|
||
|
|
description: String,
|
||
|
|
requestBody: Option[String],
|
||
|
|
responseBody: Option[String],
|
||
|
|
errorCodes: List[String],
|
||
|
|
tags: List[String],
|
||
|
|
roles: List[String] = List.empty
|
||
|
|
)
|
||
|
|
|
||
|
|
case class JsonSchema(
|
||
|
|
name: String,
|
||
|
|
properties: Map[String, Any],
|
||
|
|
required: List[String] = List.empty,
|
||
|
|
description: Option[String] = None
|
||
|
|
)
|
||
|
|
|
||
|
|
object OpenAPI31Exporter {
|
||
|
|
|
||
|
|
def main(args: Array[String]): Unit = {
|
||
|
|
val outputFile = if (args.length > 0) Some(args(0)) else None
|
||
|
|
val projectRoot = findProjectRoot()
|
||
|
|
|
||
|
|
println(s"Extracting API documentation from: $projectRoot")
|
||
|
|
val endpoints = extractEndpoints(projectRoot)
|
||
|
|
val schemas = extractSchemas(projectRoot)
|
||
|
|
|
||
|
|
val openApiYaml = generateOpenAPI31(endpoints, schemas)
|
||
|
|
|
||
|
|
outputFile match {
|
||
|
|
case Some(file) =>
|
||
|
|
val writer = new PrintWriter(new File(file))
|
||
|
|
try {
|
||
|
|
writer.write(openApiYaml)
|
||
|
|
println(s"OpenAPI 3.1 documentation written to: $file")
|
||
|
|
} finally {
|
||
|
|
writer.close()
|
||
|
|
}
|
||
|
|
case None =>
|
||
|
|
println(openApiYaml)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def findProjectRoot(): String = {
|
||
|
|
val currentDir = new File(".")
|
||
|
|
val candidates = List(
|
||
|
|
"./obp-api/src/main/scala/code/api/v6_0_0",
|
||
|
|
"../obp-api/src/main/scala/code/api/v6_0_0",
|
||
|
|
"./OBP-API/obp-api/src/main/scala/code/api/v6_0_0"
|
||
|
|
)
|
||
|
|
|
||
|
|
candidates.find(path => new File(path).exists()) match {
|
||
|
|
case Some(path) => new File(path).getParentFile.getParentFile.getParentFile.getParentFile.getParentFile.getAbsolutePath
|
||
|
|
case None =>
|
||
|
|
throw new RuntimeException("Could not find OBP API project root. Please run from the project directory.")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
def extractEndpoints(projectRoot: String): List[ApiEndpoint] = {
|
||
|
|
val apiMethodsFile = new File(s"$projectRoot/obp-api/src/main/scala/code/api/v6_0_0/APIMethods600.scala")
|
||
|
|
val endpoints = ListBuffer[ApiEndpoint]()
|
||
|
|
|
||
|
|
if (!apiMethodsFile.exists()) {
|
||
|
|
throw new RuntimeException(s"APIMethods600.scala not found at: ${apiMethodsFile.getAbsolutePath}")
|
||
|
|
}
|
||
|
|
|
||
|
|
val content = Source.fromFile(apiMethodsFile).getLines().mkString("\n")
|
||
|
|
|
||
|
|
// Extract ResourceDoc definitions
|
||
|
|
val resourceDocPattern = """staticResourceDocs \+= ResourceDoc\(\s*([^,]+),\s*[^,]+,\s*[^,]+,\s*"([^"]+)",\s*"([^"]+)",\s*"([^"]+)",\s*s?"""([^"]*(?:"[^"]*"[^"]*)*?)""",\s*([^,]+),\s*([^,]+),\s*List\(([^)]*)\),\s*List\(([^)]*)\)(?:,\s*Some\(List\(([^)]*)\)))?\s*\)""".r
|
||
|
|
|
||
|
|
resourceDocPattern.findAllMatchIn(content).foreach { m =>
|
||
|
|
val endpointName = m.group(1).trim
|
||
|
|
val method = m.group(2).trim
|
||
|
|
val path = m.group(3).trim
|
||
|
|
val summary = m.group(4).trim
|
||
|
|
val description = cleanDescription(m.group(5))
|
||
|
|
val requestBodyRef = m.group(6).trim
|
||
|
|
val responseBodyRef = m.group(7).trim
|
||
|
|
val errorCodes = parseList(m.group(8))
|
||
|
|
val tags = parseList(m.group(9))
|
||
|
|
val roles = if (m.group(10) != null) parseList(m.group(10)) else List.empty
|
||
|
|
|
||
|
|
endpoints += ApiEndpoint(
|
||
|
|
name = endpointName,
|
||
|
|
method = method,
|
||
|
|
path = path,
|
||
|
|
summary = summary,
|
||
|
|
description = description,
|
||
|
|
requestBody = if (requestBodyRef != "EmptyBody") Some(requestBodyRef) else None,
|
||
|
|
responseBody = Some(responseBodyRef),
|
||
|
|
errorCodes = errorCodes,
|
||
|
|
tags = tags,
|
||
|
|
roles = roles
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
endpoints.toList
|
||
|
|
}
|
||
|
|
|
||
|
|
def extractSchemas(projectRoot: String): List[JsonSchema] = {
|
||
|
|
val jsonFactoryFile = new File(s"$projectRoot/obp-api/src/main/scala/code/api/v6_0_0/JSONFactory6.0.0.scala")
|
||
|
|
val schemas = ListBuffer[JsonSchema]()
|
||
|
|
|
||
|
|
if (!jsonFactoryFile.exists()) {
|
||
|
|
println(s"Warning: JSONFactory6.0.0.scala not found at: ${jsonFactoryFile.getAbsolutePath}")
|
||
|
|
return schemas.toList
|
||
|
|
}
|
||
|
|
|
||
|
|
val content = Source.fromFile(jsonFactoryFile).getLines().mkString("\n")
|
||
|
|
|
||
|
|
// Extract case class definitions
|
||
|
|
val caseClassPattern = """case class ([A-Za-z0-9_]+)\(\s*(.*?)\s*\)""".r
|
||
|
|
|
||
|
|
caseClassPattern.findAllMatchIn(content).foreach { m =>
|
||
|
|
val className = m.group(1)
|
||
|
|
val fieldsStr = m.group(2)
|
||
|
|
|
||
|
|
val properties = parseFields(fieldsStr)
|
||
|
|
val required = properties.filter(_._2.asInstanceOf[Map[String, Any]].get("nullable").isEmpty).keys.toList
|
||
|
|
|
||
|
|
schemas += JsonSchema(
|
||
|
|
name = className,
|
||
|
|
properties = properties,
|
||
|
|
required = required,
|
||
|
|
description = Some(s"Schema for $className")
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
schemas.toList
|
||
|
|
}
|
||
|
|
|
||
|
|
def parseFields(fieldsStr: String): Map[String, Any] = {
|
||
|
|
val properties = scala.collection.mutable.Map[String, Any]()
|
||
|
|
|
||
|
|
if (fieldsStr.trim.nonEmpty) {
|
||
|
|
val fieldPattern = """([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*([^,]+)""".r
|
||
|
|
|
||
|
|
fieldPattern.findAllMatchIn(fieldsStr).foreach { m =>
|
||
|
|
val fieldName = m.group(1).trim
|
||
|
|
val fieldType = m.group(2).trim
|
||
|
|
|
||
|
|
val (schemaType, nullable) = mapScalaTypeToJsonSchema(fieldType)
|
||
|
|
|
||
|
|
val fieldSchema = scala.collection.mutable.Map[String, Any](
|
||
|
|
"type" -> schemaType
|
||
|
|
)
|
||
|
|
|
||
|
|
if (nullable) {
|
||
|
|
fieldSchema += "nullable" -> true
|
||
|
|
}
|
||
|
|
|
||
|
|
if (fieldType.contains("Date")) {
|
||
|
|
fieldSchema += "format" -> "date-time"
|
||
|
|
}
|
||
|
|
|
||
|
|
properties += fieldName -> fieldSchema.toMap
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
properties.toMap
|
||
|
|
}
|
||
|
|
|
||
|
|
def mapScalaTypeToJsonSchema(scalaType: String): (String, Boolean) = {
|
||
|
|
val cleanType = scalaType.replaceAll("Option\\[(.*)\\]", "$1").trim
|
||
|
|
val nullable = scalaType.contains("Option[")
|
||
|
|
|
||
|
|
val jsonType = cleanType match {
|
||
|
|
case t if t.startsWith("String") => "string"
|
||
|
|
case t if t.startsWith("Int") || t.startsWith("Long") => "integer"
|
||
|
|
case t if t.startsWith("Double") || t.startsWith("Float") || t.startsWith("BigDecimal") => "number"
|
||
|
|
case t if t.startsWith("Boolean") => "boolean"
|
||
|
|
case t if t.startsWith("List[") || t.startsWith("Array[") => "array"
|
||
|
|
case t if t.contains("Date") => "string"
|
||
|
|
case _ => "object"
|
||
|
|
}
|
||
|
|
|
||
|
|
(jsonType, nullable)
|
||
|
|
}
|
||
|
|
|
||
|
|
def cleanDescription(desc: String): String = {
|
||
|
|
desc.replaceAll("\\|", "")
|
||
|
|
.replaceAll("\\$\\{[^}]+\\}", "")
|
||
|
|
.replaceAll("\\s+", " ")
|
||
|
|
.trim
|
||
|
|
}
|
||
|
|
|
||
|
|
def parseList(listStr: String): List[String] = {
|
||
|
|
if (listStr.trim.isEmpty) List.empty
|
||
|
|
else listStr.split(",").map(_.trim.replaceAll("[\"$]", "")).filter(_.nonEmpty).toList
|
||
|
|
}
|
||
|
|
|
||
|
|
def generateOpenAPI31(endpoints: List[ApiEndpoint], schemas: List[JsonSchema]): String = {
|
||
|
|
val timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
|
||
|
|
|
||
|
|
val yaml = new StringBuilder()
|
||
|
|
|
||
|
|
// OpenAPI header
|
||
|
|
yaml.append("openapi: 3.1.0\n")
|
||
|
|
yaml.append("\n")
|
||
|
|
yaml.append("info:\n")
|
||
|
|
yaml.append(" title: Open Bank Project API v6.0.0\n")
|
||
|
|
yaml.append(" version: 6.0.0\n")
|
||
|
|
yaml.append(" description: |\n")
|
||
|
|
yaml.append(" The Open Bank Project API v6.0.0 provides standardized banking APIs.\n")
|
||
|
|
yaml.append(" \n")
|
||
|
|
yaml.append(" This specification was automatically generated from the OBP API codebase.\n")
|
||
|
|
yaml.append(s" Generated on: $timestamp\n")
|
||
|
|
yaml.append(" \n")
|
||
|
|
yaml.append(" For more information, visit: https://github.com/OpenBankProject/OBP-API\n")
|
||
|
|
yaml.append(" contact:\n")
|
||
|
|
yaml.append(" name: Open Bank Project\n")
|
||
|
|
yaml.append(" url: https://www.openbankproject.com\n")
|
||
|
|
yaml.append(" email: contact@tesobe.com\n")
|
||
|
|
yaml.append(" license:\n")
|
||
|
|
yaml.append(" name: AGPL v3\n")
|
||
|
|
yaml.append(" url: https://www.gnu.org/licenses/agpl-3.0.html\n")
|
||
|
|
yaml.append("\n")
|
||
|
|
|
||
|
|
// Servers
|
||
|
|
yaml.append("servers:\n")
|
||
|
|
yaml.append(" - url: https://api.openbankproject.com\n")
|
||
|
|
yaml.append(" description: Production server\n")
|
||
|
|
yaml.append(" - url: https://apisandbox.openbankproject.com\n")
|
||
|
|
yaml.append(" description: Sandbox server\n")
|
||
|
|
yaml.append("\n")
|
||
|
|
|
||
|
|
// Security schemes
|
||
|
|
yaml.append("components:\n")
|
||
|
|
yaml.append(" securitySchemes:\n")
|
||
|
|
yaml.append(" DirectLogin:\n")
|
||
|
|
yaml.append(" type: apiKey\n")
|
||
|
|
yaml.append(" in: header\n")
|
||
|
|
yaml.append(" name: Authorization\n")
|
||
|
|
yaml.append(" description: Direct Login token authentication\n")
|
||
|
|
yaml.append(" GatewayLogin:\n")
|
||
|
|
yaml.append(" type: apiKey\n")
|
||
|
|
yaml.append(" in: header\n")
|
||
|
|
yaml.append(" name: Authorization\n")
|
||
|
|
yaml.append(" description: Gateway Login token authentication\n")
|
||
|
|
yaml.append(" OAuth2:\n")
|
||
|
|
yaml.append(" type: oauth2\n")
|
||
|
|
yaml.append(" flows:\n")
|
||
|
|
yaml.append(" authorizationCode:\n")
|
||
|
|
yaml.append(" authorizationUrl: /oauth/authorize\n")
|
||
|
|
yaml.append(" tokenUrl: /oauth/token\n")
|
||
|
|
yaml.append(" scopes: {}\n")
|
||
|
|
yaml.append("\n")
|
||
|
|
|
||
|
|
// Schemas
|
||
|
|
if (schemas.nonEmpty) {
|
||
|
|
yaml.append(" schemas:\n")
|
||
|
|
schemas.foreach { schema =>
|
||
|
|
yaml.append(s" ${schema.name}:\n")
|
||
|
|
yaml.append(" type: object\n")
|
||
|
|
if (schema.description.isDefined) {
|
||
|
|
yaml.append(s" description: ${schema.description.get}\n")
|
||
|
|
}
|
||
|
|
if (schema.required.nonEmpty) {
|
||
|
|
yaml.append(" required:\n")
|
||
|
|
schema.required.foreach { field =>
|
||
|
|
yaml.append(s" - $field\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if (schema.properties.nonEmpty) {
|
||
|
|
yaml.append(" properties:\n")
|
||
|
|
schema.properties.foreach { case (name, propSchema) =>
|
||
|
|
val props = propSchema.asInstanceOf[Map[String, Any]]
|
||
|
|
yaml.append(s" $name:\n")
|
||
|
|
yaml.append(s" type: ${props("type")}\n")
|
||
|
|
props.get("format").foreach { format =>
|
||
|
|
yaml.append(s" format: $format\n")
|
||
|
|
}
|
||
|
|
props.get("nullable").foreach { _ =>
|
||
|
|
yaml.append(" nullable: true\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
yaml.append("\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Paths
|
||
|
|
yaml.append("paths:\n")
|
||
|
|
|
||
|
|
val groupedEndpoints = endpoints.groupBy(_.path)
|
||
|
|
groupedEndpoints.toSeq.sortBy(_._1).foreach { case (path, pathEndpoints) =>
|
||
|
|
val openApiPath = convertPathToOpenAPI(path)
|
||
|
|
yaml.append(s" $openApiPath:\n")
|
||
|
|
|
||
|
|
pathEndpoints.sortBy(_.method).foreach { endpoint =>
|
||
|
|
val method = endpoint.method.toLowerCase
|
||
|
|
yaml.append(s" $method:\n")
|
||
|
|
yaml.append(s" summary: ${endpoint.summary}\n")
|
||
|
|
yaml.append(s" operationId: ${endpoint.name}\n")
|
||
|
|
|
||
|
|
if (endpoint.description.nonEmpty) {
|
||
|
|
yaml.append(" description: |\n")
|
||
|
|
endpoint.description.split("\n").foreach { line =>
|
||
|
|
yaml.append(s" $line\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
if (endpoint.tags.nonEmpty) {
|
||
|
|
yaml.append(" tags:\n")
|
||
|
|
endpoint.tags.foreach { tag =>
|
||
|
|
yaml.append(s" - ${tag.replaceAll("apiTag", "")}\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Parameters (path parameters)
|
||
|
|
val pathParams = extractPathParameters(path)
|
||
|
|
if (pathParams.nonEmpty) {
|
||
|
|
yaml.append(" parameters:\n")
|
||
|
|
pathParams.foreach { param =>
|
||
|
|
yaml.append(s" - name: $param\n")
|
||
|
|
yaml.append(" in: path\n")
|
||
|
|
yaml.append(" required: true\n")
|
||
|
|
yaml.append(" schema:\n")
|
||
|
|
yaml.append(" type: string\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Request body
|
||
|
|
if (endpoint.requestBody.isDefined && method != "get" && method != "delete") {
|
||
|
|
yaml.append(" requestBody:\n")
|
||
|
|
yaml.append(" required: true\n")
|
||
|
|
yaml.append(" content:\n")
|
||
|
|
yaml.append(" application/json:\n")
|
||
|
|
yaml.append(" schema:\n")
|
||
|
|
yaml.append(s" $$ref: '#/components/schemas/${endpoint.requestBody.get}'\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Responses
|
||
|
|
yaml.append(" responses:\n")
|
||
|
|
yaml.append(" '200':\n")
|
||
|
|
yaml.append(" description: Success\n")
|
||
|
|
if (endpoint.responseBody.isDefined) {
|
||
|
|
yaml.append(" content:\n")
|
||
|
|
yaml.append(" application/json:\n")
|
||
|
|
yaml.append(" schema:\n")
|
||
|
|
yaml.append(s" $$ref: '#/components/schemas/${endpoint.responseBody.get}'\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
// Error responses
|
||
|
|
if (endpoint.errorCodes.nonEmpty) {
|
||
|
|
endpoint.errorCodes.filter(_.contains("400")).foreach { _ =>
|
||
|
|
yaml.append(" '400':\n")
|
||
|
|
yaml.append(" description: Bad Request\n")
|
||
|
|
}
|
||
|
|
endpoint.errorCodes.filter(_.contains("401")).foreach { _ =>
|
||
|
|
yaml.append(" '401':\n")
|
||
|
|
yaml.append(" description: Unauthorized\n")
|
||
|
|
}
|
||
|
|
endpoint.errorCodes.filter(_.contains("404")).foreach { _ =>
|
||
|
|
yaml.append(" '404':\n")
|
||
|
|
yaml.append(" description: Not Found\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Security
|
||
|
|
if (endpoint.roles.nonEmpty || !endpoint.errorCodes.exists(_.contains("UserNotLoggedIn"))) {
|
||
|
|
yaml.append(" security:\n")
|
||
|
|
yaml.append(" - DirectLogin: []\n")
|
||
|
|
yaml.append(" - GatewayLogin: []\n")
|
||
|
|
yaml.append(" - OAuth2: []\n")
|
||
|
|
}
|
||
|
|
|
||
|
|
yaml.append("\n")
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
yaml.toString()
|
||
|
|
}
|
||
|
|
|
||
|
|
def convertPathToOpenAPI(obpPath: String): String = {
|
||
|
|
obpPath.replaceAll("([A-Z_]+)", "{$1}")
|
||
|
|
.replaceAll("\\{([A-Z_]+)\\}", "{${1.toLowerCase}}")
|
||
|
|
.replaceAll("_", "-")
|
||
|
|
}
|
||
|
|
|
||
|
|
def extractPathParameters(path: String): List[String] = {
|
||
|
|
val paramPattern = """([A-Z_]+)""".r
|
||
|
|
paramPattern.findAllMatchIn(path).map(_.group(1).toLowerCase.replace("_", "-")).toList
|
||
|
|
}
|
||
|
|
}
|