Skip to content

Complete the escape hatch: Client::delete(), array bodies, deleteLink(); document it - #14

Merged
loevgaard merged 2 commits into
1.xfrom
dx/escape-hatch
Aug 17, 2026
Merged

Complete the escape hatch: Client::delete(), array bodies, deleteLink(); document it#14
loevgaard merged 2 commits into
1.xfrom
dx/escape-hatch

Conversation

@loevgaard

Copy link
Copy Markdown
Member

Part of #10 (finding 5).

Why

The SDK is deliberately narrow (no renew, session, subscriptions, …), so consumers will hit the boundary — and the low-level Client is meant to be the way through. But it had no delete() (DELETE /payments/{id}/link, a 204, was unreachable), post()/put()/patch() accepted only ?Payload (every concrete Payload is final, so a consumer had to subclass the abstract base just to send a body), and the README never mentioned get()/post() as the way to call unmodeled endpoints.

What

  • Client::delete(string $uri): array — returns [] for 204 No Content. decodeJson() now treats 204 as an empty body on every verb (an empty 200 body is still a MalformedResponseException).
  • post()/put()/patch() accept Payload|array|null. Arrays run through the same Valinor normalizer, so they are sent as given (keys untouched, nulls kept — use the snake_case keys from the Quickpay docs) while any Payload / \DateTimeInterface nested inside is still transformed; []{} like an empty Payload.
  • ResourceEndpoint::deleteSubResource() + PaymentsEndpoint::deleteLink(int $id): void — invalidate a payment-window link (e.g. order cancelled before payment).
  • README: "Calling endpoints the SDK doesn't model" — renew, subscriptions, a DELETE, request()/getLastResponse(), the host-pinning note, and how to map a raw payment array into a typed Payment. Plus a deleteLink() mention in the link-flow section.

BC note

ClientInterface gains delete() and wider body types on post()/put()/patch(). That is a BC break only for consumers who implement the interface (a mock-only interface, one week after 1.0.0); every caller is unaffected. Flagging it so you can decide whether that's acceptable in 1.x — if not, delete() can live on Client only for now.

Tests: delete() (204 → [], body decoded when present, DELETE verb/URI/auth, no Content-Type, host guard), 204 on other verbs, empty 200 still rejected, verbatim array bodies, nested Payload+date transformation, []{}, deleteLink().

@codecov

codecov Bot commented Aug 17, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 99.02%. Comparing base (b1ac5d6) to head (de836d8).

Additional details and impacted files
@@             Coverage Diff              @@
##                1.x      #14      +/-   ##
============================================
+ Coverage     98.80%   99.02%   +0.21%     
- Complexity      187      190       +3     
============================================
  Files            26       26              
  Lines           503      511       +8     
============================================
+ Hits            497      506       +9     
+ Misses            6        5       -1     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

…(); document it

The SDK is deliberately narrow (no renew, session, subscriptions, ...), so
consumers will hit the boundary — and the low-level Client is meant to be the
way through. But it had no delete() (DELETE /payments/{id}/link is a 204 the
SDK could not even reach), post()/put()/patch() accepted only ?Payload (every
concrete Payload is final, so a consumer had to subclass the abstract base
just to send a body), and the README never mentioned get()/post() as the way
to call unmodeled endpoints.

- Client::delete($uri): array — [] for 204 No Content (decodeJson() now treats
  204 as an empty body on every verb; an empty 200 body is still malformed).
- post()/put()/patch() accept Payload|array|null. Arrays run through the same
  normalizer, so they are sent as given (keys untouched, nulls kept) while any
  Payload / DateTimeInterface nested inside is still transformed; [] → {}.
- ResourceEndpoint::deleteSubResource() + PaymentsEndpoint::deleteLink().
- README: "Calling endpoints the SDK doesn't model" (renew, subscriptions,
  DELETE, request()/getLastResponse(), mapping a raw array to a Payment) and a
  deleteLink() mention in the link flow.

Note: ClientInterface gains delete() and wider body types — a BC break only for
consumers who IMPLEMENT the interface (a mock-only interface, one week after
1.0.0); callers are unaffected.

Refs #10 (finding 5).
Review question: why should post/put/patch take a nullable body? The null
predates this PR (cancel sent a bodyless POST) and put/patch only had it for
symmetry. Verified live that POST /payments/{id}/cancel accepts an explicit {}
body (200, cancel approved), so no endpoint needs a bodyless request:

- ClientInterface/Client: post()/put()/patch() take `Payload|array $body = []`;
  an empty body is always sent as `{}` (the API rejects `[]`).
- ResourceEndpoint::postOperation() likewise; cancel() passes [].
- Tests: cancel now asserts a `{}` body + JSON content type; the escape-hatch
  test covers a body-less post('payments/1/renew').
- CLAUDE.md updated.
@loevgaard

Copy link
Copy Markdown
Member Author

Good catch — the null predates this PR (?Payload $body = null was there because cancel sent a bodyless POST; put/patch only had it for symmetry), and I'd carried it over without questioning it.

I verified against the live API that POST /payments/{id}/cancel accepts an explicit {} body (HTTP 200, cancel approved, qp_status_code 20000), so no endpoint actually needs a bodyless request. Pushed a follow-up commit: post()/put()/patch() now take Payload|array $body = [] — never null; an empty body always goes out as {} (the API rejects [], which is why the rewrite exists). ResourceEndpoint::postOperation() follows, cancel() passes [], and the escape-hatch shape for parameter-less operations is simply $client->post('payments/1/renew').

@loevgaard
loevgaard merged commit fece712 into 1.x Aug 17, 2026
35 checks passed
@loevgaard
loevgaard deleted the dx/escape-hatch branch August 17, 2026 11:09
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