Skip to content

Refactor networking, JSON:API parsing, keychain reads and sign-out - #97

Merged
dadachi merged 5 commits into
mainfrom
refactor/networking-jsonapi
Oct 13, 2026
Merged

dadachi merged 5 commits into
mainfrom
refactor/networking-jsonapi

Conversation

@dadachi

@dadachi dadachi commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

Phase 5 (refactoring). This mirrors the paid app's nativeapptemplate/NativeAppTemplate-iOS#117 where the same code exists in Free. It builds on the characterization tests from #96. One bug fix comes first, as a separate commit. Apart from that fix, behavior doesn't change.

0. Bug fix (separate commit): failures were logged under the wrong action

  • What was wrong. Five repository methods logged their failure with the wrong Failure action:

    • AccountPasswordRepository.update: logged destroy, now update.
    • SignUpRepository.signUp: logged fetch, now create.
    • SignUpRepository.destroy: logged fetch, now destroy.
    • LoginRepository.login: logged fetch, now login.
    • LoginRepository.logout: logged fetch, now logout.

    These are the same mislabels the paid app fixed in #118. The two send…Instruction methods still log fetch, as in paid.

  • Reproduction test: AccountPasswordRepositoryTest.failedUpdateIsLoggedAsAnUpdateFailure.

    • It reads the log back through OSLogStore(scope: .currentProcessIdentifier), the same way the existing SessionControllerTest.failedLogoutIsLoggedAsALogoutFailure does.
    • Red check: before the fix, the test failed on the iPhone on both expectations: there was no Failed_update, and there was a Failed_destroy. After the fix it passes.
  • Why the other four have no test:

    • login and signUp build their own client with the fixed .prod environment. A test can't point them at TestHTTPServer without a new seam.
    • logout and destroy always delete the app's real saved session (LoggedInShopkeeperKeychainStore). The suite deliberately leaves that item alone (see KeychainStoreTest), and deleting it on the device would sign the app out.
    • So these four are one-word fixes without a test.

1. Service and RootPathService share one implementation

  • Shared pieces (the same as paid):
    • URLRequest(url:queryItems:for:) sets the method, the JSON Content-Type and the body.
    • URLSession.apiResponse(to:sending:announcingRejectionOf:) sends the request. A non-2xx response becomes requestFailed(status, error_message). When devise_token_auth answers with its 401, the rejected token is announced.
    • NativeAppTemplateAPI.sourceHeader replaces the second "ios" literal.
  • The differences are now explicit at the call site:
    • Path: Service sends to baseURL/{accountId}/api/v1/{path} with the request's query items. RootPathService sends to baseURL/{path} with queryItems: [].
    • Session headers: Service always sends them. RootPathService sends them only when signed in, and otherwise sends just source.
    • Expired-session notice: Service passes its authToken. RootPathService passes nil, so it never announces an expired session.
  • Error parsing is equivalent. RootPathService used a do/catch and Service used try?. Either way, a body that isn't JSON gives no message.
  • No test expectation changed. ServiceTest, RootPathServicesTest, SessionsServiceTest, SignUpsServiceTest and the header tests are unchanged. That includes the test that checks for one source header, not ios,ios.

