Skip to content

fix: keep every part of an api request the caller wrote - #311

Open
mkb79 wants to merge 7 commits into
masterfrom
fix/api-takes-a-path
Open

fix: keep every part of an api request the caller wrote#311
mkb79 wants to merge 7 commits into
masterfrom
fix/api-takes-a-path

Conversation

@mkb79

@mkb79 mkb79 commented Aug 26, 2026

Copy link
Copy Markdown
Owner

audible api is for the Audible API and for json. This narrows it to
that, and keeps every part of a request the caller wrote.

What was wrong

Reproduced against master:

Command Was
api "library?num_results=5" asked for the whole library — the query was erased
api library -p asins=A -p asins=B asked after one book
api library -p k=a=b / -p broken / -b '{' traceback, exit 3
api library -i vier exit 0, every nested line prefixed with vier
api library -f dict -o out.json TypeError, exit 3
api https://foreign.host/collect sent as given, signed for that host
a 200 that is not json quoted into a json string, exit 0
api library --timeout 60 gave up after the library's 10 seconds

The query was erased because the command always passed params={}, and
httpx replaces the url query with whatever params says rather than
merging the two.

What it does now

The endpoint is a path. The marketplace comes from the profile or
--country-code, so it is decided in one place, and a url is refused
before any credentials are loaded. A query written into the path and one
given with --query add up, repeats included. The answer has to be
json, and json is what stdout carries.

What the command cannot send it refuses rather than drops: a body on a
GET or a DELETE, a body of null, NaN and Infinity, an endpoint
that names nothing, a fragment.

New: --header/-H (repeatable, the same name twice included; the
headers that carry the authentication or describe the body are refused),
--body-file (a file, or - for standard input), --dump-header/-D
(the status line and the response headers, written even when the call
failed — total-count and continuation-token live there).

--param is now --query/-q and says it is old, which gives -p back
to the group, where it is the password. --format takes only json and
says it is on its way out; -f dict printed a Python literal that no
json reader takes.

Not covered here

The hosts that are not the API, and answers that are not json, move to
audible request, which follows before the release. Nothing is dropped
without a replacement being there.

Tests

The command had none. 60 now, including one that goes through the real
AsyncClient behind a MockTransport and reads the url off the
outgoing request — a stand-in client cannot show the httpx behaviour
that erased the query.

mkb79 added 7 commits August 26, 2026 14:08
The endpoint went to the client as it was typed and the query was built
beside it, so parts of a request never left the machine and parts that
did were not the ones that had been written.

- A query written into the endpoint was erased. `api "library?num_results=5"`
  asked for the whole library, because the command always passed
  `params={}` and httpx replaces the url query with whatever `params`
  says rather than merging the two.
- The same key twice kept only the last, so `-p asins=A -p asins=B`
  asked after one book.
- `-p k=a=b`, `-p broken` and `-b '{'` reached the code that cannot take
  them and left a traceback and exit 3. `-i vier` was worse: it exited 0
  after prefixing every nested line of the answer with `vier`.
- An absolute url was sent as given, signed for the host it named.
- A 200 carrying something other than json became a json string and
  exited 0, so a maintenance page read as success.
- The client was built here instead of through the session, so
  `--timeout` did nothing and every call gave up after the ten seconds
  the library defaults to.
- `-f dict -o FILE` handed a dict to `write_text` and died with a
  `TypeError`.

The endpoint is a path now. The marketplace comes from the profile or
`--country-code`, and a url is refused before any credentials are
loaded. A query in the path and one in `--query` add up, repeats
included. The answer has to be json, and json is what stdout carries.

What the command cannot send, it refuses rather than drops: a body on a
GET or a DELETE, a body of `null` (the client passes it to httpx as
`json=None`, which writes an empty body), `NaN` and `Infinity`, an
endpoint that names nothing, a fragment.

`--param` is spelled `--query`, which gives `-p` back to the group it
belongs to, where it is the password. `--header`, `--body-file` and
`--dump-header` are new; the headers that carry the authentication or
describe the body are refused. `--format` takes only json and says it is
on its way out.
The command had no tests. The suite pins the request that goes out and
the answers the command gives back: the query from the path and from the
options together, repeats and values carrying `=`, percent escapes the
path keeps, headers that repeat and headers that are refused, the body
from an option, a file and a pipe, and every refusal that happens before
a client is built.

One case goes through the real `AsyncClient` behind a `MockTransport`
and reads the url off the outgoing request. A stand-in client cannot
show that httpx replaces the query rather than merging it, which is the
mistake that erased the query in the first place.
The README section says that the endpoint is a path, how the two ways of
writing a query add up, where the paging headers come from, and what
happened to `-p`.
The file leaves the formatter's exclusion list. Layout only: an option
decorator puts each argument on its own line, the continuation lines of
a help text line up with the arguments around them, and the set of
refused headers spreads over the frozenset call.
Each parameter type names what it converts to, through the type
argument click's ParamType carries: `ApiPath` a path and its query, the
two pair types a name and a value. What stays `Any` is what click hands
over as `Any` -- the string a converter is given, and the mapping of
options a command receives.

Under `mypy --strict` the file is left with the two errors its
decorators bring: `run_async` and `timeout_option` are untyped, which
makes every command they wrap untyped.
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