Repository navigation
fix(connector-rest): a baseUrl that carries a query string sends each action to its own path - #28767
Conversation
… action to its own path buildUrl parses the base and lifts its query off before joining the path, then puts the base parameters back ahead of the action's own (inline path query, then `query`); on a clash the action's value wins. A base without a query sends the same URL as before, and a path still only lands under the base's origin. The dogfood carry-forward pin gains a path-bearing dispatch. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5oht6tMAsnKGYsgbiT
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 2 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7004d830fa820773336b97babcbc5acf425daefc && git checkout 7004d830fa820773336b97babcbc5acf425daefc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 706ac52d570096a47b64283e970bc1a8e569bc91 a9a80de3789bc4b0c1e9e5ab9087dc7e01d3889f && git checkout -B drift-repro 706ac52d570096a47b64283e970bc1a8e569bc91 && git merge --no-ff a9a80de3789bc4b0c1e9e5ab9087dc7e01d3889f
node scripts/docs-audit/affected-docs.mjs --json 706ac52d570096a47b64283e970bc1a8e569bc91 |
|
domain:server#2 · os-project-manager · session_0186vfjHHMsRXC9EUT9hTFMo 席位核对ACCEPT · The maintainer's standing authorisation for landing reviewed PRs, verbatim: 「按照 pm-dispatch 规则复核过的都授权」.
Generated by Claude Code |
Requested by Leehom · project thread
Fixes #28736
Clause-②: no — no accepted value or public member changes; a path-bearing action on a base URL with a query now reaches its own path.
Metadata-protocol: none
维护者速读
baseUrl自带查询参数时(例如https://api.example.com/v1?region=eu),动作的路径不再被拼进查询串里,而是拼在基址路径后面:/items发往/v1/items?region=eu。基址的查询参数保留在前,动作自己的参数(写在 path 里的,或query里的,含 api-key 的查询参数)跟在后面;同名时以动作的值为准。?region=eu加/items得到?region=eu/items,请求打到/v1,作者只看到对方 404 或错数据,毫无线索。带固定查询参数的基址是常见的服务商写法(?api-version=…)。//other.example或完整 URL 仍是路径的一段,有测试钉住。回滚即还原本 PR。packages/connectors/connector-rest/src/rest-connector.ts的buildUrl(查询参数先摘下、再拼路径、再合并,同名动作优先);src/rest-connector.test.ts新增的 "base URL with a query string" 一节。pnpm --filter @objectstack/connector-rest exec vitest run src/rest-connector.test.ts,预期 24/24 通过(其中新增 14 例,10 例在 main 上为红)。What changes
buildUrlnow parses the base, lifts its query off, joins the action path onto the base's path exactly as before (trailing slashes of the base dropped, one slash between), and puts the base's query parameters back first, then the action's own: those written inline inpath, thenquery(an api-keyparamNameridesquery). Sohttps://api.example.com/v1?region=euplus/itemssendshttps://api.example.com/v1/items?region=eu(onmain:https://api.example.com/v1?region=eu/items, which goes to/v1).queryor inline inpath) replaces the base's value. Picked over refusing because refusing would break a working action the day its base gains the parameter; stated in the code comment and the changeset.//evil.example/x,https://evil.example/x, a backslash form or/@evil.examplestay path segments (pinned, for a base with and without a query). A base fragment keeps the place the text join gave it (measured below).connector-meta-door-entry-and-url-hardening.dogfood.test.tsdispatched with an empty path only to avoid this bug; it now also dispatches/probethrough the materialized instance and pins/v1/probe?api_key=…®ion=euat the upstream.Tests
rest-connector.test.ts, new describe "base URL with a query string" (14 cases): a path, a path without a leading slash, a base with a trailing slash, a base with no path, several base parameters, an empty path; actionquerymerged after the base query; a clash withqueryand a clash with an inline path query (action wins); an api-key query parameter beside the base query; the base fragment's place; a path never leaving the base origin (base with and without a query, five hostile path shapes); a base without a query sending the text join's URL (five shapes including//evil.example/xand an inline path query).Red on base, green on head, ablations, controls
rest-connector.tsrestored to base7a48bf73:10 failed | 14 passed (24), the ten query-base cases; the empty-path, fragment, no-query origin and no-query byte-identity cases and every existing case pass on base. Restored withgit checkout HEAD,git diff HEADempty.rest-connector.tsat base,@objectstack/connector-restrebuilt (new code absent fromdist/):1 failed | 8 passed (9), Expected/v1/probe?api_key=TEST_PLACEHOLDER_CARRIED_QUERY®ion=eu, Received/v1?api_key=TEST_PLACEHOLDER_CARRIED_QUERY®ion=eu/probe. Restored and rebuilt (new code back indist/), tree clean.24 passed (24); package suite5 passedfiles,57 passedtests; dogfood file9 passed (9).scripts/ablation-replace.mjs: the base query is not lifted,base.search = base.search;):10 failed | 14 passed (24), the same ten query-base cases only. Restored: blob9180629b== HEAD,git diff HEADempty.1 failed | 23 passed (24), the inline clash case only. Restored: blob == HEAD,git diff HEADempty.dist/, not a kept test): 10 query-free bases (incl. uppercase host,//v1//, dot segments, userinfo, explicit default port, a fragment, an encoded space) × 16 paths (incl.//evil.example/x, an absolute URL, a backslash form,/../x,?only=1,/items?,//) × 3queryvalues = 480 cases: new URL == the base text join's URL in all 480. Positive control: adding one query base to the same sweep makes 45 of its 48 cases differ (the 3 identical are the empty path).Base readings (text join on main)
https://api.example.com/v1?region=eu+/items→https://api.example.com/v1?region=eu/items(request goes to/v1).…/v1?region=eu+/itemswithquery: { region: 'us' }→https://api.example.com/v1?region=us(the path is lost entirely).…/v1#frag+/items→https://api.example.com/v1#frag/items;…/v1?region=eu#frag+/items→…/v1?region=eu#frag/items. Both unchanged on head: the path still lands inside a base fragment, so the wire request goes to/v1.…/v1+//evil.example/x→https://api.example.com/v1//evil.example/x;…/v1+https://evil.example/x→https://api.example.com/v1/https://evil.example/x. No path left the base host on main either; unchanged on head.Package suite and type check
pnpm --filter @objectstack/connector-rest build && pnpm --filter @objectstack/connector-rest typecheck: exit 0 (check-dts-emitted2/2,check:test-typecheckOK).pnpm --filter @objectstack/connector-rest test:5 passedfiles,57 passedtests.Acceptance notes
packages/spec); neither sits inbuildUrlor therestprovider, and none is touched here. What stops a path from changing the origin is the append-only join, unchanged, and now pinned for both base shapes.#in abaseUrlever meaningful for an HTTP API? Not handled here; with the seat.//keeps its double slash after the base path, as on main; "one slash between" holds for every path that does not itself start with two slashes.Verification
Every local reading above is on head
a9a80de3. Derived gate families (node scripts/pm/dispatch-gates.mjs --commands, reconciled with--ran): 61 derived, 60 run with exit 0, 1 not measured (check:dual-build-cjs-loads, exit 3: its prerequisite, every package'sdist/, is not met in this checkout).check-plugin-teardown-shape --self-testfirst answered exit 3 (its pinned fixture commit was outside the shallow clone); aftergit fetch --depth=1of that commit it answered exit 0 (48 cases).check-nul-bytes: OK. The repo-wide lint,test:repoand the full farm are CI's. Full dogfood not run: no!in the title and no(narrowing)arm. Overlap: no open PR touchespackages/connectors/connector-rest/or the dogfood pin (14 open code PRs' heads diffed against their merge bases).🤖 Generated with Claude Code
https://claude.ai/code/session_01FEkQ5oht6tMAsnKGYsgbiT