Skip to content

Commit b334d32

Browse files
Add patternProperties support to JSON Schema objects (#491)
* Add patternProperties support to JSON Schema objects
1 parent 168e01f commit b334d32

9 files changed

Lines changed: 184 additions & 4 deletions

Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -340,6 +340,7 @@ extension DereferencedJSONSchema {
340340
public let maxProperties: Int?
341341
let _minProperties: Int?
342342
public let properties: OrderedDictionary<String, DereferencedJSONSchema>
343+
public let patternProperties: OrderedDictionary<String, DereferencedJSONSchema>
343344
public let additionalProperties: Either<Bool, DereferencedJSONSchema>?
344345

345346
// NOTE that an object's required properties
@@ -372,7 +373,16 @@ extension DereferencedJSONSchema {
372373
otherProperties[name] = dereferencedProperty
373374
}
374375

376+
var otherPatternProperties = OrderedDictionary<String, DereferencedJSONSchema>()
377+
for (pattern, property) in objectContext.patternProperties {
378+
guard let dereferencedPatternProperty = property.dereferenced() else {
379+
return nil
380+
}
381+
otherPatternProperties[pattern] = dereferencedPatternProperty
382+
}
383+
375384
properties = otherProperties
385+
patternProperties = otherPatternProperties
376386
maxProperties = objectContext.maxProperties
377387
_minProperties = objectContext._minProperties
378388
switch objectContext.additionalProperties {
@@ -394,6 +404,7 @@ extension DereferencedJSONSchema {
394404
following references: Set<AnyHashable>
395405
) throws {
396406
properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) }
407+
patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) }
397408
maxProperties = objectContext.maxProperties
398409
_minProperties = objectContext._minProperties
399410
switch objectContext.additionalProperties {
@@ -408,11 +419,13 @@ extension DereferencedJSONSchema {
408419

409420
internal init(
410421
properties: OrderedDictionary<String, DereferencedJSONSchema>,
422+
patternProperties: OrderedDictionary<String, DereferencedJSONSchema> = [:],
411423
additionalProperties: Either<Bool, DereferencedJSONSchema>? = nil,
412424
maxProperties: Int? = nil,
413425
minProperties: Int? = nil
414426
) {
415427
self.properties = properties
428+
self.patternProperties = patternProperties
416429
self.additionalProperties = additionalProperties
417430
self.maxProperties = maxProperties
418431
self._minProperties = minProperties
@@ -431,6 +444,7 @@ extension DereferencedJSONSchema {
431444

432445
return .init(
433446
properties: properties.mapValues { $0.jsonSchema },
447+
patternProperties: patternProperties.mapValues { $0.jsonSchema },
434448
additionalProperties: underlyingAdditionalProperties,
435449
maxProperties: maxProperties,
436450
minProperties: _minProperties
@@ -573,11 +587,15 @@ extension JSONSchema: ExternallyDereferenceable {
573587
try components.merge(c1)
574588
messages += m1
575589

590+
let (newPatternProperties, c2, m2) = try await object.patternProperties.externallyDereferenced(with: loader)
591+
try components.merge(c2)
592+
messages += m2
593+
576594
let newAdditionalProperties: Either<Bool, JSONSchema>?
577595
if case .b(let schema) = object.additionalProperties {
578-
let (additionalProperties, c2, m2) = try await schema.externallyDereferenced(with: loader)
579-
try components.merge(c2)
580-
messages += m2
596+
let (additionalProperties, c3, m3) = try await schema.externallyDereferenced(with: loader)
597+
try components.merge(c3)
598+
messages += m3
581599
newAdditionalProperties = .b(additionalProperties)
582600
} else {
583601
newAdditionalProperties = object.additionalProperties
@@ -589,6 +607,7 @@ extension JSONSchema: ExternallyDereferenceable {
589607
core,
590608
.init(
591609
properties: newProperties,
610+
patternProperties: newPatternProperties,
592611
additionalProperties: newAdditionalProperties,
593612
maxProperties: object.maxProperties,
594613
minProperties: object._minProperties

Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -528,6 +528,7 @@ extension JSONSchema.ArrayContext {
528528
extension JSONSchema.ObjectContext {
529529
internal func combined(with other: JSONSchema.ObjectContext, resolvingIn components: OpenAPI.Components) throws -> JSONSchema.ObjectContext {
530530
let combinedProperties = try combine(properties: properties, with: other.properties, resolvingIn: components)
531+
let combinedPatternProperties = try combine(properties: patternProperties, with: other.patternProperties, resolvingIn: components)
531532

532533
if let conflict = conflicting(maxProperties, other.maxProperties) {
533534
throw JSONSchemaResolutionError(.attributeConflict(jsonType: .object, name: "maxProperties", original: String(conflict.0), new: String(conflict.1)))
@@ -559,6 +560,7 @@ extension JSONSchema.ObjectContext {
559560
let newAdditionalProperties = additionalProperties ?? other.additionalProperties
560561
return .init(
561562
properties: combinedProperties,
563+
patternProperties: combinedPatternProperties,
562564
additionalProperties: newAdditionalProperties,
563565
maxProperties: newMaxProperties,
564566
minProperties: newMinProperties
@@ -712,6 +714,7 @@ extension JSONSchema.ObjectContext {
712714
}
713715
return .init(
714716
properties: properties,
717+
patternProperties: patternProperties,
715718
additionalProperties: additionalProperties,
716719
maxProperties: maxProperties,
717720
minProperties: _minProperties

Sources/OpenAPIKit/Schema Object/JSONSchema.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,7 @@ extension JSONSchema {
16141614
minProperties: Int? = nil,
16151615
maxProperties: Int? = nil,
16161616
properties: OrderedDictionary<String, JSONSchema> = [:],
1617+
patternProperties: OrderedDictionary<String, JSONSchema> = [:],
16171618
additionalProperties: Either<Bool, JSONSchema>? = nil,
16181619
allowedValues: [AnyCodable]? = nil,
16191620
defaultValue: AnyCodable? = nil,
@@ -1643,6 +1644,7 @@ extension JSONSchema {
16431644
)
16441645
let objectContext = JSONSchema.ObjectContext(
16451646
properties: properties,
1647+
patternProperties: patternProperties,
16461648
additionalProperties: additionalProperties,
16471649
maxProperties: maxProperties,
16481650
minProperties: minProperties

Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -782,6 +782,10 @@ extension JSONSchema {
782782
/// allows you to omit the property from encoding.
783783
public let additionalProperties: Either<Bool, JSONSchema>?
784784

785+
/// Schemas keyed by regular expressions that matching property names
786+
/// must satisfy.
787+
public let patternProperties: OrderedDictionary<String, JSONSchema>
788+
785789
/// The properties of this object that are required.
786790
///
787791
/// - Note: An object's required properties array
@@ -811,11 +815,13 @@ extension JSONSchema {
811815

812816
public init(
813817
properties: OrderedDictionary<String, JSONSchema>,
818+
patternProperties: OrderedDictionary<String, JSONSchema> = [:],
814819
additionalProperties: Either<Bool, JSONSchema>? = nil,
815820
maxProperties: Int? = nil,
816821
minProperties: Int? = nil
817822
) {
818823
self.properties = properties
824+
self.patternProperties = patternProperties
819825
self.additionalProperties = additionalProperties
820826
self.maxProperties = maxProperties
821827
self._minProperties = minProperties
@@ -1260,6 +1266,7 @@ extension JSONSchema.ObjectContext {
12601266
case maxProperties
12611267
case minProperties
12621268
case properties
1269+
case patternProperties
12631270
case additionalProperties
12641271
case required
12651272
}
@@ -1275,6 +1282,10 @@ extension JSONSchema.ObjectContext: Encodable {
12751282
try container.encode(properties, forKey: .properties)
12761283
}
12771284

1285+
if patternProperties.count > 0 {
1286+
try container.encode(patternProperties, forKey: .patternProperties)
1287+
}
1288+
12781289
try container.encodeIfPresent(additionalProperties, forKey: .additionalProperties)
12791290

12801291
if !requiredProperties.isEmpty {
@@ -1296,7 +1307,9 @@ extension JSONSchema.ObjectContext: Decodable {
12961307
let requiredArray = try container.decodeIfPresent([String].self, forKey: .required) ?? []
12971308

12981309
let decodedProperties = try container.decodeIfPresent(OrderedDictionary<String, JSONSchema>.self, forKey: .properties) ?? [:]
1310+
let decodedPatternProperties = try container.decodeIfPresent(OrderedDictionary<String, JSONSchema>.self, forKey: .patternProperties) ?? [:]
12991311
properties = Self.properties(decodedProperties, takingRequirementsFrom: requiredArray)
1312+
patternProperties = decodedPatternProperties.mapValues { $0.optionalSchemaObject() }
13001313
}
13011314

13021315
/// Make any property not in the given "required" array optional.

Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ extension DereferencedJSONSchema {
108108
core,
109109
.init(
110110
properties: try object.properties.mapValues { try $0.simplified() },
111+
patternProperties: try object.patternProperties.mapValues { try $0.simplified() },
111112
additionalProperties: additionalProperties,
112113
maxProperties: object.maxProperties,
113114
minProperties: object._minProperties

Tests/OpenAPIKitTests/Document/ExternalDereferencingDocumentTests.swift

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,17 @@ final class ExternalDereferencingDocumentTests: XCTestCase {
7676
""",
7777
"schemas_basic_object_json": """
7878
{
79-
"type": "object"
79+
"type": "object",
80+
"patternProperties": {
81+
"^x-": {
82+
"$ref": "file://./schemas/pattern_property.json"
83+
}
84+
}
85+
}
86+
""",
87+
"schemas_pattern_property_json": """
88+
{
89+
"type": "string"
8090
}
8191
""",
8292
"requests_hello_json": """
@@ -310,6 +320,10 @@ final class ExternalDereferencingDocumentTests: XCTestCase {
310320
// for this document, depth of 4 is enough for all the above to compare equally
311321
XCTAssertEqual(docCopy1, docCopy2)
312322
XCTAssertEqual(docCopy2, docCopy3)
323+
XCTAssertEqual(
324+
docCopy3.components.schemas["schemas_basic_object_json"]?.objectContext?.patternProperties["^x-"],
325+
.reference(.component(named: "schemas_pattern_property_json"), required: false)
326+
)
313327

314328
XCTAssertEqual(
315329
messages.sorted(),
@@ -328,6 +342,7 @@ final class ExternalDereferencingDocumentTests: XCTestCase {
328342
"file://./requests/webhook.json",
329343
"file://./responses/webhook.json",
330344
"file://./schemas/basic_object.json",
345+
"file://./schemas/pattern_property.json",
331346
"file://./schemas/string_param.json",
332347
"file://./schemas/string_param.json",
333348
"file://./schemas/string_param.json",

Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,12 @@ final class DereferencedSchemaObjectTests: XCTestCase {
304304
.boolean(.init())
305305
)
306306

307+
let tPattern = JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced()
308+
XCTAssertEqual(
309+
tPattern?.objectContext?.patternProperties["^x-"],
310+
.string(.init(), .init())
311+
)
312+
307313
let t3 = JSONSchema.object(
308314
properties: [
309315
"required": .string,
@@ -320,6 +326,37 @@ final class DereferencedSchemaObjectTests: XCTestCase {
320326
)
321327
}
322328

329+
func test_optionalObjectContextPatternPropertiesCanConvertBackToJSONSchema() throws {
330+
let context = try XCTUnwrap(
331+
DereferencedJSONSchema.ObjectContext(
332+
.init(
333+
properties: ["fixed": .string],
334+
patternProperties: ["^x-": .boolean],
335+
additionalProperties: .init(.integer)
336+
)
337+
)
338+
)
339+
340+
XCTAssertEqual(
341+
context.patternProperties["^x-"],
342+
.boolean(.init())
343+
)
344+
XCTAssertEqual(
345+
context.additionalProperties?.schemaValue,
346+
.integer(.init(), .init())
347+
)
348+
349+
let schema = DereferencedJSONSchema.object(.init(), context).jsonSchema
350+
XCTAssertEqual(
351+
schema.objectContext,
352+
.init(
353+
properties: ["fixed": .string],
354+
patternProperties: ["^x-": .boolean],
355+
additionalProperties: .init(.integer)
356+
)
357+
)
358+
}
359+
323360
func test_throwingObjectWithoutReferences() throws {
324361
let components = OpenAPI.Components.noComponents
325362
let t1 = try JSONSchema.object(properties: ["test": .string]).dereferenced(in: components)
@@ -335,6 +372,12 @@ final class DereferencedSchemaObjectTests: XCTestCase {
335372
.boolean(.init())
336373
)
337374

375+
let tPattern = try JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced(in: components)
376+
XCTAssertEqual(
377+
tPattern.objectContext?.patternProperties["^x-"],
378+
.string(.init(), .init())
379+
)
380+
338381
let t3 = try JSONSchema.object(
339382
properties: [
340383
"required": .string,
@@ -353,6 +396,7 @@ final class DereferencedSchemaObjectTests: XCTestCase {
353396

354397
func test_optionalObjectWithReferences() {
355398
XCTAssertNil(JSONSchema.object(properties: ["test": .reference(.component(named: "test"))]).dereferenced())
399+
XCTAssertNil(JSONSchema.object(patternProperties: ["^x-": .reference(.component(named: "missing"))]).dereferenced())
356400
}
357401

358402
func test_throwingObjectWithReferences() throws {
@@ -515,6 +559,24 @@ final class DereferencedSchemaObjectTests: XCTestCase {
515559
}
516560
}
517561

562+
func test_simplifiedObjectWithPatternProperties() throws {
563+
let simplified = try JSONSchema.object(
564+
patternProperties: ["^x-": .all(of: [.string])],
565+
additionalProperties: .init(.all(of: [.boolean]))
566+
)
567+
.dereferenced(in: .noComponents)
568+
.simplified()
569+
570+
XCTAssertEqual(
571+
simplified.objectContext?.patternProperties["^x-"],
572+
.string(.init(), .init())
573+
)
574+
XCTAssertEqual(
575+
simplified.objectContext?.additionalProperties?.schemaValue,
576+
.boolean(.init())
577+
)
578+
}
579+
518580
func test_withDescription() throws {
519581
let null = JSONSchema.null().dereferenced()!.with(description: "test")
520582
let object = JSONSchema.object.dereferenced()!.with(description: "test")

Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3237,6 +3237,55 @@ extension SchemaObjectTests {
32373237
XCTAssertEqual(contextB, .init(properties: ["hello": .boolean(.init(format: .generic, required: false))], additionalProperties: .init(.string)))
32383238
}
32393239

3240+
func test_encodeObjectWithPatternProperties() {
3241+
let object = JSONSchema.object(
3242+
.init(format: .unspecified, required: true),
3243+
.init(
3244+
properties: ["hello": .boolean(.init(format: .unspecified, required: false))],
3245+
patternProperties: ["^x-": .string(required: false)]
3246+
)
3247+
)
3248+
3249+
testEncodingPropertyLines(entity: object,
3250+
propertyLines: [
3251+
"\"patternProperties\" : {",
3252+
" \"^x-\" : {",
3253+
" \"type\" : \"string\"",
3254+
" }",
3255+
"},",
3256+
"\"properties\" : {",
3257+
" \"hello\" : {",
3258+
" \"type\" : \"boolean\"",
3259+
" }",
3260+
"},",
3261+
"\"type\" : \"object\""
3262+
])
3263+
}
3264+
3265+
func test_decodeObjectWithPatternProperties() {
3266+
let objectData = """
3267+
{
3268+
"patternProperties": {
3269+
"^x-": { "type": "string" }
3270+
},
3271+
"type": "object"
3272+
}
3273+
""".data(using: .utf8)!
3274+
3275+
let object = try! orderUnstableDecode(JSONSchema.self, from: objectData)
3276+
3277+
XCTAssertEqual(
3278+
object,
3279+
JSONSchema.object(
3280+
.init(format: .generic),
3281+
.init(
3282+
properties: [:],
3283+
patternProperties: ["^x-": .string(required: false)]
3284+
)
3285+
)
3286+
)
3287+
}
3288+
32403289
func test_encodeObjectWithExample() {
32413290
let string = try! JSONSchema.string(.init(format: .unspecified, required: true), .init())
32423291
.with(example: "hello")

Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1074,6 +1074,22 @@ final class SchemaFragmentCombiningTests: XCTestCase {
10741074
XCTAssert(error ~= .attributeConflict, "\(error) is not ~= `.attributeConflict` -- \(fragments)")
10751075
}
10761076
}
1077+
1078+
let patternPropertyDifference: [JSONSchema] = [
1079+
.object(.init(), .init(properties: [:], patternProperties: ["^x-": .boolean])),
1080+
.object(.init(), .init(properties: [:], patternProperties: ["^x-": .string]))
1081+
]
1082+
XCTAssertThrowsError(try patternPropertyDifference.combined(resolvingAgainst: .noComponents))
1083+
}
1084+
1085+
func test_ObjectPatternPropertiesCombine() throws {
1086+
let combined = try [
1087+
JSONSchema.object(patternProperties: ["^x-": .string]),
1088+
JSONSchema.object(patternProperties: ["^y-": .boolean])
1089+
].combined(resolvingAgainst: .noComponents)
1090+
1091+
XCTAssertEqual(combined.objectContext?.patternProperties["^x-"], .string(.init(), .init()))
1092+
XCTAssertEqual(combined.objectContext?.patternProperties["^y-"], .boolean(.init()))
10771093
}
10781094

10791095
// MARK: - Inconsistency Failures

0 commit comments

Comments
 (0)