Skip to content

Commit 1e9a7c4

Browse files
committed
reporting: reapply polling helper + tests on regenerated SDK; align tests to flat LoadResponse; lighter live query
Reapplies the .fernignore-protected Reporting API customizations (FER-11257) onto the regenerated SDK, and adapts them to the regenerated flat LoadResponse (getData()/$data; no getResults()/LoadResult). - src/Utils/ReportingHelper.php: loadAndWait polling helper. The flat LoadResponse has only optional fields, so a "Continue wait" body now deserializes cleanly with the unmapped `error` key preserved as an additional property (data null) rather than raising a TypeError; isContinueWait() detects that preserved error. The defensive TypeError catch is kept for forward compatibility. Docs updated accordingly. - tests/Integration/ReportingHelperTest.php: offline unit tests through the real LoadResponse deserializer; resolved responses built via data; the crux test now asserts the sentinel preserves error (no TypeError); assertions use getData(). - tests/Integration/ReportingTest.php: live smoke test, skipped unless TEST_SQUARE_REPORTING is set (which also supplies the prod reporting token); asserts getData() is non-null; switched the live query from the heavy Orders.count (timed out within the poll budget) to the lighter Appointments.count. - README.md: Reporting API section; load example shows getData(). - .fernignore: protects src/Utils/ReportingHelper.php. - .github/workflows/ci.yml: test job passes TEST_SQUARE_REPORTING.
1 parent fb31761 commit 1e9a7c4

6 files changed

Lines changed: 442 additions & 0 deletions

File tree

.fernignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ phpstan.neon
66
src/Exceptions/SquareApiException.php
77
src/Legacy
88
src/Utils/WebhooksHelper.php
9+
src/Utils/ReportingHelper.php
910
tests/Integration
1011
.fern/replay.lock
1112
.fern/replay.yml

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ jobs:
3131
runs-on: ubuntu-latest
3232
env:
3333
TEST_SQUARE_TOKEN: ${{ secrets.TEST_SQUARE_TOKEN }}
34+
TEST_SQUARE_REPORTING: ${{ secrets.TEST_SQUARE_REPORTING }}
3435

