diff --git a/Sources/Internal/JSONValue.swift b/Sources/Internal/JSONValue.swift new file mode 100644 index 0000000000..5ff964553a --- /dev/null +++ b/Sources/Internal/JSONValue.swift @@ -0,0 +1,212 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation +#if canImport(CoreFoundation) + import CoreFoundation +#endif + +/// A type-safe JSON value. +/// +/// This enum is used to represent JSON values in a type-safe way, avoiding the +/// use of `any Sendable` or `Any`. It guarantees that the value is Sendable and +/// Hashable. +public enum JSONValue: Equatable, Sendable, Hashable { + case null + case bool(Bool) + case string(String) + case integer(Int) + case uint64(UInt64) + case double(Double) + case array([JSONValue]) + case object([String: JSONValue]) + + /// Initializes a `JSONValue` from an `Any` value. + /// + /// This initializer attempts to convert the given value to a `JSONValue`. + /// It handles nested arrays and dictionaries recursively. + public init?(_ value: Any?) { + guard let value = value else { + return nil + } + + if let value = value as? JSONValue { + self = value + return + } + + // Fast path for typed collections + if let object = value as? [String: JSONValue] { + self = .object(object) + return + } + if let array = value as? [JSONValue] { + self = .array(array) + return + } + + // Check for specific types + if let string = value as? String { + self = .string(string) + return + } + + #if canImport(CoreFoundation) + // On platforms with CoreFoundation (Apple), NSNumber bridges from Bool, Int, Double. + if let number = value as? NSNumber { + if CFGetTypeID(number) == CFBooleanGetTypeID() { + self = .bool(number.boolValue) + return + } + if CFNumberIsFloatType(number) { + self = .double(number.doubleValue) + return + } + if let int = Int(exactly: number) { + self = .integer(int) + } else if let uint = UInt64(exactly: number) { + self = .uint64(uint) + } else { + self = .integer(Int(truncating: number)) + } + return + } + #endif + + // Fallback for platforms without CoreFoundation or if value isn't NSNumber + if let bool = value as? Bool { + self = .bool(bool) + } else if let int = value as? Int { + self = .integer(int) + } else if let uint = value as? UInt64 { + self = .uint64(uint) + } else if let double = value as? Double { + self = .double(double) + } else if let array = value as? [Any] { + self = .array(array.compactMap { JSONValue($0) }) + } else if let dict = value as? [String: Any] { + var object: [String: JSONValue] = [:] + for (key, val) in dict { + if let jsonVal = JSONValue(val) { + object[key] = jsonVal + } + } + self = .object(object) + } else if value is NSNull { + self = .null + } else { + return nil + } + } + + /// Returns the raw value as `Any`. + /// + /// This property is useful for interoperability with APIs that expect + /// standard Swift types (e.g., `JSONSerialization`). + public var any: Any { + switch self { + case .null: + return NSNull() + case let .bool(value): + return value + case let .string(value): + return value + case let .integer(value): + return value + case let .uint64(value): + return value + case let .double(value): + return value + case let .array(value): + return value.map(\.any) + case let .object(value): + return value.mapValues(\.any) + } + } + + public var bool: Bool? { + if case let .bool(v) = self { return v } + return nil + } + + public var string: String? { + if case let .string(v) = self { return v } + return nil + } + + public var integer: Int? { + if case let .integer(v) = self { return v } + if case let .uint64(v) = self { return Int(exactly: v) } + return nil + } + + public var uint64: UInt64? { + if case let .uint64(v) = self { return v } + if case let .integer(v) = self { return UInt64(exactly: v) } + return nil + } + + public var double: Double? { + if case let .double(v) = self { return v } + if case let .integer(v) = self { return Double(v) } + if case let .uint64(v) = self { return Double(v) } + return nil + } + + public var array: [JSONValue]? { + if case let .array(v) = self { return v } + return nil + } + + public var object: [String: JSONValue]? { + if case let .object(v) = self { return v } + return nil + } +} + +// MARK: - ExpressibleByLiteral Conformance + +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { + self = .null + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .integer(value) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .double(value) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} diff --git a/Sources/Shared/Toolkit/JSON.swift b/Sources/Shared/Toolkit/JSON.swift index 9f2bbeb019..61d151e644 100644 --- a/Sources/Shared/Toolkit/JSON.swift +++ b/Sources/Shared/Toolkit/JSON.swift @@ -5,6 +5,7 @@ // import Foundation +import ReadiumInternal public enum JSONError: Error { case parsing(Any.Type) @@ -61,3 +62,71 @@ public extension JSONEquatable { serializeJSONString(json) ?? String(describing: self) } } + +// MARK: - ReadResult Extensions + +public extension Result where Success == JSONValue, Failure == ReadError { + /// Decodes the JSON value as a JSON object. + func asJSONObject() -> ReadResult<[String: JSONValue]> { + flatMap { + guard case let .object(dict) = $0 else { + return .failure(.decoding(JSONError.parsing([String: JSONValue].self))) + } + return .success(dict) + } + } +} + +public extension Result where Success == JSONValue?, Failure == ReadError { + /// Decodes the JSON value as a JSON object. + func asJSONObject() -> ReadResult<[String: JSONValue]?> { + map { $0?.object } + } +} + +public extension Result where Success == Data, Failure == ReadError { + /// Decodes the data as a JSON value. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + flatMap { data in + do { + let json = try JSONSerialization.jsonObject(with: data, options: options) + guard let value = JSONValue(json) else { + return .failure(.decoding(JSONError.parsing(JSONValue.self))) + } + return .success(value) + } catch { + return .failure(.decoding(error)) + } + } + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: JSONValue]> { + asJSONValue(options: options).asJSONObject() + } +} + +public extension Result where Success == Data?, Failure == ReadError { + /// Decodes the data as a JSON value. + func asJSONValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult { + flatMap { data in + guard let data = data else { + return .success(nil) + } + do { + let json = try JSONSerialization.jsonObject(with: data, options: options) + guard let value = JSONValue(json) else { + return .failure(.decoding(JSONError.parsing(JSONValue.self))) + } + return .success(value) + } catch { + return .failure(.decoding(error)) + } + } + } + + /// Decodes the data as a JSON object. + func asJSONObjectValue(options: JSONSerialization.ReadingOptions = []) -> ReadResult<[String: JSONValue]?> { + asJSONValue(options: options).asJSONObject() + } +} diff --git a/Sources/Shared/Toolkit/UncheckedSendable.swift b/Sources/Shared/Toolkit/UncheckedSendable.swift new file mode 100644 index 0000000000..0ee82780df --- /dev/null +++ b/Sources/Shared/Toolkit/UncheckedSendable.swift @@ -0,0 +1,18 @@ +// +// Copyright 2026 Readium Foundation. All rights reserved. +// Use of this source code is governed by the BSD-style license +// available in the top-level LICENSE file of the project. +// + +import Foundation + +/// A wrapper to force a value to be `Sendable`. +/// +/// **Warning**: Use this wrapper only if you are sure that the value is thread-safe. +public struct UncheckedSendable: @unchecked Sendable { + public let value: T + + public init(_ value: T) { + self.value = value + } +} diff --git a/Support/Carthage/.xcodegen b/Support/Carthage/.xcodegen index c36b6312c0..113dd58282 100644 --- a/Support/Carthage/.xcodegen +++ b/Support/Carthage/.xcodegen @@ -327,6 +327,7 @@ ../../Sources/Internal/Extensions/UInt64.swift ../../Sources/Internal/Extensions/URL.swift ../../Sources/Internal/JSON.swift +../../Sources/Internal/JSONValue.swift ../../Sources/Internal/Keychain.swift ../../Sources/Internal/Measure.swift ../../Sources/Internal/UTI.swift @@ -800,6 +801,7 @@ ../../Sources/Shared/Toolkit/Tokenizer ../../Sources/Shared/Toolkit/Tokenizer/TextTokenizer.swift ../../Sources/Shared/Toolkit/Tokenizer/Tokenizer.swift +../../Sources/Shared/Toolkit/UncheckedSendable.swift ../../Sources/Shared/Toolkit/URL ../../Sources/Shared/Toolkit/URL/Absolute URL ../../Sources/Shared/Toolkit/URL/Absolute URL/AbsoluteURL.swift diff --git a/Support/Carthage/Readium.xcodeproj/project.pbxproj b/Support/Carthage/Readium.xcodeproj/project.pbxproj index 14cdf8562b..1170b06513 100644 --- a/Support/Carthage/Readium.xcodeproj/project.pbxproj +++ b/Support/Carthage/Readium.xcodeproj/project.pbxproj @@ -175,6 +175,7 @@ 674BEEF110667C3051296E9B /* Double.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F3481F848A616A9A825A4BD /* Double.swift */; }; 67F1C7C3D434D2AA542376E3 /* PublicationParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = F609C27F073E40D662CFE093 /* PublicationParser.swift */; }; 682DFC1AF2BD7CAE0862B331 /* CryptoSwift.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E37F94C388A86CB8A34812A5 /* CryptoSwift.xcframework */; }; + 685388EA258ACA6C80979D85 /* UncheckedSendable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E49029874507A91EA6141EDD /* UncheckedSendable.swift */; }; 689BD78B3A08D8934F441033 /* FileSystemError.swift in Sources */ = {isa = PBXBuildFile; fileRef = C96FD34093B3C3E83827B70C /* FileSystemError.swift */; }; 694AAAD5C14BC33891458A4C /* DataCompression.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACB32E55E1F3CAF1737979CC /* DataCompression.swift */; }; 69AA254E4A39D9B49FDFD648 /* UserKey.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC96A56AB406203898059B6C /* UserKey.swift */; }; @@ -379,6 +380,7 @@ E6554CEB01222DA2255163D5 /* AVTTSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = B15C9123EA383ED81DE0393A /* AVTTSEngine.swift */; }; E6AC10CCF9711168BE2BE85C /* StringSearchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9E3543F628B017E9BF65DD08 /* StringSearchService.swift */; }; E75D342B54BD25242A29B105 /* Publication+EPUB.swift in Sources */ = {isa = PBXBuildFile; fileRef = 508E0CD4F9F02CC851E6D1E1 /* Publication+EPUB.swift */; }; + E766A54C09294FDF5A827930 /* JSONValue.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06D91EB0C7AC9E96019D36F0 /* JSONValue.swift */; }; E782980A49279BCB0C70C24C /* OPDSCopies.swift in Sources */ = {isa = PBXBuildFile; fileRef = 819D931708B3EE95CF9ADFED /* OPDSCopies.swift */; }; E8293787CB5E5CECE38A63B2 /* Encryption.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54699BC0E00F327E67908F6A /* Encryption.swift */; }; E8C3B837B9FB2ABCB5F82380 /* Measure.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4FEE01FAD273864D0908C358 /* Measure.swift */; }; @@ -518,6 +520,7 @@ 067E58BE65BCB4F8D1E8B911 /* Loggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loggable.swift; sourceTree = ""; }; 06AD6A912937694B20AD54C9 /* Configurable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configurable.swift; sourceTree = ""; }; 06C4BDFF128C774BCD660419 /* ReadiumCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumCSS.swift; sourceTree = ""; }; + 06D91EB0C7AC9E96019D36F0 /* JSONValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValue.swift; sourceTree = ""; }; 07B5469E40752E598C070E5B /* OPDSParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSParser.swift; sourceTree = ""; }; 0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifiers.swift; sourceTree = ""; }; 0885992D0F70AD0B493985CE /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = ""; }; @@ -857,6 +860,7 @@ E233289C75C9F73E6E28DDB4 /* EPUBSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBSpreadView.swift; sourceTree = ""; }; E2D5DCD95C7B908BB6CA77C8 /* ResourceLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLicenseContainer.swift; sourceTree = ""; }; E37F94C388A86CB8A34812A5 /* CryptoSwift.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = CryptoSwift.xcframework; path = ../../Carthage/Build/CryptoSwift.xcframework; sourceTree = ""; }; + E49029874507A91EA6141EDD /* UncheckedSendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UncheckedSendable.swift; sourceTree = ""; }; E5D7B566F794F356878AE8E0 /* PDFOutlineNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFOutlineNode.swift; sourceTree = ""; }; E5DF154DCC73CFBDB0F919DE /* AbsoluteURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsoluteURL.swift; sourceTree = ""; }; E6CB6D3B390CC927AE547A5C /* DebugError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugError.swift; sourceTree = ""; }; @@ -1398,6 +1402,7 @@ isa = PBXGroup; children = ( 529B55BE6996FCDC1082BF0A /* JSON.swift */, + 06D91EB0C7AC9E96019D36F0 /* JSONValue.swift */, 491E1402A31F88054442D58F /* Keychain.swift */, 4FEE01FAD273864D0908C358 /* Measure.swift */, 218BE3110D2886B252A769A2 /* UTI.swift */, @@ -1909,6 +1914,7 @@ 68FF131876FA3A63025F2662 /* Language.swift */, 5BC6AE42A31D77B548CB0BB4 /* Observable.swift */, 38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */, + E49029874507A91EA6141EDD /* UncheckedSendable.swift */, EE7B762C97CFC214997EC677 /* Weak.swift */, 371E5D46DEBBE58A793B2546 /* Archive */, 0B06420A6651D6D94BE937F3 /* Data */, @@ -2584,6 +2590,7 @@ 330690F62A5F240B77A14337 /* Date+ISO8601.swift in Sources */, 674BEEF110667C3051296E9B /* Double.swift in Sources */, DDD0C8AC27EF8D1A893DF6CC /* JSON.swift in Sources */, + E766A54C09294FDF5A827930 /* JSONValue.swift in Sources */, C5D80E7716B243980FD3DFE6 /* Keychain.swift in Sources */, E8C3B837B9FB2ABCB5F82380 /* Measure.swift in Sources */, F631EA324143E669070523F3 /* NSRegularExpression.swift in Sources */, @@ -2847,6 +2854,7 @@ D4BBC0AD7652265497B5CD1C /* URLExtensions.swift in Sources */, 1600DB04CEACF97EE8AD9CEE /* URLProtocol.swift in Sources */, 222E5BC7A9E632DD6BB9A78E /* URLQuery.swift in Sources */, + 685388EA258ACA6C80979D85 /* UncheckedSendable.swift in Sources */, A1B834459A13B655624E6618 /* UnknownAbsoluteURL.swift in Sources */, A25F76D41A944B81CB911A63 /* UserRights.swift in Sources */, 3ECB525CEB712CEC5EFCD26D /* WarningLogger.swift in Sources */,