Repository navigation
fix: stop TypeError on non-object responses (public key, error bodies, pagination) - #19
Merged
Merged
Conversation
GET /public_key/ answers with a JSON-encoded PEM string. GuzzleClient
declared stdClass|array as its return type, so decoding that body threw
a TypeError and the public key could never be retrieved for callback
signature verification.
- widen ClientInterface/GuzzleClient/RetryClient request() to
stdClass|array<string, mixed>|string (BC-safe: implementers may keep
the narrower type via covariance)
- return decoded string bodies as-is; return plain-text bodies instead of
discarding them; keep stdClass for empty bodies
- PublicKeyResource::get() returns the bare PEM (and still accepts the
legacy {public_key: ...} object shape)
- BillingResource::create() return type follows the client
- tests: cover JSON-encoded PEM, plain-text PEM, legacy object body, and a
full fetch-then-verify round trip
Fixes #18
PHPStan's bleeding edge flags $body (payment methods) and $result (turnover) as never read. Replace them with real assertions so static analysis passes: GET payment_methods sends no body, and turnover maps the response array.
Folds the release heading into this PR so the release needs no follow-up commit on main (main requires a reviewed PR): after merge it is a tag push only -- `git tag v2.1.1 && git push origin v2.1.1`. No version field exists in composer.json and ci.yml has no changelog check, so the tag plus this heading are the whole version bump.
LiyanaAmil
approved these changes
Oct 23, 2026
…onses Audited every path where a decoded response reaches a typed parameter or return; three more TypeError shapes turned up beside the public-key one: - error bodies: Detail/message as a list or object was passed straight to ChipApiException::__construct(string $message), and a JSON scalar body to its ?array $responseBody -- so a 4xx/5xx raised TypeError instead of a ChipApiException the caller can catch. Normalise both in describeError(). - pagination: iterate() passed the raw next value to request(string $endpoint); next:false (seen from some deployments) raised TypeError and next:"" looped forever. Only a non-empty string continues now, across webhooks, company_statements, billing_templates, billing clients, clients. Regression tests in tests/ResponseShapeTest.php cover each shape; all five fail with the reported TypeError (or an exhausted mock loop) on the previous code. Also folds the 2.1.1 changelog entries.
The previous commit referenced these tests but did not stage the new file. Eight cases: array/object detail, JSON scalar body, plain-text body, missing detail, next:false, next:"", and a normal string next page.
QA gap: the pagination fix touched five iterate() sites but only two had a regression test. Adds the missing three (company_statements, billing template clients, clients) and pins the describeError() edge cases: detail as [], detail as "", and a message list when detail is absent. An empty detail list no longer becomes the useless message "[]" -- it falls back to the transport message.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
$chip->publicKey->get()throws:so the public key can never be fetched and callback signature verification through the SDK is impossible (closes #18).
Same bug class elsewhere. An audit of every path where a decoded response reaches a typed parameter or return found three more
TypeErrorshapes, all fixed here too:detail/messageas a list or object was passed straight intoChipApiException::__construct(string $message), and a JSON scalar body into its?array $responseBody. A 4xx/5xx therefore raisedTypeErrorinstead of aChipApiException, socatch (ChipApiException $e)never ran.iterate()passed the rawnextvalue torequest(string $endpoint):next: false(some deployments emit it) raisedTypeError: Argument #2 ($endpoint) must be of type string, false given, andnext: ""looped forever. Affectswebhooks,company_statements,billing_templates,billing templates clients,clients.Root cause
GET /public_key/does not return an object. It returns a JSON-encoded PEM string ("-----BEGIN PUBLIC KEY-----\n…", quotes included) — this is documented in the API reference (/public_key/→ "The response body is a JSON-encoded PEM string … not a bare PEM body and not a{"key": ...}object") and matched by the other SDKs (chip-go-sdkdoesjson.Unmarshal(body, &result)into astring; the Node example doesJSON.parse(data); the WooCommerce plugin treats the decoded body as a string).GuzzleClient::request()returns\stdClass|array, and its body handling wasjson_decode($body, true) ?? new \stdClass(). A JSON string body decodes to a PHPstring, which violates the declared return type →TypeError. Even without the type error,PublicKeyResource::get()did(array) $responseand$arr['public_key'] ?? '', which would have returned''for a string body.Changes
lib/Http/ClientInterface.php,GuzzleClient.php,RetryClient.php:request()return type is now\stdClass|array<string, mixed>|string.stdClass;stdClass(unchanged204/{}behaviour).lib/Resource/PublicKeyResource.php:get()returns the bare PEM (trimmed, so it is accepted byopenssl_verify()/ChipApi::verify()), and still accepts the legacy{"public_key": "…"}object body.lib/Resource/BillingResource.php:create()return type follows the client (it returns the raw response).CHANGELOG.md(Unreleased) andREADME.md(public-key example now shows theverify()round trip).lib/Http/GuzzleClient.php: the two duplicated error-mapping blocks are now onedescribeError()that returns[status, body, message]with both values coerced (is_array($decoded) ? $decoded : [], and a list/objectdetailJSON-encoded into the message; otherwise the transport message is kept).lib/Resource/{Webhooks,Statements,Billing,Clients}Resource.php:iterate()continues only whennextis a non-empty string.tests/ResponseShapeTest.php(new, 14 cases): array/objectdetail, empty-array and empty-stringdetail,messageas a list, JSON scalar body, plain-text body, missingdetail,next: false/next: ""on all fiveiterate()sites, and a normal stringnextpage.tests/ApiTest.php: two pre-existing dead locals ($bodyin the payment-methods test,$resultin the turnover test) that PHPStan's current bleeding edge flags as never read are replaced with real assertions (GET payment_methodssends no body; turnover maps the response array). Without this the Static Analysis job fails on the PR for reasons unrelated to the fix.Verification
composer test— 127 tests, 375 assertions, OK. New tests:testGetPublicKey— JSON-encoded PEM body → exact PEM returned;testPublicKeyFromApiVerifiesCallbackSignature— fetch the key, thenChipApi::verify()on the existing signed-payload fixture returnstrue;testGetPublicKeyAcceptsPlainTextPemBody— bare PEM body;testGetPublicKeyAcceptsObjectBody— legacy{"public_key": …}body.lib/changes reverted,testGetPublicKeyandtestPublicKeyFromApiVerifiesCallbackSignaturefail with exactly the reportedTypeError, and the plain-text case returns''. Same check forResponseShapeTest: 5 of its 8 cases fail on the previous code withChipApiException::__construct(): Argument #1 ($message) must be of type string, array given,Argument #3 ($responseBody) must be of type ?array, string given,RetryClient::request(): Argument #2 ($endpoint) must be of type string, false given, and an exhausted mock queue fornext: ""(the infinite loop).composer phpstan— no errors (level 8, PHPStan 2.3.1 with bleeding edge, same config CI uses).composer cs-check— clean.chip/chip-sdk-php dev-fix/... 4fdb64d, and the installed package returns the PEM frompublicKey->get()and raises a catchableClientExceptionfor{"detail": ["a","b"]}.GET /public_key/):HTTP/2 200, body ="-----BEGIN PUBLIC KEY-----\n…\n-----END PUBLIC KEY-----\n"— a JSON-encoded string, 638 bytes, exactly as documented;main):publicKey->get()throwsTypeError: Chip\Http\GuzzleClient::request(): Return value must be of type stdClass|array, string returned— the reported bug reproduces against the live API, not just in a mock;get()returns a 624-byte PEMstring;openssl_pkey_get_public()parses it (RSA, 3072-bit).success_callbackpointing at a temporary receiver, marked it paid, and verified the delivered callback with this branch:X-Signature= 512-char base64;idmatches the purchase,status: paid,event_type: purchase.paid,is_test: true;ChipApi::verify($rawBody, $signature, $chip->publicKey->get())→true;false(no false positive).Potential Risks & Review Items
\stdClass|array→\stdClass|array|string) onClientInterface::request()is additive and BC-safe for third-party implementations (narrower covariant return types stay valid). Callers that assumed only object/array responses and cast with(array)keep working; onlyPublicKeyResourceactually had to change.stdClass; it is now returned as a string. This is what makes the public key work, but it means a mis-behaving endpoint that returns text/HTML with a 200 could now surface a raw string to a resource that does(array) $response(it degrades to an empty/odd array rather than an exception). Empty bodies are unchanged.PublicKeyResource::get()still returns''when the body has neither a string nor apublic_keyfield — callers should treat an empty result as a failure, as before. Worth deciding whether that should throw instead (out of scope for this fix).detail/messageis a list or object the message becomes its JSON encoding (e.g.["first","second"]); when the body is a JSON scalar or plain text the exception keeps the transport message andgetResponseBody()is[]rather than a type error. Nothing that previously worked changes text.nextthat is not a non-empty string ends the iteration instead of raising. A paginated response whosenextis a bare relative path without a query string still works (any non-empty string is followed verbatim, as before); onlyfalse/null/""changed behaviour.Release
The
2.1.1section heading is already inCHANGELOG.mdin this PR, so releasing needs no follow-up PR onmain(which requires a reviewed PR): after merge it is a tag push only.Version facts:
composer.jsoncarries noversionkey (version = git tag + changelog heading) and.github/workflows/ci.ymlhas no changelog/release check, so nothing else needs editing. Semver:2.1.1(patch) — if a different number is wanted, only that one changelog heading changes before merge.Scope / siblings
Only the PHP SDK is affected. The OpenAPI schema declares
PublicKey: type: string, so the generated C#/Java SDKs already deserialize it as a string, andchip-go-sdk(json.Unmarshalinto astring) and the Node example (JSON.parse) handle it. No sibling SDK change is needed.