3536
steps:
3637
- name: Checkout repo

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,75 @@ $isValid = WebhooksHelper.verifySignature(
210210
)
211211
```
212212

213+
## Reporting API
214+
215+
The [Square Reporting API](https://developer.squareup.com/docs/reporting-api/overview) lets you
216+
query aggregated business data. Start by calling `getMetadata` to discover the schema — the cubes,
217+
views, measures, dimensions, and segments you can reference — then run a query with `load`:
218+
219+
```php
220+
$metadata = $client->reporting->getMetadata();
221+
222+
$response = $client->reporting->load(new Square\Reporting\Requests\LoadRequest([
223+
'query' => new Square\Types\Query([
224+
'measures' => ['Orders.count'],
225+
]),
226+
]));
227+
```
228+
229+
### Polling for long-running queries
230+
231+
The `load` endpoint is asynchronous. While a query is still being computed, the API responds with
232+
an HTTP `200` whose body is `{ "error": "Continue wait" }` instead of results, and the client is
233+
expected to re-send the identical request until the results are ready. The SDK provides
234+
`ReportingHelper::loadAndWait`, which owns that retry loop with exponential backoff:
235+
236+
```php
237+
use Square\Utils\ReportingHelper;
238+
use Square\Reporting\Requests\LoadRequest;
239+
use Square\Types\Query;
240+
241+
$response = ReportingHelper::loadAndWait(
242+
$client,
243+
new LoadRequest([
244+
'query' => new Query([
245+
'measures' => ['Orders.count'],
246+
]),
247+
]),
248+
);
249+
250+
$data = $response->getData(); // the resolved query result rows
251+
```
252+
253+
`loadAndWait` accepts an options array to tune the polling behavior:
254+
255+
| Option | Default | Description |
256+
| --- | --- | --- |
257+
| `maxAttempts` | `20` | Maximum poll attempts before giving up. |
258+
| `initialDelayMs` | `2000` | Delay before the first retry, in milliseconds. |
259+
| `maxDelayMs` | `20000` | Upper bound on the backoff delay, in milliseconds. |
260+
| `backoffFactor` | `2` | Multiplier applied to the delay after each attempt. |
261+
| `shouldCancel` | `null` | A `callable(): bool` polled before each attempt (and during the wait); aborts the loop when it returns `true`. |
262+
| `requestOptions` | `null` | Per-request options forwarded to each underlying `reporting->load` call. |
263+
264+
```php
265+
$response = ReportingHelper::loadAndWait(
266+
$client,
267+
new LoadRequest([/* ... */]),
268+
[
269+
'maxAttempts' => 30,
270+
'initialDelayMs' => 1000,
271+
'shouldCancel' => fn (): bool => /* e.g. a deadline check */ false,
272+
],
273+
);
274+
```
275+
276+
If the query does not resolve within `maxAttempts`, or if `shouldCancel` aborts it, a
277+
`Square\Exceptions\SquareException` is thrown.
278+
279+
> **Note:** The Reporting API is available in **production only** and requires a
280+
> reporting-provisioned access token; it is not available in the sandbox environment.
281+
213282
## Legacy SDK
214283

215284
While the new SDK has a lot of improvements, we at Square understand that it takes time to upgrade when there are breaking changes.

src/Utils/ReportingHelper.php

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
<?php
2+
3+
namespace Square\Utils;
4+
5+
use Square\SquareClient;
6+
use Square\Reporting\Requests\LoadRequest;
7+
use Square\Types\LoadResponse;
8+
use Square\Exceptions\SquareException;
9+
use TypeError;
10+
11+
/**
12+
* Utility to help with the Square Reporting API.
13+
* See https://developer.squareup.com/docs/reporting-api/overview for more details.
14+
*
15+
* The `/reporting/v1/load` endpoint is asynchronous: a query that is still being
16+
* computed comes back as an HTTP 200 whose body is `{ "error": "Continue wait" }`
17+
* rather than the results. Clients are expected to re-send the identical request,
18+
* with backoff, until real results arrive. {@see ReportingHelper::loadAndWait()}
19+
* owns that retry loop.
20+
*
21+
* Detecting the sentinel: the generated `LoadResponse` fields are all optional, so a
22+
* "Continue wait" body deserializes successfully — its unmapped `error` key survives
23+
* as an additional property (and `data` stays `null`). {@see isContinueWait()} treats
24+
* that preserved `error === "Continue wait"` as the retry signal. Real API / transport
25+
* failures raise `SquareApiException` / `SquareException` and are left to propagate.
26+
* (A defensive `TypeError` catch is also kept: should the schema ever reintroduce a
27+
* non-nullable required field, the sentinel would instead surface as a deserialization
28+
* `TypeError`, which is likewise swallowed as a retry signal.)
29+
*/
30+
class ReportingHelper
31+
{
32+
/**
33+
* Sentinel value returned by the Reporting API on an HTTP 200 while a
34+
* `/reporting/v1/load` query is still processing. It is NOT an error — the
35+
* request should be retried.
36+
*/
37+
private const CONTINUE_WAIT = 'Continue wait';
38+
39+
/**
40+
* Runs a reporting query and transparently polls until it resolves, returning
41+
* the final {@see LoadResponse}. Re-sends the identical request with exponential
42+
* backoff while the API answers "Continue wait".
43+
*
44+
* @param SquareClient $client The configured Square client.
45+
* @param LoadRequest $request The reporting query (same shape as `reporting->load`).
46+
* @param ?array{
47+
* maxAttempts?: int,
48+
* initialDelayMs?: int,
49+
* maxDelayMs?: int,
50+
* backoffFactor?: float,
51+
* shouldCancel?: callable(): bool,
52+
* requestOptions?: array{
53+
* baseUrl?: string,
54+
* maxRetries?: int,
55+
* timeout?: float,
56+
* headers?: array<string, string>,
57+
* queryParameters?: array<string, mixed>,
58+
* bodyProperties?: array<string, mixed>,
59+
* },
60+
* } $options Polling/backoff configuration:
61+
* - `maxAttempts` Maximum poll attempts before giving up. Default 20.
62+
* - `initialDelayMs` Delay before the first retry, in ms. Default 2000.
63+
* - `maxDelayMs` Upper bound on the backoff delay, in ms. Default 20000.
64+
* - `backoffFactor` Multiplier applied to the delay after each attempt. Default 2.
65+
* - `shouldCancel` Predicate polled before each attempt and during the
66+
* backoff wait; aborts the loop when it returns `true`.
67+
* - `requestOptions` Forwarded to each underlying `reporting->load` call.
68+
* @return LoadResponse The resolved response (never the "Continue wait" sentinel).
69+
* @throws SquareException If the query does not resolve within `maxAttempts`, or if cancelled.
70+
*/
71+
public static function loadAndWait(
72+
SquareClient $client,
73+
LoadRequest $request = new LoadRequest(),
74+
?array $options = null,
75+
): LoadResponse {
76+
$options ??= [];
77+
$maxAttempts = $options['maxAttempts'] ?? 20;
78+
$initialDelayMs = $options['initialDelayMs'] ?? 2000;
79+
$maxDelayMs = $options['maxDelayMs'] ?? 20000;
80+
$backoffFactor = $options['backoffFactor'] ?? 2;
81+
$shouldCancel = $options['shouldCancel'] ?? null;
82+
$requestOptions = $options['requestOptions'] ?? null;
83+
84+
$delayMs = $initialDelayMs;
85+
for ($attempt = 1; $attempt <= $maxAttempts; $attempt++) {
86+
if ($shouldCancel !== null && $shouldCancel()) {
87+
throw new SquareException(message: 'Reporting query polling was cancelled.');
88+
}
89+
90+
try {
91+
$response = $client->reporting->load($request, $requestOptions);
92+
if (!self::isContinueWait($response)) {
93+
return $response;
94+
}
95+
} catch (TypeError) {
96+
// Defensive: with the current all-optional LoadResponse schema the
97+
// "Continue wait" body deserializes cleanly (caught above via
98+
// isContinueWait), but if a future schema makes a field non-nullable
99+
// the sentinel would surface as a deserialization TypeError instead.
100+
// Real API/transport failures raise SquareApiException/SquareException
101+
// and are intentionally left to propagate.
102+
}
103+
104+
if ($attempt === $maxAttempts) {
105+
break;
106+
}
107+
self::sleep($delayMs, $shouldCancel);
108+
$delayMs = (int) min($delayMs * $backoffFactor, $maxDelayMs);
109+
}
110+
111+
throw new SquareException(
112+
message: sprintf(
113+
'Reporting query did not complete after %d attempts ("%s").',
114+
$maxAttempts,
115+
self::CONTINUE_WAIT,
116+
),
117+
);
118+
}
119+
120+
/**
121+
* Sentinel check: the generated `LoadResponse` has only optional fields, so a
122+
* "Continue wait" body deserializes successfully with the unmapped `error` field
123+
* preserved as an additional property (and no `data`). Treat that as a retry
124+
* signal rather than a result.
125+
*/
126+
private static function isContinueWait(LoadResponse $response): bool
127+
{
128+
return ($response->getAdditionalProperties()['error'] ?? null) === self::CONTINUE_WAIT;
129+
}
130+
131+
/**
132+
* Sleeps for the given number of milliseconds, polling `$shouldCancel` in small
133+
* slices so cancellation stays responsive during a long backoff wait.
134+
*
135+
* @param ?callable(): bool $shouldCancel
136+
* @throws SquareException If cancelled mid-wait.
137+
*/
138+
private static function sleep(int $ms, ?callable $shouldCancel): void
139+
{
140+
$sliceMs = 100;
141+
$remaining = $ms;
142+
while ($remaining > 0) {
143+
if ($shouldCancel !== null && $shouldCancel()) {
144+
throw new SquareException(message: 'Reporting query polling was cancelled.');
145+
}
146+
$chunk = min($sliceMs, $remaining);
147+
usleep($chunk * 1000);
148+
$remaining -= $chunk;
149+
}
150+
}
151+
}
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php
2+
3+
namespace Square\Tests\Integration;
4+
5+
use PHPUnit\Framework\TestCase;
6+
use Square\Reporting\ReportingClient;
7+
use Square\Reporting\Requests\LoadRequest;
8+
use Square\Exceptions\SquareException;
9+
use Square\SquareClient;
10+
use Square\Types\LoadResponse;
11+
use Square\Utils\ReportingHelper;
12+
13+
/**
14+
* The Reporting API answers a still-processing `/reporting/v1/load` query with an
15+
* HTTP 200 whose body is `{ "error": "Continue wait" }`. `ReportingHelper::loadAndWait`
16+
* owns the retry loop around that sentinel.
17+
*
18+
* These tests run fully offline: the `reporting` client is replaced with a stub
19+
* that pops a scripted sequence of responses. A "Continue wait" entry is fed as the
20+
* raw JSON body and deserialized through the *real* generated `LoadResponse::fromJson`,
21+
* so the sentinel is detected by exactly the same mechanism as in production — the
22+
* generated `LoadResponse` has only optional fields, so the body deserializes cleanly
23+
* and its unmapped `error` key survives as an additional property (with `data` null) —
24+
* rather than a hand-faked exception. Resolved entries are real `LoadResponse` objects.
25+
*/
26+
class ReportingHelperTest extends TestCase
27+
{
28+
private const CONTINUE_WAIT_BODY = '{"error":"Continue wait"}';
29+
30+
/** Builds a minimal, fully-valid resolved `LoadResponse` with one data row. */
31+
private static function resolvedResponse(): LoadResponse
32+
{
33+
return new LoadResponse([
34+
'data' => [['Orders.count' => '128']],
35+
]);
36+
}
37+
38+
/**
39+
* Builds a SquareClient whose `reporting->load` returns the next scripted entry
40+
* (the last entry repeats once exhausted), recording the call count. A string
41+
* entry is deserialized via the real `LoadResponse::fromJson`; a `LoadResponse`
42+
* entry is returned as-is.
43+
*
44+
* @param array<string|LoadResponse> $sequence One entry per expected `load` call.
45+
* @param int &$callCount Receives the number of `load` invocations.
46+
*/
47+
private function clientReturning(array $sequence, int &$callCount): SquareClient
48+
{
49+
$callCount = 0;
50+
$reporting = $this->getMockBuilder(ReportingClient::class)
51+
->disableOriginalConstructor()
52+
->onlyMethods(['load'])
53+
->getMock();
54+
$reporting->method('load')->willReturnCallback(
55+
function () use ($sequence, &$callCount): LoadResponse {
56+
$entry = $sequence[min($callCount, count($sequence) - 1)];
57+
$callCount++;
58+
// Real deserialization for the sentinel: a "Continue wait" body keeps
59+
// its unmapped `error` as an additional property here, exactly as the
60+
// generated client does in production.
61+
return is_string($entry) ? LoadResponse::fromJson($entry) : $entry;
62+
}
63+
);
64+
65+
$client = new SquareClient('test-token');
66+
$client->reporting = $reporting;
67+
return $client;
68+
}
69+
70+
public function testPollsPastContinueWaitAndReturnsResolvedResult(): void
71+
{
72+
$callCount = 0;
73+
$client = $this->clientReturning(
74+
[self::CONTINUE_WAIT_BODY, self::CONTINUE_WAIT_BODY, self::resolvedResponse()],
75+
$callCount,
76+
);
77+
78+
$response = ReportingHelper::loadAndWait(
79+
$client,
80+
new LoadRequest(),
81+
['initialDelayMs' => 1, 'maxDelayMs' => 1, 'maxAttempts' => 5],
82+
);
83+
84+
$this->assertNotNull($response->getData());
85+
$this->assertArrayNotHasKey('error', $response->getAdditionalProperties());
86+
$this->assertSame(3, $callCount);
87+
}
88+
89+
public function testReturnsImmediatelyWhenFirstResponseHasResults(): void
90+
{
91+
$callCount = 0;
92+
$client = $this->clientReturning([self::resolvedResponse()], $callCount);
93+
94+
$response = ReportingHelper::loadAndWait($client, new LoadRequest(), ['initialDelayMs' => 1]);
95+
96+
$this->assertNotNull($response->getData());
97+
$this->assertSame(1, $callCount);
98+
}
99+
100+
public function testThrowsOnceMaxAttemptsExhausted(): void
101+
{
102+
$callCount = 0;
103+
$client = $this->clientReturning([self::CONTINUE_WAIT_BODY], $callCount); // never resolves
104+
105+
try {
106+
ReportingHelper::loadAndWait(
107+
$client,
108+
new LoadRequest(),
109+
['initialDelayMs' => 1, 'maxDelayMs' => 1, 'maxAttempts' => 3],
110+
);
111+
$this->fail('Expected SquareException was not thrown.');
112+
} catch (SquareException $e) {
113+
$this->assertStringContainsString('did not complete after 3 attempts', $e->getMessage());
114+
}
115+
$this->assertSame(3, $callCount);
116+
}
117+
118+
public function testCancellationAbortsPolling(): void
119+
{
120+
$callCount = 0;
121+
$client = $this->clientReturning([self::CONTINUE_WAIT_BODY], $callCount); // would otherwise poll
122+
123+
$this->expectException(SquareException::class);
124+
$this->expectExceptionMessage('cancelled');
125+
126+
ReportingHelper::loadAndWait(
127+
$client,
128+
new LoadRequest(),
129+
['maxAttempts' => 10, 'shouldCancel' => fn (): bool => true],
130+
);
131+
}
132+
133+
/**
134+
* The crux of the design: the generated `LoadResponse` has only optional fields,
135+
* so the "Continue wait" body deserializes cleanly, keeping its unmapped `error`
136+
* key as an additional property and leaving `data` null. That preserved
137+
* `error === "Continue wait"` is how `loadAndWait` recognizes the sentinel; if the
138+
* deserializer ever dropped it, the helper would mistake "Continue wait" for a result.
139+
*/
140+
public function testContinueWaitBodyPreservesErrorOnDeserialization(): void
141+
{
142+
$response = LoadResponse::fromJson(self::CONTINUE_WAIT_BODY);
143+
$this->assertSame('Continue wait', $response->getAdditionalProperties()['error'] ?? null);
144+
$this->assertNull($response->getData());
145+
}
146+
147+
/** A real (here, empty) result body deserializes cleanly, with no sentinel. */
148+
public function testResolvedBodyDeserializesCleanly(): void
149+
{
150+
$resolved = LoadResponse::fromJson('{"data":[]}');
151+
$this->assertSame([], $resolved->getData());
152+
$this->assertArrayNotHasKey('error', $resolved->getAdditionalProperties());
153+
}
154+
}

0 commit comments

Comments
 (0)