From 40c737859ab1c083ad028377b5421a29d4104344 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 26 Aug 2026 20:57:52 +0200 Subject: [PATCH 1/2] fix: widen DynamicResourceDoc example/response body columns to text ExampleRequestBody, SuccessResponseBody and ErrorResponseBodies were capped at varchar(255), which a realistic multi-field JSON example routinely exceeds even with minimal values. Endpoints with wider payloads had to omit the example entirely, leaving the request-body editor empty for API consumers instead of pre-filled. Widen all three to unbounded text, matching MethodBody's existing type. On an existing database this needs an explicit ALTER COLUMN migration (Schemifier only creates missing tables/columns, it does not widen existing ones), added as MigrationOfDynamicResourceDocBodyFieldsLength and wired into the existing migration-scripts pipeline. Verified: DynamicResourceDocTest, MigrationsTest, ResourceDocsTest, SwaggerDocsTest and SwaggerFactoryUnitTest all pass; manually confirmed the migration runs cleanly against a live Postgres instance and widens the columns to text. --- .../code/api/util/migration/Migration.scala | 8 +++ ...OfDynamicResourceDocBodyFieldsLength.scala | 64 +++++++++++++++++++ .../DynamicResourceDoc.scala | 6 +- 3 files changed, 75 insertions(+), 3 deletions(-) create mode 100644 obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicResourceDocBodyFieldsLength.scala diff --git a/obp-api/src/main/scala/code/api/util/migration/Migration.scala b/obp-api/src/main/scala/code/api/util/migration/Migration.scala index ed8fefb1fa..517263a544 100644 --- a/obp-api/src/main/scala/code/api/util/migration/Migration.scala +++ b/obp-api/src/main/scala/code/api/util/migration/Migration.scala @@ -157,6 +157,7 @@ object Migration extends MdcLoggable { migrateMetricConsentReferenceId(startedBeforeSchemifier) migrateMetricCertificateTrust(startedBeforeSchemifier) dropFastFirehoseAccountsViews(startedBeforeSchemifier) + alterDynamicResourceDocBodyFieldsLength() } /** @@ -511,6 +512,13 @@ object Migration extends MdcLoggable { // Retire the fast-firehose SQL views (firehose -> account directory + ABAC). Runs after the create // migrations above, so a fresh DB creates-then-drops them and an existing DB just drops them. See // MigrationOfDropFastFireHoseViews. + private def alterDynamicResourceDocBodyFieldsLength(): Boolean = { + val name = nameOf(alterDynamicResourceDocBodyFieldsLength) + runOnce(name) { + MigrationOfDynamicResourceDocBodyFieldsLength.alterColumnsType(name) + } + } + private def dropFastFirehoseAccountsViews(startedBeforeSchemifier: Boolean): Boolean = { if(startedBeforeSchemifier == true) { logger.warn(s"Migration.database.dropFastFirehoseAccountsViews(true) cannot be run before Schemifier.") diff --git a/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicResourceDocBodyFieldsLength.scala b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicResourceDocBodyFieldsLength.scala new file mode 100644 index 0000000000..187ab049cf --- /dev/null +++ b/obp-api/src/main/scala/code/api/util/migration/MigrationOfDynamicResourceDocBodyFieldsLength.scala @@ -0,0 +1,64 @@ +package code.api.util.migration + +import code.api.util.APIUtil +import code.api.util.migration.Migration.{DbFunction, saveLog} +import code.dynamicResourceDoc.DynamicResourceDoc +import net.liftweb.common.Full +import net.liftweb.mapper.Schemifier + +object MigrationOfDynamicResourceDocBodyFieldsLength { + + def alterColumnsType(name: String): Boolean = { + DbFunction.tableExists(DynamicResourceDoc) match { + case true => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + var isSuccessful = false + + val executedSql = + DbFunction.maybeWrite(true, Schemifier.infoF _) { + APIUtil.getPropsValue("db.driver") match { + case Full(dbDriver) if dbDriver.contains("com.microsoft.sqlserver.jdbc.SQLServerDriver") => + () => + """ + |-- A realistic dynamic-endpoint request/response body example (or full error + |-- response list) routinely exceeds varchar(255) once it has more than a + |-- handful of JSON fields + |ALTER TABLE dynamicresourcedoc ALTER COLUMN examplerequestbody VARCHAR(MAX); + |ALTER TABLE dynamicresourcedoc ALTER COLUMN successresponsebody VARCHAR(MAX); + |ALTER TABLE dynamicresourcedoc ALTER COLUMN errorresponsebodies VARCHAR(MAX); + |""".stripMargin + case _ => + () => + """ + |-- A realistic dynamic-endpoint request/response body example (or full error + |-- response list) routinely exceeds varchar(255) once it has more than a + |-- handful of JSON fields + |ALTER TABLE dynamicresourcedoc ALTER COLUMN examplerequestbody TYPE text; + |ALTER TABLE dynamicresourcedoc ALTER COLUMN successresponsebody TYPE text; + |ALTER TABLE dynamicresourcedoc ALTER COLUMN errorresponsebodies TYPE text; + |""".stripMargin + } + } + + val endDate = System.currentTimeMillis() + val comment: String = + s"""Executed SQL: + |$executedSql + |""".stripMargin + isSuccessful = true + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + + case false => + val startDate = System.currentTimeMillis() + val commitId: String = APIUtil.gitCommit + val isSuccessful = false + val endDate = System.currentTimeMillis() + val comment: String = + s"""${DynamicResourceDoc._dbTableNameLC} table does not exist""".stripMargin + saveLog(name, commitId, isSuccessful, startDate, endDate, comment) + isSuccessful + } + } +} diff --git a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala index d11c21ae41..a96c164350 100644 --- a/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala +++ b/obp-api/src/main/scala/code/dynamicResourceDoc/DynamicResourceDoc.scala @@ -19,9 +19,9 @@ class DynamicResourceDoc extends LongKeyedMapper[DynamicResourceDoc] with IdPK w object RequestUrl extends MappedString(this, 255) object Summary extends MappedString(this, 255) object Description extends MappedString(this, 255) - object ExampleRequestBody extends MappedString(this, 255) - object SuccessResponseBody extends MappedString(this, 255) - object ErrorResponseBodies extends MappedString(this, 255) + object ExampleRequestBody extends MappedText(this) + object SuccessResponseBody extends MappedText(this) + object ErrorResponseBodies extends MappedText(this) object Tags extends MappedString(this, 255) object Roles extends MappedString(this, 255) object MethodBody extends MappedText(this) From 1464d4d9803fcb442bc3d943194162cf34983254 Mon Sep 17 00:00:00 2001 From: Hongwei Date: Wed, 26 Aug 2026 21:09:30 +0200 Subject: [PATCH 2/2] fix: null-guard getCanonicalName in resource-doc field option check Class.getCanonicalName() returns null for a local or anonymous class. A nested case class declared inside a runtime-compiled dynamic-endpoint method body is exactly that from the JVM's perspective, so any example body with a nested object (once the previous varchar(255) limit no longer forces such examples to be omitted) crashed the entire resource-docs listing with a NullPointerException, not just the endpoint that declared the nested field. Default to false (not an Option-typed field) when no canonical name is available, matching Option's own canonical name always being present since it is a top-level class. --- .../src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala index b70552fe01..b850b84581 100644 --- a/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala +++ b/obp-api/src/main/scala/code/api/v1_4_0/JSONFactory1_4_0.scala @@ -522,7 +522,13 @@ object JSONFactory1_4_0 extends MdcLoggable{ } def checkFieldOption(jsonBody: Any, rootFields: List[Field]) = { - val types = rootFields.map(f => (f.getName(), f.getType().getCanonicalName().contains("Option"))) + // getCanonicalName() is null for a local/anonymous class (Java reflection spec) -- which is + // exactly what a nested case class declared inside a runtime-compiled DynamicResourceDoc + // method body is, from the JVM's perspective (e.g. an example_request_body with a nested + // object generates a locally-scoped case class for that object's type). A field whose + // declared type is such a class is never itself an Option (Option's own canonical name is + // always present, since scala.Option is a top-level class), so None safely defaults to false. + val types = rootFields.map(f => (f.getName(), Option(f.getType().getCanonicalName()).exists(_.contains("Option")))) (decompose(jsonBody), types) }