Skip to content

fix(bfabric): canonicalise base_url without a trailing slash - #596

Draft
leoschwarz wants to merge 3 commits into
mainfrom
fix/base-url-canonical-form
Draft

fix(bfabric): canonicalise base_url without a trailing slash#596
leoschwarz wants to merge 3 commits into
mainfrom
fix/base-url-canonical-form

Conversation

@leoschwarz

@leoschwarz leoschwarz commented Aug 13, 2026

Copy link
Copy Markdown
Member
  • Change BfabricClientConfig.base_url to be canonicalised without a trailing slash. Config files and connect_* arguments still accept one.
  • Add bfabric.BaseUrl, a str subclass holding a validated slash-free instance URL, so an unvalidated base URL is a type error rather than a stray // or a token-cache miss. It behaves as a str everywhere, so interpolation, comparison and dict keys are unaffected.
  • Change BfabricClientConfig.base_url, Entity.bfabric_instance and EntityUriComponents.bfabric_instance (was a pydantic HttpUrl) to this type, and require it for EntityReader's bfabric_instance arguments. Constructing BfabricClientConfig directly now needs BaseUrl(...); model_validate still accepts a plain string.
  • Change connect_oauth / connect_pkce / connect_device_code / connect_pat and WebappClient.create to canonicalise base_url, so a mixed-case host or a default port is normalised too and a non-HTTP URL is rejected up front with a plain ValueError.
  • Fix the SUDS and Zeep WSDL URLs, and the show.html links printed by bfabric_read and bfabric-cli api read, to no longer contain a doubled slash.
  • Fix validate_token to canonicalise both the token's caller and the configured supported_bfabric_instances before comparing, so a trailing slash on either side no longer rejects a valid token.
  • Change bfabric-cli auth register to canonicalise its base_url argument like the other auth commands.
  • Change bfabric-cli auth login / auth pat to write the config with yaml.safe_dump, matching the safe_load used to read it back.
  • Cached OAuth tokens are unaffected: the cache key is byte-identical to the old one, so nobody has to re-login.
  • The integration tests in bfabricPy-tests may assert the old trailing-slash form of base_url.

Closes #576

🤖 Prepared with assistance from Claude Opus 5 via Claude Code.

`_validate_base_url` appended a trailing slash that essentially nothing
wanted: 16 call sites stripped it right back off to build a URL, and the
two SOAP engines forgot to, emitting `.../bfabric//workunit?wsdl` on every
`Bfabric.connect()`. The CLI's `normalize_base_url` had already settled on
the slash-free form, which is also what gets persisted to `~/.bfabricpy.yml`.

Flip the canonical form and canonicalise once at each public boundary --
the `connect_*` classmethods and `WebappClient.create` now build the
`BfabricClientConfig` first and read `base_url` back from it -- so the
downstream strips become dead rather than merely relocated. Going through
the validator is also stronger than `rstrip`: it lowercases the host, drops
a default port, and rejects a non-HTTP URL up front.

`entities/core/uri.py` had to flip alongside. It hardcoded the instance
component as `.../bfabric/`, and `EntityReader` compares that for string
equality against `config.base_url` at three sites, so changing only the
config would have made every `read_uris`/`query` raise "Unsupported
B-Fabric instance". `EntityUri` strings are unaffected -- `as_uri()`
appends its own slash before `urljoin`.

Two strips survive deliberately. `api_to_rest_url` is exported from
`bfabric.transfer` and its strip is load-bearing for the `.../api/` case,
where `endswith("/api")` is False without it. The one in
`compute_token_cache_path` is gone instead: every caller passes a
canonical value and the resulting key is byte-identical to today's, so no
cached token is orphaned -- a test now pins exactly that.

The trailing slash was originally introduced in #341 so that
`urljoin(base_url, ...)` in `Entity.web_url` would not drop the `/bfabric`
path segment. That call site has since been replaced by the `EntityUri`
machinery, and both remaining `urljoin` callers append their own slash, so
the reason no longer applies.

