diff --git a/NativeAppTemplate.xcodeproj/project.pbxproj b/NativeAppTemplate.xcodeproj/project.pbxproj index 9ed3710..74b4098 100644 --- a/NativeAppTemplate.xcodeproj/project.pbxproj +++ b/NativeAppTemplate.xcodeproj/project.pbxproj @@ -12,7 +12,6 @@ 0106414029A9F2EC00B46FED /* AccountPasswordService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0106413F29A9F2EC00B46FED /* AccountPasswordService.swift */; }; 0106414229A9F51700B46FED /* AccountPasswordRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0106414129A9F51700B46FED /* AccountPasswordRepository.swift */; }; 0106414429AA061100B46FED /* PasswordEditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0106414329AA061100B46FED /* PasswordEditView.swift */; }; - 010A30A528A4A285001D6BD1 /* DataCacheUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010A30A428A4A285001D6BD1 /* DataCacheUpdate.swift */; }; 010F86AE2621A2A900B6C62A /* ShopDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F86AD2621A2A900B6C62A /* ShopDetailView.swift */; }; 010F86BE2622F9C900B6C62A /* ShopListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 010F86BD2622F9C900B6C62A /* ShopListView.swift */; }; 0110A15F2AC816F5003EDCBA /* SendConfirmation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0110A15E2AC816F5003EDCBA /* SendConfirmation.swift */; }; @@ -36,7 +35,6 @@ 0158BA0125C174E0008EC9D5 /* ShopsRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0158BA0025C174E0008EC9D5 /* ShopsRequest.swift */; }; 015C78052B72DA2C00B6523C /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 015C78042B72DA2C00B6523C /* PrivacyInfo.xcprivacy */; }; 0172033725A9642E008FD63B /* JSONAPIResource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030825A9642D008FD63B /* JSONAPIResource.swift */; }; - 0172033925A9642E008FD63B /* JSONAPIRelationship.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */; }; 0172033A25A9642E008FD63B /* JSONAPIDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030B25A9642E008FD63B /* JSONAPIDocument.swift */; }; 0172033C25A9642E008FD63B /* NativeAppTemplateAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */; }; 0172033D25A9642E008FD63B /* NativeAppTemplateEnvironment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */; }; @@ -167,7 +165,6 @@ 0106413F29A9F2EC00B46FED /* AccountPasswordService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPasswordService.swift; sourceTree = ""; }; 0106414129A9F51700B46FED /* AccountPasswordRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountPasswordRepository.swift; sourceTree = ""; }; 0106414329AA061100B46FED /* PasswordEditView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PasswordEditView.swift; sourceTree = ""; }; - 010A30A428A4A285001D6BD1 /* DataCacheUpdate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataCacheUpdate.swift; sourceTree = ""; }; 010F86AD2621A2A900B6C62A /* ShopDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopDetailView.swift; sourceTree = ""; }; 010F86BD2622F9C900B6C62A /* ShopListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopListView.swift; sourceTree = ""; }; 0110A15E2AC816F5003EDCBA /* SendConfirmation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SendConfirmation.swift; sourceTree = ""; }; @@ -194,7 +191,6 @@ 0158BA0025C174E0008EC9D5 /* ShopsRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShopsRequest.swift; sourceTree = ""; }; 015C78042B72DA2C00B6523C /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 0172030825A9642D008FD63B /* JSONAPIResource.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIResource.swift; sourceTree = ""; }; - 0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIRelationship.swift; sourceTree = ""; }; 0172030B25A9642E008FD63B /* JSONAPIDocument.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = JSONAPIDocument.swift; sourceTree = ""; }; 0172030E25A9642E008FD63B /* NativeAppTemplateAPI.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateAPI.swift; sourceTree = ""; }; 0172030F25A9642E008FD63B /* NativeAppTemplateEnvironment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NativeAppTemplateEnvironment.swift; sourceTree = ""; }; @@ -465,7 +461,6 @@ isa = PBXGroup; children = ( 0172030B25A9642E008FD63B /* JSONAPIDocument.swift */, - 0172030A25A9642E008FD63B /* JSONAPIRelationship.swift */, 0172030825A9642D008FD63B /* JSONAPIResource.swift */, ); path = JSONAPI; @@ -497,7 +492,6 @@ 0172031F25A9642E008FD63B /* Adapters */ = { isa = PBXGroup; children = ( - 010A30A428A4A285001D6BD1 /* DataCacheUpdate.swift */, 0172032025A9642E008FD63B /* EntityAdapter.swift */, 0172032225A9642E008FD63B /* EntityAdapters */, ); @@ -900,9 +894,7 @@ 01B9E45228A5070D00CAC681 /* ShopkeeperSignInAdapter.swift in Sources */, 0172040025AA6775008FD63B /* LoginRepository.swift in Sources */, 0172034B25A9642E008FD63B /* EntityAdapter.swift in Sources */, - 010A30A528A4A285001D6BD1 /* DataCacheUpdate.swift in Sources */, 01E0A60C25BD440300298D35 /* SignInEmailAndPasswordView.swift in Sources */, - 0172033925A9642E008FD63B /* JSONAPIRelationship.swift in Sources */, 01B526562AF4E82A00655131 /* ScrollToTopID.swift in Sources */, 7249A60C06FE44338E16BC50 /* APISessionDelegate.swift in Sources */, 0172033C25A9642E008FD63B /* NativeAppTemplateAPI.swift in Sources */, diff --git a/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift b/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift index 88f6dfd..cda724d 100644 --- a/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift +++ b/NativeAppTemplate/Data/Repositories/AccountPasswordRepository.swift @@ -17,7 +17,7 @@ try await accountPasswordService.updatePassword(updatePassword: updatePassword) } catch { Failure - .destroy(from: Self.self, reason: error.codedDescription) + .update(from: Self.self, reason: error.codedDescription) .log() throw error } diff --git a/NativeAppTemplate/Login/LoginRepository.swift b/NativeAppTemplate/Login/LoginRepository.swift index 3eeefc6..e0cb37d 100644 --- a/NativeAppTemplate/Login/LoginRepository.swift +++ b/NativeAppTemplate/Login/LoginRepository.swift @@ -39,7 +39,7 @@ import os _currentShopkeeper = shopkeeper } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .login(from: Self.self, reason: error.codedDescription) .log() throw error } @@ -54,7 +54,7 @@ import os _currentShopkeeper = .none } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .logout(from: Self.self, reason: error.codedDescription) .log() removeShopkeeper() _currentShopkeeper = .none diff --git a/NativeAppTemplate/Login/RootPathService.swift b/NativeAppTemplate/Login/RootPathService.swift index 6b85d0e..c2cacc9 100644 --- a/NativeAppTemplate/Login/RootPathService.swift +++ b/NativeAppTemplate/Login/RootPathService.swift @@ -7,7 +7,11 @@ import Foundation /// A service for devise_token_auth's routes (sign-in, sign-up, passwords), which sit at /// the API root, without the account and /api/v1 prefix that `Service` adds. -/// Signed out, the client has no token, so no session headers are sent. +/// Sending and error handling are shared with `Service` (see `URLSession.apiResponse`); +/// it differs in three ways: +/// - Signed out, the client has no token, so only the `source` header is sent. +/// - A request's query items are not sent. No devise_token_auth request has any. +/// - devise_token_auth's 401 is not announced as an expired session (`.apiSessionDidExpire`). protocol RootPathService { var networkClient: NativeAppTemplateAPI { get } var session: URLSession { get } @@ -17,59 +21,23 @@ extension RootPathService { func prepare(request: some NativeAppTemplate.Request) throws -> URLRequest { let pathURL = networkClient.environment.baseURL.appendingPathComponent(request.path) - guard let components = URLComponents( - url: pathURL, - resolvingAgainstBaseURL: false - ) else { - throw URLError(.badURL) + var urlRequest = try URLRequest(url: pathURL, queryItems: [], for: request) + if networkClient.authToken.isEmpty { + // No session to send, only the platform (which sessionHeaders also carry) + urlRequest.addHeaders([NativeAppTemplateAPI.sourceHeader]) + } else { + urlRequest.addHeaders(networkClient.sessionHeaders) } - - guard let url = components.url - else { throw URLError(.badURL) } - - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = request.method.rawValue - - // body *needs* to be the last property that we set, because of this bug: - // https://bugs.swift.org/browse/SR-6687 - urlRequest.httpBody = request.body - - if !networkClient.authToken.isEmpty { - networkClient.sessionHeaders.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.key) } - } - - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") - urlRequest.setValue("ios", forHTTPHeaderField: "source") - return urlRequest } @MainActor func makeRequest( request: Request ) async throws -> Request.Response { - let (data, response) = try await session.data( - for: prepare(request: request) + try await session.apiResponse( + to: request, + sending: prepare(request: request), + announcingRejectionOf: nil ) - - let statusCode = (response as? HTTPURLResponse)?.statusCode - guard statusCode.map((200 ..< 300).contains) == true - else { - var errorMessage: String? - - do { - if let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], - let theErrorMessage = json["error_message"] as? String { - errorMessage = theErrorMessage - } - } catch { - // Not JSON (e.g. a proxy's HTML error page): no message to show, - // so the error falls back to its generic description - throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, nil) - } - - throw NativeAppTemplateAPIError.requestFailed(nil, statusCode ?? 0, errorMessage) - } - - return try request.handle(response: data) } } diff --git a/NativeAppTemplate/Login/SignUpRepository.swift b/NativeAppTemplate/Login/SignUpRepository.swift index 8ea0f1e..5f1c472 100644 --- a/NativeAppTemplate/Login/SignUpRepository.swift +++ b/NativeAppTemplate/Login/SignUpRepository.swift @@ -21,7 +21,7 @@ import os shopkeeper = try await signUpsService.makeShopkeeper(signUp: signUp) } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .create(from: Self.self, reason: error.codedDescription) .log() throw error } @@ -50,7 +50,7 @@ import os removeShopkeeper() } catch { Failure - .fetch(from: Self.self, reason: error.codedDescription) + .destroy(from: Self.self, reason: error.codedDescription) .log() removeShopkeeper() diff --git a/NativeAppTemplate/Networking/Adapters/DataCacheUpdate.swift b/NativeAppTemplate/Networking/Adapters/DataCacheUpdate.swift deleted file mode 100644 index 1672ad5..0000000 --- a/NativeAppTemplate/Networking/Adapters/DataCacheUpdate.swift +++ /dev/null @@ -1,81 +0,0 @@ -// -// DataCacheUpdate.swift -// NativeAppTemplate -// - -typealias JSONEntityRelationships = (entity: EntityIdentity?, jsonRelationships: [JSONAPIRelationship]) - -struct DataCacheUpdate { - let shops: [Shop] - let shopkeepers: [Shopkeeper] - let relationships: [EntityRelationship] - - static func loadFrom(document: JSONAPIDocument) throws -> DataCacheUpdate { - let data = try DataCacheUpdate(resources: document.data) - let included = try DataCacheUpdate( - resources: document.included, - relationships: document.data.map { (entity: $0.entityID, $0.relationships) } - ) - return data.merged(with: included) - } - - init( - shops: [Shop] = [], - shopkeepers: [Shopkeeper] = [], - relationships: [EntityRelationship] = [] - ) { - self.shops = shops - self.shopkeepers = shopkeepers - self.relationships = relationships - } - - init(resources: [JSONAPIResource], relationships jsonEntityRelationships: [JSONEntityRelationships] = []) throws { - let relationships = DataCacheUpdate.relationships(from: resources, with: jsonEntityRelationships) - shops = try resources - .filter { $0.type == "shop" } - .map { try ShopAdapter.process(resource: $0, relationships: relationships) } - shopkeepers = try resources - .filter { $0.type == "shopkeeper" } - .map { try ShopkeeperAdapter.process(resource: $0, relationships: relationships) } - self.relationships = relationships - } - - func merged(with other: DataCacheUpdate) -> DataCacheUpdate { - .init( - shops: shops + other.shops, - shopkeepers: shopkeepers + other.shopkeepers, - relationships: relationships + other.relationships - ) - } - - private static func relationships( - from resources: [JSONAPIResource], - with additionalRelationships: [JSONEntityRelationships] - ) -> [EntityRelationship] { - var relationshipsToReturn = additionalRelationships.flatMap { entityRelationship -> [EntityRelationship] in - guard let entityID = entityRelationship.entity else { return [] } - return entityRelationships(from: entityRelationship.jsonRelationships, fromEntity: entityID) - } - relationshipsToReturn += resources.flatMap { resource -> [EntityRelationship] in - guard let resourceEntityID = resource.entityID else { return [] } - return entityRelationships(from: resource.relationships, fromEntity: resourceEntityID) - } - return relationshipsToReturn - } - - private static func entityRelationships( - from jsonRelationships: [JSONAPIRelationship], - fromEntity: EntityIdentity - ) -> [EntityRelationship] { - jsonRelationships.flatMap { relationship in - relationship.data.compactMap { resource in - guard let toEntity = resource.entityID else { return nil } - return EntityRelationship( - name: relationship.type, - from: fromEntity, - to: toEntity - ) - } - } - } -} diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift index bc76ea5..d47b3ce 100644 --- a/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapter.swift @@ -3,18 +3,7 @@ // NativeAppTemplate // -import Foundation - -protocol EntityAdapter { - associatedtype Response - - static func process( - resource: JSONAPIResource, - relationships: [EntityRelationship], - cacheUpdate: DataCacheUpdate - ) throws -> Response -} - +/// The API's resource types (JSON:API `type`) that an adapter turns into a model enum EntityType { case shop case shopkeeper @@ -36,14 +25,3 @@ enum EntityType { } } } - -struct EntityIdentity: Identifiable { - let id: String - let type: EntityType -} - -struct EntityRelationship { - let name: String - let from: EntityIdentity - let to: EntityIdentity -} diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ItemTagAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ItemTagAdapter.swift index c468801..b1a79b9 100644 --- a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ItemTagAdapter.swift +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ItemTagAdapter.swift @@ -5,12 +5,8 @@ import struct Foundation.URL -struct ItemTagAdapter: EntityAdapter { - static func process( - resource: JSONAPIResource, - relationships: [EntityRelationship] = [], - cacheUpdate: DataCacheUpdate = DataCacheUpdate() - ) throws -> ItemTag { +enum ItemTagAdapter { + static func process(resource: JSONAPIResource) throws -> ItemTag { guard resource.entityType == .itemTag else { throw EntityAdapterError.invalidResourceTypeForAdapter } guard let shopId = resource.attributes["shop_id"] as? String, diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift index a8a0e9b..3a4cef3 100644 --- a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopAdapter.swift @@ -5,12 +5,8 @@ import struct Foundation.URL -struct ShopAdapter: EntityAdapter { - static func process( - resource: JSONAPIResource, - relationships: [EntityRelationship] = [], - cacheUpdate: DataCacheUpdate = DataCacheUpdate() - ) throws -> Shop { +enum ShopAdapter { + static func process(resource: JSONAPIResource) throws -> Shop { guard resource.entityType == .shop else { throw EntityAdapterError.invalidResourceTypeForAdapter } guard let name = resource.attributes["name"] as? String, diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperAdapter.swift index d7cd600..2be6729 100644 --- a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperAdapter.swift +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperAdapter.swift @@ -5,12 +5,8 @@ import Foundation -struct ShopkeeperAdapter: EntityAdapter { - static func process( - resource: JSONAPIResource, - relationships: [EntityRelationship] = [], - cacheUpdate: DataCacheUpdate = DataCacheUpdate() - ) throws -> Shopkeeper { +enum ShopkeeperAdapter { + static func process(resource: JSONAPIResource) throws -> Shopkeeper { guard resource.entityType == .shopkeeper else { throw EntityAdapterError.invalidResourceTypeForAdapter } diff --git a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperSignInAdapter.swift b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperSignInAdapter.swift index a2a6b35..54bcdf0 100644 --- a/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperSignInAdapter.swift +++ b/NativeAppTemplate/Networking/Adapters/EntityAdapters/ShopkeeperSignInAdapter.swift @@ -5,12 +5,8 @@ import Foundation -struct ShopkeeperSignInAdapter: EntityAdapter { - static func process( - resource: JSONAPIResource, - relationships: [EntityRelationship] = [], - cacheUpdate: DataCacheUpdate = DataCacheUpdate() - ) throws -> Shopkeeper { +enum ShopkeeperSignInAdapter { + static func process(resource: JSONAPIResource) throws -> Shopkeeper { guard resource.entityType == .shopkeeperSignIn else { throw EntityAdapterError.invalidResourceTypeForAdapter } diff --git a/NativeAppTemplate/Networking/JSONAPI/JSONAPIDocument.swift b/NativeAppTemplate/Networking/JSONAPI/JSONAPIDocument.swift index 8775483..67d8992 100644 --- a/NativeAppTemplate/Networking/JSONAPI/JSONAPIDocument.swift +++ b/NativeAppTemplate/Networking/JSONAPI/JSONAPIDocument.swift @@ -3,44 +3,35 @@ // NativeAppTemplate // -import struct Foundation.URL - -class JSONAPIDocument { +/// A JSON:API document (https://jsonapi.org/format/#document-structure), reduced to +/// what the requests read: `data`, `included` and the top-level `meta`. +/// Anything but a JSON object reads as an empty document. +struct JSONAPIDocument { // MARK: - Properties - var meta: [String: Any] = [:] - var included: [JSONAPIResource] = [] - var data: [JSONAPIResource] = [] - var links: [String: URL] = [:] + let meta: [String: Any] + let data: [JSONAPIResource] + let included: [JSONAPIResource] // MARK: - Initializers - convenience init(_ json: Any) { - self.init() - - guard let dict = json as? [String: Any] else { return } + init(_ json: Any) { + let dict = json as? [String: Any] ?? [:] meta = dict["meta"] as? [String: Any] ?? [:] + data = Self.resources(dict["data"]) + included = Self.resources(dict["included"]) + } - if let dataArray = dict["data"] as? [[String: Any]] { - data = dataArray.map { JSONAPIResource($0, parent: self) } - } else if let dataDict = dict["data"] as? [String: Any] { - data = [JSONAPIResource(dataDict, parent: self)] - } - - if let includedArray = dict["included"] as? [[String: Any]] { - included = includedArray.map { JSONAPIResource($0, parent: self) } - } else if let includedDict = dict["included"] as? [String: Any] { - included = [JSONAPIResource(includedDict, parent: self)] + /// `data` is an object for one resource and an array for a collection; + /// `included` is read the same way + private static func resources(_ json: Any?) -> [JSONAPIResource] { + if let array = json as? [[String: Any]] { + return array.map(JSONAPIResource.init) } - - if let linksDict = dict["links"] as? [String: Any] { - for link in linksDict { - if let strValue = link.value as? String, - let url = URL(string: strValue) { - links[link.key] = url - } - } + if let object = json as? [String: Any] { + return [JSONAPIResource(object)] } + return [] } } diff --git a/NativeAppTemplate/Networking/JSONAPI/JSONAPIRelationship.swift b/NativeAppTemplate/Networking/JSONAPI/JSONAPIRelationship.swift deleted file mode 100644 index fdb6f27..0000000 --- a/NativeAppTemplate/Networking/JSONAPI/JSONAPIRelationship.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// JSONAPIRelationship.swift -// NativeAppTemplate -// - -import struct Foundation.URL - -public class JSONAPIRelationship { - // MARK: - Properties - - var meta: [String: Any] = [:] - var data: [JSONAPIResource] = [] - var links: [String: URL] = [:] - var type: String = "" - - // MARK: - Initializers - - convenience init(_ json: Any, type: String) { - self.init() - - guard let dict = json as? [String: Any] else { return } - - self.type = type - meta = dict["meta"] as? [String: Any] ?? [:] - data = (dict["data"] as? [[String: Any]] ?? []).map { - JSONAPIResource($0, parent: nil) - } - - if let singleData = dict["data"] as? [String: Any] { - data.append(JSONAPIResource(singleData, parent: nil)) - } - } -} diff --git a/NativeAppTemplate/Networking/JSONAPI/JSONAPIResource.swift b/NativeAppTemplate/Networking/JSONAPI/JSONAPIResource.swift index 8f72cb2..dee1d11 100644 --- a/NativeAppTemplate/Networking/JSONAPI/JSONAPIResource.swift +++ b/NativeAppTemplate/Networking/JSONAPI/JSONAPIResource.swift @@ -3,62 +3,25 @@ // NativeAppTemplate // -import struct Foundation.URL - -public class JSONAPIResource { +/// A JSON:API resource object, reduced to what the adapters read +struct JSONAPIResource { // MARK: - Properties - weak var parent: JSONAPIDocument? - var id: String = "0" - var type: String = "" - var relationships: [JSONAPIRelationship] = [] - var attributes: [String: Any] = [:] - var links: [String: URL] = [:] - var meta: [String: Any] = [:] + /// "" when missing + let id: String + /// "" when missing, which matches no EntityType + let type: String + let attributes: [String: Any] + var entityType: EntityType? { EntityType(from: type) } - var entityID: EntityIdentity? { - guard let entityType else { return nil } - - return EntityIdentity(id: id, type: entityType) - } - // MARK: - Initializers - convenience init( - _ json: Any, - parent: JSONAPIDocument? - ) { - self.init() - - guard let dict = json as? [String: Any] else { return } - - if let doc = parent { - self.parent = doc - } - - id = (dict["id"] as? String) ?? "" - type = (dict["type"] as? String) ?? "" - - if let rels = dict["relationships"] as? [String: Any] { - for (key, value) in rels { - relationships.append(JSONAPIRelationship(value, type: key)) - } - } - + init(_ dict: [String: Any]) { + id = dict["id"] as? String ?? "" + type = dict["type"] as? String ?? "" attributes = dict["attributes"] as? [String: Any] ?? [:] - - if let linksDict = dict["links"] as? [String: Any] { - for link in linksDict { - if let strValue = link.value as? String, - let url = URL(string: strValue) { - links[link.key] = url - } - } - } - - meta = dict["meta"] as? [String: Any] ?? [:] } } diff --git a/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift b/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift index 8696d47..f51c4c2 100644 --- a/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift +++ b/NativeAppTemplate/Networking/Network/NativeAppTemplateAPI.swift @@ -54,7 +54,10 @@ extension NativeAppTemplateAPI { ("client", client), ("expiry", expiry), ("uid", uid), - ("source", "ios") + Self.sourceHeader ] } + + /// The platform the request comes from + nonisolated static let sourceHeader: HTTPHeader = ("source", "ios") } diff --git a/NativeAppTemplate/Networking/Services/Service.swift b/NativeAppTemplate/Networking/Services/Service.swift index 417dfd6..992f5d2 100644 --- a/NativeAppTemplate/Networking/Services/Service.swift +++ b/NativeAppTemplate/Networking/Services/Service.swift @@ -5,6 +5,9 @@ import Foundation +/// A service for the API's account-scoped routes: every request goes to +/// baseURL/{accountId}/api/v1/{path}, with its query items and the session headers. +/// devise_token_auth's own routes, at the API root, use `RootPathService`. protocol Service { var networkClient: NativeAppTemplateAPI { get } var session: URLSession { get } @@ -17,47 +20,73 @@ extension Service { } extension Service { + func prepare(request: some NativeAppTemplate.Request) throws -> URLRequest { + let pathURL = networkClient.environment.baseURL + .appendingPathComponent(networkClient.accountId) + .appendingPathComponent(networkClient.environment.basePath) + .appendingPathComponent(request.path) + + var urlRequest = try URLRequest(url: pathURL, queryItems: request.queryItems, for: request) + urlRequest.addHeaders(networkClient.sessionHeaders) + return urlRequest + } + @MainActor func makeRequest( request: Request ) async throws -> Request.Response { - func prepare( - request: some NativeAppTemplate.Request - ) throws -> URLRequest { - var pathURL = networkClient.environment.baseURL.appendingPathComponent(networkClient.accountId) - pathURL = pathURL.appendingPathComponent(networkClient.environment.basePath) - pathURL = pathURL.appendingPathComponent(request.path) - - guard var components = URLComponents( - url: pathURL, - resolvingAgainstBaseURL: false - ) else { - throw URLError(.badURL) - } + // Only these routes announce devise_token_auth's 401 as an expired session + try await session.apiResponse( + to: request, + sending: prepare(request: request), + announcingRejectionOf: networkClient.authToken + ) + } +} - if !request.queryItems.isEmpty { - components.queryItems = request.queryItems - } +// MARK: - Shared by Service and RootPathService - guard let url = components.url - else { throw URLError(.badURL) } +extension URLRequest { + /// A request to `url` (plus `queryItems`, if any) with `request`'s method and + /// body, sent as JSON. The caller adds the session headers. + init(url: URL, queryItems: [URLQueryItem], for request: some NativeAppTemplate.Request) throws { + guard var components = URLComponents(url: url, resolvingAgainstBaseURL: false) else { + throw URLError(.badURL) + } - var urlRequest = URLRequest(url: url) - urlRequest.httpMethod = request.method.rawValue + if !queryItems.isEmpty { + components.queryItems = queryItems + } - urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + guard let url = components.url else { throw URLError(.badURL) } - // body *needs* to be the last property that we set, because of this bug: - // https://bugs.swift.org/browse/SR-6687 - urlRequest.httpBody = request.body + self.init(url: url) + httpMethod = request.method.rawValue + setValue("application/json", forHTTPHeaderField: "Content-Type") - networkClient.sessionHeaders.forEach { urlRequest.addValue($0.value, forHTTPHeaderField: $0.key) } + // body *needs* to be the last property that we set, because of this bug: + // https://bugs.swift.org/browse/SR-6687 + httpBody = request.body + } - return urlRequest - } + mutating func addHeaders(_ headers: [HTTPHeader]) { + headers.forEach { addValue($0.value, forHTTPHeaderField: $0.key) } + } +} - let (data, response) = try await session.data( - for: prepare(request: request) - ) +extension URLSession { + /// Sends `urlRequest` and hands a 2xx body to `request.handle(response:)`. Any + /// other status throws `requestFailed` with the status and the API's + /// `error_message`, if the body has one. + /// + /// - Parameter authToken: The token `urlRequest` was sent with, to announce as + /// rejected (`.apiSessionDidExpire`) when devise_token_auth answers with its 401. + /// `nil` never announces anything. + @MainActor func apiResponse( + to request: Request, + sending urlRequest: URLRequest, + announcingRejectionOf authToken: String? + ) async throws -> Request.Response { + let (data, response) = try await data(for: urlRequest) let statusCode = (response as? HTTPURLResponse)?.statusCode guard statusCode.map((200 ..< 300).contains) == true @@ -70,11 +99,11 @@ extension Service { // devise_token_auth rejects a missing, wrong or expired token with a 401 // whose body has no error_message. The API's own 401 (a Pundit denial) // has one and keeps the session. - if statusCode == 401, errorMessage == nil, !networkClient.authToken.isEmpty { + if statusCode == 401, errorMessage == nil, let authToken, !authToken.isEmpty { NotificationCenter.default.post( name: .apiSessionDidExpire, object: nil, - userInfo: [Notification.authTokenKey: networkClient.authToken] + userInfo: [Notification.authTokenKey: authToken] ) } diff --git a/NativeAppTemplate/Persistence/KeychainStore/KeychainStore.swift b/NativeAppTemplate/Persistence/KeychainStore/KeychainStore.swift index 8f8ecf2..073d489 100644 --- a/NativeAppTemplate/Persistence/KeychainStore/KeychainStore.swift +++ b/NativeAppTemplate/Persistence/KeychainStore/KeychainStore.swift @@ -27,27 +27,35 @@ extension KeychainStore { } } + /// - Throws: `notFound` when there is no item, `secCallFailed` when the keychain + /// can't be read, and `archiveFailure` when the item can't be decoded as `DataType` func retrieve() throws -> DataType { let keychain = Keychain(service: service) - let archived: Data? - archived = try? keychain.data(for: account) + let archived: Data? + do { + archived = try keychain.data(for: account) + } catch { + throw KeychainStoreError.secCallFailed(error) + } - guard archived != nil else { + guard let archived else { throw KeychainStoreError.notFound } + let unarchived: DataType? do { - guard - let unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClass: DataType.self, from: archived!) - else { - throw KeychainStoreError.badData - } - - return unarchived + unarchived = try NSKeyedUnarchiver.unarchivedObject(ofClass: DataType.self, from: archived) } catch { throw KeychainStoreError.archiveFailure(error) } + + // An archive of another class decodes to nil without an error + guard let unarchived else { + throw KeychainStoreError.archiveFailure(KeychainStoreError.badData) + } + + return unarchived } func store(_ data: DataType) throws { diff --git a/NativeAppTemplate/Sessions/SessionController.swift b/NativeAppTemplate/Sessions/SessionController.swift index 4d6dd7c..f20fe68 100644 --- a/NativeAppTemplate/Sessions/SessionController.swift +++ b/NativeAppTemplate/Sessions/SessionController.swift @@ -152,22 +152,17 @@ import Observation } func logout() async throws { + shouldPopToRootView = true + // Signed out here even when the API call fails; shopkeeper's didSet resets + // userState and permissionState + defer { shopkeeper = nil } + do { - shouldPopToRootView = true try await loginRepository.logout(networkClient: client) - - userState = .notLoggedIn - permissionState = .notLoaded - shopkeeper = nil } catch { Failure .logout(from: Self.self, reason: error.codedDescription) .log() - - userState = .notLoggedIn - permissionState = .notLoaded - shopkeeper = nil - throw error } } diff --git a/NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift b/NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift index 5e46faa..f29ca68 100644 --- a/NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift +++ b/NativeAppTemplateTests/Data/Repositories/AccountPasswordRepositoryTest.swift @@ -5,6 +5,7 @@ import Foundation @testable import NativeAppTemplate +import OSLog import Testing /// AccountPasswordRepository with the real AccountPasswordService and @@ -66,4 +67,33 @@ struct AccountPasswordRepositoryTest { #expect(statusCode == 422) #expect(message == "Current password is invalid") } + + /// A failed password update is logged as Failure.update ("Failed_update"), not as a + /// deletion. Failure.log writes "Failed_\(action) source=\(source) reason=\(reason)" + /// to appLogger (subsystem com.nativeapptemplate), which this process can read back + /// (as SessionControllerTest.failedLogoutIsLoggedAsALogoutFailure does). + @Test + func failedUpdateIsLoggedAsAnUpdateFailure() async throws { + let server = try await TestHTTPServer.start { _ in + TestHTTPServer.json(status: 422, ["error_message": "Current password is invalid"]) + } + defer { server.stop() } + let start = Date.now.addingTimeInterval(-1) + + _ = try await requestFailed { + try await makeRepository(server).update(updatePassword: updatePassword) + } + + let store = try OSLogStore(scope: .currentProcessIdentifier) + let messages = try store + .getEntries( + at: store.position(date: start), + matching: NSPredicate(format: "subsystem == %@", "com.nativeapptemplate") + ) + .compactMap { ($0 as? OSLogEntryLog)?.composedMessage } + .filter { $0.contains("source=AccountPasswordRepository ") } + + #expect(messages.contains { $0.hasPrefix("Failed_update ") }) + #expect(!messages.contains { $0.hasPrefix("Failed_destroy ") }) + } } diff --git a/NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift b/NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift deleted file mode 100644 index bb22134..0000000 --- a/NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift +++ /dev/null @@ -1,123 +0,0 @@ -// -// DataCacheUpdateTest.swift -// NativeAppTemplate -// - -import Foundation -@testable import NativeAppTemplate -import Testing - -/// Characterizes DataCacheUpdate.loadFrom(document:). No request calls it today -/// (every Request adapts `doc.data` itself), so this pins what the layer does -/// before it is simplified or removed. -/// -/// Attributes follow ShopAdapter (name, time_zone) and ShopkeeperAdapter -/// (email, name, time_zone), the fields they require. -struct DataCacheUpdateTest { - private func document(_ json: String) throws -> JSONAPIDocument { - try JSONAPIDocument(JSONSerialization.jsonObject(with: Data(json.utf8))) - } - - private static let shopAttributes = #"{"name": "Shop", "time_zone": "Tokyo"}"# - private static let shopkeeperAttributes = #"{"email": "a@example.com", "name": "Name", "time_zone": "Tokyo"}"# - - /// Shops and shopkeepers go into their own lists, data before included - @Test - func resourcesAreSortedByTypeWithDataFirst() throws { - let doc = try document(#""" - {"data": [{"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes)}], - "included": [ - {"id": "k1", "type": "shopkeeper", "attributes": \#(Self.shopkeeperAttributes)}, - {"id": "s2", "type": "shop", "attributes": \#(Self.shopAttributes)} - ]} - """#) - - let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) - - #expect(cacheUpdate.shops.map(\.id) == ["s1", "s2"]) - #expect(cacheUpdate.shopkeepers.map(\.id) == ["k1"]) - #expect(cacheUpdate.shopkeepers.first?.email == "a@example.com") - } - - /// Only shop and shopkeeper are adapted: an item_tag (a known EntityType) and an - /// unknown type are skipped without an error - @Test - func otherTypesAreSkipped() throws { - let doc = try document(#""" - {"data": [ - {"id": "t1", "type": "item_tag", "attributes": {}}, - {"id": "x1", "type": "unknown", "attributes": {}}, - {"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes)} - ]} - """#) - - let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) - - #expect(cacheUpdate.shops.map(\.id) == ["s1"]) - #expect(cacheUpdate.shopkeepers.isEmpty) - } - - /// An included resource the adapter rejects fails the whole document - @Test - func invalidIncludedResourceFailsTheDocument() throws { - let doc = try document(#""" - {"data": [{"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes)}], - "included": [{"id": "k1", "type": "shopkeeper", "attributes": {"name": "No email"}}]} - """#) - - #expect(throws: EntityAdapterError.invalidOrMissingAttributes) { - try DataCacheUpdate.loadFrom(document: doc) - } - } - - /// The data resources' relationships are collected twice: once by the data pass - /// (from the resources themselves) and again by the included pass (passed in as - /// additional relationships). A shop with one shopkeeper and two item tags: - /// 1 + 2 = 3 relationships, collected twice = 6. - /// A relationship whose target has no EntityType (account) is dropped. - @Test - func dataRelationshipsAreCollectedTwice() throws { - let doc = try document(#""" - {"data": {"id": "s1", "type": "shop", "attributes": \#(Self.shopAttributes), "relationships": { - "shopkeeper": {"data": {"id": "k1", "type": "shopkeeper"}}, - "item_tags": {"data": [{"id": "t1", "type": "item_tag"}, {"id": "t2", "type": "item_tag"}]}, - "account": {"data": {"id": "a1", "type": "account"}} - }}} - """#) - - let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) - - #expect(cacheUpdate.relationships.count == 6) - // Each relationship's name is the key in `relationships`, from the shop - #expect(cacheUpdate.relationships.allSatisfy { $0.from.id == "s1" && $0.from.type == .shop }) - let pairs = cacheUpdate.relationships.map { "\($0.name)->\($0.to.id)" }.sorted() - #expect(pairs == [ - "item_tags->t1", "item_tags->t1", "item_tags->t2", "item_tags->t2", - "shopkeeper->k1", "shopkeeper->k1" - ]) - } - - /// Relationships of a resource without an EntityType are not collected - @Test - func relationshipsOfAnUnknownTypeAreDropped() throws { - let doc = try document(#""" - {"data": {"id": "x1", "type": "unknown", "relationships": { - "shopkeeper": {"data": {"id": "k1", "type": "shopkeeper"}} - }}} - """#) - - let cacheUpdate = try DataCacheUpdate.loadFrom(document: doc) - - #expect(cacheUpdate.relationships.isEmpty) - } - - /// merged(with:) appends the other update's lists after its own - @Test - func mergedAppendsInOrder() { - let first = DataCacheUpdate(shops: [Shop(id: "s1", name: "A", description: "", timeZone: "Tokyo")]) - let second = DataCacheUpdate(shops: [Shop(id: "s2", name: "B", description: "", timeZone: "Tokyo")]) - - #expect(first.merged(with: second).shops.map(\.id) == ["s1", "s2"]) - #expect(second.merged(with: first).shops.map(\.id) == ["s2", "s1"]) - } -} diff --git a/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift b/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift index 9e2b2a7..5feb5c1 100644 --- a/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift +++ b/NativeAppTemplateTests/Networking/JSONAPI/JSONAPIDocumentTest.swift @@ -7,10 +7,9 @@ import Foundation @testable import NativeAppTemplate import Testing -/// Characterizes how JSONAPIDocument, JSONAPIResource and JSONAPIRelationship read a -/// JSON:API document (https://jsonapi.org/format/#document-structure) as the API's -/// jsonapi-serializer gem writes it: `data` is an object for one resource and an array -/// for a collection, `relationships` map a name to `{ "data": identifier | [identifier] | null }`. +/// Characterizes how JSONAPIDocument and JSONAPIResource read a JSON:API document +/// (https://jsonapi.org/format/#document-structure) as the API's jsonapi-serializer +/// gem writes it: `data` is an object for one resource and an array for a collection. struct JSONAPIDocumentTest { private func document(_ json: String) throws -> JSONAPIDocument { try JSONAPIDocument(JSONSerialization.jsonObject(with: Data(json.utf8))) @@ -53,51 +52,15 @@ struct JSONAPIDocumentTest { #expect(doc.meta["created_shops_count"] as? Int == 3) } - /// Only string links that parse as URLs are kept; a links object - /// (`{"href": ...}`, allowed by JSON:API) and null are dropped + /// A missing id or type reads as "", which matches no EntityType @Test - func onlyStringLinksAreKept() throws { - let doc = try document(#""" - {"data": {"id": "1", "type": "shop", "links": {"self": "https://example.com/shops/1", "related": {"href": "https://example.com"}}}, - "links": {"next": "https://example.com/shops?page=2", "prev": null}} - """#) - - let next = try #require(URL(string: "https://example.com/shops?page=2")) - let selfLink = try #require(URL(string: "https://example.com/shops/1")) - #expect(doc.links == ["next": next]) - #expect(doc.data.first?.links == ["self": selfLink]) - } - - /// A resource's own meta is kept - @Test - func resourceMetaIsKept() throws { - let doc = try document(#"{"data": {"id": "1", "type": "shop", "meta": {"position": 2}}}"#) - - #expect(doc.data.first?.meta["position"] as? Int == 2) - } - - /// Resources in `data` and `included` point back to their document (weakly) - @Test - func resourcesKnowTheirDocument() throws { - let doc = try document(#""" - {"data": {"id": "1", "type": "shop"}, "included": [{"id": "k", "type": "shopkeeper"}]} - """#) - - #expect(doc.data.first?.parent === doc) - #expect(doc.included.first?.parent === doc) - } - - /// A missing id or type reads as "" (not the property's initial "0"), which - /// matches no EntityType - @Test - func resourceWithoutIdOrTypeHasEmptyStringsAndNoEntityID() throws { + func resourceWithoutIdOrTypeHasEmptyStringsAndNoEntityType() throws { let doc = try document(#"{"data": {"attributes": {"name": "A"}}}"#) let resource = try #require(doc.data.first) #expect(resource.id.isEmpty) #expect(resource.type.isEmpty) #expect(resource.entityType == nil) - #expect(resource.entityID == nil) } /// EntityType(from:) knows the API's snake_case type names (the serializers' @@ -110,10 +73,10 @@ struct JSONAPIDocumentTest { ]) func entityTypeFromTypeName(type: String, expected: EntityType) throws { let doc = try document(#"{"data": {"id": "7", "type": "\#(type)"}}"#) - let entityID = try #require(doc.data.first?.entityID) + let resource = try #require(doc.data.first) - #expect(entityID.type == expected) - #expect(entityID.id == "7") + #expect(resource.entityType == expected) + #expect(resource.id == "7") } /// Only those exact names: camelCase, capitalized, or a type EntityType does not @@ -123,44 +86,6 @@ struct JSONAPIDocumentTest { let doc = try document(#"{"data": {"id": "1", "type": "\#(type)"}}"#) #expect(doc.data.first?.entityType == nil) - #expect(doc.data.first?.entityID == nil) - } - - /// To-one, to-many and null relationships - @Test - func toOneToManyAndNullRelationships() throws { - let doc = try document(#""" - {"data": {"id": "1", "type": "shop", "relationships": { - "shopkeeper": {"data": {"id": "k1", "type": "shopkeeper"}}, - "item_tags": {"data": [{"id": "t1", "type": "item_tag"}, {"id": "t2", "type": "item_tag"}]}, - "owner": {"data": null, "meta": {"count": 0}} - }}} - """#) - let relationships = try #require(doc.data.first?.relationships) - // Not uniqueKeysWithValues: a duplicate name would trap and end the test run - let byName = Dictionary(relationships.map { ($0.type, $0) }) { first, _ in first } - - // The relationship's `type` is its name, not the related resource's type - #expect(relationships.count == 3) - #expect(Set(byName.keys) == ["shopkeeper", "item_tags", "owner"]) - #expect(byName["shopkeeper"]?.data.map(\.id) == ["k1"]) - #expect(byName["shopkeeper"]?.data.first?.type == "shopkeeper") - #expect(byName["item_tags"]?.data.map(\.id) == ["t1", "t2"]) - #expect(byName["owner"]?.data.isEmpty == true) - #expect(byName["owner"]?.meta["count"] as? Int == 0) - // Resource identifiers inside a relationship have no document - #expect(byName["shopkeeper"]?.data.first?.parent == nil) - } - - /// A relationship that is not an object is kept with no name and no data - @Test - func nonObjectRelationshipHasNoNameOrData() throws { - let doc = try document(#"{"data": {"id": "1", "type": "shop", "relationships": {"shopkeeper": "k1"}}}"#) - let relationship = try #require(doc.data.first?.relationships.first) - - #expect(doc.data.first?.relationships.count == 1) - #expect(relationship.type.isEmpty) - #expect(relationship.data.isEmpty) } /// Anything but a JSON object (e.g. an array body) is an empty document, not an error @@ -171,6 +96,5 @@ struct JSONAPIDocumentTest { #expect(doc.data.isEmpty) #expect(doc.included.isEmpty) #expect(doc.meta.isEmpty) - #expect(doc.links.isEmpty) } } diff --git a/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift b/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift index be9b358..6d024b5 100644 --- a/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift +++ b/NativeAppTemplateTests/Persistence/KeychainStoreTest.swift @@ -135,9 +135,8 @@ struct KeychainStoreTest { } /// An archive of another class (NSNumber where NSString is expected): - /// NSKeyedUnarchiver.unarchivedObject returns nil without throwing, so - /// retrieve() throws badData inside its do block, and the catch wraps that - /// in archiveFailure + /// NSKeyedUnarchiver.unarchivedObject returns nil without throwing, and + /// retrieve() throws archiveFailure wrapping badData @Test func retrieveOfAnotherClassIsAnArchiveFailureOfBadData() throws { let archived = try NSKeyedArchiver.archivedData(withRootObject: NSNumber(value: 1), requiringSecureCoding: true)