diff --git a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift index 1e146f1ff..d4a0bef8e 100644 --- a/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/DereferencedJSONSchema.swift @@ -340,6 +340,7 @@ extension DereferencedJSONSchema { public let maxProperties: Int? let _minProperties: Int? public let properties: OrderedDictionary + public let patternProperties: OrderedDictionary public let additionalProperties: Either? // NOTE that an object's required properties @@ -372,7 +373,16 @@ extension DereferencedJSONSchema { otherProperties[name] = dereferencedProperty } + var otherPatternProperties = OrderedDictionary() + for (pattern, property) in objectContext.patternProperties { + guard let dereferencedPatternProperty = property.dereferenced() else { + return nil + } + otherPatternProperties[pattern] = dereferencedPatternProperty + } + properties = otherProperties + patternProperties = otherPatternProperties maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { @@ -394,6 +404,7 @@ extension DereferencedJSONSchema { following references: Set ) throws { properties = try objectContext.properties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } + patternProperties = try objectContext.patternProperties.mapValues { try $0._dereferenced(in: components, following: references, dereferencedFromComponentNamed: nil) } maxProperties = objectContext.maxProperties _minProperties = objectContext._minProperties switch objectContext.additionalProperties { @@ -408,11 +419,13 @@ extension DereferencedJSONSchema { internal init( properties: OrderedDictionary, + patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, maxProperties: Int? = nil, minProperties: Int? = nil ) { self.properties = properties + self.patternProperties = patternProperties self.additionalProperties = additionalProperties self.maxProperties = maxProperties self._minProperties = minProperties @@ -431,6 +444,7 @@ extension DereferencedJSONSchema { return .init( properties: properties.mapValues { $0.jsonSchema }, + patternProperties: patternProperties.mapValues { $0.jsonSchema }, additionalProperties: underlyingAdditionalProperties, maxProperties: maxProperties, minProperties: _minProperties @@ -573,11 +587,15 @@ extension JSONSchema: ExternallyDereferenceable { try components.merge(c1) messages += m1 + let (newPatternProperties, c2, m2) = try await object.patternProperties.externallyDereferenced(with: loader) + try components.merge(c2) + messages += m2 + let newAdditionalProperties: Either? if case .b(let schema) = object.additionalProperties { - let (additionalProperties, c2, m2) = try await schema.externallyDereferenced(with: loader) - try components.merge(c2) - messages += m2 + let (additionalProperties, c3, m3) = try await schema.externallyDereferenced(with: loader) + try components.merge(c3) + messages += m3 newAdditionalProperties = .b(additionalProperties) } else { newAdditionalProperties = object.additionalProperties @@ -589,6 +607,7 @@ extension JSONSchema: ExternallyDereferenceable { core, .init( properties: newProperties, + patternProperties: newPatternProperties, additionalProperties: newAdditionalProperties, maxProperties: object.maxProperties, minProperties: object._minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift index cac3517a3..129888695 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema+Combining.swift @@ -528,6 +528,7 @@ extension JSONSchema.ArrayContext { extension JSONSchema.ObjectContext { internal func combined(with other: JSONSchema.ObjectContext, resolvingIn components: OpenAPI.Components) throws -> JSONSchema.ObjectContext { let combinedProperties = try combine(properties: properties, with: other.properties, resolvingIn: components) + let combinedPatternProperties = try combine(properties: patternProperties, with: other.patternProperties, resolvingIn: components) if let conflict = conflicting(maxProperties, other.maxProperties) { throw JSONSchemaResolutionError(.attributeConflict(jsonType: .object, name: "maxProperties", original: String(conflict.0), new: String(conflict.1))) @@ -559,6 +560,7 @@ extension JSONSchema.ObjectContext { let newAdditionalProperties = additionalProperties ?? other.additionalProperties return .init( properties: combinedProperties, + patternProperties: combinedPatternProperties, additionalProperties: newAdditionalProperties, maxProperties: newMaxProperties, minProperties: newMinProperties @@ -712,6 +714,7 @@ extension JSONSchema.ObjectContext { } return .init( properties: properties, + patternProperties: patternProperties, additionalProperties: additionalProperties, maxProperties: maxProperties, minProperties: _minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift index f6200e540..a4c02bedd 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchema.swift @@ -1614,6 +1614,7 @@ extension JSONSchema { minProperties: Int? = nil, maxProperties: Int? = nil, properties: OrderedDictionary = [:], + patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, allowedValues: [AnyCodable]? = nil, defaultValue: AnyCodable? = nil, @@ -1643,6 +1644,7 @@ extension JSONSchema { ) let objectContext = JSONSchema.ObjectContext( properties: properties, + patternProperties: patternProperties, additionalProperties: additionalProperties, maxProperties: maxProperties, minProperties: minProperties diff --git a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift index a517efe05..431e9148e 100644 --- a/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift +++ b/Sources/OpenAPIKit/Schema Object/JSONSchemaContext.swift @@ -782,6 +782,10 @@ extension JSONSchema { /// allows you to omit the property from encoding. public let additionalProperties: Either? + /// Schemas keyed by regular expressions that matching property names + /// must satisfy. + public let patternProperties: OrderedDictionary + /// The properties of this object that are required. /// /// - Note: An object's required properties array @@ -811,11 +815,13 @@ extension JSONSchema { public init( properties: OrderedDictionary, + patternProperties: OrderedDictionary = [:], additionalProperties: Either? = nil, maxProperties: Int? = nil, minProperties: Int? = nil ) { self.properties = properties + self.patternProperties = patternProperties self.additionalProperties = additionalProperties self.maxProperties = maxProperties self._minProperties = minProperties @@ -1260,6 +1266,7 @@ extension JSONSchema.ObjectContext { case maxProperties case minProperties case properties + case patternProperties case additionalProperties case required } @@ -1275,6 +1282,10 @@ extension JSONSchema.ObjectContext: Encodable { try container.encode(properties, forKey: .properties) } + if patternProperties.count > 0 { + try container.encode(patternProperties, forKey: .patternProperties) + } + try container.encodeIfPresent(additionalProperties, forKey: .additionalProperties) if !requiredProperties.isEmpty { @@ -1291,6 +1302,7 @@ extension JSONSchema.ObjectContext: Decodable { maxProperties = try container.decodeIfPresent(Int.self, forKey: .maxProperties) _minProperties = try container.decodeIfPresent(Int.self, forKey: .minProperties) + patternProperties = try container.decodeIfPresent(OrderedDictionary.self, forKey: .patternProperties) ?? [:] additionalProperties = try container.decodeIfPresent(Either.self, forKey: .additionalProperties) let requiredArray = try container.decodeIfPresent([String].self, forKey: .required) ?? [] diff --git a/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift b/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift index 3c06ab72f..a873d5989 100644 --- a/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift +++ b/Sources/OpenAPIKit/Schema Object/SimplifiedJSONSchema.swift @@ -108,6 +108,7 @@ extension DereferencedJSONSchema { core, .init( properties: try object.properties.mapValues { try $0.simplified() }, + patternProperties: try object.patternProperties.mapValues { try $0.simplified() }, additionalProperties: additionalProperties, maxProperties: object.maxProperties, minProperties: object._minProperties diff --git a/Tests/OpenAPIKitTests/JSONReferenceTests.swift b/Tests/OpenAPIKitTests/JSONReferenceTests.swift index fc0f5447d..ba4a47984 100644 --- a/Tests/OpenAPIKitTests/JSONReferenceTests.swift +++ b/Tests/OpenAPIKitTests/JSONReferenceTests.swift @@ -447,6 +447,34 @@ extension JSONReferenceTests { XCTAssertEqual(components, .init(schemas: ["__schema_json__components_schemas_test": .string])) XCTAssertEqual(messages, ["./schema.json#/components/schemas/test"]) } + + func test_externalDerefObjectPatternProperties() async throws { + let schema = JSONSchema.object( + patternProperties: ["^x-": .reference(.external(.init(string: "./pattern.json")!))], + additionalProperties: .init(.reference(.external(.init(string: "./additional.json")!))) + ) + + let (newSchema, components, messages) = try await schema.externallyDereferenced(with: SchemaLoader.self) + + XCTAssertEqual( + newSchema.objectContext?.patternProperties["^x-"], + .reference(.component(named: "__pattern_json")) + ) + XCTAssertEqual( + newSchema.objectContext?.additionalProperties?.schemaValue, + .reference(.component(named: "__additional_json")) + ) + XCTAssertEqual( + components, + .init( + schemas: [ + "__pattern_json": .string, + "__additional_json": .string + ] + ) + ) + XCTAssertEqual(messages, ["./pattern.json", "./additional.json"]) + } } // MARK: - Test Types diff --git a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift index f3bf0acf4..cc4caabf7 100644 --- a/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/DereferencedSchemaObjectTests.swift @@ -304,6 +304,12 @@ final class DereferencedSchemaObjectTests: XCTestCase { .boolean(.init()) ) + let tPattern = JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced() + XCTAssertEqual( + tPattern?.objectContext?.patternProperties["^x-"], + .string(.init(), .init()) + ) + let t3 = JSONSchema.object( properties: [ "required": .string, @@ -320,6 +326,48 @@ final class DereferencedSchemaObjectTests: XCTestCase { ) } + func test_optionalObjectContextPatternPropertiesRoundTrip() throws { + let context = try XCTUnwrap( + DereferencedJSONSchema.ObjectContext( + .init( + properties: ["fixed": .string], + patternProperties: ["^x-": .boolean], + additionalProperties: .init(.integer) + ) + ) + ) + + XCTAssertEqual( + context.patternProperties["^x-"], + .boolean(.init()) + ) + XCTAssertEqual( + context.additionalProperties?.schemaValue, + .integer(.init(), .init()) + ) + + let schema = DereferencedJSONSchema.object(.init(), context).jsonSchema + XCTAssertEqual( + schema.objectContext, + .init( + properties: ["fixed": .string], + patternProperties: ["^x-": .boolean], + additionalProperties: .init(.integer) + ) + ) + } + + func test_optionalObjectContextPatternPropertiesReferenceFailure() { + XCTAssertNil( + DereferencedJSONSchema.ObjectContext( + .init( + properties: [:], + patternProperties: ["^x-": .reference(.component(named: "missing"))] + ) + ) + ) + } + func test_throwingObjectWithoutReferences() throws { let components = OpenAPI.Components.noComponents let t1 = try JSONSchema.object(properties: ["test": .string]).dereferenced(in: components) @@ -335,6 +383,12 @@ final class DereferencedSchemaObjectTests: XCTestCase { .boolean(.init()) ) + let tPattern = try JSONSchema.object(patternProperties: ["^x-": .string]).dereferenced(in: components) + XCTAssertEqual( + tPattern.objectContext?.patternProperties["^x-"], + .string(.init(), .init()) + ) + let t3 = try JSONSchema.object( properties: [ "required": .string, @@ -367,6 +421,32 @@ final class DereferencedSchemaObjectTests: XCTestCase { XCTAssertThrowsError(try JSONSchema.object(properties: ["missing": .reference(.component(named: "missing"))]).dereferenced(in: components)) } + func test_simplifiedObjectWithPatternProperties() throws { + let simplified = try JSONSchema.object( + patternProperties: ["^x-": .all(of: [.string])], + additionalProperties: .init(.all(of: [.boolean])) + ) + .dereferenced(in: .noComponents) + .simplified() + + XCTAssertEqual( + simplified.objectContext?.patternProperties["^x-"], + .string(.init(), .init()) + ) + XCTAssertEqual( + simplified.objectContext?.additionalProperties?.schemaValue, + .boolean(.init()) + ) + XCTAssertEqual( + simplified.jsonSchema.objectContext, + .init( + properties: [:], + patternProperties: ["^x-": .string], + additionalProperties: .init(.boolean) + ) + ) + } + func test_optionalArrayWithoutReferences() { let t1 = JSONSchema.array(items: .boolean).dereferenced() XCTAssertEqual( diff --git a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift index 06c0ab59e..261f22469 100644 --- a/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/JSONSchemaTests.swift @@ -3237,6 +3237,55 @@ extension SchemaObjectTests { XCTAssertEqual(contextB, .init(properties: ["hello": .boolean(.init(format: .generic, required: false))], additionalProperties: .init(.string))) } + func test_encodeObjectWithPatternProperties() { + let object = JSONSchema.object( + .init(format: .unspecified, required: true), + .init( + properties: ["hello": .boolean(.init(format: .unspecified, required: false))], + patternProperties: ["^x-": .string(required: false)] + ) + ) + + testEncodingPropertyLines(entity: object, + propertyLines: [ + "\"patternProperties\" : {", + " \"^x-\" : {", + " \"type\" : \"string\"", + " }", + "},", + "\"properties\" : {", + " \"hello\" : {", + " \"type\" : \"boolean\"", + " }", + "},", + "\"type\" : \"object\"" + ]) + } + + func test_decodeObjectWithPatternProperties() { + let objectData = """ + { + "patternProperties": { + "^x-": { "type": "string" } + }, + "type": "object" + } + """.data(using: .utf8)! + + let object = try! orderUnstableDecode(JSONSchema.self, from: objectData) + + XCTAssertEqual( + object, + JSONSchema.object( + .init(format: .generic), + .init( + properties: [:], + patternProperties: ["^x-": .string] + ) + ) + ) + } + func test_encodeObjectWithExample() { let string = try! JSONSchema.string(.init(format: .unspecified, required: true), .init()) .with(example: "hello") diff --git a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift index f4662cb81..21657a672 100644 --- a/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift +++ b/Tests/OpenAPIKitTests/Schema Object/SchemaFragmentCombiningTests.swift @@ -1074,6 +1074,22 @@ final class SchemaFragmentCombiningTests: XCTestCase { XCTAssert(error ~= .attributeConflict, "\(error) is not ~= `.attributeConflict` -- \(fragments)") } } + + let patternPropertyDifference: [JSONSchema] = [ + .object(.init(), .init(properties: [:], patternProperties: ["^x-": .boolean])), + .object(.init(), .init(properties: [:], patternProperties: ["^x-": .string])) + ] + XCTAssertThrowsError(try patternPropertyDifference.combined(resolvingAgainst: .noComponents)) + } + + func test_ObjectPatternPropertiesCombine() throws { + let combined = try [ + JSONSchema.object(patternProperties: ["^x-": .string]), + JSONSchema.object(patternProperties: ["^y-": .boolean]) + ].combined(resolvingAgainst: .noComponents) + + XCTAssertEqual(combined.objectContext?.patternProperties["^x-"], .string(.init(), .init())) + XCTAssertEqual(combined.objectContext?.patternProperties["^y-"], .boolean(.init())) } // MARK: - Inconsistency Failures