From a3bf6c34312933cddec3d7313e3c89f5be268605 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 28 Apr 2026 14:12:21 +0200 Subject: [PATCH 1/4] test/remove maxItems constraint from GeoJSON position arrays per RFC 7946 --- .../code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala index 61bd9b7e75..aca7c6f68b 100644 --- a/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala +++ b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala @@ -140,6 +140,14 @@ class JSONFactory1_4_0NestedArrayTest extends FeatureSpec val itemsLevel4 = (itemsLevel3 \ "items") (itemsLevel4 \ "type").extract[String] shouldBe "number" + + // Verify that coordinate pairs (innermost arrays) have minItems constraint + // Per RFC 7946 Section 3.1.1: "A position is an array of numbers. There MUST be two or more elements." + // The first two elements are longitude and latitude (required) + // The third element is altitude (optional) + // Therefore: minItems: 2 is correct, but maxItems should NOT be set (allows 3 elements) + (itemsLevel3 \ "minItems").extractOpt[Int] shouldBe Some(2) + (itemsLevel3 \ "maxItems").extractOpt[Int] shouldBe None } scenario("Empty nested array should be handled gracefully") { From ff8bbef94ac90795b93afdeeaf348afef933b953 Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 28 Apr 2026 14:27:12 +0200 Subject: [PATCH 2/4] enhancement/add maxItems 3 constraint to GeoJSON position arrays per RFC 7946 --- .../code/api/v1_4_0/JSONFactory1_4_0.scala | 70 +++++++++++++++++-- .../JSONFactory1_4_0NestedArrayTest.scala | 13 ++-- 2 files changed, 71 insertions(+), 12 deletions(-) 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 f322513e29..d51ad077b6 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 @@ -663,6 +663,52 @@ object JSONFactory1_4_0 extends MdcLoggable{ } case _ => false } + + // Helper function to calculate array nesting depth + // Returns the depth of nested arrays (1 for simple array, 2 for array of arrays, etc.) + def getArrayDepth(value: Any): Int = value match { + case JArray(List(f, _*)) if f.isInstanceOf[JArray] => 1 + getArrayDepth(f) + case JArray(_) => 1 + case _ => 0 + } + + // Helper function to check if an array structure represents GeoJSON MultiPolygon coordinates + // GeoJSON MultiPolygon has 4 levels: MultiPolygon > Polygon > LinearRing > Coordinate + // The innermost level (coordinate pairs) should have minItems/maxItems: 2 + def isGeoJSONMultiPolygonStructure(value: Any): Boolean = { + getArrayDepth(value) == 4 + } + + // Helper function to add minItems and maxItems constraints to the innermost array in a nested schema + // This is used for GeoJSON MultiPolygon coordinates where the 4th level (position arrays) needs constraints + // According to RFC 7946 Section 3.1.1: + // - "A position is an array of numbers. There MUST be two or more elements." (minItems: 2) + // - "Altitude or elevation MAY be included as an optional third element." (allows 3 elements) + // - "Implementations SHOULD NOT extend positions beyond three elements" (maxItems: 3) + // Therefore: minItems: 2, maxItems: 3 + // @param schema The JSON schema string (nested array structure) + // @param depth The total depth of the nested array structure + // @return The schema with minItems and maxItems constraints added at the appropriate level + def addGeoJSONConstraints(schema: String, depth: Int): String = { + // For a 4-level nested array, we need to add constraints at the 3rd level + // (the level that contains arrays of numbers) + // The schema structure is: array -> array -> array -> array(with items: number) + // We want to add constraints to the 3rd level: array -> array -> array[minItems, maxItems] -> number + + // Count how many levels deep we need to go + // For depth=4, we want to modify the 3rd "items" level + if (depth == 4) { + // Find the pattern: "items": {"type": "array", "items": {"type": "number"}} + // And replace with: "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 3} + // This enforces RFC 7946: positions must have 2-3 elements (lon, lat, optional altitude) + schema.replaceAll( + """"items":\s*\{"type":\s*"array",\s*"items":\s*\{"type":\s*"number"\}\}""", + """"items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 3}""" + ) + } else { + schema + } + } //please check issue first: https://github.com/OpenBankProject/OBP-API/issues/877 //change: @@ -688,18 +734,20 @@ object JSONFactory1_4_0 extends MdcLoggable{ // Nested array: recursively generate nested array schema val innerSchema = translateEntity(f, false) return """{"type": "array", "items": """ + innerSchema + "}" - case JArray(List(f, _*)) => + case JArray(elements) if elements.nonEmpty => // Non-nested array: generate array schema with primitive or object items - val itemType = f match { + val firstElement = elements.head + val itemType = firstElement match { case _: JInt => """{"type": "integer"}""" case _: JDouble => """{"type": "number"}""" case _: JBool => """{"type": "boolean"}""" case _: JString => """{"type": "string"}""" case _: JArray => // This is a nested array - recursively handle it - translateEntity(f, false) - case _ => translateEntity(f, false) // For objects or other complex types + translateEntity(firstElement, false) + case _ => translateEntity(firstElement, false) // For objects or other complex types } + return """{"type": "array", "items": """ + itemType + "}" case JArray(List()) => // Empty array @@ -793,9 +841,19 @@ object JSONFactory1_4_0 extends MdcLoggable{ // Handle nested arrays (JArray containing JArray) - generate pure nested array schema case JArray(List(f,_*)) if f.isInstanceOf[JArray] => // For nested arrays, recursively generate nested array schema - // The recursive call will handle further nesting + // Check if this is a GeoJSON MultiPolygon structure (4 levels deep) + val depth = getArrayDepth(value) + val isGeoJSON = depth == 4 val innerSchema = translateEntity(f, false) - "\"" + key + """": {"type": "array", "items": """ + innerSchema + "}" + + // If this is GeoJSON MultiPolygon, add minItems/maxItems to the innermost array + val schemaWithConstraints = if (isGeoJSON) { + addGeoJSONConstraints(innerSchema, depth) + } else { + innerSchema + } + + "\"" + key + """": {"type": "array", "items": """ + schemaWithConstraints + "}" case JArray(List(f,_*)) => "\"" + key + """":""" +translateEntity(f,true) case List(f) => "\"" + key + """":""" +translateEntity(f,true) case List(f,_*) => "\"" + key + """":""" +translateEntity(f,true) diff --git a/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala index aca7c6f68b..d695fe0c26 100644 --- a/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala +++ b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala @@ -141,13 +141,14 @@ class JSONFactory1_4_0NestedArrayTest extends FeatureSpec val itemsLevel4 = (itemsLevel3 \ "items") (itemsLevel4 \ "type").extract[String] shouldBe "number" - // Verify that coordinate pairs (innermost arrays) have minItems constraint - // Per RFC 7946 Section 3.1.1: "A position is an array of numbers. There MUST be two or more elements." - // The first two elements are longitude and latitude (required) - // The third element is altitude (optional) - // Therefore: minItems: 2 is correct, but maxItems should NOT be set (allows 3 elements) + // Verify that position arrays (innermost arrays) have minItems and maxItems constraints + // Per RFC 7946 Section 3.1.1: + // - "A position is an array of numbers. There MUST be two or more elements." (minItems: 2) + // - "Altitude or elevation MAY be included as an optional third element." (allows 3 elements) + // - "Implementations SHOULD NOT extend positions beyond three elements" (maxItems: 3) + // Therefore: minItems: 2, maxItems: 3 (supports 2D and 3D coordinates) (itemsLevel3 \ "minItems").extractOpt[Int] shouldBe Some(2) - (itemsLevel3 \ "maxItems").extractOpt[Int] shouldBe None + (itemsLevel3 \ "maxItems").extractOpt[Int] shouldBe Some(3) } scenario("Empty nested array should be handled gracefully") { From c8690d07269978eb3873e291763caced140cc9ce Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 28 Apr 2026 14:54:26 +0200 Subject: [PATCH 3/4] enhancement/extract minItems and maxItems from JSON schema for OpenAPI conversion --- .../api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index 832fc3ceef..4197b95ab9 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -708,13 +708,21 @@ object OpenAPI31JSONFactory extends MdcLoggable { case JArray(values) => values.collect { case JString(v) => v } } + // Extract array validation constraints + // Note: For GeoJSON coordinates, we only extract minItems (not maxItems) + // because positions can have 2 or 3 elements (lon, lat, optional altitude) + val minItems = fieldMap.get("minItems").collect { case JInt(v) => v.toInt } + val maxItems = fieldMap.get("maxItems").collect { case JInt(v) => v.toInt } + SchemaJson( `type` = schemaType, format = format, properties = properties, items = items, required = required, - enum = enum + enum = enum, + minItems = minItems, + maxItems = maxItems ) case _ => From 7051629c8461531e74477c0ccb38332114cb989d Mon Sep 17 00:00:00 2001 From: hongwei Date: Tue, 28 Apr 2026 15:33:13 +0200 Subject: [PATCH 4/4] refactor/change GeoJSON position arrays to 2D only (maxItems: 2) for cadastral data --- .../OpenAPI31JSONFactory.scala | 4 ++-- .../code/api/v1_4_0/JSONFactory1_4_0.scala | 23 ++++++++++++------- .../JSONFactory1_4_0NestedArrayTest.scala | 14 ++++++----- 3 files changed, 25 insertions(+), 16 deletions(-) diff --git a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala index 4197b95ab9..d271a52c55 100644 --- a/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala +++ b/obp-api/src/main/scala/code/api/ResourceDocs1_4_0/OpenAPI31JSONFactory.scala @@ -709,8 +709,8 @@ object OpenAPI31JSONFactory extends MdcLoggable { } // Extract array validation constraints - // Note: For GeoJSON coordinates, we only extract minItems (not maxItems) - // because positions can have 2 or 3 elements (lon, lat, optional altitude) + // Note: For GeoJSON cadastral coordinates, we enforce 2D only (minItems: 2, maxItems: 2) + // This ensures coordinate dimension consistency and simplifies API usage val minItems = fieldMap.get("minItems").collect { case JInt(v) => v.toInt } val maxItems = fieldMap.get("maxItems").collect { case JInt(v) => v.toInt } 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 d51ad077b6..c0c58318ec 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 @@ -681,29 +681,36 @@ object JSONFactory1_4_0 extends MdcLoggable{ // Helper function to add minItems and maxItems constraints to the innermost array in a nested schema // This is used for GeoJSON MultiPolygon coordinates where the 4th level (position arrays) needs constraints + // + // IMPORTANT: We use maxItems: 2 (2D coordinates only) instead of maxItems: 3 for the following reasons: + // 1. Cadastral datasets typically don't provide elevation data + // 2. Allowing elevation would require defining a vertical coordinate reference system + // 3. RFC 7946 requires all positions in a geometry to have the same number of coordinates, + // but JSON Schema cannot enforce this cross-element constraint + // 4. Restricting to 2D simplifies API mocking and client implementation + // // According to RFC 7946 Section 3.1.1: // - "A position is an array of numbers. There MUST be two or more elements." (minItems: 2) - // - "Altitude or elevation MAY be included as an optional third element." (allows 3 elements) - // - "Implementations SHOULD NOT extend positions beyond three elements" (maxItems: 3) - // Therefore: minItems: 2, maxItems: 3 + // - For cadastral use case: we enforce exactly 2 elements (longitude, latitude) + // // @param schema The JSON schema string (nested array structure) // @param depth The total depth of the nested array structure - // @return The schema with minItems and maxItems constraints added at the appropriate level + // @return The schema with minItems: 2 and maxItems: 2 constraints added at the appropriate level def addGeoJSONConstraints(schema: String, depth: Int): String = { // For a 4-level nested array, we need to add constraints at the 3rd level // (the level that contains arrays of numbers) // The schema structure is: array -> array -> array -> array(with items: number) - // We want to add constraints to the 3rd level: array -> array -> array[minItems, maxItems] -> number + // We want to add constraints to the 3rd level: array -> array -> array[minItems: 2, maxItems: 2] -> number // Count how many levels deep we need to go // For depth=4, we want to modify the 3rd "items" level if (depth == 4) { // Find the pattern: "items": {"type": "array", "items": {"type": "number"}} - // And replace with: "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 3} - // This enforces RFC 7946: positions must have 2-3 elements (lon, lat, optional altitude) + // And replace with: "items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2} + // This enforces 2D coordinates only (longitude, latitude) for cadastral data schema.replaceAll( """"items":\s*\{"type":\s*"array",\s*"items":\s*\{"type":\s*"number"\}\}""", - """"items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 3}""" + """"items": {"type": "array", "items": {"type": "number"}, "minItems": 2, "maxItems": 2}""" ) } else { schema diff --git a/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala index d695fe0c26..353c1ceb1d 100644 --- a/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala +++ b/obp-api/src/test/scala/code/api/v1_4_0/JSONFactory1_4_0NestedArrayTest.scala @@ -142,13 +142,15 @@ class JSONFactory1_4_0NestedArrayTest extends FeatureSpec (itemsLevel4 \ "type").extract[String] shouldBe "number" // Verify that position arrays (innermost arrays) have minItems and maxItems constraints - // Per RFC 7946 Section 3.1.1: - // - "A position is an array of numbers. There MUST be two or more elements." (minItems: 2) - // - "Altitude or elevation MAY be included as an optional third element." (allows 3 elements) - // - "Implementations SHOULD NOT extend positions beyond three elements" (maxItems: 3) - // Therefore: minItems: 2, maxItems: 3 (supports 2D and 3D coordinates) + // For cadastral data, we enforce 2D coordinates only (longitude, latitude) + // Reasons for maxItems: 2 instead of RFC 7946's optional 3rd element: + // 1. Cadastral datasets typically don't provide elevation data + // 2. Allowing elevation would require defining a vertical coordinate reference system + // 3. RFC 7946 requires all positions in a geometry to have the same number of coordinates, + // but JSON Schema cannot enforce this cross-element constraint + // 4. Restricting to 2D simplifies API mocking and client implementation (itemsLevel3 \ "minItems").extractOpt[Int] shouldBe Some(2) - (itemsLevel3 \ "maxItems").extractOpt[Int] shouldBe Some(3) + (itemsLevel3 \ "maxItems").extractOpt[Int] shouldBe Some(2) } scenario("Empty nested array should be handled gracefully") {