Skip to content

Split AcquirersClient, refresh tokens on 401, fix CI and README for 2.0 - #2

Merged
matasarei merged 10 commits into
mainfrom
feature/2.0
Sep 26, 2026
Merged

matasarei merged 10 commits into
mainfrom
feature/2.0

Conversation

@matasarei

@matasarei matasarei commented Sep 26, 2026 •

Copy link
Copy Markdown
Contributor

What and why

AcquirersClient handled every Diia resource and took five ResponseJsonMapper arguments of the same type, so swapping two compiled fine and failed at runtime. BearerTokenProvider cached the token for its full 2-hour lifetime and never refreshed it, so requests close to expiry failed with 401. The README described wiring and methods that no longer existed. CI linted nothing and never ran on a schedule, so the badge showed "no status".

This is 2.0. It splits the client by API resource, refreshes the token, and rewrites the README and CI. Endpoints, request bodies, DTO names and enum names stay the same.

How

  • Step 0 records 1.0's behaviour first. Every 1.0 request (method, path, query, headers, body) is saved as a JSON fixture in tests/fixtures/requests/. The 2.0 clients are tested against the same fixtures, so any request that differs from 1.0 fails a test.
  • BranchClient, OfferClient, OfferRequestClient, DocumentRequestClient each take one typed mapper per job, and ClientFactory builds them. A reflection test checks that no client constructor takes two parameters of the same class.
  • Http\ApiTransport sets the headers, encodes and decodes JSON, and turns every failure into DiiaApiException (HTTP status, Diia's error code, raw body). On a 401 it drops the cached token, gets a new one and retries once. The token is now cached for 7200 − 300 s.
  • The acquirer token never reaches an exception. Diia's auth URL contains the token, and Guzzle's error messages quote that URL. When the token request fails, the exception names the API call that needed the token and does not include Guzzle's exception.
  • Path IDs are URL-encoded. Before, get('a/../b?c') requested /api/v2/acquirers/branch/b. Real Diia IDs come out the same, so the fixtures recorded from 1.0 still match.
  • The README examples are executed. tests/ReadmeExamplesTest.php extracts every PHP block from the README and runs them in order against mocked responses.

The table mapping each old method to its new one is in CHANGELOG.md and in the README under Upgrading from 1.x.

Testing

Run locally in Docker, on the current HEAD:

  • PHP 8.4 (composer:lts): OK (46 tests, 297 assertions)
  • PHP 7.4.33 (php:7.4-cli, dependencies resolved for 7.4): OK (46 tests, 297 assertions)
  • PHP 8.0.30 and 8.5 ran green before the review fixes (OK (42 tests, 279 assertions)) but were not re-run after them.
  • actionlint: clean. composer validate --strict: valid.

CI (run 36274838984): all 7 PHP jobs pass, each with OK (46 tests, 297 assertions) and 45 files linted, and the Composer validation job passes.

Reviewed with /gku:review in the same session that wrote the code. That is a self-review, so weaker than a fresh reviewer. Its findings are fixed in the last five commits.

Acceptance criteria

  • No client constructor takes two parameters of the same class
  • A 401 drops the cached token, gets a new one, retries once, then fails with DiiaApiException (tested with Guzzle MockHandler)
  • README starts with the new title and a [!NOTE] notice; composer.json description updated
  • README badges: CI (?branch=main), PHP versions, Packagist release
  • README: old wiring removed, no calls to methods that don't exist, no broken code fences, Callbacks section added, every PHP example executed by ReadmeExamplesTest
  • CI runs PHPUnit on 7.4 and 8.0–8.5 and every job passes: run 36274838984, each job OK (46 tests, 297 assertions) and 45 files linted
  • The CI badge shows the latest main run: can only be confirmed after merge and the first monthly run
  • CHANGELOG.md 2.0.0 maps every AcquirersClient method to its new client method
  • Tagged 2.0.0 and visible on Packagist: manual, after merge

Notes

  • Breaking release (2.0.0). Removed: AcquirersClient, Dependency/, RequestJsonMapper, ResponseJsonMapper, HttpHeadersProvider. HTTP and transport failures now throw DiiaApiException instead of Guzzle exceptions.
  • Still UnexpectedValueException: a list response missing its items key, and a token response missing its token. Documented in the CHANGELOG; worth deciding before tagging.
  • Tests stub euspe 2.0 (tests/Support/euspe-stubs.php), written from its published contract, because the real library needs IIT's proprietary extension and 2.0 isn't released yet. If euspe's API changes before its release, the README and the stubs change together.
  • Scopes remark in the README: kept as "observed in 2024, not confirmed by Diia's current docs", because Diia's docs are behind the partner portal and I couldn't check them.
  • After merge (manual): set Actions retention to 90 days, then tag 2.0.0.
  • Deliberately left out:
    • Implicitly nullable parameters in ScopesMapper and ItemsListResponse raise deprecation notices on PHP 8.4 and later.
    • OfferMapper reads returnLink without a null check, so an offer with no return link raises a warning.
    • When the API call itself gets no response, Guzzle's exception is still attached, and its message includes the status query (barcode, otp).
    • The token cache key is a fixed 'token', so two factories with different credentials sharing one cache would overwrite each other's token (same as 1.x).
  • The first commit (f6cb741, the recorded 1.0 requests) also sits on a local 1.x branch, which has not been pushed.

🤖 Generated with Claude Code

matasarei and others added 10 commits September 27, 2026 00:35
Every public AcquirersClient method is now asserted (method, path,
query, headers, decoded body) against tests/fixtures/requests/*.json on
a Guzzle MockHandler, and the parsed DTOs are checked field by field.
The 2.0 clients load the same fixtures, so a request that differs is a
regression rather than a test to update.

Ruling: fixtures hold request and response together, one file per
exchange, and headers are compared case-insensitively without Host,
User-Agent and Content-Length (Guzzle adds those) — if a Guzzle-added
header ever matters, the helper's exclusion list is the one place to change.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ApiTransport sends the same headers and JSON body as 1.0 (checked
against the step 0 fixtures), maps every failure to DiiaApiException
(HTTP status, Diia's body "code", raw body), and on a 401 drops the
cached token, fetches a new one and retries exactly once.
BearerTokenProvider caches for 7200 - 300 s and gains invalidate().

Ruling: failures with no response (connection refused, timeout) are a
DiiaApiException with status 0 and the Guzzle exception as previous, so
callers catch one type — if callers want Guzzle's own, unwrap getPrevious().
Ruling: a successful response whose body is not a JSON object is a
DiiaApiException too, not UnexpectedValueException — one failure type
for the whole API; a 2.0 break, listed in the changelog.
Ruling: HttpHeadersProvider stays until AcquirersClient is removed in
the next commit, so this commit keeps the suite green.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
BranchClient, OfferClient, OfferRequestClient and DocumentRequestClient
each take one typed mapper per job over the shared ApiTransport, so no
constructor takes two parameters of the same class (asserted by
reflection). ClientFactory is the one place that wires them; clients
from one factory share one transport and one token.

Removed: AcquirersClient, Dependency/ (DependencyResolver and
SupportedDependencyInterface), RequestJsonMapper, ResponseJsonMapper,
HttpHeadersProvider. The 1.0 cases moved into one test per client and
still replay the step 0 fixtures unchanged.

Ruling: ResponseJsonMapper is deleted too, not only RequestJsonMapper —
ApiTransport now decodes JSON, so it had nothing left to do — if anyone
used it directly, json_decode + the item mapper replaces it.
Ruling: a status response without "status" is a DiiaApiException with
HTTP 200 (was UnexpectedValueException), via an @internal StatusResponse
helper shared by both status endpoints.
Ruling: the clients and ClientFactory are not final, so applications can
mock them; ApiTransport is final.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Runs on push and pull_request to main, on demand, and monthly, so the
?branch=main badge always has a recent main run. A keepalive job on the
scheduled run re-enables the workflow before GitHub's 60-day inactivity
auto-disable. Replaces `php -l src/ tests/`, which lints nothing and
exits 0, with a per-file lint on every PHP version in the matrix, and
php-actions/composer (its own image) with shivammathur/setup-php.

Ruling: the lint runs inside each matrix job rather than once — syntax
support differs between 7.4 and 8.5 — at the cost of seven lint runs.
Ruling: dependencies are resolved with `composer update` since
composer.lock is not committed for this library.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
README: the title loses "[Unofficial]" and gets a [!NOTE] community
notice and CI / PHP / Packagist badges. The 45 lines of manual wiring
become one ClientFactory call with timeouts from config. Fixed: the
four-backtick fence, the missing `;`, `$brancId`, the non-existent
`hashFile()` (now euspe 2.0 Hasher, base64-encoded as registries does),
and `makeOfferRequest($branchId, $offerId)`. New: Errors, Callbacks
(what Diia posts, what the receiver must verify), Upgrading from 1.x.
The scopes remark is kept as observed behaviour, dated 2024, since
Diia's current docs could not be checked.

tests/ReadmeExamplesTest.php runs every ```php block of the README in
order against a MockHandler, so the examples cannot drift again.

composer.json: description "Diia (Дія) API client for PHP"; php
^7.4 || ^8.0; guzzle ^7.8; psr/simple-cache and psr/log 1 to 3.

Ruling: the test extracts and evals the README blocks instead of
keeping hand-made copies — a copy is what drifted in 1.x — at the cost
of one str_replace that injects the mock handler into the Guzzle client.
Ruling: euspe is stubbed from its published 2.0 contract
(tests/Support/euspe-stubs.php), not required — it needs IIT's
proprietary extension and 2.0 is not released; if its API changes
before release, the stubs and the README change together.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The token endpoint carries the acquirer token in its URL
(/api/v1/auth/acquirer/<token>), and Guzzle quotes the URL in its
messages. DiiaApiException copied that message, and kept the Guzzle
exception as previous, so logging a failed call wrote the token to the
logs (reproduced with real cURL against a closed port).

Token-request failures now get their own mapping: the message names the
API call that needed the token and says the token request failed, and
the Guzzle exception is not chained. For failures of the API call
itself, the message uses cURL's error text instead of Guzzle's message;
the Guzzle exception stays as previous, as its URL holds no secret.
This also stops naming the API path as the failed request when it was
the token request that failed (the review's first nit).

Ruling: token-request failures drop the previous exception entirely —
loggers such as Monolog print previous exceptions — at the cost of the
Guzzle stack trace for that case; status, Diia's code and body are kept.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The euspe stubs record calls in static arrays that were never reset, so
`--repeat 2` failed (1 failure) because the second run saw both runs'
calls. The test now empties them before running the examples.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The signing callback example indexed $hashes[$item['name']] directly, so
a copied handler passed null to base64_decode for a name it never sent.
It now rejects the callback when the name has no stored hash.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
ApiTransport encodes with JSON_THROW_ON_ERROR, so a DTO holding invalid
UTF-8 throws JsonException before sending; 1.x's json_encode returned
false and Guzzle sent an empty body (checked). Documented as a 2.0
change rather than wrapped: it is a caller error, not an API failure.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
The clients built paths with sprintf('/…/branch/%s', $branchId), so an
ID holding `/`, `?` or `../` changed the endpoint: get('a/../b?c')
requested /api/v2/acquirers/branch/b, with the acquirer's bearer token.
Every path ID now goes through rawurlencode(). Diia's own IDs are
alphanumeric, so the step 0 fixtures still match unchanged; same
behaviour as 1.0 until now, so it is listed in the CHANGELOG.

Ruling: rawurlencode() at each call site rather than a path builder in
ApiTransport — seven one-line changes, each visible where the path is
written — if more resources are added, a helper would remove the repetition.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@matasarei
matasarei merged commit ff62b52 into main Sep 26, 2026
9 checks passed
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