From adfc4364267c701c0395ec470b93da6a227aa8a3 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 13:43:55 +0900 Subject: [PATCH 1/5] Log each repository failure under the action that failed AccountPasswordRepository.update logged a failure as Failed_destroy. LoginRepository.login and .logout, and SignUpRepository.signUp and .destroy, logged theirs as Failed_fetch. They now use update, login, logout, create and destroy. failedUpdateIsLoggedAsAnUpdateFailure reads the log back through OSLogStore, the same way failedLogoutIsLoggedAsALogoutFailure does. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../AccountPasswordRepository.swift | 2 +- NativeAppTemplate/Login/LoginRepository.swift | 4 +-- .../Login/SignUpRepository.swift | 4 +-- .../AccountPasswordRepositoryTest.swift | 30 +++++++++++++++++++ 4 files changed, 35 insertions(+), 5 deletions(-) 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/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/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 ") }) + } } From af98a69213fe27d9ec11e6e68f57adfd22558126 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 13:46:19 +0900 Subject: [PATCH 2/5] Share request building and error handling between Service and RootPathService URLRequest(url:queryItems:for:) builds the request and URLSession.apiResponse(to:sending:announcingRejectionOf:) sends it and maps a non-2xx response to requestFailed. NativeAppTemplateAPI.sourceHeader replaces the second "ios" literal. The differences between the two services are now explicit at the call sites: the path prefix, query items (none for RootPathService), session headers only when signed in, and no expired-session notice from devise_token_auth's root routes. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate/Login/RootPathService.swift | 62 +++---------- .../Network/NativeAppTemplateAPI.swift | 5 +- .../Networking/Services/Service.swift | 93 ++++++++++++------- 3 files changed, 80 insertions(+), 80 deletions(-) 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/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] ) } From ee98d4993410b66885d151d206c6582a385c6862 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 13:46:19 +0900 Subject: [PATCH 3/5] Drop the JSON:API relationships, links, parent and DataCacheUpdate No request reads them: every request adapts doc.data itself, and DataCacheUpdate.loadFrom had no production caller. JSONAPIDocument and JSONAPIResource become structs with only what the requests and adapters read. JSONAPIRelationship, DataCacheUpdate, the EntityAdapter protocol, EntityIdentity and EntityRelationship are removed, and the adapters become caseless enums with process(resource:). Tests that only pinned the removed internals go with them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- NativeAppTemplate.xcodeproj/project.pbxproj | 8 -- .../Networking/Adapters/DataCacheUpdate.swift | 81 ------------ .../Networking/Adapters/EntityAdapter.swift | 24 +--- .../EntityAdapters/ItemTagAdapter.swift | 8 +- .../Adapters/EntityAdapters/ShopAdapter.swift | 8 +- .../EntityAdapters/ShopkeeperAdapter.swift | 8 +- .../ShopkeeperSignInAdapter.swift | 8 +- .../Networking/JSONAPI/JSONAPIDocument.swift | 49 +++---- .../JSONAPI/JSONAPIRelationship.swift | 33 ----- .../Networking/JSONAPI/JSONAPIResource.swift | 59 ++------- .../Adapters/DataCacheUpdateTest.swift | 123 ------------------ .../JSONAPI/JSONAPIDocumentTest.swift | 92 ++----------- 12 files changed, 48 insertions(+), 453 deletions(-) delete mode 100644 NativeAppTemplate/Networking/Adapters/DataCacheUpdate.swift delete mode 100644 NativeAppTemplate/Networking/JSONAPI/JSONAPIRelationship.swift delete mode 100644 NativeAppTemplateTests/Networking/Adapters/DataCacheUpdateTest.swift 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/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/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) } } From cd1f3e4b7a06557c991f2fbb95231618f1e407b3 Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 13:46:19 +0900 Subject: [PATCH 4/5] Make KeychainStore.retrieve's error handling explicit A keychain read error is now secCallFailed instead of being turned into notFound by try?, as store and remove already do. The badData case for an archive of another class keeps its error, archiveFailure(badData), now outside the do/catch. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../KeychainStore/KeychainStore.swift | 28 ++++++++++++------- .../Persistence/KeychainStoreTest.swift | 5 ++-- 2 files changed, 20 insertions(+), 13 deletions(-) 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/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) From b328bb465ccb04af58bbbdfa46f7079807a44d1f Mon Sep 17 00:00:00 2001 From: dadachi Date: Tue, 13 Oct 2026 13:46:19 +0900 Subject: [PATCH 5/5] Clear the session once in SessionController.logout shopkeeper's didSet already resets userState and permissionState, so one defer replaces the duplicated lines in both branches. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH --- .../Sessions/SessionController.swift | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) 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 } }