From 8c85ef6f01caed4848d64e76eeabf3e36c06536a Mon Sep 17 00:00:00 2001 From: Hongwei Date: Sun, 16 Nov 2025 22:41:36 +0100 Subject: [PATCH 01/11] docfix/ Update .gitignore to exclude .specstory and .cursorindexingignore files --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index e40c578..7acaec1 100644 --- a/.gitignore +++ b/.gitignore @@ -320,3 +320,5 @@ temp/ # OS generated files .DS_Store? Icon? +.specstory +.cursorindexingignore From e301e44ab8fb72c8d68bbadff15f3b2ea6f0fd72 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 17 Nov 2025 00:24:19 +0100 Subject: [PATCH 02/11] feature/chore/Update crossScalaVersions and add airframe-log dependency --- build.sbt | 2 +- project/plugins.sbt | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/build.sbt b/build.sbt index c5f5106..55f7884 100644 --- a/build.sbt +++ b/build.sbt @@ -4,7 +4,7 @@ version := "0.1.0-SNAPSHOT" scalaVersion := "2.13.14" -crossScalaVersions := Seq("2.12.17", "2.13.14", "3.3.1") +crossScalaVersions := Seq("2.12.20", "2.13.14", "3.3.1") // Organization and metadata ThisBuild / organization := "com.openbankproject" diff --git a/project/plugins.sbt b/project/plugins.sbt index 181d8cd..41a22e7 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -8,3 +8,6 @@ addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0") // sbt-ci-release for CI/CD release automation - updated to compatible version addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12") + +// Add airframe-log to suppress AirframeLogManager warning from sbt-ci-release +libraryDependencies += "org.wvlet.airframe" %% "airframe-log" % "24.5.0" From c5aa11954b67dde8945ccf36290c43c1f38778fc Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 17 Nov 2025 00:24:41 +0100 Subject: [PATCH 03/11] feature/Add core models and access control for resource documentation - Introduced `AccessChecker` for enforcing access checks based on user roles and operation IDs. - Added core model classes: `ApiRole`, `ErrorDoc`, `HttpMethod`, `RequestContext`, `RequiredRole`, `ResourceDocJson`, and `Tag`. - Implemented registries for resource documentation and roles with `ResourceDocRegistry` and `RoleRegistry`. - Developed exporters for Markdown and JSON formats: `MarkdownExporter`, `OBPLikeJsonExporter`, and `OpenApiLikeJsonExporter`. - Included tests for resource documentation structure and exporter functionality. --- .../core/enforce/AccessChecker.scala | 39 +++ .../resourcedocs/core/model/ApiRole.scala | 8 + .../resourcedocs/core/model/ErrorDoc.scala | 8 + .../resourcedocs/core/model/HttpMethod.scala | 23 ++ .../core/model/RequestContext.scala | 11 + .../core/model/RequiredRole.scala | 40 +++ .../core/model/ResourceDocJson.scala | 46 +++ .../resourcedocs/core/model/Tag.scala | 8 + .../core/registry/ConsistencyCheck.scala | 10 + .../core/registry/ResourceDocRegistry.scala | 27 ++ .../core/registry/RoleRegistry.scala | 17 ++ .../export/MarkdownExporter.scala | 25 ++ .../export/OBPLikeJsonExporter.scala | 187 ++++++++++++ .../export/OpenApiLikeJsonExporter.scala | 79 +++++ .../model/GetBanksResourceDocJsonSpec.scala | 78 +++++ .../export/MarkdownExporterSpec.scala | 68 +++++ .../export/OBPLikeJsonExporterSpec.scala | 283 ++++++++++++++++++ .../export/OpenApiLikeJsonExporterSpec.scala | 91 ++++++ 18 files changed, 1048 insertions(+) create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala create mode 100644 src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala create mode 100644 src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala create mode 100644 src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala create mode 100644 src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala create mode 100644 src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala b/src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala new file mode 100644 index 0000000..8eb0cac --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala @@ -0,0 +1,39 @@ +package com.openbankproject.resourcedocs.core.enforce + +import com.openbankproject.resourcedocs.core.model.{RequiredRole, RequestContext, RoleInfoJson} +import com.openbankproject.resourcedocs.core.registry.ResourceDocRegistry + +/** + * Performs access checks against registered ResourceDocs by operationId. + */ +object AccessChecker { + + /** + * Convert role descriptors to RequiredRole for authorization checks. + * The semantics follow OBP-API: providing multiple roles means logical OR. + */ + private def rolesToRequiredRole(roles: Option[List[RoleInfoJson]]): RequiredRole = { + roles match { + case None => RequiredRole.public + case Some(Nil) => RequiredRole.public + case Some(roleInfos) => + val identifiers = roleInfos.map(_.role).toSet + if (identifiers.isEmpty) RequiredRole.public else RequiredRole.AnyOf(identifiers) + } + } + + def require(operationId: String, ctx: RequestContext): Either[String, Unit] = { + ResourceDocRegistry.get(operationId) match { + case None => Left(s"OperationNotRegistered: $operationId") + case Some(docJson) => + val requiredRole = rolesToRequiredRole(docJson.roles) + if (isAuthorized(requiredRole, ctx.userRoles)) Right(()) + else Left(s"InsufficientRoles: $operationId") + } + } + + private def isAuthorized(required: RequiredRole, userRoles: Set[String]): Boolean = + required.isAuthorized(userRoles) +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala new file mode 100644 index 0000000..7a85fa6 --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala @@ -0,0 +1,8 @@ +package com.openbankproject.resourcedocs.core.model + +/** + * Represents a named API role. + */ +final case class ApiRole(name: String, description: Option[String] = None) + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala new file mode 100644 index 0000000..478578c --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala @@ -0,0 +1,8 @@ +package com.openbankproject.resourcedocs.core.model + +/** + * Error documentation entry for an endpoint. + */ +final case class ErrorDoc(code: String, httpStatus: Int, message: Option[String] = None) + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala new file mode 100644 index 0000000..f0c2e78 --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala @@ -0,0 +1,23 @@ +package com.openbankproject.resourcedocs.core.model + +/** + * HTTP method enumeration independent of any web framework. + */ +sealed trait HttpMethod extends Product with Serializable { def name: String } + +object HttpMethod { + case object GET extends HttpMethod { val name: String = "GET" } + case object POST extends HttpMethod { val name: String = "POST" } + case object PUT extends HttpMethod { val name: String = "PUT" } + case object DELETE extends HttpMethod { val name: String = "DELETE" } + case object PATCH extends HttpMethod { val name: String = "PATCH" } + case object HEAD extends HttpMethod { val name: String = "HEAD" } + case object OPTIONS extends HttpMethod { val name: String = "OPTIONS" } + + val all: Set[HttpMethod] = Set(GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS) + + def fromString(method: String): Option[HttpMethod] = + all.find(_.name.equalsIgnoreCase(method)) +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala new file mode 100644 index 0000000..bc3c3b5 --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala @@ -0,0 +1,11 @@ +package com.openbankproject.resourcedocs.core.model + +/** + * Minimal request context required for access checks. + */ +final case class RequestContext( + userRoles: Set[String], + attributes: Map[String, String] = Map.empty +) + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala new file mode 100644 index 0000000..c73dcc7 --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala @@ -0,0 +1,40 @@ +package com.openbankproject.resourcedocs.core.model + +/** + * Expresses required roles for accessing an endpoint. + */ +sealed trait RequiredRole extends Product with Serializable { + def isAuthorized(userRoles: Set[String]): Boolean +} + +object RequiredRole { + case object Public extends RequiredRole { + override def isAuthorized(userRoles: Set[String]): Boolean = true + override def toString: String = "Public" + } + + final case class AnyOf(roles: Set[String]) extends RequiredRole { + override def isAuthorized(userRoles: Set[String]): Boolean = + roles.isEmpty || roles.exists(userRoles.contains) + } + + final case class AllOf(roles: Set[String]) extends RequiredRole { + override def isAuthorized(userRoles: Set[String]): Boolean = + roles.forall(userRoles.contains) + } + + /** + * Disjunction of conjunctions; represents (A & B) | (C) ... + */ + final case class OrOfAnds(groups: Seq[Set[String]]) extends RequiredRole { + override def isAuthorized(userRoles: Set[String]): Boolean = + groups.exists(group => group.forall(userRoles.contains)) + } + + def anyOf(first: String, rest: String*): RequiredRole = AnyOf((first +: rest).toSet) + def allOf(first: String, rest: String*): RequiredRole = AllOf((first +: rest).toSet) + def oneOfAllOf(firstGroup: Set[String], otherGroups: Set[String]*): RequiredRole = OrOfAnds(firstGroup +: otherGroups) + val public: RequiredRole = Public +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala new file mode 100644 index 0000000..c63ef7c --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala @@ -0,0 +1,46 @@ +package com.openbankproject.resourcedocs.core.model + +/** + * JSON-friendly DTO for API resource documentation. + * This structure avoids framework types and uses primitive-friendly fields. + */ +final case class RoleInfoJson( + role: String, + requires_bank_id: Boolean = false +) + +/** + * Represents where an API call is implemented. + */ +final case class ImplementedByJson( + version: String, // Short hand for the version e.g. "OBPv3.0.0" means Implementations3_0_0 + function: String // The val / partial function that implements the call e.g. "getBanks" +) + +/** + * Extended JSON-friendly DTO for API resource documentation that matches OBP-API structure. + * This structure includes all fields from OBP-API's ResourceDocJson for compatibility. + * Field names use snake_case to match OBP-API's ResourceDocJson structure. */ +final case class OBPResourceDocJson( + operation_id: String, + implemented_by: ImplementedByJson, + request_verb: String, + request_url: String, + summary: String, + description: String, // HTML format + description_markdown: String, // Markdown format + example_request_body: Option[String] = None, + success_response_body: Option[String] = None, + error_response_bodies: List[String] = List.empty, + tags: List[String] = List.empty, + typed_request_body: Option[String] = None, // JSON Schema as string + typed_success_response_body: Option[String] = None, // JSON Schema as string + roles: Option[List[RoleInfoJson]] = None, + is_featured: Boolean = false, + special_instructions: String = "", + specified_url: String, + connector_methods: List[String] = List.empty, + created_by_bank_id: Option[String] = None +) + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala new file mode 100644 index 0000000..951bf26 --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala @@ -0,0 +1,8 @@ +package com.openbankproject.resourcedocs.core.model + +/** + * Simple tag value object. + */ +final case class Tag(value: String) extends AnyVal + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala new file mode 100644 index 0000000..cf6ed2c --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala @@ -0,0 +1,10 @@ +package com.openbankproject.resourcedocs.core.registry + +import com.openbankproject.resourcedocs.core.model.OBPResourceDocJson + +object ConsistencyCheck { + def findDuplicateOperationIds(docs: Seq[OBPResourceDocJson]): Map[String, Int] = + docs.groupBy(_.operation_id).view.mapValues(_.size).filter(_._2 > 1).toMap +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala new file mode 100644 index 0000000..b9a772c --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala @@ -0,0 +1,27 @@ +package com.openbankproject.resourcedocs.core.registry + +import com.openbankproject.resourcedocs.core.model.OBPResourceDocJson + +import scala.collection.concurrent.TrieMap + +/** + * Thread-safe registry for OBPResourceDocJson by operationId. + */ +object ResourceDocRegistry { + private[this] val byOperationId: TrieMap[String, OBPResourceDocJson] = TrieMap.empty + + def register(doc: OBPResourceDocJson): Unit = { + byOperationId.put(doc.operation_id, doc) + () + } + + def registerAll(docs: Iterable[OBPResourceDocJson]): Unit = docs.foreach(register) + + def get(operationId: String): Option[OBPResourceDocJson] = byOperationId.get(operationId) + + def all: Vector[OBPResourceDocJson] = byOperationId.values.toVector.sortBy(_.operation_id) + + def clear(): Unit = byOperationId.clear() +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala b/src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala new file mode 100644 index 0000000..e43345a --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala @@ -0,0 +1,17 @@ +package com.openbankproject.resourcedocs.core.registry + +import scala.collection.concurrent.TrieMap + +/** + * Registry for known role names. This is optional but helps to audit roles used across docs. + */ +object RoleRegistry { + private[this] val roles: TrieMap[String, Unit] = TrieMap.empty + + def register(role: String): Unit = { roles.put(role, ()) ; () } + def registerAll(rs: Iterable[String]): Unit = rs.foreach(register) + def all: Vector[String] = roles.keys.toVector.sorted + def clear(): Unit = roles.clear() +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala new file mode 100644 index 0000000..4c99718 --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala @@ -0,0 +1,25 @@ +package com.openbankproject.resourcedocs.export + +import com.openbankproject.resourcedocs.core.model.OBPResourceDocJson + +/** + * Simple Markdown exporter. + */ +object MarkdownExporter { + def export(docs: Seq[OBPResourceDocJson]): String = { + val sb = new StringBuilder + val ordered = docs.sortBy(_.operation_id) + ordered.foreach { d => + sb.append(s"### ${d.operation_id}\n\n") + sb.append(s"- Method: ${d.request_verb}\n") + sb.append(s"- Path: ${d.request_url}\n") + if (d.summary.nonEmpty) sb.append(s"- Summary: ${d.summary}\n") + if (d.description.nonEmpty) sb.append(s"\n${d.description}\n") + if (d.tags.nonEmpty) sb.append(s"\nTags: ${d.tags.sorted.mkString(", ")}\n") + sb.append("\n\n") + } + sb.toString() + } +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala new file mode 100644 index 0000000..846a77d --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala @@ -0,0 +1,187 @@ +package com.openbankproject.resourcedocs.export + +import com.openbankproject.resourcedocs.core.model.{OBPResourceDocJson, RoleInfoJson} + +import scala.collection.mutable.ArrayBuffer + +import java.time.Instant +import java.time.format.DateTimeFormatter + +/** + * Produces a JSON document that mirrors OBP-API's ResourceDoc response structure. + * The output contains a resource_docs array plus meta information (response date and count). + * + * This exporter deliberately avoids external JSON libraries to keep the module lightweight. + */ +object OBPLikeJsonExporter { + + private val isoFormatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT + + def export(docs: Seq[OBPResourceDocJson], responseDate: Instant = Instant.now()): String = { + val orderedDocs = docs.sortBy(_.operation_id) + val sb = new StringBuilder + sb.append("{\n") + sb.append(" \"resource_docs\": [\n") + + orderedDocs.zipWithIndex.foreach { case (doc, idx) => + sb.append(" {\n") + val fields = ArrayBuffer[String]() + fields += stringField(6, "operation_id", doc.operation_id) + fields += implementedByField(6, doc) + fields += stringField(6, "request_verb", doc.request_verb) + fields += stringField(6, "request_url", doc.request_url) + fields += stringField(6, "summary", doc.summary) + fields += stringField(6, "description", doc.description) + fields += stringField(6, "description_markdown", doc.description_markdown) + doc.example_request_body.foreach { body => + fields += jsonField(6, "example_request_body", body, 8) + } + doc.success_response_body.foreach { body => + fields += jsonField(6, "success_response_body", body, 8) + } + fields += stringArrayField(6, "error_response_bodies", doc.error_response_bodies) + fields += stringArrayField(6, "tags", doc.tags) + doc.typed_request_body.foreach { body => + fields += jsonField(6, "typed_request_body", body, 8) + } + doc.typed_success_response_body.foreach { body => + fields += jsonField(6, "typed_success_response_body", body, 8) + } + doc.roles.foreach { roleInfos => + fields += rolesField(6, roleInfos) + } + fields += booleanField(6, "is_featured", doc.is_featured) + fields += stringField(6, "special_instructions", doc.special_instructions) + fields += stringField(6, "specified_url", doc.specified_url) + fields += stringArrayField(6, "connector_methods", doc.connector_methods) + doc.created_by_bank_id.foreach { bankId => + fields += stringField(6, "created_by_bank_id", bankId) + } + + val lastIdx = fields.size - 1 + fields.zipWithIndex.foreach { case (entry, fieldIdx) => + sb.append(entry) + val suffix = if (fieldIdx < lastIdx) ",\n" else "\n" + sb.append(suffix) + } + + val comma = if (idx < orderedDocs.size - 1) "," else "" + sb.append(" }" + comma + "\n") + } + sb.append(" ],\n") + sb.append(" \"meta\": {\n") + sb.append(" \"response_date\": \"" + isoFormatter.format(responseDate) + "\",\n") + sb.append(" \"count\": " + orderedDocs.size + "\n") + sb.append(" }\n") + sb.append("}\n") + sb.toString() + } + + private def implementedByField(indent: Int, doc: OBPResourceDocJson): String = { + val prefix = spaces(indent) + val builder = new StringBuilder + builder.append(prefix + "\"implemented_by\": {\n") + builder.append(prefix + " \"version\": \"" + escape(doc.implemented_by.version) + "\",\n") + builder.append(prefix + " \"function\": \"" + escape(doc.implemented_by.function) + "\"\n") + builder.append(prefix + "}") + builder.toString() + } + + private def stringField(indent: Int, name: String, value: String): String = { + spaces(indent) + "\"" + name + "\": \"" + escape(value) + "\"" + } + + private def booleanField(indent: Int, name: String, value: Boolean): String = { + spaces(indent) + "\"" + name + "\": " + value + } + + private def stringArrayField(indent: Int, name: String, values: Seq[String]): String = { + val prefix = spaces(indent) + "\"" + name + "\": " + if (values.isEmpty) { + prefix + "[]" + } else { + val innerIndent = spaces(indent + 2) + val rendered = values.zipWithIndex.map { case (v, idx) => + val comma = if (idx < values.size - 1) "," else "" + innerIndent + "\"" + escape(v) + "\"" + comma + }.mkString("\n") + prefix + "[\n" + rendered + "\n" + spaces(indent) + "]" + } + } + + private def jsonField(indent: Int, name: String, raw: String, nestedIndent: Int): String = { + val builder = new StringBuilder + builder.append(spaces(indent) + "\"" + name + "\": ") + builder.append(renderJsonValue(raw, nestedIndent)) + builder.toString() + } + + private def rolesField(indent: Int, roleInfos: List[RoleInfoJson]): String = { + val indentStr = spaces(indent) + if (roleInfos.isEmpty) indentStr + "\"roles\": []" + else { + val entries = roleInfos.map { info => + val builder = new StringBuilder + builder.append(spaces(indent + 2) + "{\n") + builder.append(spaces(indent + 4) + "\"role\": \"" + escape(info.role) + "\",\n") + builder.append(spaces(indent + 4) + "\"requires_bank_id\": " + info.requires_bank_id + "\n") + builder.append(spaces(indent + 2) + "}") + builder.toString() + }.mkString(",\n") + indentStr + "\"roles\": [\n" + entries + "\n" + indentStr + "]" + } + } + + private def renderJsonValue(raw: String, indent: Int): String = { + val trimmed = raw.trim + val isJsonBlock = (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + if (isJsonBlock) { + reindent(trimmed, indent) + } else { + "\"" + escape(trimmed) + "\"" + } + } + + private def reindent(json: String, indent: Int): String = { + val lines = json.linesIterator.toVector + if (lines.isEmpty) json + else { + val interiorLines = lines.tail.filter { line => + val trimmed = line.trim + trimmed.nonEmpty && !trimmed.startsWith("}") && !trimmed.startsWith("]") + } + val baseIndent = + if (interiorLines.isEmpty) 0 + else interiorLines.map(countLeadingSpaces).min + val pad = "\n" + spaces(indent) + lines.head + lines.tail.map { line => + val trimmed = line.trim + val normalized = + if (trimmed.isEmpty) "" + else line.drop(math.min(baseIndent, countLeadingSpaces(line))) + pad + normalized + }.mkString + } + } + + private def countLeadingSpaces(line: String): Int = line.prefixLength(_ == ' ') + + private def toJsonArray(values: Seq[String]): String = { + if (values.isEmpty) "[]" + else values.map(v => "\"" + escape(v) + "\"").mkString("[ ", ", ", " ]") + } + + private def escape(value: String): String = { + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } + + private def spaces(count: Int): String = " " * count +} + + diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala new file mode 100644 index 0000000..f9682c0 --- /dev/null +++ b/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala @@ -0,0 +1,79 @@ +package com.openbankproject.resourcedocs.export + +import com.openbankproject.resourcedocs.core.model.{OBPResourceDocJson, RoleInfoJson} + +/** + * Lightweight JSON exporter without third-party JSON dependencies. + * It produces an OpenAPI-like document containing essential fields only. + */ +object OpenApiLikeJsonExporter { + + def export(docs: Seq[OBPResourceDocJson]): String = { + val byPath = docs.groupBy(_.request_url) + val sb = new StringBuilder + sb.append("{\n") + sb.append(" \"openapi\": \"3.0.0\",\n") + sb.append(" \"info\": { \"title\": \"OBP ResourceDocs\", \"version\": \"0.1.0\" },\n") + sb.append(" \"paths\": {\n") + + val pathEntries = byPath.toVector.sortBy(_._1).zipWithIndex + pathEntries.foreach { case ((path, pathDocs), i) => + sb.append(" \"" + escape(path) + "\": {\n") + val methodEntries = pathDocs.sortBy(_.request_verb).zipWithIndex + methodEntries.foreach { case (doc, j) => + val methodName = doc.request_verb.toLowerCase + sb.append(" \"" + methodName + "\": {\n") + sb.append(" \"operationId\": \"" + escape(doc.operation_id) + "\",\n") + sb.append(" \"summary\": \"" + escape(doc.summary) + "\",\n") + sb.append(" \"description\": \"" + escape(doc.description) + "\",\n") + sb.append(" \"tags\": " + toJsonArray(doc.tags.sorted.toVector) + ",\n") + sb.append(" \"security\": " + rolesToSecurityJson(doc.roles) + ",\n") + sb.append(" \"responses\": {\n") + val errs = if (doc.error_response_bodies.isEmpty) Vector("200") else { + // Extract HTTP status codes from error messages if possible + val statusPattern = """OBP-(\d+):""".r + doc.error_response_bodies.flatMap { errMsg => + statusPattern.findFirstMatchIn(errMsg).map(_.group(1)) + }.distinct.sorted.toVector + } + val errEntries = errs.zipWithIndex + errEntries.foreach { case (status, k) => + val comma = if (k < errEntries.size - 1) "," else "" + sb.append(" \"" + status + "\": { \"description\": \"\" }" + comma + "\n") + } + sb.append(" }\n") + val comma = if (j < methodEntries.size - 1) "," else "" + sb.append(" }" + comma + "\n") + } + val comma = if (i < pathEntries.size - 1) "," else "" + sb.append(" }" + comma + "\n") + } + + sb.append(" }\n") + sb.append("}\n") + sb.toString() + } + + private def rolesToSecurityJson(roleInfos: Option[List[RoleInfoJson]]): String = + roleInfos match { + case None | Some(Nil) => "[]" + case Some(infos) => + val identifiers = infos.map(_.role).toVector.sorted + "[{\"rolesAnyOf\": " + toJsonArray(identifiers) + "}]" + } + + private def toJsonArray(values: Vector[String]): String = { + val body = values.map(v => "\"" + escape(v) + "\"").mkString(", ") + "[ " + body + " ]" + } + + private def escape(s: String): String = { + s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + } +} + + diff --git a/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala new file mode 100644 index 0000000..38ac285 --- /dev/null +++ b/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala @@ -0,0 +1,78 @@ +package com.openbankproject.resourcedocs.core.model + +import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson} +import org.scalatest.funsuite.AnyFunSuite + +class GetBanksResourceDocJsonSpec extends AnyFunSuite { + + test("GetBanks ResourceDocJson should match expected structure") { + val getBanksDocJson: OBPResourceDocJson = OBPResourceDocJson( + operation_id = "OBPv3.0.0-getBanks", + implemented_by = ImplementedByJson(version = "OBPv3.0.0", function = "getBanks"), + request_verb = "GET", + request_url = "/obp/v3.0.0/banks", + summary = "Get Banks", + description = "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routing:

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

