Skip to content

Commit 225dab0

Browse files
committed
Add Reporting API polling helper + tests (FER-11257)
The Reporting API's /reporting/v1/load endpoint is asynchronous: a still-processing query returns HTTP 200 with {"error":"Continue wait"} and must be re-sent until results arrive. - ReportingHelper::loadAndWait wraps reporting->load in an exponential- backoff retry loop (defaults: 2s -> 20s, factor 2, 20 attempts) with an optional shouldCancel predicate. Under PHP strict typing the sentinel body omits the non-nullable LoadResponse::$results, so the generated deserializer raises a TypeError; that is the retry signal (real API / transport errors propagate). Exported via src/Utils, .fernignore-protected. - Offline unit tests (tests/Integration/ReportingHelperTest.php) exercise the loop through the real LoadResponse deserializer, including a probe proving the Continue-wait body raises TypeError. - Live smoke test (tests/Integration/ReportingTest.php) targets production and is skipped unless TEST_SQUARE_REPORTING is set. - README: hand-authored Reporting API section documenting getMetadata, the Continue-wait behavior, and loadAndWait options.
1 parent 575ab0a commit 225dab0

5 files changed

Lines changed: 451 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

README.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,77 @@ $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+
foreach ($response->getResults() as $result) {
251+
// ...
252+
}
253+
```
254+
255+
`loadAndWait` accepts an options array to tune the polling behavior:
256+
257+
| Option | Default | Description |
258+
| --- | --- | --- |
259+
| `maxAttempts` | `20` | Maximum poll attempts before giving up. |
260+
| `initialDelayMs` | `2000` | Delay before the first retry, in milliseconds. |
261+
| `maxDelayMs` | `20000` | Upper bound on the backoff delay, in milliseconds. |
262+
| `backoffFactor` | `2` | Multiplier applied to the delay after each attempt. |
263+
| `shouldCancel` | `null` | A `callable(): bool` polled before each attempt (and during the wait); aborts the loop when it returns `true`. |
264+
| `requestOptions` | `null` | Per-request options forwarded to each underlying `reporting->load` call. |
265+
266+
```php
267+
$response = ReportingHelper::loadAndWait(
268+
$client,
269+
new LoadRequest([/* ... */]),
270+
[
271+
'maxAttempts' => 30,
272+
'initialDelayMs' => 1000,
273+
'shouldCancel' => fn (): bool => /* e.g. a deadline check */ false,
274+
],
275+
);
276+
```
277+
278+
If the query does not resolve within `maxAttempts`, or if `shouldCancel` aborts it, a
279+
`Square\Exceptions\SquareException` is thrown.
280+
281+
> **Note:** The Reporting API is available in **production only** and requires a
282+
> reporting-provisioned access token; it is not available in the sandbox environment.
283+
213284
## Legacy SDK
214285

215286
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: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
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 under PHP's strict typing: a "Continue wait" body omits
22+
* the `LoadResponse::$results` field (which is non-nullable), so the generated
23+
* `reporting->load()` raises a `TypeError` while deserializing it rather than
24+
* returning a populated object. That `TypeError` — distinct from the
25+
* `SquareApiException` / `SquareException` raised for real API and transport
26+
* failures, which are allowed to propagate — is the retry signal. (Should the
27+
* `LoadResponse` schema ever make `results` optional, the sentinel would instead
28+
* survive as an unmapped `error` property; the helper checks for that case too.)
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+
// The still-processing "Continue wait" body omits the required
97+
// `results` field, so the generated deserializer raises a TypeError.
98+
// Real API/transport failures raise SquareApiException/SquareException
99+
// and are intentionally left to propagate.
100+
}
101+
102+
if ($attempt === $maxAttempts) {
103+
break;
104+
}
105+
self::sleep($delayMs, $shouldCancel);
106+
$delayMs = (int) min($delayMs * $backoffFactor, $maxDelayMs);
107+
}
108+
109+
throw new SquareException(
110+
message: sprintf(
111+
'Reporting query did not complete after %d attempts ("%s").',
112+
$maxAttempts,
113+
self::CONTINUE_WAIT,
114+
),
115+
);
116+
}
117+
118+
/**
119+
* Forward-compatible sentinel check: if the generated `LoadResponse` ever makes
120+
* `results` optional, a "Continue wait" body would deserialize successfully with
121+
* the unmapped `error` field preserved as an additional property. Treat that as
122+
* a retry signal rather than a result.
123+
*/
124+
private static function isContinueWait(LoadResponse $response): bool
125+
{
126+
return ($response->getAdditionalProperties()['error'] ?? null) === self::CONTINUE_WAIT;
127+
}
128+
129+
/**
130+
* Sleeps for the given number of milliseconds, polling `$shouldCancel` in small
131+
* slices so cancellation stays responsive during a long backoff wait.
132+
*
133+
* @param ?callable(): bool $shouldCancel
134+
* @throws SquareException If cancelled mid-wait.
135+
*/
136+
private static function sleep(int $ms, ?callable $shouldCancel): void
137+
{
138+
$sliceMs = 100;
139+
$remaining = $ms;
140+
while ($remaining > 0) {
141+
if ($shouldCancel !== null && $shouldCancel()) {
142+
throw new SquareException(message: 'Reporting query polling was cancelled.');
143+
}
144+
$chunk = min($sliceMs, $remaining);
145+
usleep($chunk * 1000);
146+
$remaining -= $chunk;
147+
}
148+
}
149+
}
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
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\Types\LoadResult;
12+
use Square\Types\LoadResultAnnotation;
13+
use Square\Utils\ReportingHelper;
14+
use TypeError;
15+
16+
/**
17+
* The Reporting API answers a still-processing `/reporting/v1/load` query with an
18+
* HTTP 200 whose body is `{ "error": "Continue wait" }`. `ReportingHelper::loadAndWait`
19+
* owns the retry loop around that sentinel.
20+
*
21+
* These tests run fully offline: the `reporting` client is replaced with a stub
22+
* that pops a scripted sequence of responses. A "Continue wait" entry is fed as the
23+
* raw JSON body and deserialized through the *real* generated `LoadResponse::fromJson`,
24+
* so the sentinel is detected by exactly the same mechanism as in production — the
25+
* deserializer raising a `TypeError` on the missing, non-nullable `results` field —
26+
* rather than a hand-faked exception. Resolved entries are real `LoadResponse` objects.
27+
*/
28+
class ReportingHelperTest extends TestCase
29+
{
30+
private const CONTINUE_WAIT_BODY = '{"error":"Continue wait"}';
31+
32+
/** Builds a minimal, fully-valid resolved `LoadResponse` with one result row. */
33+
private static function resolvedResponse(): LoadResponse
34+
{
35+
return new LoadResponse([
36+
'results' => [
37+
new LoadResult([
38+
'annotation' => new LoadResultAnnotation([
39+
'measures' => [],
40+
'dimensions' => [],
41+
'segments' => [],
42+
'timeDimensions' => [],
43+
]),
44+
'data' => [['Orders.count' => '128']],
45+
]),
46+
],
47+
]);
48+
}
49+
50+
/**
51+
* Builds a SquareClient whose `reporting->load` returns the next scripted entry
52+
* (the last entry repeats once exhausted), recording the call count. A string
53+
* entry is deserialized via the real `LoadResponse::fromJson`; a `LoadResponse`
54+
* entry is returned as-is.
55+
*
56+
* @param array<string|LoadResponse> $sequence One entry per expected `load` call.
57+
* @param int &$callCount Receives the number of `load` invocations.
58+
*/
59+
private function clientReturning(array $sequence, int &$callCount): SquareClient
60+
{
61+
$callCount = 0;
62+
$reporting = $this->getMockBuilder(ReportingClient::class)
63+
->disableOriginalConstructor()
64+
->onlyMethods(['load'])
65+
->getMock();
66+
$reporting->method('load')->willReturnCallback(
67+
function () use ($sequence, &$callCount): LoadResponse {
68+
$entry = $sequence[min($callCount, count($sequence) - 1)];
69+
$callCount++;
70+
// Real deserialization for the sentinel: a "Continue wait" body raises
71+
// a TypeError here, exactly as the generated client does in production.
72+
return is_string($entry) ? LoadResponse::fromJson($entry) : $entry;
73+
}
74+
);
75+
76+
$client = new SquareClient('test-token');
77+
$client->reporting = $reporting;
78+
return $client;
79+
}
80+
81+
public function testPollsPastContinueWaitAndReturnsResolvedResult(): void
82+
{
83+
$callCount = 0;
84+
$client = $this->clientReturning(
85+
[self::CONTINUE_WAIT_BODY, self::CONTINUE_WAIT_BODY, self::resolvedResponse()],
86+
$callCount,
87+
);
88+
89+
$response = ReportingHelper::loadAndWait(
90+
$client,
91+
new LoadRequest(),
92+
['initialDelayMs' => 1, 'maxDelayMs' => 1, 'maxAttempts' => 5],
93+
);
94+
95+
$this->assertCount(1, $response->getResults());
96+
$this->assertArrayNotHasKey('error', $response->getAdditionalProperties());
97+
$this->assertSame(3, $callCount);
98+
}
99+
100+
public function testReturnsImmediatelyWhenFirstResponseHasResults(): void
101+
{
102+
$callCount = 0;
103+
$client = $this->clientReturning([self::resolvedResponse()], $callCount);
104+
105+
$response = ReportingHelper::loadAndWait($client, new LoadRequest(), ['initialDelayMs' => 1]);
106+
107+
$this->assertCount(1, $response->getResults());
108+
$this->assertSame(1, $callCount);
109+
}
110+
111+
public function testThrowsOnceMaxAttemptsExhausted(): void
112+
{
113+
$callCount = 0;
114+
$client = $this->clientReturning([self::CONTINUE_WAIT_BODY], $callCount); // never resolves
115+
116+
try {
117+
ReportingHelper::loadAndWait(
118+
$client,
119+
new LoadRequest(),
120+
['initialDelayMs' => 1, 'maxDelayMs' => 1, 'maxAttempts' => 3],
121+
);
122+
$this->fail('Expected SquareException was not thrown.');
123+
} catch (SquareException $e) {
124+
$this->assertStringContainsString('did not complete after 3 attempts', $e->getMessage());
125+
}
126+
$this->assertSame(3, $callCount);
127+
}
128+
129+
public function testCancellationAbortsPolling(): void
130+
{
131+
$callCount = 0;
132+
$client = $this->clientReturning([self::CONTINUE_WAIT_BODY], $callCount); // would otherwise poll
133+
134+
$this->expectException(SquareException::class);
135+
$this->expectExceptionMessage('cancelled');
136+
137+
ReportingHelper::loadAndWait(
138+
$client,
139+
new LoadRequest(),
140+
['maxAttempts' => 10, 'shouldCancel' => fn (): bool => true],
141+
);
142+
}
143+
144+
/**
145+
* The crux of the design: the generated `reporting->load` deserializes the
146+
* "Continue wait" body into a TypeError (the non-nullable `results` is absent).
147+
* That raised TypeError is how `loadAndWait` recognizes the sentinel; if it ever
148+
* stops throwing, the helper would mistake "Continue wait" for a result.
149+
*/
150+
public function testContinueWaitBodyRaisesTypeErrorOnDeserialization(): void
151+
{
152+
$this->expectException(TypeError::class);
153+
LoadResponse::fromJson(self::CONTINUE_WAIT_BODY);
154+
}
155+
156+
/** A real (here, empty) result body deserializes cleanly, with no sentinel. */
157+
public function testResolvedBodyDeserializesCleanly(): void
158+
{
159+
$resolved = LoadResponse::fromJson('{"results":[]}');
160+
$this->assertSame([], $resolved->getResults());
161+
$this->assertArrayNotHasKey('error', $resolved->getAdditionalProperties());
162+
}
163+
}

0 commit comments

Comments
 (0)