Closes #576
…stem

The slash-free canonical form was a convention nothing checked: a caller
could hand a raw string to `pkce_login` or `compute_token_cache_path` and
nothing would object. That is not hypothetical -- `bfabric-cli auth register`
was passing a raw `--base-url` straight into `register_client`, previously
masked by a defensive `rstrip`.

Add `bfabric.config.CanonicalBaseUrl`, a `str` subclass that validates and
canonicalises in `__new__`, mirroring the existing `EntityUri` pattern. It
stays a `str`, so interpolation, comparison and dict keys are unaffected,
but annotating a parameter with it lets basedpyright reject a value that
never passed through canonicalisation -- which `Annotated[str,
AfterValidator(...)]` cannot, being indistinguishable from `str`.

Public entry points (`connect_*`, `WebappClient.create`) keep taking `str`
and canonicalise once; private helpers and the entity layer require the
type. That collapses the previous construct-then-read-back dance into
`base_url = CanonicalBaseUrl(base_url)`, with the config built where it
belongs.

`EntityUriComponents.bfabric_instance` moves from pydantic's `HttpUrl` to
the same type, removing the third spelling of one concept. The `str()`
coercion when building a `GroupKey` existed only because `HttpUrl` is not a
`str`, and sat on exactly the seam that diverged in #576; the retype also
clears a grandfathered `reportArgumentType` from the baseline.

Two fixes fall out of typing the boundary:

`validate_token` compared the server's `caller` against the configured
`supported_bfabric_instances` by exact string equality, so a trailing slash
on either side rejected a valid token. Both sides are now canonicalised.
This is legacy API, so the fix is local and the settings stay `str`.

Typing `normalize_base_url`'s return exposed that the CLI writes its config
through unsafe `yaml.dump`, which serialised the subclass as
`!!python/object/new:` -- a file the `safe_load` on read cannot parse. The
writer now uses `safe_dump` (matching the reader) and the CLI writes plain
strings, so a non-YAML type fails loudly instead of corrupting
`~/.bfabricpy.yml`.

The baseline loses two entries and gains none.
#596 fixed the canonical form of base_url and canonicalises it once per public
boundary, but the resulting invariant was convention only: nothing stopped a
caller handing a raw string to pkce_login or compute_token_cache_path. That was
not hypothetical -- `auth register` was doing exactly that, masked by a
defensive rstrip until #596 removed it.

The failure modes are asymmetric. A stray slash in a URL yields `//`, which
servers tolerate, which is why #576 went unnoticed for months. A stray slash in
compute_token_cache_path changes the SHA-256, so the cache misses and the user
is told to log in again while their token sits on disk.

bfabric.BaseUrl is a str subclass that validates and canonicalises in __new__,
so basedpyright rejects an unvalidated string at the boundary while every
f-string, dict key and httpx call keeps working. It replaces three spellings of
one concept: base_url: str, bfabric_instance: str, and
EntityUriComponents.bfabric_instance: HttpUrl -- the last of which forced the
str() coercion in uri.py that sat on the exact seam #576 broke.

Verified the type checker actually catches it before migrating: annotating one
function surfaced two real call sites, so neither the position-independent
baselines nor the neighbouring inline ignores absorb the error.

Two consequences worth noting:

- yaml.dump would serialise a str subclass as `!!python/object/new:`, silently
  writing a config that no longer loads. config_writer now uses safe_dump,
  which rejects it loudly instead; the two CLI write paths coerce to str.
- BaseUrl raises a plain ValueError rather than a pydantic ValidationError, so
  the CLI can print it directly. Pydantic still wraps it on a model field.

Also fixes a latent bug in validate_token, which compared the server's caller
against the configured instances with no canonicalisation on either side.
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.

base_url definition is inconsistent

1 participant