Skip to content

Fix path building: encode values once and keep literal slashes - #24

Merged
zac merged 2 commits into
mainfrom
claude/fix-path-double-encoding
Oct 25, 2026
Merged

zac merged 2 commits into
mainfrom
claude/fix-path-double-encoding

Conversation

@zac

@zac zac commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Summary

This PR fixes two long-standing bugs in how PathTemplate builds request paths:

  1. Values were percent-encoded twice. a b went out as a%2520b.
  2. includesSlash: false deleted slashes written in the template. "testing/\(path: \.name, includesSlash: false).test" built testingfirst.test.

1. Encode path values exactly once

The bug had been there since the first commit. String.pathSafe encoded each value, then urlRequest(in:) assigned the joined path to URLComponents.path, which encodes again. In 2019, 5077dd2 ("Possible fix for double-encoding issues") fixed the same problem for query and form values, but the path side was missed, because every path test used plain values. I found no issue, PR, or test that relied on the double encoding.

  • String.pathSafe encodes a value as one segment, including /, ?, and #. It keeps . and .. as encoded segments so URL resolution doesn't collapse them.
  • Every component goes through a normalizer, and the result is assigned to percentEncodedPath. The normalizer keeps valid %XX escapes and encodes anything else not allowed in a path. Custom PathRepresentable values that return raw text are now encoded instead of trapping.
  • Literal template text keeps its slashes and honours %XX escapes. Form body encoding is byte-identical to before.

2. Keep literal slashes next to embedded values

This was the failing test added on feature/path-slash-fix in January 2021, which never got a fix. includesSlash: false removed whatever slash came before the value. It now removes only a slash the template inserted automatically.

Default interpolations are unchanged: each value is its own path segment, so "files/\(path: \.id).json" still builds files/42/.json. Embedding a value uses includesSlash: false, giving files/42.json. The PathTemplate docs and Examples.md now state the rule. No existing test expectation changed.

Behaviour changes to review

  • A / inside a String value is now sent as %2F. Before, "folder/file.txt" became two segments. This is the one change someone might have relied on deliberately. It matches RFC 3986 and OpenAPI, and it stops values from adding path structure. To insert a value that spans several segments, conform a type to PathRepresentable and return its separators unencoded.
  • Pass raw values. A pre-encoded a%20b is now sent as a%2520b (a literal %20).
  • Templates that combine literal / with includesSlash: false now keep that slash. They previously lost it.

Testing

  • Encoding: new tests assert both url.absoluteString and percentEncodedPath for:
    • a space, %, Unicode, embedded / and //, ?, #, . and .., and a pre-encoded value
    • custom conformers returning raw text, valid escapes, invalid escapes, and incomplete escapes
    • a value adjacent to literal text, optional values, and literal template encoding
  • Slashes: new tests cover:
    • the two 2021 cases
    • an embedded files/{id}.json
    • two embedded values in one segment
    • the default files/42/.json, pinned
    • nil optionals at the start, middle, and end
  • All of these failed before the fixes.
  • macOS (Swift 6.4): 103 tests pass.
  • Linux (swift:6.0 in Docker): 55 tests pass. Linux runs fewer suites because EndpointsMocking is Apple-only.

Coverage (line, macOS)

PathTemplate.swift 98.3%, Endpoint+URLRequest.swift 97.0%, Array+Form.swift 85.7%. Endpoints overall is 74.9%; the low-coverage files aren't touched by this PR.

String.pathSafe percent-encoded each value, and urlRequest(in:) then
assigned the joined path to URLComponents.path, which encodes again. A
value like "a b" went out as "a%2520b". This has been the case since the
first commit; 5077dd2 fixed the same problem for query and form values in
2019, but the path side was missed because every path test used plain
values.

- String.pathSafe encodes a value as one segment, including "/", "?" and
  "#", and keeps "." and ".." as encoded segments so URL resolution
  doesn't collapse them.
- PathTemplate passes every component through a normalizer that keeps
  valid %XX escapes and encodes anything else not allowed in a path. The
  result is assigned to percentEncodedPath, so nothing is encoded twice
  and custom PathRepresentable values that return raw text don't trap.
- Literal template text keeps its slashes and honours %XX escapes.
- Form body encoding is byte-identical to before.
includesSlash: false removed whatever slash came before the value,
including one written in the template, so
"testing/\(path: \.name, includesSlash: false).test" built
"testingfirst.test". It now removes only a slash the template inserted
automatically, which is what the 2021 failing test on feature/path-slash-fix
expected.

Default interpolations are unchanged: each value is its own segment, so
"files/\(path: \.id).json" still builds "files/42/.json". Embed a value with
includesSlash: false ("files/42.json"). The PathTemplate docs and
Examples.md now state the rule.
@zac zac changed the title Encode path parameter values exactly once Fix path building: encode values once and keep literal slashes Oct 24, 2026
@zac
zac merged commit 43b8c4e into main Oct 25, 2026
2 checks passed
@zac
zac deleted the claude/fix-path-double-encoding branch October 25, 2026 15:49
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