diff --git a/Sources/JSONSchema/Utilities/LockIsolated.swift b/Sources/JSONSchema/Utilities/LockIsolated.swift new file mode 100644 index 00000000..3f3e6422 --- /dev/null +++ b/Sources/JSONSchema/Utilities/LockIsolated.swift @@ -0,0 +1,24 @@ +import Foundation + +/// A thread-safe wrapper that uses a lock to protect access to a mutable value. +/// +/// This type provides a simple way to make a value safe to access from multiple threads +/// by wrapping it in a lock. Access to the value is controlled through the `withLock` method. +final class LockIsolated: @unchecked Sendable { + private var value: Value + private let lock = NSRecursiveLock() + + init(_ value: @autoclosure @Sendable () throws -> Value) rethrows { + self.value = try value() + } + + func withLock( + _ operation: @Sendable (inout Value) throws -> T + ) rethrows -> T { + lock.lock() + defer { lock.unlock() } + var value = self.value + defer { self.value = value } + return try operation(&value) + } +} diff --git a/Sources/JSONSchema/Validation/Context.swift b/Sources/JSONSchema/Validation/Context.swift index 0127676d..e81e9eb2 100644 --- a/Sources/JSONSchema/Validation/Context.swift +++ b/Sources/JSONSchema/Validation/Context.swift @@ -2,24 +2,57 @@ import Foundation /// Container for information used when validating a schema. public final class Context: Sendable { - var dialect: Dialect + private let lockedDialect: LockIsolated + var dialect: Dialect { + get { lockedDialect.withLock { $0 } } + set { lockedDialect.withLock { $0 = newValue } } + } - var rootRawSchema: JSONValue? + private let lockedRootRawSchema: LockIsolated + var rootRawSchema: JSONValue? { + get { lockedRootRawSchema.withLock { $0 } } + set { lockedRootRawSchema.withLock { $0 = newValue } } + } - var identifierRegistry: [URL: JSONPointer] = [:] + private let lockedIdentifierRegistry: LockIsolated<[URL: JSONPointer]> + var identifierRegistry: [URL: JSONPointer] { + get { lockedIdentifierRegistry.withLock { $0 } } + set { lockedIdentifierRegistry.withLock { $0 = newValue } } + } - var remoteSchemaStorage: [String: JSONValue] = [:] - var schemaCache = [String: Schema]() + private let lockedRemoteSchemaStorage: LockIsolated<[String: JSONValue]> + var remoteSchemaStorage: [String: JSONValue] { + get { lockedRemoteSchemaStorage.withLock { $0 } } + set { lockedRemoteSchemaStorage.withLock { $0 = newValue } } + } - var anchors = [URL: JSONPointer]() + private let lockedSchemaCache: LockIsolated<[String: Schema]> + var schemaCache: [String: Schema] { + get { lockedSchemaCache.withLock { $0 } } + set { lockedSchemaCache.withLock { $0 = newValue } } + } + + private let lockedAnchors: LockIsolated<[URL: JSONPointer]> + var anchors: [URL: JSONPointer] { + get { lockedAnchors.withLock { $0 } } + set { lockedAnchors.withLock { $0 = newValue } } + } /// Stack of dynamic scopes used to resolve ``$dynamicRef`` references. /// Each scope maps an anchor name to the schema location pointer and its /// associated base URI. - var dynamicScopes: [[String: (pointer: JSONPointer, baseURI: URL)]] = [] + private let lockedDynamicScopes: LockIsolated<[[String: (pointer: JSONPointer, baseURI: URL)]]> + var dynamicScopes: [[String: (pointer: JSONPointer, baseURI: URL)]] { + get { lockedDynamicScopes.withLock { $0 } } + set { lockedDynamicScopes.withLock { $0 = newValue } } + } /// Validators used when the ``Keywords.Format`` keyword is present. - var formatValidators: [String: any FormatValidator] = [:] + private let lockedFormatValidators: LockIsolated<[String: any FormatValidator]> + var formatValidators: [String: any FormatValidator] { + get { lockedFormatValidators.withLock { $0 } } + set { lockedFormatValidators.withLock { $0 = newValue } } + } /// A dictionary that tracks whether the `minContains` constraint is effectively zero /// for specific schema locations. @@ -30,7 +63,11 @@ public final class Context: Sendable { /// at the specified schema location. A value of `true` means that the constraint /// is effectively zero, allowing for validation to pass even if no instances match /// the `contains` keyword. - var minContainsIsZero = [JSONPointer: Bool]() + private let lockedMinContainsIsZero: LockIsolated<[JSONPointer: Bool]> + var minContainsIsZero: [JSONPointer: Bool] { + get { lockedMinContainsIsZero.withLock { $0 } } + set { lockedMinContainsIsZero.withLock { $0 = newValue } } + } /// A dictionary that stores the results of conditional validations within a schema. /// @@ -38,17 +75,28 @@ public final class Context: Sendable { /// path to the "if", "else", or "then" conditions. /// - Value: An optional `ValidationResult` that represents the outcome of the /// conditional validation at the specified schema location. - var ifConditionalResults = [JSONPointer: ValidationResult]() + private let lockedIfConditionalResults: LockIsolated<[JSONPointer: ValidationResult]> + var ifConditionalResults: [JSONPointer: ValidationResult] { + get { lockedIfConditionalResults.withLock { $0 } } + set { lockedIfConditionalResults.withLock { $0 = newValue } } + } public init( dialect: Dialect, remoteSchema: [String: JSONValue] = [:], formatValidators: [any FormatValidator] = [] ) { - self.dialect = dialect - self.remoteSchemaStorage = remoteSchema - self.formatValidators = Dictionary( - uniqueKeysWithValues: formatValidators.map { ($0.formatName, $0) } + self.lockedDialect = LockIsolated(dialect) + self.lockedRootRawSchema = LockIsolated(nil) + self.lockedIdentifierRegistry = LockIsolated([:]) + self.lockedRemoteSchemaStorage = LockIsolated(remoteSchema) + self.lockedSchemaCache = LockIsolated([:]) + self.lockedAnchors = LockIsolated([:]) + self.lockedDynamicScopes = LockIsolated([]) + self.lockedFormatValidators = LockIsolated( + Dictionary(uniqueKeysWithValues: formatValidators.map { ($0.formatName, $0) }) ) + self.lockedMinContainsIsZero = LockIsolated([:]) + self.lockedIfConditionalResults = LockIsolated([:]) } } diff --git a/Sources/JSONSchemaBuilder/Builders/JSONPropertySchemaBuilder.swift b/Sources/JSONSchemaBuilder/Builders/JSONPropertySchemaBuilder.swift index 36c50127..c8ff8033 100644 --- a/Sources/JSONSchemaBuilder/Builders/JSONPropertySchemaBuilder.swift +++ b/Sources/JSONSchemaBuilder/Builders/JSONPropertySchemaBuilder.swift @@ -33,7 +33,7 @@ import JSONSchema } } -public protocol PropertyCollection: Sendable { +public protocol PropertyCollection { associatedtype Output var schemaValue: SchemaValue { get } diff --git a/Sources/JSONSchemaBuilder/JSONComponent/JSONSchema.swift b/Sources/JSONSchemaBuilder/JSONComponent/JSONSchema.swift index 794aae66..4c003446 100644 --- a/Sources/JSONSchemaBuilder/JSONComponent/JSONSchema.swift +++ b/Sources/JSONSchemaBuilder/JSONComponent/JSONSchema.swift @@ -8,7 +8,7 @@ public struct JSONSchema: JSONSchema set { components.schemaValue = newValue } } - let transform: @Sendable (Components.Output) -> NewOutput + let transform: (Components.Output) -> NewOutput var components: Components @@ -17,7 +17,7 @@ public struct JSONSchema: JSONSchema /// - transform: The transform to apply to the output. /// - component: The components to group together. public init( - _ transform: @Sendable @escaping (Components.Output) -> NewOutput, + _ transform: @escaping (Components.Output) -> NewOutput, @JSONSchemaBuilder component: () -> Components ) { self.transform = transform diff --git a/Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent.swift b/Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent.swift index 907b6b04..bfea24cb 100644 --- a/Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent.swift +++ b/Sources/JSONSchemaBuilder/JSONComponent/JSONSchemaComponent.swift @@ -2,7 +2,7 @@ import Foundation import JSONSchema /// A component for use in ``JSONSchemaBuilder`` to build, annotate, and validate schemas. -public protocol JSONSchemaComponent: Sendable { +public protocol JSONSchemaComponent { associatedtype Output var schemaValue: SchemaValue { get set } @@ -10,7 +10,7 @@ public protocol JSONSchemaComponent: Sendable { /// Parse a JSON instance into a Swift type using the schema. /// - Parameter value: The value (aka instance or document) to validate. /// - Returns: A validated output or error messages. - @Sendable func parse(_ value: JSONValue) -> Parsed + func parse(_ value: JSONValue) -> Parsed } extension JSONSchemaComponent { diff --git a/Sources/JSONSchemaBuilder/JSONComponent/Modifier/AnySchemaComponent.swift b/Sources/JSONSchemaBuilder/JSONComponent/Modifier/AnySchemaComponent.swift index bfc3375d..1a1475de 100644 --- a/Sources/JSONSchemaBuilder/JSONComponent/Modifier/AnySchemaComponent.swift +++ b/Sources/JSONSchemaBuilder/JSONComponent/Modifier/AnySchemaComponent.swift @@ -9,7 +9,7 @@ extension JSONSchemaComponent { extension JSONComponents { /// Component for type erasure. public struct AnySchemaComponent: JSONSchemaComponent { - private let validate: @Sendable (JSONValue) -> Parsed + private let validate: (JSONValue) -> Parsed public var schemaValue: SchemaValue public init(_ component: Component) diff --git a/Sources/JSONSchemaBuilder/JSONPropertyComponent/JSONPropertyComponent.swift b/Sources/JSONSchemaBuilder/JSONPropertyComponent/JSONPropertyComponent.swift index b7cce6a3..1af24eb9 100644 --- a/Sources/JSONSchemaBuilder/JSONPropertyComponent/JSONPropertyComponent.swift +++ b/Sources/JSONSchemaBuilder/JSONPropertyComponent/JSONPropertyComponent.swift @@ -2,7 +2,7 @@ import JSONSchema /// A component that represents a JSON property. /// Used in the ``JSONObject/init(with:)`` initializer to define the properties of an object schema. -public protocol JSONPropertyComponent: Sendable { +public protocol JSONPropertyComponent { associatedtype Value: JSONSchemaComponent associatedtype Output