Skip to content

refactor(api): collapse three query-value encoders into one - #111

Merged
Adron merged 1 commit into
mainfrom
refactor/shared-query-value-encoder
Sep 17, 2026
Merged

Adron merged 1 commit into
mainfrom
refactor/shared-query-value-encoder

Conversation

@Adron

@Adron Adron commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary

Closes #95. CharacterSet.urlQueryAllowed permits +, &, =, ?, # and /, so
percent-encoding a query value with it leaves them intact. A raw + reaches the backend as a
space (params are read through URLSearchParams); a raw & or = splits the value into
another query parameter
. For a search box that silently searches for the wrong thing; for an
opaque base64 cursor, paging quietly breaks with no error.

Three independent local fixes for this had accumulated on parallel branches. Now that #86 and #93
are both on main, all three live in one tree and collapse into a single helper.

What's included

  • One queryValue(_:) in Services/APIClientTransport.swift, internal rather than private
    so the per-feature APIClient+*.swift extensions can see it (private is file-scoped in Swift —
    that internal seam is deliberate, see CLAUDE.md). Allowed set is
    .urlQueryAllowed.subtracting(CharacterSet(charactersIn: "+&=?#/")), and the "why" note travels
    with it.
  • pathSegment(_:) and queryValue(_:) now sit adjacent under a // MARK: - URL encoding
    heading, each documented against the other, so the next caller sees both and picks the right one
    without having to reason it out. Path encoding via .urlPathAllowed is otherwise untouched —
    different job, different allowed set.
  • All three local copies deleted: orgQueryValueAllowed (APIClient+Organizations.swift),
    encodedQueryValue (APIClient+DirectMessages.swift), queryValueAllowed
    (APIClient.swift). A grep confirms exactly one query-value encoder remains in the codebase.
  • No behaviour change beyond correct encoding. Nothing moved between files, nothing restructured.

Call sites converted

Six raw .urlQueryAllowed values in Services/APIClient.swift — exactly the count the issue
estimated, though the line numbers had shifted:

Method Value
searchDocuments(q:limit:offset:) search term
searchLists(q:limit:offset:) search term
searchWatcherCandidates(listId:…:search:) search term
searchDocumentCollaboratorCandidates(id:query:) search term
searchMessages(q:limit:offset:) search term
dmThreadUpdates(username:after:) after cursor

Three sites already encoding correctly were repointed at the shared helper, no behaviour change:
unlinkIdentity(provider:), organizationUsers(id:search:excludeMembers:…) (both search and
excludeMembers — the literal commas the backend splits on are still preserved), and
dmConversations(cursor:take:).

Testing

InterlinedListTests/APIClientTests/APIClientQueryValueEncodingTests.swift (new, hand-slotted into
project.pbxproj) covers every converted site plus the three pre-existing ones. Each case asserts
the value round-trips unchanged through URLComponents(...).queryItems, that it adds no extra query
parameter, and that the wire form carries no literal + — the last one matters because a raw +
survives URLComponents intact and only goes wrong server-side. A tenth test pins that path
segments stay path-encoded, so the two helpers can't be quietly swapped.

  • Search term c++ & a=b (contains +, &, =, space) round-trips unchanged.
  • Base64 cursor YWJjKz0vZGVm+/w== (contains +, /, =) round-trips unchanged.
  • The existing DM-conversations cursor test and identity-unlink test still pass.

Full suite: 1330 tests, 0 failures (baseline on main was 1320; +10 new), run with
-parallel-testing-enabled NO -skip-testing:InterlinedListTests/E2EReadOnlyTests on a pinned
simulator UDID and a worktree-local DerivedData.

The tests bite. Reverting the allowed set to plain .urlQueryAllowed and re-running fails all 9
encoding tests in the new file and the 3 pre-existing ones (32 assertion failures). The 10th new
test, the path-encoding guard, correctly stays green. Helper restored and the full suite re-run
green afterwards.

Noted, not fixed (out of scope)

Path segments are still encoded inline as
id.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? id at many call sites even
though pathSegment(_:) exists for exactly that. Correct as written, just not using the helper —
worth a separate sweep.

🤖 Generated with Claude Code

`.urlQueryAllowed` permits `+ & = ? # /`, so percent-encoding a query
*value* with it leaves them intact: a raw `+` reaches the backend as a
space (params are read via `URLSearchParams`) and a raw `&` or `=`
splits the value into another query parameter. Six call sites still
passed user-typed search terms and an opaque cursor through the raw set,
while three separate local fixes for the same hazard had accumulated in
parallel branches.

Hoist one `queryValue(_:)` into `APIClientTransport.swift`, next to
`pathSegment(_:)` under a shared "URL encoding" heading so the next
caller sees both and picks deliberately. It is `internal`, not
`private`, so the per-feature `APIClient+*.swift` extensions can reach
it. The three local copies (`orgQueryValueAllowed`, `encodedQueryValue`,
`queryValueAllowed`) are gone.

Path encoding via `.urlPathAllowed` is untouched — different job,
different allowed set.

Closes #95.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017bss5MgZa7Jvj2m9zdaUd1
@Adron
Adron merged commit 4e64be5 into main Sep 17, 2026
1 check passed
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.

Hoist a shared query-value encoder — six call sites still use raw .urlQueryAllowed for user-typed values

1 participant