Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
212 changes: 212 additions & 0 deletions Sources/Internal/JSONValue.swift
Original file line number Diff line number Diff line change
@@ -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))
}
}
69 changes: 69 additions & 0 deletions Sources/Shared/Toolkit/JSON.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
//

import Foundation
import ReadiumInternal

public enum JSONError: Error {
case parsing(Any.Type)
Expand Down Expand Up @@ -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<JSONValue> {
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<JSONValue?> {
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()
}
}
18 changes: 18 additions & 0 deletions Sources/Shared/Toolkit/UncheckedSendable.swift
Original file line number Diff line number Diff line change
@@ -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<T>: @unchecked Sendable {
public let value: T

public init(_ value: T) {
self.value = value
}
}
2 changes: 2 additions & 0 deletions Support/Carthage/.xcodegen
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions Support/Carthage/Readium.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */; };
Expand Down Expand Up @@ -518,6 +520,7 @@
067E58BE65BCB4F8D1E8B911 /* Loggable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Loggable.swift; sourceTree = "<group>"; };
06AD6A912937694B20AD54C9 /* Configurable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Configurable.swift; sourceTree = "<group>"; };
06C4BDFF128C774BCD660419 /* ReadiumCSS.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadiumCSS.swift; sourceTree = "<group>"; };
06D91EB0C7AC9E96019D36F0 /* JSONValue.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = JSONValue.swift; sourceTree = "<group>"; };
07B5469E40752E598C070E5B /* OPDSParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OPDSParser.swift; sourceTree = "<group>"; };
0812058DB4FBFDF0A862E57E /* KeyModifiers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyModifiers.swift; sourceTree = "<group>"; };
0885992D0F70AD0B493985CE /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/Localizable.strings; sourceTree = "<group>"; };
Expand Down Expand Up @@ -857,6 +860,7 @@
E233289C75C9F73E6E28DDB4 /* EPUBSpreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EPUBSpreadView.swift; sourceTree = "<group>"; };
E2D5DCD95C7B908BB6CA77C8 /* ResourceLicenseContainer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceLicenseContainer.swift; sourceTree = "<group>"; };
E37F94C388A86CB8A34812A5 /* CryptoSwift.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; name = CryptoSwift.xcframework; path = ../../Carthage/Build/CryptoSwift.xcframework; sourceTree = "<group>"; };
E49029874507A91EA6141EDD /* UncheckedSendable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UncheckedSendable.swift; sourceTree = "<group>"; };
E5D7B566F794F356878AE8E0 /* PDFOutlineNode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PDFOutlineNode.swift; sourceTree = "<group>"; };
E5DF154DCC73CFBDB0F919DE /* AbsoluteURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AbsoluteURL.swift; sourceTree = "<group>"; };
E6CB6D3B390CC927AE547A5C /* DebugError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugError.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1398,6 +1402,7 @@
isa = PBXGroup;
children = (
529B55BE6996FCDC1082BF0A /* JSON.swift */,
06D91EB0C7AC9E96019D36F0 /* JSONValue.swift */,
491E1402A31F88054442D58F /* Keychain.swift */,
4FEE01FAD273864D0908C358 /* Measure.swift */,
218BE3110D2886B252A769A2 /* UTI.swift */,
Expand Down Expand Up @@ -1909,6 +1914,7 @@
68FF131876FA3A63025F2662 /* Language.swift */,
5BC6AE42A31D77B548CB0BB4 /* Observable.swift */,
38984FD65CFF1D54FF7F794F /* ReadiumLocalizedString.swift */,
E49029874507A91EA6141EDD /* UncheckedSendable.swift */,
EE7B762C97CFC214997EC677 /* Weak.swift */,
371E5D46DEBBE58A793B2546 /* Archive */,
0B06420A6651D6D94BE937F3 /* Data */,
Expand Down Expand Up @@ -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 */,
Expand Down Expand Up @@ -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 */,
Expand Down