scheme: OBP

\n

short_name:

\n

website: www.openbankproject.com

\n", + description_markdown = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", + success_response_body = Some("""{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}"""), + error_response_bodies = List("OBP-50000: Unknown Error."), + tags = List("Bank", "Account Information Service (AIS)", "PSD2"), + typed_success_response_body = Some("""{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}"""), + is_featured = false, + special_instructions = "", + specified_url = "/obp/v3.1.0/banks", + connector_methods = List("obp.getBanks", "obp.getBankAccountsForUser") + ) + + // Verify structure + assert(getBanksDocJson.operation_id == "OBPv3.0.0-getBanks") + assert(getBanksDocJson.request_verb == "GET") + assert(getBanksDocJson.request_url == "/obp/v3.0.0/banks") + assert(getBanksDocJson.summary == "Get Banks") + assert(getBanksDocJson.implemented_by.version == "OBPv3.0.0") + assert(getBanksDocJson.implemented_by.function == "getBanks") + + val docTags = getBanksDocJson.tags + assert(docTags.contains("Bank")) + assert(docTags.contains("Account Information Service (AIS)")) + assert(docTags.contains("PSD2")) + + assert(getBanksDocJson.error_response_bodies.contains("OBP-50000: Unknown Error.")) + assert(getBanksDocJson.connector_methods.contains("obp.getBanks")) + assert(getBanksDocJson.connector_methods.contains("obp.getBankAccountsForUser")) + assert(getBanksDocJson.specified_url == "/obp/v3.1.0/banks") + } + + test("GetBanks ResourceDocJson JSON output should match expected JSON string") { + val getBanksDocJson = OBPResourceDocJson( + operation_id = "OBPv3.0.0-getBanks", + implemented_by = ImplementedByJson(version = "OBPv3.0.0", function = "getBanks"), + request_verb = "GET", + request_url = "/obp/v3.0.0/banks", + summary = "Get Banks", + description = "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routing:

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

