Skip to content

Add Auth and ZammadClient::create(), fix outdated v3 docs (#168) - #171

Merged
derpixler merged 4 commits into
developfrom
feature/rr/168/auth-and-client-create
Oct 14, 2026
Merged

derpixler merged 4 commits into
developfrom
feature/rr/168/auth-and-client-create

Conversation

@derpixler

@derpixler derpixler commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Closes #168.

Why

#168 reported that the README and migration docs show ZammadClient::withToken(), which does not exist. Looking into it, the docs were not merely stale — they described the API people reach for: both the reporter and the commenter independently expected it.

The actual construction was awkward enough to explain why:

$client = new ZammadClient(GuzzleClientFactory::withToken($url, $token));

Two objects nested in one line, the factory forced on newcomers, and with* colliding with the PSR-7 meaning of "copy with a changed property". Typing new ZammadClient( gave no hint what belongs inside.

The root cause sat deeper: authentication was Guzzle configuration. The Authorization header was set as a Guzzle client default header, so anything wanting authentication had to go through GuzzleClientFactory — which is also why the custom HTTP client guide carried a hand-written AuthMiddleware decorator for Symfony's Psr18Client, "because it does not accept global default headers". PSR-18 has no concept of default headers at all.

What changed

Authentication became a transport-independent value. The new Auth carries the credential; RequestHandler applies it when composing each request, the same place ImpersonationHandler already injects From. Any PSR-18 client now authenticates identically. That section went from 41 lines to 15: the decorator is still documented, but only for the case that genuinely needs it — credentials that rotate at runtime.

$client = ZammadClient::create($url, Auth::token($token));
$client = ZammadClient::create($url, Auth::oauth2($token));
$client = ZammadClient::create($url, Auth::basic($user, $pass));

// Your own transport — same credentials, same options, no workaround
$handler = new RequestHandler($psr18, $psr17, $url, maxRetries: 5, auth: Auth::token($token));
$client  = new ZammadClient($handler);

Auth has a private constructor, so the set of valid schemes is closed by construction rather than by convention, and an empty token or username is rejected on creation. The credential is kept out of var_dump() output via __debugInfo().

Docs. Every ZammadClient::withToken() example corrected. Two further defects of the same class turned up and are fixed here: the README and migration guide documented performOnBehalfOf() / setOnBehalfOfUser(), removed back in 3.0.0, and docs/ imported ZammadAPIClient\Core\RequestHandler, which is not where that class lives. The CHANGELOG said 3.0.0 was "unreleased" and had no 3.1.0 section at all; both now carry their release dates, and the German fragments are translated.

The second commit fixes the same guide further. Its Laravel override and custom-middleware examples built a RequestHandler with no credentials at all, so anyone copying them got a client that failed with HTTP 401 on every request. That predates this PR — Symfony's Psr18Client never set an Authorization header there, and the Guzzle default header does not apply on that path — but the new auth: parameter is what made it visible. The same commit points the security notes at ZammadClient::create() instead of the now-deprecated factory helpers, defines a service the Symfony YAML referenced but never declared, and settles the guide on one PSR-17 factory instead of recommending one and using another.

Cookbook. 09-slim.php contained no Slim — it was the non-Guzzle setup. It is now 07-custom-http-client.php, using the terminology Twilio, Square and PSR-18 use, and a real Slim recipe took the Slim name. 00-plain.php became 00-connect.php and no longer claims to be "used by recipes 01-06" (nothing includes it). Recipes are grouped, and the transport doc and recipe finally link to each other.

Compatibility

Additive — targeting 3.2.0. Every v3.0/v3.1 call form still works; GuzzleClientFactory::with*() keeps its signatures and delegates to Auth. A data-provider test pins that equivalence.

The helpers are not deprecated and carry no removal date. An earlier revision marked them @deprecated … removed in v4.0; there is no v4 milestone, issue or roadmap, and the project has no deprecation policy at all (the v2→v3 bump removed APIs with no deprecation period whatsoever), so the annotation promised a schedule nothing could keep. Nor would removal buy anything: each helper is four lines delegating to Auth. The same reasoning retired the @deprecated marker on SerializesToArray::id(), which shipped in 3.0.0 and 3.1.0 — DTOInterface requires that method and Resource calls it three times, so it was never going anywhere either. ZammadClient::create() is steered to by the class docblock, README and CHANGELOG instead.

One behavioural change worth knowing: an empty or whitespace-only token is now rejected with InvalidArgumentException at construction, where it previously produced a client that failed with HTTP 401 on its first request. Code building a client from an unset environment variable fails earlier and more clearly. Both bridges resolve the client lazily, so this surfaces on first access rather than at build time: Laravel falls back to an empty token (env('ZAMMAD_TOKEN', '')) and throws InvalidArgumentException; Symfony falls back to %env(ZAMMAD_TOKEN)% and throws EnvNotFoundException when the variable is unset, or InvalidArgumentException when it is set but empty.

Verification

332 tests (24 new), phpcs, phpstan at level max. The transport-independence claim is covered by a test using the suite's plain PSR-18 recording client — not Guzzle — asserting the header actually goes out, including on the getRaw() attachment path.

src/Bridge/ has neither tests nor static analysis (#169), so the SymfonyBundle change was verified outside this repository: a throwaway project on symfony/dependency-injection 7.4 registers the extension, lets a consumer service inject ZammadClient, compiles, dumps the container and boots the dumped one — which is what prod/dev cache actually runs.

scenario before after
token set, consumer injects the client ServiceNotFoundException at compile compiles, client injected
token empty (composer install) InvalidArgumentException at compile compiles, throws on first access
no zammad.yaml, env vars set — compiles, fallback resolves
no zammad.yaml, no env vars — compiles, EnvNotFoundException on access

The token is absent from the dumped container in every case. The #[\SensitiveParameter] fix has a regression test that was confirmed to fail without the attribute. Not verified locally: that the attribute is inert on PHP 8.1 — composer.json declares >=8.1, but only PHP 8.5 is available here and .github/workflows/tests.yml pins 8.3 with no matrix.

Every use statement and static call in the README and docs/ code blocks was checked against the real classes; the only remaining unresolvable ones sit in blocks explicitly labelled v2:.

Changed in review

Four findings from @bialaps, all reproduced before fixing:

  • SymfonyBundle never worked in a compiled container. It registered the client with ContainerBuilder::set(), which stores a live instance; instances set that way do not survive compilation, so any service injecting ZammadClient failed the build with "non-existent service". This PR's new empty-token validation turned that silent breakage into a failing cache:clear, and with it a failing composer install. Now a lazy Definition with ZammadClient::create() as factory and an inlined Auth, resolving %env(ZAMMAD_TOKEN)% at runtime so the token stays out of var/cache. The CHANGELOG previously justified the behaviour change by claiming 3.1 would "boot and return 401" — that never happened, and the claim is gone.
  • Basic-auth passwords leaked into stack traces. An empty username throws from inside Auth::basic(), so with PHP's default zend.exception_ignore_args=0 the password was printed as a literal argument. Both password parameters now carry #[\SensitiveParameter].
  • The Symfony YAML in the custom HTTP client guide referenced class names, not service IDs. FrameworkBundle aliases Psr\Http\Client\ClientInterface (Resources/config/http_client.php), and nyholm.psr7.psr17_factory comes from the Flex recipe — nyholm/psr7 itself ships no DI configuration at all, which the guide now says, with a fallback for installs without Flex.
  • The deprecation notices promised a v4.0 that does not exist — see Compatibility.

Not in this PR

#170 — ClientInterface exposes only repo(), marked @internal, so the contract cannot be type-hinted usefully. Changing that changes the published contract.

#169 stays open. The SymfonyBundle bug it predicted is fixed here, but the underlying gap is not: src/Bridge/ still has no tests and is still excluded from PHPStan, because the repo carries neither Symfony nor Laravel as a dev dependency. The verification above was done in a throwaway project, which is a workaround, not a fix.

🤖 Generated with Claude Code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Oct 12, 2026 •

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review: 1
  • main

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration
  • Configuration used: Organization UI
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 51d3e219-1f08-484c-901f-ce04705fca4f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@derpixler derpixler self-assigned this Oct 12, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ials

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment thread src/Factory/GuzzleClientFactory.php
Comment thread src/Bridge/SymfonyBundle.php Outdated
Comment thread docs/custom-http-client.md Outdated
Comment thread src/Core/Auth.php Outdated
@dvuckovic
dvuckovic self-requested a review October 13, 2026 08:25

@dvuckovic dvuckovic left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@derpixler Works pretty nice, great job!

I played around with some samples, and inadvertently created a Devcontainer definition 😬 Let me know if this is something you would like to see as a separate, DX-minded improvement PR.

@derpixler
derpixler force-pushed the feature/rr/168/auth-and-client-create branch 2 times, most recently from 6d5a35e to d44dcda Compare October 13, 2026 14:26
Trim credentials before validation, so a whitespace-only token is
rejected like an empty one, and document that a token is required.

Drop the @deprecated notices on GuzzleClientFactory::with*() and on
SerializesToArray::id(). Both promised removal in v4.0, but there is no
v4 milestone, issue or roadmap, and the project has no deprecation or
versioning policy at all — the v2 to v3 bump removed APIs with no
deprecation period whatsoever. Nor is there a reason to remove them: the
helpers are four-line delegations to Auth that cost no maintenance, and
id() is required by DTOInterface and called by Resource itself, so the
marker contradicted a live contract and the library's own usage.

ZammadClient::create() remains the recommended entry point; that is said
in the class docblock, the README and the CHANGELOG instead of by a
removal date the project cannot keep.

Fix three defects found in review:

SymfonyBundle registered the client with ContainerBuilder::set(), which
stores a live instance. Instances set that way do not survive container
compilation, so injecting ZammadClient failed the build with "non-existent
service" — the bundle never worked in a compiled container. It now
registers a lazy Definition with ZammadClient::create() as its factory and
an inlined Auth definition, resolving %env(ZAMMAD_TOKEN)% at runtime so
the token stays out of var/cache. Verified by compiling and dumping a
container against symfony/dependency-injection 7.4 outside this repo,
since src/Bridge/ has no tests and no static analysis (#169).

The password parameters of Auth::basic() and withBasicAuth() are marked
#[\SensitiveParameter]. An empty username throws from inside basic(), so
with PHP's default zend.exception_ignore_args=0 the password appeared
verbatim in the stack trace. Covered by a regression test that skips on
PHP 8.1 and when the ini strips arguments.

The Symfony service IDs in the custom HTTP client guide were class names
rather than registered services, so copying that YAML failed the build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@derpixler
derpixler force-pushed the feature/rr/168/auth-and-client-create branch from d44dcda to 2926f7a Compare October 14, 2026 05:52
@derpixler
derpixler merged commit db77427 into develop Oct 14, 2026
4 checks passed
@derpixler
derpixler deleted the feature/rr/168/auth-and-client-create branch October 14, 2026 10:31
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.

3 participants