Update JSON usage from Any to JSONValue#740
Update JSON usage from Any to JSONValue#740stevenzeck wants to merge 14 commits intoreadium:developfrom
Conversation
Renamed to avoid conflicts with the existing JSON.swift file in Shared
There was a problem hiding this comment.
Pull request overview
This PR migrates JSON handling across the Readium Swift codebase from untyped Any / [String: Any] to the type-safe JSONValue, and relocates/renames the JSONDictionary helper into ReadiumShared to support Equatable/Hashable JSON-backed models.
Changes:
- Introduces
JSONDictionary(now backed by[String: JSONValue]) plus updated parsing/encoding helpers, and removes the old internalJSON.swift. - Updates core publication models, parsers, and format sniffers to parse/serialize via
JSONValue(with compatibilityinit(json: Any?)kept in many places). - Updates test fixtures and assertion helpers to work with
JSONValue-based JSON structures and normalization.
Reviewed changes
Copilot reviewed 75 out of 75 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
| Tests/StreamerTests/Parser/Image/ComicInfoParserTests.swift | Adjusts test expectations for otherMetadata to use JSONValue accessors. |
| Tests/StreamerTests/Parser/EPUB/Services/EPUBPositionsServiceTests.swift | Updates test helper properties to build JSONValue objects/arrays. |
| Tests/StreamerTests/Parser/EPUB/EPUBMetadataParserTests.swift | Rewrites metadata fixtures to use .object/.array/.string JSONValue literals. |
| Tests/StreamerTests/Parser/EPUB/EPUBManifestParserTests.swift | Rewrites manifest fixtures to use JSONValue. |
| Tests/StreamerTests/Asserts.swift | Normalizes Any via JSONValue(...).any before JSONSerialization compare. |
| Tests/SharedTests/Publication/Services/Content/Iterators/HTMLResourceContentIteratorTests.swift | Updates locator otherLocations test data to .string. |
| Tests/SharedTests/Publication/LocatorTests.swift | Updates minimal JSON expectation to align with new encoding rules. |
| Tests/SharedTests/Publication/Extensions/OPDS/Properties+OPDSTests.swift | Removes legacy [String: Any] cast in test fixture. |
| Tests/SharedTests/Publication/Extensions/HTML/Locator+HTMLTests.swift | Removes legacy [String: Any] cast in test fixture. |
| Tests/SharedTests/Publication/Extensions/EPUB/Metadata+EPUBTests.swift | Updates EPUBMediaOverlay JSON fixtures to new typed JSON forms. |
| Tests/SharedTests/Publication/Extensions/Archive/Properties+ArchiveTests.swift | Removes legacy [String: Any] cast in test fixture. |
| Tests/SharedTests/Asserts.swift | Updates file default and normalizes Any via JSONValue before compare. |
| Support/Carthage/Readium.xcodeproj/project.pbxproj | Moves JSONDictionary.swift into Shared target sources; removes Internal JSON.swift. |
| Support/Carthage/.xcodegen | Updates XcodeGen inputs to reflect JSONDictionary move/rename. |
| Sources/Streamer/Parser/Readium/ReadiumWebPubParser.swift | Switches manifest decoding to asJSONObjectValue() and wraps into .object. |
| Sources/Streamer/Parser/Readium/ReadiumGuidedNavigationService.swift | Switches JSON decoding to asJSONObjectValue() and wraps into .object. |
| Sources/Streamer/Parser/Image/ComicInfoParser.swift | Encodes ComicInfo otherMetadata as [String: JSONValue] strings. |
| Sources/Streamer/Parser/EPUB/OPFParser.swift | Encodes properties/encryption using JSONValue objects/arrays. |
| Sources/Streamer/Parser/EPUB/OPFMeta.swift | Converts unknown metadata aggregation to [String: JSONValue]. |
| Sources/Streamer/Parser/EPUB/EPUBMetadataParser.swift | Wraps mediaOverlay JSON into .object in otherMetadata. |
| Sources/Shared/Toolkit/JSONDictionary.swift | Adds new Shared JSONDictionary + parse/encode helper APIs using JSONValue. |
| Sources/Shared/Toolkit/JSON.swift | Updates serialization + JSONEquatable to better interop with JSONValue. |
| Sources/Shared/Toolkit/Format/Sniffers/RWPMFormatSniffer.swift | Uses asJSONObjectValue() and wraps into .object for Manifest init. |
| Sources/Shared/Toolkit/Format/Sniffers/RPFFormatSniffer.swift | Uses asJSONObjectValue() and wraps into .object for Manifest init. |
| Sources/Shared/Toolkit/Format/Sniffers/OPDSFormatSniffer.swift | Uses asJSONObjectValue() and wraps into .object for Manifest init. |
| Sources/Shared/Toolkit/Format/Sniffers/LCPLicenseFormatSniffer.swift | Adjusts JSON reading path and guards with updated optional handling. |
| Sources/Shared/Toolkit/Format/Sniffers/JSONFormatSniffer.swift | Updates to reflect readAsJSON() now yielding JSONValue?. |
| Sources/Shared/Toolkit/Format/FormatSnifferBlob.swift | Changes cached JSON from Any? to JSONValue?. |
| Sources/Shared/Toolkit/Archive/ArchiveProperties.swift | Migrates ArchiveProperties JSON parsing/encoding to JSONValue. |
| Sources/Shared/Publication/TDM.swift | Migrates TDM JSON parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Subject.swift | Migrates Subject parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Services/Positions/PositionsService.swift | Emits/consumes positions JSON using JSONValue and asJSONObjectValue(). |
| Sources/Shared/Publication/Services/Content/Iterators/HTMLResourceContentIterator.swift | Makes locator otherLocations cssSelector explicitly typed JSONValue. |
| Sources/Shared/Publication/PublicationCollection.swift | Migrates collection JSON parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Publication.swift | Adds init(json: JSONValue) and bridges Any to JSONValue. |
| Sources/Shared/Publication/Properties.swift | Migrates properties storage and subscript to JSONValue. |
| Sources/Shared/Publication/Metadata.swift | Migrates metadata parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Manifest.swift | Migrates manifest parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Locator.swift | Migrates locator + nested types parsing/encoding to JSONValue with Any convenience inits. |
| Sources/Shared/Publication/LocalizedString.swift | Migrates LocalizedString parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/LinkRelation.swift | Migrates LinkRelation array parsing to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Link.swift | Migrates Link parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/GuidedNavigation/GuidedNavigationObject.swift | Migrates guided navigation object parsing to JSONValue with Any convenience init. |
| Sources/Shared/Publication/GuidedNavigation/GuidedNavigationDocument.swift | Migrates guided navigation document parsing to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Extensions/Presentation/Properties+Presentation.swift | Updates property access to JSONValue.bool. |
| Sources/Shared/Publication/Extensions/Presentation/Presentation.swift | Migrates Presentation parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Extensions/HTML/Locator+HTML.swift | Updates otherLocations access to JSONValue.string. |
| Sources/Shared/Publication/Extensions/HTML/DOMRange.swift | Migrates DOMRange parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Extensions/Encryption/Encryption.swift | Migrates Encryption parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Extensions/EPUB/EPUBMediaOverlay.swift | Migrates EPUBMediaOverlay parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift | Migrates archive properties parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Contributor.swift | Migrates Contributor parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/Publication/Accessibility/Accessibility.swift | Migrates Accessibility parsing/encoding to JSONValue with Any convenience init. |
| Sources/Shared/OPDS/OPDSPrice.swift | Migrates OPDSPrice parsing/encoding to JSONValue. |
| Sources/Shared/OPDS/OPDSHolds.swift | Migrates OPDSHolds parsing to JSONValue-backed JSONDictionary. |
| Sources/Shared/OPDS/OPDSCopies.swift | Migrates OPDSCopies parsing to JSONValue-backed JSONDictionary. |
| Sources/Shared/OPDS/OPDSAvailability.swift | Migrates OPDSAvailability parsing to JSONValue-backed JSONDictionary. |
| Sources/Shared/OPDS/OPDSAcquisition.swift | Migrates OPDSAcquisition encoding to JSONValue and adjusts parsing. |
| Sources/OPDS/OPDS1Parser.swift | Emits OPDS link properties using [String: JSONValue]. |
| Sources/Navigator/EPUB/HTMLDecorationTemplate.swift | Migrates decoration template JSON output to [String: JSONValue]. |
| Sources/LCP/License/Model/StatusDocument.swift | Migrates LSD parsing to JSONValue-backed JSONDictionary and helpers. |
| Sources/LCP/License/Model/LicenseDocument.swift | Migrates license document parsing to JSONValue-backed JSONDictionary and helpers. |
| Sources/LCP/License/Model/Components/Links.swift | Adds JSONValue-based Links initializer; bridges legacy initializer. |
| Sources/LCP/License/Model/Components/Link.swift | Adds JSONValue-based Link initializer; bridges legacy initializer. |
| Sources/LCP/License/Model/Components/LSD/PotentialRights.swift | Adds JSONValue-based initializer; bridges legacy initializer. |
| Sources/LCP/License/Model/Components/LSD/Event.swift | Adds JSONValue-based initializer; bridges legacy initializer. |
| Sources/LCP/License/Model/Components/LCP/UserKey.swift | Adds JSONValue-based initializer; bridges legacy initializer. |
| Sources/LCP/License/Model/Components/LCP/User.swift | Migrates extensions storage to [String: JSONValue] and JSONValue parsing. |
| Sources/LCP/License/Model/Components/LCP/Signature.swift | Adds JSONValue-based initializer; bridges legacy initializer. |
| Sources/LCP/License/Model/Components/LCP/Rights.swift | Migrates extensions storage to [String: JSONValue] and JSONValue parsing. |
| Sources/LCP/License/Model/Components/LCP/Encryption.swift | Adds JSONValue-based initializer; bridges legacy initializer. |
| Sources/LCP/License/Model/Components/LCP/ContentKey.swift | Adds JSONValue-based initializer; bridges legacy initializer. |
| Sources/Internal/JSON.swift | Removes the old Internal JSONDictionary/parse helpers implementation. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if | ||
| let mediaOverlayId = item.mediaOverlayId, | ||
| let smilIndex = items.firstIndex(where: { $0.id == mediaOverlayId && $0.link.mediaType?.matches(.smil) == true }) | ||
| let mediaOverlayIndex = items.firstIndex(where: { $0.id == mediaOverlayId }) | ||
| { | ||
| let smilItem = items.remove(at: smilIndex) | ||
| spineLink.alternates.append(smilItem.link) | ||
| let mediaOverlayItem = items.remove(at: mediaOverlayIndex) | ||
| spineLink.alternates.append(mediaOverlayItem.link) | ||
| } |
There was a problem hiding this comment.
Media overlays are now attached based only on id match, without verifying the target item is actually a SMIL resource. If the OPF is malformed (or IDs collide), this can remove an arbitrary manifest item from resources and add a non-SMIL alternate. Restoring the media type check (or otherwise validating the overlay target) would prevent unintended removals.
| var json: [String: JSONValue] { | ||
| [ | ||
| "entryLength": entryLength as NSNumber, | ||
| "isEntryCompressed": isEntryCompressed, | ||
| "entryLength": .integer(Int(entryLength)), | ||
| "isEntryCompressed": .bool(isEntryCompressed), |
There was a problem hiding this comment.
Encoding entryLength as .integer(Int(entryLength)) can trap if entryLength exceeds Int.max. To avoid a potential crash on large entries, encode without lossy/unsafe narrowing (e.g. clamp, encode as .double, or encode as a string) and parse accordingly.
Sources/Shared/Publication/Extensions/Archive/Properties+Archive.swift
Outdated
Show resolved
Hide resolved
| public func serializeJSONString(_ object: Any) -> String? { | ||
| let unwrappedObject = JSONValue(object)?.any ?? object | ||
|
|
||
| guard | ||
| let data = try? JSONSerialization.data(withJSONObject: object, options: .sortedKeys), | ||
| JSONSerialization.isValidJSONObject(unwrappedObject) || JSONSerialization.isValidJSONObject([unwrappedObject]), | ||
| let data = try? JSONSerialization.data(withJSONObject: unwrappedObject, options: .sortedKeys), | ||
| let string = String(data: data, encoding: .utf8) |
There was a problem hiding this comment.
serializeJSONString allows JSON fragments via isValidJSONObject([unwrappedObject]), but still calls JSONSerialization.data(withJSONObject: unwrappedObject), which will always fail for fragment-only values (e.g. String/Int/Bool). Either remove the fragment check, or actually serialize as a fragment (e.g. using .fragmentsAllowed or wrapping in an array and unwrapping consistently).
| init(json: Any?, warnings: WarningLogger? = nil) { | ||
| self.init() | ||
| guard let json = json as? [[String: Any]] else { | ||
| guard let json = json else { | ||
| return | ||
| } | ||
|
|
||
| let acquisitions = json.compactMap { try? OPDSAcquisition(json: $0, warnings: warnings) } | ||
| let rawJson: Any | ||
| if let j = json as? JSONValue { | ||
| rawJson = j.any | ||
| } else { | ||
| rawJson = json | ||
| } | ||
|
|
||
| guard let array = rawJson as? [[String: Any]] else { | ||
| return | ||
| } | ||
|
|
||
| let acquisitions = array.compactMap { try? OPDSAcquisition(json: $0, warnings: warnings) } | ||
| append(contentsOf: acquisitions) |
There was a problem hiding this comment.
Array<OPDSAcquisition>.init(json:) casts to [[String: Any]], which won’t work when the JSON comes from the new json properties ([[String: JSONValue]]). This breaks round-tripping and parsing of already-normalized JSONValue trees. Consider switching this initializer to accept JSONValue? and building acquisitions with parseArray(json) (mapping each element into OPDSAcquisition).
|
@mickael-menu I'll leave the remaining Copilot comments as unresolved in case you want to review the changes I made based on them. |
This PR does the following:
Anyor[String: Any]were updated to use JSONValue.init(json: [String: Any]?)and the like were left for convenience, but ultimately use the new init in it.Note this has breaking changes. I've drafted them in the Migration Guide.