scheme: OBP

\n

short_name:

\n

website: www.openbankproject.com

\n", + description_markdown = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", + success_response_body = Some("""{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}"""), + error_response_bodies = List("OBP-50000: Unknown Error."), + tags = List("Bank", "Account Information Service (AIS)", "PSD2"), + typed_success_response_body = Some("""{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}"""), + is_featured = false, + special_instructions = "", + specified_url = "/obp/v3.1.0/banks", + connector_methods = List("obp.getBanks", "obp.getBankAccountsForUser") + ) + + assert(getBanksDocJson.operation_id == "OBPv3.0.0-getBanks") + assert(getBanksDocJson.implemented_by.version == "OBPv3.0.0") + assert(getBanksDocJson.implemented_by.function == "getBanks") + assert(getBanksDocJson.request_verb == "GET") + assert(getBanksDocJson.request_url == "/obp/v3.0.0/banks") + assert(getBanksDocJson.summary == "Get Banks") + assert(getBanksDocJson.specified_url == "/obp/v3.1.0/banks") + assert(getBanksDocJson.tags.length == 3) + assert(getBanksDocJson.error_response_bodies.length == 1) + assert(getBanksDocJson.connector_methods.length == 2) + } +} + + diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala new file mode 100644 index 0000000..88384c6 --- /dev/null +++ b/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala @@ -0,0 +1,68 @@ +package com.openbankproject.resourcedocs.export + +import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson} +import org.scalatest.funsuite.AnyFunSuite + +class MarkdownExporterSpec extends AnyFunSuite { + + private val getBanksDescriptionHtml: String = + "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routings: bank routing in form of (scheme, address)

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

name: ACCOUNT_MANAGEMENT_FEE

\n

scheme: OBP

\n

short_name:

\n

value: 5987953

\n

website: www.openbankproject.com

\n

attributes: attribute value in form of (name, value)

\n" + + private val getBanksDescriptionMarkdown: String = + "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routings**](/glossary#bank_routings): bank routing in form of (scheme, address)\n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**name**](/glossary#name): ACCOUNT_MANAGEMENT_FEE\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**value**](/glossary#): 5987953\n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n\n[attributes](/glossary#attributes): attribute value in form of (name, value)\n\n\n" + + private val getBanksSuccessResponse: String = + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routings":[{"scheme":"OBP","address":"gh.29.uk"}],"attributes":[{"name":"ACCOUNT_MANAGEMENT_FEE","value":"5987953"}]}]}""" + + private val getBanksTypedSuccessResponse: String = + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"bank_routings":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}}},"website":{"type":"string"},"logo":{"type":"string"},"attributes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + + private def getBanksDoc(operationId: String, verb: String, path: String, summary: String, tags: List[String]): OBPResourceDocJson = { + OBPResourceDocJson( + operation_id = operationId, + implemented_by = ImplementedByJson(version = "OBPv4.0.0", function = "getBanks"), + request_verb = verb, + request_url = path, + summary = summary, + description = getBanksDescriptionHtml, + description_markdown = getBanksDescriptionMarkdown, + success_response_body = Some(getBanksSuccessResponse), + error_response_bodies = List("OBP-50000: Unknown Error."), + tags = tags, + typed_success_response_body = Some(getBanksTypedSuccessResponse), + specified_url = "/obp/v5.1.0/banks" + ) + } + + test("export should order getBanks docs and list key metadata") { + val getBanksV3 = getBanksDoc( + operationId = "OBPv3.0.0-getBanks", + verb = "GET", + path = "/obp/v3.0.0/banks", + summary = "Get Banks V3", + tags = List("BankAccountTag1", "Bank") + ) + val getBanksV4 = getBanksDoc( + operationId = "OBPv4.0.0-getBanks", + verb = "GET", + path = "/obp/v4.0.0/banks", + summary = "Get Banks V4", + tags = List("PSD2", "Account Information Service (AIS)") + ) + + val markdown = MarkdownExporter.export(Seq(getBanksV4, getBanksV3)) // intentionally shuffled + + assert(markdown.contains("### OBPv3.0.0-getBanks")) + assert(markdown.contains("- Method: GET")) + assert(markdown.contains("- Path: /obp/v3.0.0/banks")) + assert(markdown.contains("- Summary: Get Banks V3")) + assert(markdown.contains("Get banks on this API instance")) + assert(markdown.contains("Tags: Bank, BankAccountTag1")) + + assert(markdown.contains("### OBPv4.0.0-getBanks")) + assert(markdown.contains("- Path: /obp/v4.0.0/banks")) + assert(markdown.indexOf("### OBPv3.0.0-getBanks") < markdown.indexOf("### OBPv4.0.0-getBanks")) + } +} + + diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala new file mode 100644 index 0000000..41b44a8 --- /dev/null +++ b/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala @@ -0,0 +1,283 @@ +package com.openbankproject.resourcedocs.export + +import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson, RoleInfoJson} +import org.scalatest.funsuite.AnyFunSuite + +import java.time.Instant + +class OBPLikeJsonExporterSpec extends AnyFunSuite { + + private val getBanksDescriptionHtml: String = + "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routings: bank routing in form of (scheme, address)

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

name: ACCOUNT_MANAGEMENT_FEE

\n

scheme: OBP

\n

short_name:

\n

value: 5987953

\n

website: www.openbankproject.com

\n

attributes: attribute value in form of (name, value)

\n" + + private val getBanksDescriptionMarkdown: String = + "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routings**](/glossary#bank_routings): bank routing in form of (scheme, address)\n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**name**](/glossary#name): ACCOUNT_MANAGEMENT_FEE\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**value**](/glossary#): 5987953\n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n\n[attributes](/glossary#attributes): attribute value in form of (name, value)\n\n\n" + + private val getBanksSuccessResponse: String = + """{ + | "banks": [ + | { + | "id": "gh.29.uk", + | "short_name": "short_name ", + | "full_name": "full_name", + | "logo": "logo", + | "website": "www.openbankproject.com", + | "bank_routings": [ + | { + | "scheme": "OBP", + | "address": "gh.29.uk" + | } + | ], + | "attributes": [ + | { + | "name": "ACCOUNT_MANAGEMENT_FEE", + | "value": "5987953" + | } + | ] + | } + | ] + |}""".stripMargin + + private val getBanksTypedSuccessResponse: String = + """{ + | "type": "object", + | "properties": { + | "banks": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "bank_routings": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "address": { + | "type": "string" + | }, + | "scheme": { + | "type": "string" + | } + | } + | } + | }, + | "website": { + | "type": "string" + | }, + | "logo": { + | "type": "string" + | }, + | "attributes": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "name": { + | "type": "string" + | }, + | "value": { + | "type": "string" + | } + | } + | } + | }, + | "short_name": { + | "type": "string" + | }, + | "id": { + | "type": "string" + | }, + | "full_name": { + | "type": "string" + | } + | } + | } + | } + | } + |}""".stripMargin + + private def baseDoc(operationId: String, request_url: String = "/obp/v4.0.0/banks") = OBPResourceDocJson( + operation_id = operationId, + implemented_by = ImplementedByJson(version = "OBPv4.0.0", function = "getBanks"), + request_verb = "GET", + request_url = request_url, + summary = "Get Banks", + description = getBanksDescriptionHtml, + description_markdown = getBanksDescriptionMarkdown, + success_response_body = Some(getBanksSuccessResponse), + error_response_bodies = List("OBP-50000: Unknown Error."), + tags = List("BankAccountTag1", "BankAccountTag1", "BankAccountTag1", "Bank", "Account Information Service (AIS)", "PSD2"), + typed_success_response_body = Some(getBanksTypedSuccessResponse), + specified_url = "/obp/v5.1.0/banks", + special_instructions = "", + connector_methods = Nil + ) + + private def escapeForJson(value: String): String = + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t") + + private def normalizeWhitespace(value: String): String = + value.filterNot(_.isWhitespace) + + test("export should match the official getBanks JSON payload exactly") { + val doc = baseDoc("OBPv4.0.0-getBanks") + + val json = OBPLikeJsonExporter.export(Seq(doc), Instant.parse("2025-11-16T21:57:26Z")) + + val expectedJson = + s""" + |{ + | "resource_docs": [ + | { + | "operation_id": "OBPv4.0.0-getBanks", + | "implemented_by": { + | "version": "OBPv4.0.0", + | "function": "getBanks" + | }, + | "request_verb": "GET", + | "request_url": "/obp/v4.0.0/banks", + | "summary": "Get Banks", + | "description": "${escapeForJson(getBanksDescriptionHtml)}", + | "description_markdown": "${escapeForJson(getBanksDescriptionMarkdown)}", + | "success_response_body": { + | "banks": [ + | { + | "id": "gh.29.uk", + | "short_name": "short_name ", + | "full_name": "full_name", + | "logo": "logo", + | "website": "www.openbankproject.com", + | "bank_routings": [ + | { + | "scheme": "OBP", + | "address": "gh.29.uk" + | } + | ], + | "attributes": [ + | { + | "name": "ACCOUNT_MANAGEMENT_FEE", + | "value": "5987953" + | } + | ] + | } + | ] + | }, + | "error_response_bodies": [ + | "OBP-50000: Unknown Error." + | ], + | "tags": [ + | "BankAccountTag1", + | "BankAccountTag1", + | "BankAccountTag1", + | "Bank", + | "Account Information Service (AIS)", + | "PSD2" + | ], + | "typed_success_response_body": { + | "type": "object", + | "properties": { + | "banks": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "bank_routings": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "address": { + | "type": "string" + | }, + | "scheme": { + | "type": "string" + | } + | } + | } + | }, + | "website": { + | "type": "string" + | }, + | "logo": { + | "type": "string" + | }, + | "attributes": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "name": { + | "type": "string" + | }, + | "value": { + | "type": "string" + | } + | } + | } + | }, + | "short_name": { + | "type": "string" + | }, + | "id": { + | "type": "string" + | }, + | "full_name": { + | "type": "string" + | } + | } + | } + | } + | } + | }, + | "is_featured": false, + | "special_instructions": "", + | "specified_url": "/obp/v5.1.0/banks", + | "connector_methods": [] + | } + | ], + | "meta": { + | "response_date": "2025-11-16T21:57:26Z", + | "count": 1 + | } + |} + |""".stripMargin.stripPrefix("\n") + + assert(normalizeWhitespace(json) == normalizeWhitespace(expectedJson)) + } + + test("export should keep duplicates, render roles, and order by operation id") { + val docWithRoles = baseDoc("BX-getCustomers").copy( + roles = Some(List(RoleInfoJson("CanRead"))), + tags = List("A", "A", "B") + ) + val laterDoc = baseDoc("CX-getBanks").copy( + success_response_body = None, + typed_success_response_body = None, + connector_methods = Nil + ) + + val json = OBPLikeJsonExporter.export(Seq(laterDoc, docWithRoles), Instant.parse("2025-01-01T00:00:00Z")) + + val firstIdx = json.indexOf("BX-getCustomers") + val secondIdx = json.indexOf("CX-getBanks") + assert(firstIdx >= 0 && secondIdx > firstIdx) + assert(json.contains(""""tags": [ + | "A", + | "A", + | "B" + | ],""".stripMargin)) + assert(json.contains(""""roles": [ + | { + | "role": "CanRead", + | "requires_bank_id": false + | } + | ]""".stripMargin)) + } +} + + diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala new file mode 100644 index 0000000..f5c8f88 --- /dev/null +++ b/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala @@ -0,0 +1,91 @@ +package com.openbankproject.resourcedocs.export + +import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson, RoleInfoJson} +import org.scalatest.funsuite.AnyFunSuite + +class OpenApiLikeJsonExporterSpec extends AnyFunSuite { + + private val getBanksDescriptionHtml: String = + "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routings: bank routing in form of (scheme, address)

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

name: ACCOUNT_MANAGEMENT_FEE

\n

scheme: OBP

\n

short_name:

\n

value: 5987953

\n

website: www.openbankproject.com

\n

attributes: attribute value in form of (name, value)

\n" + + private val getBanksDescriptionMarkdown: String = + "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routings**](/glossary#bank_routings): bank routing in form of (scheme, address)\n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**name**](/glossary#name): ACCOUNT_MANAGEMENT_FEE\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**value**](/glossary#): 5987953\n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n\n[attributes](/glossary#attributes): attribute value in form of (name, value)\n\n\n" + + private val getBanksSuccessResponse: String = + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routings":[{"scheme":"OBP","address":"gh.29.uk"}],"attributes":[{"name":"ACCOUNT_MANAGEMENT_FEE","value":"5987953"}]}]}""" + + private val getBanksTypedSuccessResponse: String = + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"bank_routings":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}}},"website":{"type":"string"},"logo":{"type":"string"},"attributes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + + private def getBanksDoc(operationId: String, verb: String, path: String, summary: String): OBPResourceDocJson = { + OBPResourceDocJson( + operation_id = operationId, + implemented_by = ImplementedByJson("OBPv4.0.0", "getBanks"), + request_verb = verb, + request_url = path, + summary = summary, + description = getBanksDescriptionHtml, + description_markdown = getBanksDescriptionMarkdown, + success_response_body = Some(getBanksSuccessResponse), + error_response_bodies = List("OBP-50000: Unknown Error."), + tags = List("Bank", "Account Information Service (AIS)", "PSD2"), + typed_success_response_body = Some(getBanksTypedSuccessResponse), + specified_url = "/obp/v5.1.0/banks" + ) + } + + test("export should group getBanks methods by path and verb") { + val getBanksV3 = getBanksDoc( + operationId = "OBPv3.0.0-getBanks", + verb = "GET", + path = "/obp/v3.0.0/banks", + summary = "Get Banks V3" + ) + val createBanks = getBanksDoc( + operationId = "OBPv4.0.0-createBank", + verb = "POST", + path = "/obp/v4.0.0/banks", + summary = "Create Bank" + ) + val getBankAttributes = getBanksDoc( + operationId = "OBPv4.0.0-getBankAttributes", + verb = "GET", + path = "/obp/v4.0.0/banks/{bankId}/attributes", + summary = "Get Bank Attributes" + ) + + val json = OpenApiLikeJsonExporter.export(Seq(getBankAttributes, createBanks, getBanksV3)) + + assert(json.contains("\"openapi\": \"3.0.0\"")) + assert(json.contains("\"/obp/v3.0.0/banks\"")) + assert(json.contains("\"/obp/v4.0.0/banks\"")) + assert(json.contains("\"post\"")) + assert(json.contains("\"get\"")) + assert(json.contains("\"operationId\": \"OBPv3.0.0-getBanks\"")) + assert(json.contains("\"operationId\": \"OBPv4.0.0-createBank\"")) + assert(json.contains("\"/obp/v4.0.0/banks/{bankId}/attributes\"")) + assert(json.contains("\"operationId\": \"OBPv4.0.0-getBankAttributes\"")) + } + + test("export should encode getBanks role expressions and include response codes") { + val securedDoc = getBanksDoc( + operationId = "OBPv5.1.0-getBanksSecure", + verb = "GET", + path = "/obp/v5.1.0/banks", + summary = "Get Banks Secure" + ).copy( + roles = Some(List(RoleInfoJson("CanGetBanks"), RoleInfoJson("CanReadBanks"))), + error_response_bodies = List("OBP-40300: Insufficient Privileges.", "OBP-50000: Unknown Error.") + ) + + val json = OpenApiLikeJsonExporter.export(Seq(securedDoc)) + + assert(json.contains("\"rolesAnyOf\"")) + assert(json.contains("CanGetBanks")) + assert(json.contains("CanReadBanks")) + assert(json.contains("\"40300\"")) + assert(json.contains("\"50000\"")) + } +} + + From d5da82a4cddbf7939872f5107e52b468e0182b89 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 17 Nov 2025 10:42:48 +0100 Subject: [PATCH 04/11] feature/Update CI workflow to install SBT --- .github/workflows/ci.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6c5481..1c3b082 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,6 +26,11 @@ jobs: java-version: ${{ matrix.java }} distribution: 'temurin' + - name: Install sbt + uses: coursier/setup-action@v1 + with: + apps: sbt + - name: Cache SBT uses: actions/cache@v4 with: @@ -63,6 +68,11 @@ jobs: java-version: '17' distribution: 'temurin' + - name: Install sbt + uses: coursier/setup-action@v1 + with: + apps: sbt + - name: Cache SBT uses: actions/cache@v4 with: From 264b340db141581ba6fe349d4c297cd5185de9d8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 17 Nov 2025 11:14:47 +0100 Subject: [PATCH 05/11] refactor/Update supported Scala version from 2.12.17 to 2.12.20 across documentation and configuration files --- .github/PUBLISHING.md | 2 +- .github/workflows/ci.yml | 2 +- .github/workflows/release.yml | 2 +- DEVELOPMENT.md | 6 +++--- README.md | 2 +- docker/dev.sh | 2 +- docker/sbt/Dockerfile | 2 +- examples/java-example-app/README.md | 2 +- examples/maven-example-app/pom.xml | 2 +- 9 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/PUBLISHING.md b/.github/PUBLISHING.md index 60ce2da..21596c7 100644 --- a/.github/PUBLISHING.md +++ b/.github/PUBLISHING.md @@ -17,7 +17,7 @@ git tag v1.0.0 git push origin v1.0.0 # GitHub Actions automatically: -# ✅ Builds all Scala versions (2.12.17, 2.13.14, 3.3.1) +# ✅ Builds all Scala versions (2.12.20, 2.13.14, 3.3.1) # ✅ Runs complete test suite # ✅ Signs artifacts with GPG # ✅ Publishes to Maven Central diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1c3b082..7468179 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,7 +14,7 @@ jobs: strategy: matrix: java: [17] - scala: [2.12.17, 2.13.14, 3.3.1] + scala: [2.12.20, 2.13.14, 3.3.1] steps: - name: Checkout code diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8fe79fa..544c0df 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -84,7 +84,7 @@ jobs: ``` ### Supported Scala Versions - - Scala 2.12.17 + - Scala 2.12.20 - Scala 2.13.14 - Scala 3.3.1 diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 1075f39..e77888d 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -26,7 +26,7 @@ This guide covers Docker-based development for the OBP Scala Library. The Docker ### SBT Operations ```bash ./dev.sh compile # Compile all versions -./dev.sh compile 2.12.17 # Compile specific version +./dev.sh compile 2.12.20 # Compile specific version ./dev.sh test # Run tests for all versions ./dev.sh test 3.3.1 # Run tests for specific version ./dev.sh publish # Publish locally (all versions) @@ -75,7 +75,7 @@ implementation 'com.openbankproject:obp-scala-library_2.13:0.1.0-SNAPSHOT' ## Supported Scala Versions -- **2.12.17** - LTS version, widely used in enterprise +- **2.12.20** - LTS version, widely used in enterprise - **2.13.14** - Current stable version (default) - **3.3.1** - Latest Scala 3 version @@ -153,7 +153,7 @@ sbt ### 1. Update build.sbt ```scala -crossScalaVersions := Seq("2.12.17", "2.13.14", "3.3.1", "3.4.0") // Add new version +crossScalaVersions := Seq("2.12.20", "2.13.14", "3.3.1", "3.4.0") // Add new version ``` ### 2. Rebuild Docker image diff --git a/README.md b/README.md index 48934d2..22bac4c 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@ git push origin v1.0.0 ## Supported Scala Versions -- **2.12.17** - LTS version +- **2.12.20** - LTS version - **2.13.14** - Current stable (default) - **3.3.1** - Latest Scala 3 diff --git a/docker/dev.sh b/docker/dev.sh index 931cec1..7f6ea74 100755 --- a/docker/dev.sh +++ b/docker/dev.sh @@ -58,7 +58,7 @@ Examples: $0 all # Full build + publish to host Maven repo Supported Scala Versions: - 2.12.17, 2.13.14, 3.3.1 + 2.12.20, 2.13.14, 3.3.1 EOF } diff --git a/docker/sbt/Dockerfile b/docker/sbt/Dockerfile index ae872a7..5d3bc91 100644 --- a/docker/sbt/Dockerfile +++ b/docker/sbt/Dockerfile @@ -21,7 +21,7 @@ WORKDIR /tmp/warmup # Create minimal build.sbt to download SBT dependencies RUN echo 'scalaVersion := "2.13.14"' > build.sbt && \ - echo 'crossScalaVersions := Seq("2.12.17", "2.13.14", "3.3.1")' >> build.sbt && \ + echo 'crossScalaVersions := Seq("2.12.20", "2.13.14", "3.3.1")' >> build.sbt && \ echo 'name := "warmup"' >> build.sbt && \ echo 'libraryDependencies += "org.scalatest" %% "scalatest" % "3.2.19" % Test' >> build.sbt diff --git a/examples/java-example-app/README.md b/examples/java-example-app/README.md index f0c4841..1c2323c 100644 --- a/examples/java-example-app/README.md +++ b/examples/java-example-app/README.md @@ -189,7 +189,7 @@ The library is cross-compiled for multiple Scala versions: org.scala-lang scala-library - 2.12.17 + 2.12.20 ``` diff --git a/examples/maven-example-app/pom.xml b/examples/maven-example-app/pom.xml index f3a22da..3338f02 100644 --- a/examples/maven-example-app/pom.xml +++ b/examples/maven-example-app/pom.xml @@ -90,7 +90,7 @@ scala-2.12 - 2.12.17 + 2.12.20 From cc9d6b3f39fc0002d68c245da7e631114cf895ef Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 17 Nov 2025 12:32:59 +0100 Subject: [PATCH 06/11] feature/Add scalafmt configuration and update CI workflow for formatting checks - Introduced `.scalafmt.conf` for consistent code formatting with scalafmt version 3.7.17. - Updated CI workflow to include formatting checks for all Scala versions. - Added sbt-scalafmt plugin to project dependencies for formatting enforcement. - Refactored comments in various model files for improved clarity and consistency. --- .github/workflows/ci.yml | 1 - .scalafmt.conf | 5 ++ project/plugins.sbt | 3 + .../core/enforce/AccessChecker.scala | 16 ++--- .../resourcedocs/core/model/ApiRole.scala | 7 +- .../resourcedocs/core/model/ErrorDoc.scala | 7 +- .../resourcedocs/core/model/HttpMethod.scala | 7 +- .../core/model/RequestContext.scala | 11 ++- .../core/model/RequiredRole.scala | 12 ++-- .../core/model/ResourceDocJson.scala | 68 +++++++++---------- .../resourcedocs/core/model/Tag.scala | 7 +- .../core/registry/ConsistencyCheck.scala | 9 ++- .../core/registry/ResourceDocRegistry.scala | 7 +- .../core/registry/RoleRegistry.scala | 9 +-- .../export/MarkdownExporter.scala | 11 ++- .../export/OBPLikeJsonExporter.scala | 45 ++++++------ .../export/OpenApiLikeJsonExporter.scala | 33 +++++---- .../model/GetBanksResourceDocJsonSpec.scala | 30 +++++--- .../export/MarkdownExporterSpec.scala | 14 ++-- .../export/OBPLikeJsonExporterSpec.scala | 19 ++++-- .../export/OpenApiLikeJsonExporterSpec.scala | 8 +-- 21 files changed, 162 insertions(+), 167 deletions(-) create mode 100644 .scalafmt.conf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7468179..3772a49 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,7 +49,6 @@ jobs: run: sbt "++${{ matrix.scala }}" test - name: Check formatting - if: matrix.scala == '2.13.14' run: sbt scalafmtCheckAll publish-snapshot: diff --git a/.scalafmt.conf b/.scalafmt.conf new file mode 100644 index 0000000..93d6261 --- /dev/null +++ b/.scalafmt.conf @@ -0,0 +1,5 @@ +version = 3.7.17 +runner.dialect = scala213 +maxColumn = 120 +# Keep default formatting for ScalaDoc and comments (use scalafmt defaults) + diff --git a/project/plugins.sbt b/project/plugins.sbt index 41a22e7..c29eb50 100644 --- a/project/plugins.sbt +++ b/project/plugins.sbt @@ -9,5 +9,8 @@ addSbtPlugin("com.github.sbt" % "sbt-release" % "1.1.0") // sbt-ci-release for CI/CD release automation - updated to compatible version addSbtPlugin("com.github.sbt" % "sbt-ci-release" % "1.5.12") +// scalafmt for consistent formatting checks +addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.5.2") + // Add airframe-log to suppress AirframeLogManager warning from sbt-ci-release libraryDependencies += "org.wvlet.airframe" %% "airframe-log" % "24.5.0" diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala b/src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala index 8eb0cac..3faba9d 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/enforce/AccessChecker.scala @@ -3,18 +3,16 @@ package com.openbankproject.resourcedocs.core.enforce import com.openbankproject.resourcedocs.core.model.{RequiredRole, RequestContext, RoleInfoJson} import com.openbankproject.resourcedocs.core.registry.ResourceDocRegistry -/** - * Performs access checks against registered ResourceDocs by operationId. - */ +/** Performs access checks against registered ResourceDocs by operationId. + */ object AccessChecker { - /** - * Convert role descriptors to RequiredRole for authorization checks. - * The semantics follow OBP-API: providing multiple roles means logical OR. - */ + /** Convert role descriptors to RequiredRole for authorization checks. The semantics follow OBP-API: providing + * multiple roles means logical OR. + */ private def rolesToRequiredRole(roles: Option[List[RoleInfoJson]]): RequiredRole = { roles match { - case None => RequiredRole.public + case None => RequiredRole.public case Some(Nil) => RequiredRole.public case Some(roleInfos) => val identifiers = roleInfos.map(_.role).toSet @@ -35,5 +33,3 @@ object AccessChecker { private def isAuthorized(required: RequiredRole, userRoles: Set[String]): Boolean = required.isAuthorized(userRoles) } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala index 7a85fa6..14f59d8 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ApiRole.scala @@ -1,8 +1,5 @@ package com.openbankproject.resourcedocs.core.model -/** - * Represents a named API role. - */ +/** Represents a named API role. + */ final case class ApiRole(name: String, description: Option[String] = None) - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala index 478578c..1882496 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ErrorDoc.scala @@ -1,8 +1,5 @@ package com.openbankproject.resourcedocs.core.model -/** - * Error documentation entry for an endpoint. - */ +/** Error documentation entry for an endpoint. + */ final case class ErrorDoc(code: String, httpStatus: Int, message: Option[String] = None) - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala index f0c2e78..52aea7c 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/HttpMethod.scala @@ -1,8 +1,7 @@ package com.openbankproject.resourcedocs.core.model -/** - * HTTP method enumeration independent of any web framework. - */ +/** HTTP method enumeration independent of any web framework. + */ sealed trait HttpMethod extends Product with Serializable { def name: String } object HttpMethod { @@ -19,5 +18,3 @@ object HttpMethod { def fromString(method: String): Option[HttpMethod] = all.find(_.name.equalsIgnoreCase(method)) } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala index bc3c3b5..a838865 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequestContext.scala @@ -1,11 +1,8 @@ package com.openbankproject.resourcedocs.core.model -/** - * Minimal request context required for access checks. - */ +/** Minimal request context required for access checks. + */ final case class RequestContext( - userRoles: Set[String], - attributes: Map[String, String] = Map.empty + userRoles: Set[String], + attributes: Map[String, String] = Map.empty ) - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala index c73dcc7..95984ca 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/RequiredRole.scala @@ -1,8 +1,7 @@ package com.openbankproject.resourcedocs.core.model -/** - * Expresses required roles for accessing an endpoint. - */ +/** Expresses required roles for accessing an endpoint. + */ sealed trait RequiredRole extends Product with Serializable { def isAuthorized(userRoles: Set[String]): Boolean } @@ -23,9 +22,8 @@ object RequiredRole { roles.forall(userRoles.contains) } - /** - * Disjunction of conjunctions; represents (A & B) | (C) ... - */ + /** Disjunction of conjunctions; represents (A & B) | (C) ... + */ final case class OrOfAnds(groups: Seq[Set[String]]) extends RequiredRole { override def isAuthorized(userRoles: Set[String]): Boolean = groups.exists(group => group.forall(userRoles.contains)) @@ -36,5 +34,3 @@ object RequiredRole { def oneOfAllOf(firstGroup: Set[String], otherGroups: Set[String]*): RequiredRole = OrOfAnds(firstGroup +: otherGroups) val public: RequiredRole = Public } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala index c63ef7c..9981fc0 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala @@ -1,46 +1,42 @@ package com.openbankproject.resourcedocs.core.model -/** - * JSON-friendly DTO for API resource documentation. - * This structure avoids framework types and uses primitive-friendly fields. - */ +/** JSON-friendly DTO for API resource documentation. This structure avoids framework types and uses primitive-friendly + * fields. + */ final case class RoleInfoJson( - role: String, - requires_bank_id: Boolean = false + role: String, + requires_bank_id: Boolean = false ) -/** - * Represents where an API call is implemented. - */ +/** Represents where an API call is implemented. + */ final case class ImplementedByJson( - version: String, // Short hand for the version e.g. "OBPv3.0.0" means Implementations3_0_0 - function: String // The val / partial function that implements the call e.g. "getBanks" + version: String, // Short hand for the version e.g. "OBPv3.0.0" means Implementations3_0_0 + function: String // The val / partial function that implements the call e.g. "getBanks" ) -/** - * Extended JSON-friendly DTO for API resource documentation that matches OBP-API structure. - * This structure includes all fields from OBP-API's ResourceDocJson for compatibility. - * Field names use snake_case to match OBP-API's ResourceDocJson structure. */ +/** Extended JSON-friendly DTO for API resource documentation that matches OBP-API structure. This structure includes + * all fields from OBP-API's ResourceDocJson for compatibility. Field names use snake_case to match OBP-API's + * ResourceDocJson structure. + */ final case class OBPResourceDocJson( - operation_id: String, - implemented_by: ImplementedByJson, - request_verb: String, - request_url: String, - summary: String, - description: String, // HTML format - description_markdown: String, // Markdown format - example_request_body: Option[String] = None, - success_response_body: Option[String] = None, - error_response_bodies: List[String] = List.empty, - tags: List[String] = List.empty, - typed_request_body: Option[String] = None, // JSON Schema as string - typed_success_response_body: Option[String] = None, // JSON Schema as string - roles: Option[List[RoleInfoJson]] = None, - is_featured: Boolean = false, - special_instructions: String = "", - specified_url: String, - connector_methods: List[String] = List.empty, - created_by_bank_id: Option[String] = None + operation_id: String, + implemented_by: ImplementedByJson, + request_verb: String, + request_url: String, + summary: String, + description: String, // HTML format + description_markdown: String, // Markdown format + example_request_body: Option[String] = None, + success_response_body: Option[String] = None, + error_response_bodies: List[String] = List.empty, + tags: List[String] = List.empty, + typed_request_body: Option[String] = None, // JSON Schema as string + typed_success_response_body: Option[String] = None, // JSON Schema as string + roles: Option[List[RoleInfoJson]] = None, + is_featured: Boolean = false, + special_instructions: String = "", + specified_url: String, + connector_methods: List[String] = List.empty, + created_by_bank_id: Option[String] = None ) - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala index 951bf26..cc31dee 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/Tag.scala @@ -1,8 +1,5 @@ package com.openbankproject.resourcedocs.core.model -/** - * Simple tag value object. - */ +/** Simple tag value object. + */ final case class Tag(value: String) extends AnyVal - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala index cf6ed2c..43b402f 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ConsistencyCheck.scala @@ -4,7 +4,10 @@ import com.openbankproject.resourcedocs.core.model.OBPResourceDocJson object ConsistencyCheck { def findDuplicateOperationIds(docs: Seq[OBPResourceDocJson]): Map[String, Int] = - docs.groupBy(_.operation_id).view.mapValues(_.size).filter(_._2 > 1).toMap + docs + .groupBy(_.operation_id) + .iterator + .map { case (operationId, entries) => operationId -> entries.size } + .filter(_._2 > 1) + .toMap } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala index b9a772c..313a850 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/registry/ResourceDocRegistry.scala @@ -4,9 +4,8 @@ import com.openbankproject.resourcedocs.core.model.OBPResourceDocJson import scala.collection.concurrent.TrieMap -/** - * Thread-safe registry for OBPResourceDocJson by operationId. - */ +/** Thread-safe registry for OBPResourceDocJson by operationId. + */ object ResourceDocRegistry { private[this] val byOperationId: TrieMap[String, OBPResourceDocJson] = TrieMap.empty @@ -23,5 +22,3 @@ object ResourceDocRegistry { def clear(): Unit = byOperationId.clear() } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala b/src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala index e43345a..629b347 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/registry/RoleRegistry.scala @@ -2,16 +2,13 @@ package com.openbankproject.resourcedocs.core.registry import scala.collection.concurrent.TrieMap -/** - * Registry for known role names. This is optional but helps to audit roles used across docs. - */ +/** Registry for known role names. This is optional but helps to audit roles used across docs. + */ object RoleRegistry { private[this] val roles: TrieMap[String, Unit] = TrieMap.empty - def register(role: String): Unit = { roles.put(role, ()) ; () } + def register(role: String): Unit = { roles.put(role, ()); () } def registerAll(rs: Iterable[String]): Unit = rs.foreach(register) def all: Vector[String] = roles.keys.toVector.sorted def clear(): Unit = roles.clear() } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala index 4c99718..43c15b8 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala @@ -1,12 +1,11 @@ -package com.openbankproject.resourcedocs.export +package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.OBPResourceDocJson -/** - * Simple Markdown exporter. - */ +/** Simple Markdown exporter. + */ object MarkdownExporter { - def export(docs: Seq[OBPResourceDocJson]): String = { + def render(docs: Seq[OBPResourceDocJson]): String = { val sb = new StringBuilder val ordered = docs.sortBy(_.operation_id) ordered.foreach { d => @@ -21,5 +20,3 @@ object MarkdownExporter { sb.toString() } } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala index 846a77d..69bdd30 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala @@ -1,4 +1,4 @@ -package com.openbankproject.resourcedocs.export +package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{OBPResourceDocJson, RoleInfoJson} @@ -7,17 +7,16 @@ import scala.collection.mutable.ArrayBuffer import java.time.Instant import java.time.format.DateTimeFormatter -/** - * Produces a JSON document that mirrors OBP-API's ResourceDoc response structure. - * The output contains a resource_docs array plus meta information (response date and count). - * - * This exporter deliberately avoids external JSON libraries to keep the module lightweight. - */ +/** Produces a JSON document that mirrors OBP-API's ResourceDoc response structure. The output contains a resource_docs + * array plus meta information (response date and count). + * + * This exporter deliberately avoids external JSON libraries to keep the module lightweight. + */ object OBPLikeJsonExporter { private val isoFormatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT - def export(docs: Seq[OBPResourceDocJson], responseDate: Instant = Instant.now()): String = { + def render(docs: Seq[OBPResourceDocJson], responseDate: Instant = Instant.now()): String = { val orderedDocs = docs.sortBy(_.operation_id) val sb = new StringBuilder sb.append("{\n") @@ -101,10 +100,12 @@ object OBPLikeJsonExporter { prefix + "[]" } else { val innerIndent = spaces(indent + 2) - val rendered = values.zipWithIndex.map { case (v, idx) => - val comma = if (idx < values.size - 1) "," else "" - innerIndent + "\"" + escape(v) + "\"" + comma - }.mkString("\n") + val rendered = values.zipWithIndex + .map { case (v, idx) => + val comma = if (idx < values.size - 1) "," else "" + innerIndent + "\"" + escape(v) + "\"" + comma + } + .mkString("\n") prefix + "[\n" + rendered + "\n" + spaces(indent) + "]" } } @@ -120,14 +121,16 @@ object OBPLikeJsonExporter { val indentStr = spaces(indent) if (roleInfos.isEmpty) indentStr + "\"roles\": []" else { - val entries = roleInfos.map { info => - val builder = new StringBuilder - builder.append(spaces(indent + 2) + "{\n") - builder.append(spaces(indent + 4) + "\"role\": \"" + escape(info.role) + "\",\n") - builder.append(spaces(indent + 4) + "\"requires_bank_id\": " + info.requires_bank_id + "\n") - builder.append(spaces(indent + 2) + "}") - builder.toString() - }.mkString(",\n") + val entries = roleInfos + .map { info => + val builder = new StringBuilder + builder.append(spaces(indent + 2) + "{\n") + builder.append(spaces(indent + 4) + "\"role\": \"" + escape(info.role) + "\",\n") + builder.append(spaces(indent + 4) + "\"requires_bank_id\": " + info.requires_bank_id + "\n") + builder.append(spaces(indent + 2) + "}") + builder.toString() + } + .mkString(",\n") indentStr + "\"roles\": [\n" + entries + "\n" + indentStr + "]" } } @@ -183,5 +186,3 @@ object OBPLikeJsonExporter { private def spaces(count: Int): String = " " * count } - - diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala index f9682c0..8662790 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala @@ -1,14 +1,13 @@ -package com.openbankproject.resourcedocs.export +package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{OBPResourceDocJson, RoleInfoJson} -/** - * Lightweight JSON exporter without third-party JSON dependencies. - * It produces an OpenAPI-like document containing essential fields only. - */ +/** Lightweight JSON exporter without third-party JSON dependencies. It produces an OpenAPI-like document containing + * essential fields only. + */ object OpenApiLikeJsonExporter { - def export(docs: Seq[OBPResourceDocJson]): String = { + def render(docs: Seq[OBPResourceDocJson]): String = { val byPath = docs.groupBy(_.request_url) val sb = new StringBuilder sb.append("{\n") @@ -29,13 +28,19 @@ object OpenApiLikeJsonExporter { sb.append(" \"tags\": " + toJsonArray(doc.tags.sorted.toVector) + ",\n") sb.append(" \"security\": " + rolesToSecurityJson(doc.roles) + ",\n") sb.append(" \"responses\": {\n") - val errs = if (doc.error_response_bodies.isEmpty) Vector("200") else { - // Extract HTTP status codes from error messages if possible - val statusPattern = """OBP-(\d+):""".r - doc.error_response_bodies.flatMap { errMsg => - statusPattern.findFirstMatchIn(errMsg).map(_.group(1)) - }.distinct.sorted.toVector - } + val errs = + if (doc.error_response_bodies.isEmpty) Vector("200") + else { + // Extract HTTP status codes from error messages if possible + val statusPattern = """OBP-(\d+):""".r + doc.error_response_bodies + .flatMap { errMsg => + statusPattern.findFirstMatchIn(errMsg).map(_.group(1)) + } + .distinct + .sorted + .toVector + } val errEntries = errs.zipWithIndex errEntries.foreach { case (status, k) => val comma = if (k < errEntries.size - 1) "," else "" @@ -75,5 +80,3 @@ object OpenApiLikeJsonExporter { .replace("\t", "\\t") } } - - diff --git a/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala index 38ac285..6bf7b4e 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala @@ -12,12 +12,18 @@ class GetBanksResourceDocJsonSpec extends AnyFunSuite { request_verb = "GET", request_url = "/obp/v3.0.0/banks", summary = "Get Banks", - description = "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routing:

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

scheme: OBP

\n

short_name:

\n

website: www.openbankproject.com

\n", - description_markdown = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", - success_response_body = Some("""{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}"""), + description = + "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routing:

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

scheme: OBP

\n

short_name:

\n

website: www.openbankproject.com

\n", + description_markdown = + "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", + success_response_body = Some( + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}""" + ), error_response_bodies = List("OBP-50000: Unknown Error."), tags = List("Bank", "Account Information Service (AIS)", "PSD2"), - typed_success_response_body = Some("""{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}"""), + typed_success_response_body = Some( + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + ), is_featured = false, special_instructions = "", specified_url = "/obp/v3.1.0/banks", @@ -50,12 +56,18 @@ class GetBanksResourceDocJsonSpec extends AnyFunSuite { request_verb = "GET", request_url = "/obp/v3.0.0/banks", summary = "Get Banks", - description = "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routing:

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

scheme: OBP

\n

short_name:

\n

website: www.openbankproject.com

\n", - description_markdown = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", - success_response_body = Some("""{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}"""), + description = + "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routing:

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

scheme: OBP

\n

short_name:

\n

website: www.openbankproject.com

\n", + description_markdown = + "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", + success_response_body = Some( + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}""" + ), error_response_bodies = List("OBP-50000: Unknown Error."), tags = List("Bank", "Account Information Service (AIS)", "PSD2"), - typed_success_response_body = Some("""{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}"""), + typed_success_response_body = Some( + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + ), is_featured = false, special_instructions = "", specified_url = "/obp/v3.1.0/banks", @@ -74,5 +86,3 @@ class GetBanksResourceDocJsonSpec extends AnyFunSuite { assert(getBanksDocJson.connector_methods.length == 2) } } - - diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala index 88384c6..82bf5a5 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala @@ -1,4 +1,4 @@ -package com.openbankproject.resourcedocs.export +package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson} import org.scalatest.funsuite.AnyFunSuite @@ -17,7 +17,13 @@ class MarkdownExporterSpec extends AnyFunSuite { private val getBanksTypedSuccessResponse: String = """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"bank_routings":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}}},"website":{"type":"string"},"logo":{"type":"string"},"attributes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" - private def getBanksDoc(operationId: String, verb: String, path: String, summary: String, tags: List[String]): OBPResourceDocJson = { + private def getBanksDoc( + operationId: String, + verb: String, + path: String, + summary: String, + tags: List[String] + ): OBPResourceDocJson = { OBPResourceDocJson( operation_id = operationId, implemented_by = ImplementedByJson(version = "OBPv4.0.0", function = "getBanks"), @@ -50,7 +56,7 @@ class MarkdownExporterSpec extends AnyFunSuite { tags = List("PSD2", "Account Information Service (AIS)") ) - val markdown = MarkdownExporter.export(Seq(getBanksV4, getBanksV3)) // intentionally shuffled + val markdown = MarkdownExporter.render(Seq(getBanksV4, getBanksV3)) // intentionally shuffled assert(markdown.contains("### OBPv3.0.0-getBanks")) assert(markdown.contains("- Method: GET")) @@ -64,5 +70,3 @@ class MarkdownExporterSpec extends AnyFunSuite { assert(markdown.indexOf("### OBPv3.0.0-getBanks") < markdown.indexOf("### OBPv4.0.0-getBanks")) } } - - diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala index 41b44a8..6da2299 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala @@ -1,4 +1,4 @@ -package com.openbankproject.resourcedocs.export +package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson, RoleInfoJson} import org.scalatest.funsuite.AnyFunSuite @@ -106,7 +106,14 @@ class OBPLikeJsonExporterSpec extends AnyFunSuite { description_markdown = getBanksDescriptionMarkdown, success_response_body = Some(getBanksSuccessResponse), error_response_bodies = List("OBP-50000: Unknown Error."), - tags = List("BankAccountTag1", "BankAccountTag1", "BankAccountTag1", "Bank", "Account Information Service (AIS)", "PSD2"), + tags = List( + "BankAccountTag1", + "BankAccountTag1", + "BankAccountTag1", + "Bank", + "Account Information Service (AIS)", + "PSD2" + ), typed_success_response_body = Some(getBanksTypedSuccessResponse), specified_url = "/obp/v5.1.0/banks", special_instructions = "", @@ -126,8 +133,8 @@ class OBPLikeJsonExporterSpec extends AnyFunSuite { test("export should match the official getBanks JSON payload exactly") { val doc = baseDoc("OBPv4.0.0-getBanks") - - val json = OBPLikeJsonExporter.export(Seq(doc), Instant.parse("2025-11-16T21:57:26Z")) + + val json = OBPLikeJsonExporter.render(Seq(doc), Instant.parse("2025-11-16T21:57:26Z")) val expectedJson = s""" @@ -261,7 +268,7 @@ class OBPLikeJsonExporterSpec extends AnyFunSuite { connector_methods = Nil ) - val json = OBPLikeJsonExporter.export(Seq(laterDoc, docWithRoles), Instant.parse("2025-01-01T00:00:00Z")) + val json = OBPLikeJsonExporter.render(Seq(laterDoc, docWithRoles), Instant.parse("2025-01-01T00:00:00Z")) val firstIdx = json.indexOf("BX-getCustomers") val secondIdx = json.indexOf("CX-getBanks") @@ -279,5 +286,3 @@ class OBPLikeJsonExporterSpec extends AnyFunSuite { | ]""".stripMargin)) } } - - diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala index f5c8f88..d507841 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala @@ -1,4 +1,4 @@ -package com.openbankproject.resourcedocs.export +package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson, RoleInfoJson} import org.scalatest.funsuite.AnyFunSuite @@ -54,7 +54,7 @@ class OpenApiLikeJsonExporterSpec extends AnyFunSuite { summary = "Get Bank Attributes" ) - val json = OpenApiLikeJsonExporter.export(Seq(getBankAttributes, createBanks, getBanksV3)) + val json = OpenApiLikeJsonExporter.render(Seq(getBankAttributes, createBanks, getBanksV3)) assert(json.contains("\"openapi\": \"3.0.0\"")) assert(json.contains("\"/obp/v3.0.0/banks\"")) @@ -78,7 +78,7 @@ class OpenApiLikeJsonExporterSpec extends AnyFunSuite { error_response_bodies = List("OBP-40300: Insufficient Privileges.", "OBP-50000: Unknown Error.") ) - val json = OpenApiLikeJsonExporter.export(Seq(securedDoc)) + val json = OpenApiLikeJsonExporter.render(Seq(securedDoc)) assert(json.contains("\"rolesAnyOf\"")) assert(json.contains("CanGetBanks")) @@ -87,5 +87,3 @@ class OpenApiLikeJsonExporterSpec extends AnyFunSuite { assert(json.contains("\"50000\"")) } } - - From dbd4ddcf9279e3606612fcc224361bf81f71e9b8 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Mon, 17 Nov 2025 13:33:09 +0100 Subject: [PATCH 07/11] refactor/Update project description in build.sbt for POM generation - Replaced POM customization with a direct project description assignment for clarity. - Ensured the description is now used in the generated POM for Maven Central. --- build.sbt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/build.sbt b/build.sbt index 55f7884..69a0fb8 100644 --- a/build.sbt +++ b/build.sbt @@ -55,10 +55,8 @@ ThisBuild / developers := List( ) ) -// POM customization for Maven Central -pomExtra := ( - Open Bank Project Scala Library - A comprehensive library for banking operations -) +// Project description (will be used in generated POM) +description := "Open Bank Project Scala Library - A comprehensive library for banking operations" // Cross-compilation settings crossPaths := true From f28fdd6a6a17d4d6ace91547c8b04f06a618f568 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Tue, 25 Nov 2025 13:01:34 +0100 Subject: [PATCH 08/11] docfix/Add workspace file to .gitignore for improved project management --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 7acaec1..6ac68ec 100644 --- a/.gitignore +++ b/.gitignore @@ -322,3 +322,4 @@ temp/ Icon? .specstory .cursorindexingignore +OBP-Scala-Library.code-workspace From cb1f2f5c6a056f3d431aebbee2c0c227428d01e9 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 26 Nov 2025 10:07:58 +0100 Subject: [PATCH 09/11] feature/Add Circe dependencies for JSON handling in build.sbt --- build.sbt | 2 + .../core/model/ResourceDocJson.scala | 13 +- .../export/OBPLikeJsonExporter.scala | 12 +- .../model/GetBanksResourceDocJsonSpec.scala | 21 ++- .../export/MarkdownExporterSpec.scala | 17 +- .../export/OBPLikeJsonExporterSpec.scala | 171 +++++++++--------- .../export/OpenApiLikeJsonExporterSpec.scala | 17 +- 7 files changed, 152 insertions(+), 101 deletions(-) diff --git a/build.sbt b/build.sbt index 69a0fb8..df94431 100644 --- a/build.sbt +++ b/build.sbt @@ -13,6 +13,8 @@ ThisBuild / organizationHomepage := Some(url("https://www.openbankproject.com/") // Dependencies libraryDependencies ++= Seq( + "io.circe" %% "circe-core" % "0.14.10", + "io.circe" %% "circe-parser" % "0.14.10", "org.scalatest" %% "scalatest" % "3.2.19" % Test ) diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala index 9981fc0..2f6d91e 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala @@ -1,5 +1,7 @@ package com.openbankproject.resourcedocs.core.model +import io.circe.Json + /** JSON-friendly DTO for API resource documentation. This structure avoids framework types and uses primitive-friendly * fields. */ @@ -27,12 +29,15 @@ final case class OBPResourceDocJson( summary: String, description: String, // HTML format description_markdown: String, // Markdown format - example_request_body: Option[String] = None, - success_response_body: Option[String] = None, + example_request_body: Option[Json] = None, + success_response_body: Option[Json] = None, + error_response_bodies: List[String] = List.empty, tags: List[String] = List.empty, - typed_request_body: Option[String] = None, // JSON Schema as string - typed_success_response_body: Option[String] = None, // JSON Schema as string + + typed_request_body: Option[Json] = None, + typed_success_response_body: Option[Json] = None, + roles: Option[List[RoleInfoJson]] = None, is_featured: Boolean = false, special_instructions: String = "", diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala index 69bdd30..63575f6 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala @@ -1,6 +1,7 @@ package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{OBPResourceDocJson, RoleInfoJson} +import io.circe.{Json, Printer} import scala.collection.mutable.ArrayBuffer @@ -15,6 +16,7 @@ import java.time.format.DateTimeFormatter object OBPLikeJsonExporter { private val isoFormatter: DateTimeFormatter = DateTimeFormatter.ISO_INSTANT + private val jsonPrinter: Printer = Printer.spaces2 def render(docs: Seq[OBPResourceDocJson], responseDate: Instant = Instant.now()): String = { val orderedDocs = docs.sortBy(_.operation_id) @@ -33,18 +35,18 @@ object OBPLikeJsonExporter { fields += stringField(6, "description", doc.description) fields += stringField(6, "description_markdown", doc.description_markdown) doc.example_request_body.foreach { body => - fields += jsonField(6, "example_request_body", body, 8) + fields += jsonField(6, "example_request_body", jsonToString(body), 8) } doc.success_response_body.foreach { body => - fields += jsonField(6, "success_response_body", body, 8) + fields += jsonField(6, "success_response_body", jsonToString(body), 8) } fields += stringArrayField(6, "error_response_bodies", doc.error_response_bodies) fields += stringArrayField(6, "tags", doc.tags) doc.typed_request_body.foreach { body => - fields += jsonField(6, "typed_request_body", body, 8) + fields += jsonField(6, "typed_request_body", jsonToString(body), 8) } doc.typed_success_response_body.foreach { body => - fields += jsonField(6, "typed_success_response_body", body, 8) + fields += jsonField(6, "typed_success_response_body", jsonToString(body), 8) } doc.roles.foreach { roleInfos => fields += rolesField(6, roleInfos) @@ -185,4 +187,6 @@ object OBPLikeJsonExporter { } private def spaces(count: Int): String = " " * count + + private def jsonToString(value: Json): String = jsonPrinter.print(value) } diff --git a/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala index 6bf7b4e..f49cc25 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/core/model/GetBanksResourceDocJsonSpec.scala @@ -1,10 +1,15 @@ package com.openbankproject.resourcedocs.core.model import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson} +import io.circe.Json +import io.circe.parser.parse import org.scalatest.funsuite.AnyFunSuite class GetBanksResourceDocJsonSpec extends AnyFunSuite { + private def parseJson(value: String): Json = + parse(value).fold(throw _, identity) + test("GetBanks ResourceDocJson should match expected structure") { val getBanksDocJson: OBPResourceDocJson = OBPResourceDocJson( operation_id = "OBPv3.0.0-getBanks", @@ -17,12 +22,16 @@ class GetBanksResourceDocJsonSpec extends AnyFunSuite { description_markdown = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", success_response_body = Some( - """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}""" + parseJson( + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}""" + ) ), error_response_bodies = List("OBP-50000: Unknown Error."), tags = List("Bank", "Account Information Service (AIS)", "PSD2"), typed_success_response_body = Some( - """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + parseJson( + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + ) ), is_featured = false, special_instructions = "", @@ -61,12 +70,16 @@ class GetBanksResourceDocJsonSpec extends AnyFunSuite { description_markdown = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routing**](/glossary#bank_routing): \n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n", success_response_body = Some( - """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}""" + parseJson( + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routing":{"scheme":"OBP","address":"gh.29.uk"}}]}""" + ) ), error_response_bodies = List("OBP-50000: Unknown Error."), tags = List("Bank", "Account Information Service (AIS)", "PSD2"), typed_success_response_body = Some( - """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + parseJson( + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"website":{"type":"string"},"logo":{"type":"string"},"bank_routing":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + ) ), is_featured = false, special_instructions = "", diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala index 82bf5a5..88c73d1 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala @@ -1,21 +1,30 @@ package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson} +import io.circe.Json +import io.circe.parser.parse import org.scalatest.funsuite.AnyFunSuite class MarkdownExporterSpec extends AnyFunSuite { + private def parseJson(value: String): Json = + parse(value).fold(throw _, identity) + private val getBanksDescriptionHtml: String = "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routings: bank routing in form of (scheme, address)

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

name: ACCOUNT_MANAGEMENT_FEE

\n

scheme: OBP

\n

short_name:

\n

value: 5987953

\n

website: www.openbankproject.com

\n

attributes: attribute value in form of (name, value)

\n" private val getBanksDescriptionMarkdown: String = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routings**](/glossary#bank_routings): bank routing in form of (scheme, address)\n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**name**](/glossary#name): ACCOUNT_MANAGEMENT_FEE\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**value**](/glossary#): 5987953\n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n\n[attributes](/glossary#attributes): attribute value in form of (name, value)\n\n\n" - private val getBanksSuccessResponse: String = - """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routings":[{"scheme":"OBP","address":"gh.29.uk"}],"attributes":[{"name":"ACCOUNT_MANAGEMENT_FEE","value":"5987953"}]}]}""" + private val getBanksSuccessResponse = + parseJson( + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routings":[{"scheme":"OBP","address":"gh.29.uk"}],"attributes":[{"name":"ACCOUNT_MANAGEMENT_FEE","value":"5987953"}]}]}""" + ) - private val getBanksTypedSuccessResponse: String = - """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"bank_routings":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}}},"website":{"type":"string"},"logo":{"type":"string"},"attributes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + private val getBanksTypedSuccessResponse = + parseJson( + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"bank_routings":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}}},"website":{"type":"string"},"logo":{"type":"string"},"attributes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + ) private def getBanksDoc( operationId: String, diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala index 6da2299..390a05a 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala @@ -1,100 +1,109 @@ package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson, RoleInfoJson} +import io.circe.Json +import io.circe.parser.parse import org.scalatest.funsuite.AnyFunSuite import java.time.Instant class OBPLikeJsonExporterSpec extends AnyFunSuite { + private def parseJson(value: String): Json = + parse(value).fold(throw _, identity) + private val getBanksDescriptionHtml: String = "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routings: bank routing in form of (scheme, address)

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

name: ACCOUNT_MANAGEMENT_FEE

\n

scheme: OBP

\n

short_name:

\n

value: 5987953

\n

website: www.openbankproject.com

\n

attributes: attribute value in form of (name, value)

\n" private val getBanksDescriptionMarkdown: String = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routings**](/glossary#bank_routings): bank routing in form of (scheme, address)\n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**name**](/glossary#name): ACCOUNT_MANAGEMENT_FEE\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**value**](/glossary#): 5987953\n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n\n[attributes](/glossary#attributes): attribute value in form of (name, value)\n\n\n" - private val getBanksSuccessResponse: String = - """{ - | "banks": [ - | { - | "id": "gh.29.uk", - | "short_name": "short_name ", - | "full_name": "full_name", - | "logo": "logo", - | "website": "www.openbankproject.com", - | "bank_routings": [ - | { - | "scheme": "OBP", - | "address": "gh.29.uk" - | } - | ], - | "attributes": [ - | { - | "name": "ACCOUNT_MANAGEMENT_FEE", - | "value": "5987953" - | } - | ] - | } - | ] - |}""".stripMargin + private val getBanksSuccessResponse = + parseJson( + """{ + | "banks": [ + | { + | "id": "gh.29.uk", + | "short_name": "short_name ", + | "full_name": "full_name", + | "logo": "logo", + | "website": "www.openbankproject.com", + | "bank_routings": [ + | { + | "scheme": "OBP", + | "address": "gh.29.uk" + | } + | ], + | "attributes": [ + | { + | "name": "ACCOUNT_MANAGEMENT_FEE", + | "value": "5987953" + | } + | ] + | } + | ] + |}""".stripMargin + ) - private val getBanksTypedSuccessResponse: String = - """{ - | "type": "object", - | "properties": { - | "banks": { - | "type": "array", - | "items": { - | "type": "object", - | "properties": { - | "bank_routings": { - | "type": "array", - | "items": { - | "type": "object", - | "properties": { - | "address": { - | "type": "string" - | }, - | "scheme": { - | "type": "string" - | } - | } - | } - | }, - | "website": { - | "type": "string" - | }, - | "logo": { - | "type": "string" - | }, - | "attributes": { - | "type": "array", - | "items": { - | "type": "object", - | "properties": { - | "name": { - | "type": "string" - | }, - | "value": { - | "type": "string" - | } - | } - | } - | }, - | "short_name": { - | "type": "string" - | }, - | "id": { - | "type": "string" - | }, - | "full_name": { - | "type": "string" - | } - | } - | } - | } - | } - |}""".stripMargin + private val getBanksTypedSuccessResponse = + parseJson( + """{ + | "type": "object", + | "properties": { + | "banks": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "bank_routings": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "address": { + | "type": "string" + | }, + | "scheme": { + | "type": "string" + | } + | } + | } + | }, + | "website": { + | "type": "string" + | }, + | "logo": { + | "type": "string" + | }, + | "attributes": { + | "type": "array", + | "items": { + | "type": "object", + | "properties": { + | "name": { + | "type": "string" + | }, + | "value": { + | "type": "string" + | } + | } + | } + | }, + | "short_name": { + | "type": "string" + | }, + | "id": { + | "type": "string" + | }, + | "full_name": { + | "type": "string" + | } + | } + | } + | } + | } + |}""".stripMargin + ) private def baseDoc(operationId: String, request_url: String = "/obp/v4.0.0/banks") = OBPResourceDocJson( operation_id = operationId, diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala index d507841..2b5dc3e 100644 --- a/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala +++ b/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala @@ -1,21 +1,30 @@ package com.openbankproject.resourcedocs.exporter import com.openbankproject.resourcedocs.core.model.{ImplementedByJson, OBPResourceDocJson, RoleInfoJson} +import io.circe.Json +import io.circe.parser.parse import org.scalatest.funsuite.AnyFunSuite class OpenApiLikeJsonExporterSpec extends AnyFunSuite { + private def parseJson(value: String): Json = + parse(value).fold(throw _, identity) + private val getBanksDescriptionHtml: String = "

Get banks on this API instance
\nReturns a list of banks supported on this server:

\n
    \n
  • ID used as parameter in URLs
  • \n
  • Short and full name of bank
  • \n
  • Logo URL
  • \n
  • Website
  • \n
\n

User Authentication is Optional. The User need not be logged in.

\n

JSON response body fields:

\n

address:

\n

bank_routings: bank routing in form of (scheme, address)

\n

banks:

\n

full_name: full name string

\n

id: d8839721-ad8f-45dd-9f78-2080414b93f9

\n

logo: logo url

\n

name: ACCOUNT_MANAGEMENT_FEE

\n

scheme: OBP

\n

short_name:

\n

value: 5987953

\n

website: www.openbankproject.com

\n

attributes: attribute value in form of (name, value)

\n" private val getBanksDescriptionMarkdown: String = "Get banks on this API instance\nReturns a list of banks supported on this server:\n\n* ID used as parameter in URLs\n* Short and full name of bank\n* Logo URL\n* Website\n\nUser Authentication is Optional. The User need not be logged in.\n\n\n**JSON response body fields:**\n\n\n\n[**address**](/glossary#address): \n\n\n\n[**bank_routings**](/glossary#bank_routings): bank routing in form of (scheme, address)\n\n\n\n[**banks**](/glossary#banks): \n\n\n\n[**full_name**](/glossary#full_name): full name string\n\n\n\n[**id**](/glossary#id): d8839721-ad8f-45dd-9f78-2080414b93f9\n\n\n\n[**logo**](/glossary#logo): logo url\n\n\n\n[**name**](/glossary#name): ACCOUNT_MANAGEMENT_FEE\n\n\n\n[**scheme**](/glossary#scheme): OBP\n\n\n\n[**short_name**](/glossary#short_name): \n\n\n\n[**value**](/glossary#): 5987953\n\n\n\n[**website**](/glossary#website): www.openbankproject.com\n\n\n\n[attributes](/glossary#attributes): attribute value in form of (name, value)\n\n\n" - private val getBanksSuccessResponse: String = - """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routings":[{"scheme":"OBP","address":"gh.29.uk"}],"attributes":[{"name":"ACCOUNT_MANAGEMENT_FEE","value":"5987953"}]}]}""" + private val getBanksSuccessResponse = + parseJson( + """{"banks":[{"id":"gh.29.uk","short_name":"short_name ","full_name":"full_name","logo":"logo","website":"www.openbankproject.com","bank_routings":[{"scheme":"OBP","address":"gh.29.uk"}],"attributes":[{"name":"ACCOUNT_MANAGEMENT_FEE","value":"5987953"}]}]}""" + ) - private val getBanksTypedSuccessResponse: String = - """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"bank_routings":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}}},"website":{"type":"string"},"logo":{"type":"string"},"attributes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + private val getBanksTypedSuccessResponse = + parseJson( + """{"type":"object","properties":{"banks":{"type":"array","items":{"type":"object","properties":{"bank_routings":{"type":"array","items":{"type":"object","properties":{"address":{"type":"string"},"scheme":{"type":"string"}}}},"website":{"type":"string"},"logo":{"type":"string"},"attributes":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"value":{"type":"string"}}}},"short_name":{"type":"string"},"id":{"type":"string"},"full_name":{"type":"string"}}}}}}""" + ) private def getBanksDoc(operationId: String, verb: String, path: String, summary: String): OBPResourceDocJson = { OBPResourceDocJson( From c2b8a3392a1ec6f87d793c934cea67df11f59159 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 26 Nov 2025 10:15:04 +0100 Subject: [PATCH 10/11] feature/Add Markdown, OBP-like JSON, and OpenAPI-like JSON exporters with corresponding tests - Implemented `MarkdownExporter` for generating Markdown documentation from resource docs. - Developed `OBPLikeJsonExporter` to produce JSON documents mirroring OBP-API's ResourceDoc response structure. - Created `OpenApiLikeJsonExporter` for generating OpenAPI-like JSON documents. - Added unit tests for each exporter to ensure correct functionality and output formatting. --- .../resourcedocs/{export => exporter}/MarkdownExporter.scala | 0 .../resourcedocs/{export => exporter}/OBPLikeJsonExporter.scala | 0 .../{export => exporter}/OpenApiLikeJsonExporter.scala | 0 .../resourcedocs/{export => exporter}/MarkdownExporterSpec.scala | 0 .../{export => exporter}/OBPLikeJsonExporterSpec.scala | 0 .../{export => exporter}/OpenApiLikeJsonExporterSpec.scala | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename src/main/scala/com/openbankproject/resourcedocs/{export => exporter}/MarkdownExporter.scala (100%) rename src/main/scala/com/openbankproject/resourcedocs/{export => exporter}/OBPLikeJsonExporter.scala (100%) rename src/main/scala/com/openbankproject/resourcedocs/{export => exporter}/OpenApiLikeJsonExporter.scala (100%) rename src/test/scala/com/openbankproject/resourcedocs/{export => exporter}/MarkdownExporterSpec.scala (100%) rename src/test/scala/com/openbankproject/resourcedocs/{export => exporter}/OBPLikeJsonExporterSpec.scala (100%) rename src/test/scala/com/openbankproject/resourcedocs/{export => exporter}/OpenApiLikeJsonExporterSpec.scala (100%) diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/exporter/MarkdownExporter.scala similarity index 100% rename from src/main/scala/com/openbankproject/resourcedocs/export/MarkdownExporter.scala rename to src/main/scala/com/openbankproject/resourcedocs/exporter/MarkdownExporter.scala diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/exporter/OBPLikeJsonExporter.scala similarity index 100% rename from src/main/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporter.scala rename to src/main/scala/com/openbankproject/resourcedocs/exporter/OBPLikeJsonExporter.scala diff --git a/src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala b/src/main/scala/com/openbankproject/resourcedocs/exporter/OpenApiLikeJsonExporter.scala similarity index 100% rename from src/main/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporter.scala rename to src/main/scala/com/openbankproject/resourcedocs/exporter/OpenApiLikeJsonExporter.scala diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/exporter/MarkdownExporterSpec.scala similarity index 100% rename from src/test/scala/com/openbankproject/resourcedocs/export/MarkdownExporterSpec.scala rename to src/test/scala/com/openbankproject/resourcedocs/exporter/MarkdownExporterSpec.scala diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/exporter/OBPLikeJsonExporterSpec.scala similarity index 100% rename from src/test/scala/com/openbankproject/resourcedocs/export/OBPLikeJsonExporterSpec.scala rename to src/test/scala/com/openbankproject/resourcedocs/exporter/OBPLikeJsonExporterSpec.scala diff --git a/src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/exporter/OpenApiLikeJsonExporterSpec.scala similarity index 100% rename from src/test/scala/com/openbankproject/resourcedocs/export/OpenApiLikeJsonExporterSpec.scala rename to src/test/scala/com/openbankproject/resourcedocs/exporter/OpenApiLikeJsonExporterSpec.scala From 70704767beac52884bb55f09e6b6093ed9acc811 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 26 Nov 2025 15:36:41 +0100 Subject: [PATCH 11/11] 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. --- build.sbt | 3 + .../core/model/ResourceDocJson.scala | 30 ++++++-- .../core/model/OBPResourceDocJsonSpec.scala | 70 +++++++++++++++++++ 3 files changed, 98 insertions(+), 5 deletions(-) create mode 100644 src/test/scala/com/openbankproject/resourcedocs/core/model/OBPResourceDocJsonSpec.scala diff --git a/build.sbt b/build.sbt index df94431..aa21f16 100644 --- a/build.sbt +++ b/build.sbt @@ -15,6 +15,9 @@ ThisBuild / organizationHomepage := Some(url("https://www.openbankproject.com/") libraryDependencies ++= Seq( "io.circe" %% "circe-core" % "0.14.10", "io.circe" %% "circe-parser" % "0.14.10", + // JSON Schema generation + "com.github.andyglow" %% "scala-jsonschema" % "0.7.11", + "com.github.andyglow" %% "scala-jsonschema-circe-json" % "0.7.11", "org.scalatest" %% "scalatest" % "3.2.19" % Test ) diff --git a/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala index 2f6d91e..f1f2d4b 100644 --- a/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala +++ b/src/main/scala/com/openbankproject/resourcedocs/core/model/ResourceDocJson.scala @@ -1,6 +1,9 @@ package com.openbankproject.resourcedocs.core.model -import io.circe.Json +import io.circe.{Json => CirceJson} +import com.github.andyglow.jsonschema.AsCirce._ +import json._ +import json.schema.Version.Draft04 /** JSON-friendly DTO for API resource documentation. This structure avoids framework types and uses primitive-friendly * fields. @@ -17,6 +20,21 @@ final case class ImplementedByJson( function: String // The val / partial function that implements the call e.g. "getBanks" ) +object OBPResourceDocJson { + /** Helper to generate Schema JSON from a Case Class type. + * + * @tparam T + * The case class type to generate schema for + * @return + * Circe JSON representing the Draft-04 JSON Schema + */ + def generateSchema[T: Schema]: CirceJson = { + // Instantiate the final class directly + val v = new json.schema.Version.Draft04() + Json.schema[T].asCirce(v) + } +} + /** Extended JSON-friendly DTO for API resource documentation that matches OBP-API structure. This structure includes * all fields from OBP-API's ResourceDocJson for compatibility. Field names use snake_case to match OBP-API's * ResourceDocJson structure. @@ -29,14 +47,16 @@ final case class OBPResourceDocJson( summary: String, description: String, // HTML format description_markdown: String, // Markdown format - example_request_body: Option[Json] = None, - success_response_body: Option[Json] = None, + example_request_body: Option[CirceJson] = None, + success_response_body: Option[CirceJson] = None, error_response_bodies: List[String] = List.empty, tags: List[String] = List.empty, - typed_request_body: Option[Json] = None, - typed_success_response_body: Option[Json] = None, + // Schema for request body (Draft-04 JSON Schema) + typed_request_body: Option[CirceJson] = None, + // Schema for success response (Draft-04 JSON Schema) + typed_success_response_body: Option[CirceJson] = None, roles: Option[List[RoleInfoJson]] = None, is_featured: Boolean = false, diff --git a/src/test/scala/com/openbankproject/resourcedocs/core/model/OBPResourceDocJsonSpec.scala b/src/test/scala/com/openbankproject/resourcedocs/core/model/OBPResourceDocJsonSpec.scala new file mode 100644 index 0000000..a1e4b80 --- /dev/null +++ b/src/test/scala/com/openbankproject/resourcedocs/core/model/OBPResourceDocJsonSpec.scala @@ -0,0 +1,70 @@ +package com.openbankproject.resourcedocs.core.model + +import io.circe.{Json => CirceJson} +import org.scalatest.flatspec.AnyFlatSpec +import org.scalatest.matchers.should.Matchers +import json._ + +case class SampleRequest(id: String, quantity: Int, tags: List[String]) +case class SampleResponse(success: Boolean, message: Option[String]) + +class OBPResourceDocJsonSpec extends AnyFlatSpec with Matchers { + + // Define implicits using the macro + implicit val sampleRequestSchema: Schema[SampleRequest] = Json.schema[SampleRequest] + implicit val sampleResponseSchema: Schema[SampleResponse] = Json.schema[SampleResponse] + + "OBPResourceDocJson.generateSchema" should "generate correct Draft-04 schema for a case class" in { + val schema: CirceJson = OBPResourceDocJson.generateSchema[SampleRequest] + + val schemaObj = schema.asObject.get + + // Handle Schema with Definitions (Draft-04 style) + // The root might contain $ref, and properties are in definitions + val properties = if (schemaObj.contains("definitions")) { + val definitions = schemaObj("definitions").flatMap(_.asObject).get + // Assuming only one definition is generated for the simple case class + definitions.values.head.asObject.get("properties").flatMap(_.asObject).get + } else { + schemaObj("properties").flatMap(_.asObject).get + } + + properties("id").flatMap(_.asObject).flatMap(_("type").flatMap(_.asString)) should be(Some("string")) + properties("quantity").flatMap(_.asObject).flatMap(_("type").flatMap(_.asString)) should be(Some("integer")) + + // Verify array handling + val tagsProp = properties("tags").flatMap(_.asObject).get + tagsProp("type").flatMap(_.asString) should be(Some("array")) + tagsProp("items").flatMap(_.asObject).flatMap(_("type").flatMap(_.asString)) should be(Some("string")) + + // Verify required fields + // If using definitions, 'required' is inside the definition + val requiredSource = if (schemaObj.contains("definitions")) { + val definitions = schemaObj("definitions").flatMap(_.asObject).get + definitions.values.head.asObject.get + } else { + schemaObj + } + val required = requiredSource("required").flatMap(_.asArray).getOrElse(Vector.empty).flatMap(_.asString) + required should contain allOf ("id", "quantity", "tags") + } + + it should "handle Option types as optional fields in schema" in { + val schema: CirceJson = OBPResourceDocJson.generateSchema[SampleResponse] + + val schemaObj = schema.asObject.get + + val requiredSource = if (schemaObj.contains("definitions")) { + val definitions = schemaObj("definitions").flatMap(_.asObject).get + definitions.values.head.asObject.get + } else { + schemaObj + } + + val required = requiredSource("required").flatMap(_.asArray).map(_.flatMap(_.asString)).getOrElse(Vector.empty) + + // 'success' should be required, 'message' should NOT be in required list + required should contain("success") + required should not contain "message" + } +}