Skip to content

Commit 87a8eea

Browse files
authored
feat: add bulk story creation with rate limit handling (#4)
* feat: add bulk story creation with rate limit handling * test: use jsonmockresponse to improve readability
1 parent 27ea37c commit 87a8eea

File tree

5 files changed

+286
-16
lines changed

5 files changed

+286
-16
lines changed

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@
77
/.phpunit.result.cache
88
.phpunit.cache/test-results
99
/test-output
10-
10+
.phpactor.json

src/Endpoints/StoryApi.php

Lines changed: 97 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,10 @@
44

55
namespace Storyblok\ManagementApi\Endpoints;
66

7-
use Storyblok\ManagementApi\QueryParameters\AssetsParams;
87
use Storyblok\ManagementApi\QueryParameters\Filters\QueryFilters;
98
use Storyblok\ManagementApi\QueryParameters\PaginationParams;
109
use Storyblok\ManagementApi\QueryParameters\StoriesParams;
10+
use Storyblok\ManagementApi\Data\StoryblokDataInterface;
1111
use Symfony\Contracts\HttpClient\HttpClientInterface;
1212
use Storyblok\ManagementApi\Data\StoriesData;
1313
use Storyblok\ManagementApi\Data\StoryData;
@@ -145,6 +145,7 @@ public function get(string $storyId): StoryblokResponseInterface
145145
* Creates a new story
146146
*
147147
* @throws InvalidStoryDataException
148+
* @throws StoryblokApiException
148149
*/
149150
public function create(StoryData $storyData): StoryblokResponseInterface
150151
{
@@ -156,14 +157,53 @@ public function create(StoryData $storyData): StoryblokResponseInterface
156157
]);
157158
}
158159

159-
return $this->makeRequest(
160-
"POST",
161-
$this->buildStoriesEndpoint(),
162-
[
163-
"body" => json_encode(["story" => $storyData->toArray()]),
164-
],
165-
dataClass: StoryData::class,
166-
);
160+
try {
161+
$response = $this->makeRequest(
162+
"POST",
163+
$this->buildStoriesEndpoint(),
164+
[
165+
"body" => json_encode(["story" => $storyData->toArray()]),
166+
],
167+
dataClass: StoryData::class,
168+
);
169+
170+
if ($response->isOk()) {
171+
$this->logger->info('Story created successfully', [
172+
'story_name' => $storyData->name(),
173+
]);
174+
return $response;
175+
}
176+
177+
$this->logger->error('Failed to create story', [
178+
'status_code' => $response->getResponseStatusCode(),
179+
'error_message' => $response->getErrorMessage(),
180+
'story_name' => $storyData->name(),
181+
]);
182+
183+
throw new StoryblokApiException(
184+
sprintf(
185+
'Failed to create story: %s (Status code: %d)',
186+
$response->getErrorMessage(),
187+
$response->getResponseStatusCode(),
188+
),
189+
$response->getResponseStatusCode(),
190+
);
191+
} catch (\Exception $exception) {
192+
if ($exception instanceof StoryblokApiException) {
193+
throw $exception;
194+
}
195+
196+
$this->logger->error('Unexpected error while creating story', [
197+
'error' => $exception->getMessage(),
198+
'story_name' => $storyData->name(),
199+
]);
200+
201+
throw new StoryblokApiException(
202+
'Failed to create story: ' . $exception->getMessage(),
203+
0,
204+
$exception,
205+
);
206+
}
167207
}
168208

169209
/**
@@ -240,6 +280,53 @@ public function unpublish(
240280
);
241281
}
242282

283+
/**
284+
* Creates multiple stories with rate limit handling and retries
285+
*
286+
* @param StoryData[] $stories Array of stories to create
287+
* @return \Generator<StoryblokDataInterface> Generated stories
288+
* @throws StoryblokApiException
289+
*/
290+
public function createBulk(array $stories): \Generator
291+
{
292+
$retryCount = 0;
293+
294+
foreach ($stories as $storyData) {
295+
while (true) {
296+
try {
297+
$response = $this->create($storyData);
298+
yield $response->data();
299+
$retryCount = 0;
300+
break;
301+
} catch (StoryblokApiException $e) {
302+
if ($e->getCode() === self::RATE_LIMIT_STATUS_CODE) {
303+
if ($retryCount >= self::MAX_RETRIES) {
304+
$this->logger->error('Max retries reached while creating story', [
305+
'story_name' => $storyData->name(),
306+
]);
307+
throw new StoryblokApiException(
308+
'Rate limit exceeded maximum retries',
309+
self::RATE_LIMIT_STATUS_CODE,
310+
);
311+
}
312+
313+
$this->logger->warning('Rate limit reached while creating story, retrying...', [
314+
'retry_count' => $retryCount + 1,
315+
'max_retries' => self::MAX_RETRIES,
316+
'story_name' => $storyData->name(),
317+
]);
318+
319+
$this->handleRateLimit();
320+
++$retryCount;
321+
continue;
322+
}
323+
324+
throw $e;
325+
}
326+
}
327+
}
328+
}
329+
243330
/**
244331
* Handles successful API response
245332
*/
@@ -281,7 +368,7 @@ private function handleErrorResponse(StoryblokResponseInterface $response, int $
281368
/**
282369
* Handles rate limiting by implementing a delay
283370
*/
284-
private function handleRateLimit(): void
371+
protected function handleRateLimit(): void
285372
{
286373
$this->logger->warning('Rate limit reached, waiting before retry...');
287374
sleep(self::RETRY_DELAY);

src/StoryblokResponse.php

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66

77
use Storyblok\ManagementApi\Data\StoryblokData;
88
use Storyblok\ManagementApi\Data\StoryblokDataInterface;
9-
use Storyblok\ManagementApi\Data\StoryData;
109
use Symfony\Contracts\HttpClient\Exception\ClientExceptionInterface;
1110
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
1211
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
@@ -33,7 +32,6 @@ public function getResponse(): ResponseInterface
3332
return $this->response;
3433
}
3534

36-
3735
public function data(): StoryblokDataInterface
3836
{
3937

@@ -42,8 +40,6 @@ public function data(): StoryblokDataInterface
4240
}
4341

4442
return $this->dataClass::make($this->toArray());
45-
//return new $dataClass($this->toArray());
46-
4743
}
4844

4945

src/StoryblokResponseInterface.php

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
namespace Storyblok\ManagementApi;
66

7-
use Storyblok\ManagementApi\Data\StoryblokData;
87
use Storyblok\ManagementApi\Data\StoryblokDataInterface;
98
use Symfony\Contracts\HttpClient\ResponseInterface;
109

tests/Feature/StoryTest.php

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,20 @@
99
use Storyblok\ManagementApi\QueryParameters\PaginationParams;
1010
use Storyblok\ManagementApi\QueryParameters\StoriesParams;
1111
use Symfony\Component\HttpClient\Response\MockResponse;
12+
use Symfony\Component\HttpClient\Response\JsonMockResponse;
1213
use Symfony\Component\HttpClient\MockHttpClient;
1314
use Psr\Log\NullLogger;
1415

16+
// This is a mock class to eliminate the sleep from the rate limit handling
17+
class TestStoryApi extends \Storyblok\ManagementApi\Endpoints\StoryApi
18+
{
19+
#[\Override]
20+
protected function handleRateLimit(): void
21+
{
22+
// No sleep and no logs for testing
23+
}
24+
}
25+
1526
test('Testing One Story, StoryData', function (): void {
1627
$responses = [
1728
\mockResponse("one-story", 200),
@@ -235,6 +246,183 @@ public function error(string|\Stringable $message, array $context = []): void
235246
foreach ($storyblokResponse as $story) {
236247
expect($story->name())->toBe("My third post");
237248
}
249+
});
250+
251+
test('createBulk handles rate limiting and creates multiple stories', function (): void {
252+
$mockLogger = new class extends NullLogger {
253+
public array $logs = [];
254+
255+
public function log($level, string|\Stringable $message, array $context = []): void
256+
{
257+
$this->logs[] = [
258+
'level' => $level,
259+
'message' => $message,
260+
'context' => $context
261+
];
262+
}
263+
264+
public function warning(string|\Stringable $message, array $context = []): void
265+
{
266+
$this->log('warning', $message, $context);
267+
}
268+
};
269+
270+
$story1Data = [
271+
'story' => [
272+
'name' => 'Story 1',
273+
'slug' => 'story-1',
274+
'content' => ['component' => 'blog'],
275+
'created_at' => '2024-02-08 09:40:59.123',
276+
'published_at' => null,
277+
'id' => 1,
278+
'uuid' => '1234-5678'
279+
]
280+
];
281+
282+
$story2Data = [
283+
'story' => [
284+
'name' => 'Story 2',
285+
'slug' => 'story-2',
286+
'content' => ['component' => 'blog'],
287+
'created_at' => '2024-02-08 09:41:59.123',
288+
'published_at' => null,
289+
'id' => 2,
290+
'uuid' => '8765-4321'
291+
]
292+
];
293+
294+
$responses = [
295+
// First story - Rate limit hit, then success
296+
\mockResponse('empty-story', 429, ['error' => 'Rate limit exceeded']),
297+
new JsonMockResponse($story1Data, ['http_code' => 201]),
298+
// Second story - Immediate success
299+
new JsonMockResponse($story2Data, ['http_code' => 201]),
300+
];
301+
302+
$client = new MockHttpClient($responses);
303+
$mapiClient = ManagementApiClient::initTest($client);
304+
305+
// Use TestStoryApi instead of regular StoryApi
306+
$storyApi = new TestStoryApi($client, '222', $mockLogger);
307+
308+
// Create test stories
309+
$stories = [
310+
StoryData::make([
311+
'name' => 'Story 1',
312+
'slug' => 'story-1',
313+
'content' => ['component' => 'blog']
314+
]),
315+
StoryData::make([
316+
'name' => 'Story 2',
317+
'slug' => 'story-2',
318+
'content' => ['component' => 'blog']
319+
]),
320+
];
321+
322+
// Execute bulk creation
323+
$createdStories = iterator_to_array($storyApi->createBulk($stories));
324+
325+
// Verify number of created stories
326+
expect($createdStories)->toHaveCount(2);
327+
328+
// Verify rate limit warning was logged
329+
$hasRateLimitWarning = false;
330+
foreach ($mockLogger->logs as $log) {
331+
if ($log['level'] === 'warning' && $log['message'] === 'Rate limit reached while creating story, retrying...') {
332+
$hasRateLimitWarning = true;
333+
break;
334+
}
335+
}
336+
337+
expect($hasRateLimitWarning)->toBeTrue();
338+
339+
// Verify created stories
340+
expect($createdStories[0]->name())->toBe('Story 1');
341+
expect($createdStories[1]->name())->toBe('Story 2');
342+
expect($createdStories[0]->slug())->toBe('story-1');
343+
expect($createdStories[1]->slug())->toBe('story-2');
344+
});
345+
346+
test('createBulk throws exception when max retries is reached', function (): void {
347+
$mockLogger = new class extends NullLogger {
348+
public array $logs = [];
349+
350+
public function log($level, string|\Stringable $message, array $context = []): void
351+
{
352+
$this->logs[] = [
353+
'level' => $level,
354+
'message' => $message,
355+
'context' => $context
356+
];
357+
}
358+
359+
public function warning(string|\Stringable $message, array $context = []): void
360+
{
361+
$this->log('warning', $message, $context);
362+
}
363+
364+
public function error(string|\Stringable $message, array $context = []): void
365+
{
366+
$this->log('error', $message, $context);
367+
}
368+
};
369+
370+
// Create responses that always return rate limit error (429)
371+
// We need MAX_RETRIES + 1 responses to trigger the exception
372+
$responses = array_fill(0, 4, new JsonMockResponse([
373+
'error' => 'Rate limit exceeded'
374+
], [
375+
'http_code' => 429
376+
]));
377+
378+
$client = new MockHttpClient($responses);
379+
$mapiClient = ManagementApiClient::initTest($client);
380+
381+
// Use TestStoryApi instead of regular StoryApi
382+
$storyApi = new TestStoryApi($client, '222', $mockLogger);
383+
384+
// Create test story
385+
$stories = [
386+
StoryData::make([
387+
'name' => 'Story 1',
388+
'slug' => 'story-1',
389+
'content' => ['component' => 'blog']
390+
]),
391+
];
392+
393+
// Execute bulk creation and expect exception
394+
expect(fn (): array => iterator_to_array($storyApi->createBulk($stories)))
395+
->toThrow(
396+
\Storyblok\ManagementApi\Exceptions\StoryblokApiException::class,
397+
'Rate limit exceeded maximum retries'
398+
);
399+
400+
// Verify warning logs for each retry
401+
$warningCount = 0;
402+
$hasErrorLog = false;
403+
404+
foreach ($mockLogger->logs as $log) {
405+
if ($log['level'] === 'warning' &&
406+
$log['message'] === 'Rate limit reached while creating story, retrying...'
407+
) {
408+
++$warningCount;
409+
}
410+
411+
if ($log['level'] === 'error' &&
412+
$log['message'] === 'Max retries reached while creating story'
413+
) {
414+
$hasErrorLog = true;
415+
}
416+
}
417+
418+
// We should see MAX_RETRIES number of warning logs
419+
expect($warningCount)->toBe(3)
420+
->and($hasErrorLog)->toBeTrue();
238421

422+
// Verify the last log context contains story information
423+
$lastErrorLog = array_filter($mockLogger->logs, fn($log): bool => $log['level'] === 'error');
424+
$lastErrorLog = end($lastErrorLog);
239425

426+
expect($lastErrorLog['context'])->toHaveKey('story_name')
427+
->and($lastErrorLog['context']['story_name'])->toBe('Story 1');
240428
});

0 commit comments

Comments
 (0)