2. JSON:API layer: remove what no caller reads

  • What changed:
    • JSONAPIDocument is now a struct with meta, data and included.
    • JSONAPIResource is now a struct with id, type and attributes.
    • The document and resource links, the resource meta, the relationships and the weak parent are removed.
    • The adapters become caseless enums with process(resource:). SwiftFormat's enumNamespaces asks for this once the protocol conformance is gone.
  • Deleted, along with their pbxproj entries (4 each):
    • JSONAPIRelationship.swift.
    • DataCacheUpdate.swift. In Free, DataCacheUpdate.loadFrom has no production caller: every request adapts doc.data itself. Unlike paid, nothing still needs it.
  • Also removed: the EntityAdapter protocol (never used as a type), EntityIdentity and EntityRelationship. EntityType stays.
  • Same behavior: resources are still read only from JSON objects. The old "0" default id was always overwritten by ?? "", so no reader saw it.
  • Test changes. Each removed or changed test pinned only internal detail that is gone:
    • DataCacheUpdateTest.swift is deleted. Its tests characterized DataCacheUpdate itself, and no request calls it. Its own doc comment said the tests existed "before it is simplified or removed". The cases it covered:
      • type sorting;
      • skipping other types;
      • "invalid included fails";
      • relationships collected twice;
      • unknown-type relationships dropped;
      • merged(with:).
    • JSONAPIDocumentTest:
      • Removed onlyStringLinksAreKept, resourceMetaIsKept, resourcesKnowTheirDocument, toOneToManyAndNullRelationships and nonObjectRelationshipHasNoNameOrData. Links, resource meta, parent and relationships are gone.
      • resourceWithoutIdOrTypeHasEmptyStringsAndNoEntityID is renamed …NoEntityType. It and entityTypeFromTypeName now check entityType/id instead of entityID.
      • otherTypeNamesHaveNoEntityType and nonObjectJsonIsAnEmptyDocument each lose one line, on entityID and links.
    • Kept unchanged: single-object and array data, data order, included read as an array or an object, top-level meta, an empty document from non-object JSON, and every adapter and request test.

3. KeychainStore.retrieve() error handling is explicit

  • Keychain read errors: an error from keychain.data(for:) is now secCallFailed(error). Before, try? turned it into notFound. store and remove already map read errors this way.
  • Why app behavior doesn't change: the only caller, LoginRepository.currentShopkeeper, catches every error, logs it and treats the user as signed out. Only the debug log differs.
  • Not tested: there is no seam over Keychain to make the read fail.
  • The badData branch:
    • An archive of another class decodes to nil without an error. It still ends as archiveFailure(badData), now outside the do/catch.
    • retrieveOfAnotherClassIsAnArchiveFailureOfBadData already pins exactly this and is unchanged. Only its comment, which described the old control flow, is updated.

4. SessionController.logout()

Red checks

  • failedUpdateIsLoggedAsAnUpdateFailure failed on the iPhone before the fix (2 issues) and passes after it.
  • No other new tests.

Local checks

  • make lint: passes. SwiftLint found 0 violations, and SwiftFormat found 0 of 134 files to format.
  • Full suite on the iPhone (00008150-0019095C1A08401C):
    • Swift Testing: 427 tests in 67 suites passed, with 1 known issue (WaitUntilTest's intentional withKnownIssue).
    • Across all 563 test cases, nothing failed. 6 were skipped: the LocalizationTest catalog checks, which run only on the simulator. No strings changed.
  • pbxproj: the diff contains only the removal of JSONAPIRelationship.swift and DataCacheUpdate.swift. Xcode's re-sort noise was reverted.

Device check (local API at 192.168.1.11:3000, server log)

  • App launch:
    • I installed the Debug build with devicectl, without uninstalling, and launched it.
    • The saved session was read through the refactored KeychainStore.retrieve.
    • The server log shows GET .../api/v1/shopkeeper/permissions and GET .../shopkeeper/shops, both 200.
  • Walk-through: I ran a temporary test on the iPhone, not committed, through the refactored services as free-ios-qa@example.com. Server log:
    • POST /shopkeeper_auth/sign_in returned 200. It went to the root path, with no account and no /api/v1.
    • permissions, shops, shops/:id, shops/:id/item_tags and item_tags?page=2 returned 200. The query item was sent.
    • DELETE /shopkeeper_auth/sign_out returned 200.
    • A request with the signed-out token then got a 401, and .apiSessionDidExpire was posted with that token.

Reported, not changed

  • RootPathService drops query items. No devise_token_auth request has any, so this is only latent. It's kept, and it's now visible as queryItems: [].
  • Not done:
    • A seam over Keychain to test the secCallFailed read path.
    • A seam for LoginRepository/SignUpRepository's environment and keychain, to test their log actions.

🤖 Generated with Claude Code

https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH

dadachi and others added 5 commits October 13, 2026 13:43
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
…hService

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K5hZEFqXhBsUQwSWsgR6eH
@dadachi
dadachi merged commit 63daa3a into main Oct 13, 2026
2 checks passed
@dadachi
dadachi deleted the refactor/networking-jsonapi branch October 13, 2026 04:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant