Skip to content

Commit ec10780

Browse files
committed
feat: add bulk story creation with rate limit handling
1 parent 5d8b0da commit ec10780

File tree

5 files changed

+292
-16
lines changed

5 files changed

+292
-16
lines changed

.gitignore

+1-1
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

+97-10
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

-4
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

-1
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

+194
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,16 @@
1212
use Symfony\Component\HttpClient\MockHttpClient;
1313
use Psr\Log\NullLogger;
1414

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

428+
// Verify the last log context contains story information
429+
$lastErrorLog = array_filter($mockLogger->logs, fn($log): bool => $log['level'] === 'error');
430+
$lastErrorLog = end($lastErrorLog);
239431

432+
expect($lastErrorLog['context'])->toHaveKey('story_name')
433+
->and($lastErrorLog['context']['story_name'])->toBe('Story 1');
240434
});

0 commit comments

Comments
 (0)