Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config/packages/api_platform.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@
'mapping' => [
'paths' => ['%kernel.project_dir%/src/Api'],
],
// The wire format of the public API is snake_case (puzzle_id, is_private, ...);
// the PHP side follows the project's camelCase standard. The converter maps
// both directions (responses, request inputs, the OpenAPI schema and the
// propertyPath of validation violations) - adding a DTO property means
// writing it in camelCase, nothing else. Scoped to API Platform, the rest of
// the application's serializer is untouched.
'name_converter' => 'serializer.name_converter.camel_case_to_snake_case',
'formats' => [
'json' => ['mime_types' => ['application/json']],
],
Expand Down
6 changes: 6 additions & 0 deletions config/services.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
use Sentry\Monolog\LogToSentryIssueHandler;
use Sentry\State\HubInterface;
use SpeedPuzzling\Web\Doctrine\RegexSchemaAssetFilter;
use SpeedPuzzling\Web\Services\Api\ApiDtoNormalizer;
use SpeedPuzzling\Web\Services\Doctrine\FixDoctrineMigrationTableSchema;
use SpeedPuzzling\Web\Services\SentryTracesSampler;
use SpeedPuzzling\Web\Services\Storage\FailoverS3Adapter;
Expand Down Expand Up @@ -220,6 +221,11 @@
// API Resource Providers and Processors
$services->load('SpeedPuzzling\\Web\\Api\\', __DIR__ . '/../src/Api/**/{*Provider.php,*Processor.php}');

// The nested API DTOs are normalized with the same snake_case converter API Platform
// uses for the resources themselves (config/packages/api_platform.php)
$services->set(ApiDtoNormalizer::class)
->arg('$nameConverter', service('serializer.name_converter.camel_case_to_snake_case'));

// Components
$services->load('SpeedPuzzling\\Web\\Component\\', __DIR__ . '/../src/Component/**/{*.php}');

Expand Down
10 changes: 10 additions & 0 deletions docs/features/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,16 @@ Full policy page at `/en/fair-use-policy` with 10 sections: welcome, rate limits

Configured in `config/packages/nelmio_cors.php` with `allow_origin: ['*']` globally.

## Conventions: camelCase PHP, snake_case JSON

The wire format of the API is snake_case (`puzzle_id`, `is_private`, `predicted_seconds`, ...) and is a contract. The PHP DTOs in `src/Api/V1` use the project's camelCase standard (`$puzzleId`) — the mapping is automatic in both directions:

- `config/packages/api_platform.php` sets `name_converter: serializer.name_converter.camel_case_to_snake_case`, which API Platform applies to the resources it serializes, to request inputs (`puzzle_id` → `$puzzleId`), to the OpenAPI schema and to the `propertyPath` of validation violations;
- `src/Services/Api/ApiDtoNormalizer.php` applies the same converter to the **nested** DTOs (statistics groups, cards inside a list, rating entries, ...), which the framework serializer would otherwise emit camelCase — scoped to `SpeedPuzzling\Web\Api\*` non-resource classes so nothing else in the application changes;
- `phpcs.xml` enforces camelCase in `src/Api/` (`Squiz.NamingConventions.ValidVariableName`), so a snake_case property cannot come back.

Adding a field therefore means writing it in camelCase; the JSON key follows. The 2026-08-19 migration was verified by a byte-identical OpenAPI export before/after and the full API test suite (which asserts JSON keys).

## Deprecated: V0 Legacy API

> **Deprecated** — Do not develop further. Kept for backward compatibility only.
Expand Down
11 changes: 11 additions & 0 deletions phpcs.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,17 @@
<property name="searchAnnotations" value="true"/>
</properties>
</rule>
<!-- The API DTOs (src/Api) follow the camelCase standard like the rest of the code; the
snake_case wire format (puzzle_id, is_private, ...) is produced by the name converter in
config/packages/api_platform.php + ApiDtoNormalizer, never by property names. Scoped to
src/Api because elsewhere the sniffs would only flag third-party members (Stripe objects) and
fixture variables; promoted constructor properties are parameters to the sniff, hence both. -->
<rule ref="Squiz.NamingConventions.ValidVariableName.NotCamelCaps">
<include-pattern>src/Api/</include-pattern>
</rule>
<rule ref="Squiz.NamingConventions.ValidVariableName.MemberNotCamelCaps">
<include-pattern>src/Api/</include-pattern>
</rule>
<rule ref="SlevomatCodingStandard.Namespaces.UseFromSameNamespace"/>
<rule ref="SlevomatCodingStandard.PHP.UselessSemicolon"/>
<rule ref="SlevomatCodingStandard.Arrays.TrailingArrayComma"/>
Expand Down
2 changes: 1 addition & 1 deletion src/Api/V1/AddCollectionItemInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
final class AddCollectionItemInput
{
#[Assert\NotBlank]
public string $puzzle_id = '';
public string $puzzleId = '';

#[Assert\Length(max: 500)]
public null|string $comment = null;
Expand Down
18 changes: 9 additions & 9 deletions src/Api/V1/AddCollectionItemProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$dbCollectionId = $collectionId === 'default' ? null : $collectionId;

// Validate here so an invalid/unknown id surfaces as 404 instead of a wrapped 500 from the handler
$this->puzzleRepository->get($data->puzzle_id);
$this->puzzleRepository->get($data->puzzleId);

if ($dbCollectionId !== null) {
$collection = $this->collectionRepository->get($dbCollectionId);
Expand All @@ -68,13 +68,13 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$this->messageBus->dispatch(
new AddPuzzleToCollection(
playerId: $playerId,
puzzleId: $data->puzzle_id,
puzzleId: $data->puzzleId,
collectionId: $dbCollectionId,
comment: $data->comment,
),
);

$item = $this->getCollectionItems->getByPuzzleIdAndPlayerId($data->puzzle_id, $playerId, $dbCollectionId);
$item = $this->getCollectionItems->getByPuzzleIdAndPlayerId($data->puzzleId, $playerId, $dbCollectionId);

if ($item === null) {
throw new \RuntimeException('Collection item was not found after adding it to the collection.');
Expand All @@ -84,14 +84,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$insights = $this->puzzleResponseFactory->insightsFor([$item->puzzleId], solvesOfPlayerId: $playerId, includePrediction: true);

return new CollectionItemResponse(
collection_item_id: $item->collectionItemId,
puzzle_id: $item->puzzleId,
puzzle_name: $item->puzzleName,
manufacturer_name: $item->manufacturerName,
pieces_count: $item->piecesCount,
collectionItemId: $item->collectionItemId,
puzzleId: $item->puzzleId,
puzzleName: $item->puzzleName,
manufacturerName: $item->manufacturerName,
piecesCount: $item->piecesCount,
image: $item->image,
comment: $item->comment,
added_at: $item->addedAt->format('c'),
addedAt: $item->addedAt->format('c'),
statistics: $insights->statistics($item->puzzleId),
difficulty: $insights->difficulty($item->puzzleId),
prediction: $insights->prediction($item->puzzleId),
Expand Down
12 changes: 6 additions & 6 deletions src/Api/V1/CollectionItemResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,14 @@
final class CollectionItemResponse
{
public function __construct(
public string $collection_item_id,
public string $puzzle_id,
public string $puzzle_name,
public null|string $manufacturer_name,
public int $pieces_count,
public string $collectionItemId,
public string $puzzleId,
public string $puzzleName,
public null|string $manufacturerName,
public int $piecesCount,
public null|string $image,
public null|string $comment,
public string $added_at,
public string $addedAt,
public PuzzleStatisticsResponse $statistics,
public null|PuzzleDifficultyResponse $difficulty = null,
public null|TimePredictionResponse $prediction = null,
Expand Down
2 changes: 1 addition & 1 deletion src/Api/V1/CollectionResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
final class CollectionResponse
{
public function __construct(
public string $collection_id,
public string $collectionId,
public string $name,
public null|string $description,
public string $visibility,
Expand Down
12 changes: 6 additions & 6 deletions src/Api/V1/CompetitionDetailResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@ public function __construct(
public null|string $logo,
public null|string $description,
public null|string $location,
public null|string $country_code,
public bool $is_online,
public null|string $date_from,
public null|string $date_to,
public null|string $countryCode,
public bool $isOnline,
public null|string $dateFrom,
public null|string $dateTo,
public null|string $link,
public null|string $registration_link,
public null|string $results_link,
public null|string $registrationLink,
public null|string $resultsLink,
array $rounds,
) {
$this->rounds = $rounds;
Expand Down
20 changes: 10 additions & 10 deletions src/Api/V1/CompetitionDetailResponseProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,13 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
logo: $competition->logo,
description: $competition->description,
location: $competition->location,
country_code: $competition->locationCountryCode?->name,
is_online: $competition->isOnline,
date_from: $competition->dateFrom?->format('c'),
date_to: $competition->dateTo?->format('c'),
countryCode: $competition->locationCountryCode?->name,
isOnline: $competition->isOnline,
dateFrom: $competition->dateFrom?->format('c'),
dateTo: $competition->dateTo?->format('c'),
link: $competition->link,
registration_link: $competition->registrationLink,
results_link: $competition->resultsLink,
registrationLink: $competition->registrationLink,
resultsLink: $competition->resultsLink,
rounds: $rounds,
);
}
Expand All @@ -73,8 +73,8 @@ private function mapRound(EditionRoundDetail $round): CompetitionRoundResponse
return new CompetitionRoundResponse(
id: $round->id,
name: $round->name,
starts_at: $round->startsAt->format('c'),
minutes_limit: $round->minutesLimit,
startsAt: $round->startsAt->format('c'),
minutesLimit: $round->minutesLimit,
category: $round->category->value,
puzzles: array_map($this->mapPuzzle(...), $round->puzzles),
);
Expand All @@ -85,9 +85,9 @@ private function mapPuzzle(EditionRoundPuzzle $puzzle): CompetitionRoundPuzzleRe
return new CompetitionRoundPuzzleResponse(
id: $puzzle->puzzleId,
name: $puzzle->puzzleName,
pieces_count: $puzzle->piecesCount,
piecesCount: $puzzle->piecesCount,
image: $puzzle->puzzleImage,
manufacturer_name: $puzzle->manufacturerName,
manufacturerName: $puzzle->manufacturerName,
);
}
}
12 changes: 6 additions & 6 deletions src/Api/V1/CompetitionListItemResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@ public function __construct(
public null|string $slug,
public null|string $logo,
public null|string $location,
public null|string $country_code,
public bool $is_online,
public null|string $date_from,
public null|string $date_to,
public null|string $countryCode,
public bool $isOnline,
public null|string $dateFrom,
public null|string $dateTo,
public null|string $status,
public null|string $link,
public null|string $registration_link,
public null|string $results_link,
public null|string $registrationLink,
public null|string $resultsLink,
) {
}
}
12 changes: 6 additions & 6 deletions src/Api/V1/CompetitionListResponseProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -45,14 +45,14 @@ public function provide(Operation $operation, array $uriVariables = [], array $c
slug: $event->slug,
logo: $event->logo,
location: $event->location,
country_code: $event->locationCountryCode?->name,
is_online: $event->isOnline,
date_from: $event->dateFrom?->format('c'),
date_to: $event->dateTo?->format('c'),
countryCode: $event->locationCountryCode?->name,
isOnline: $event->isOnline,
dateFrom: $event->dateFrom?->format('c'),
dateTo: $event->dateTo?->format('c'),
status: $event->eventStatus,
link: $event->link,
registration_link: $event->registrationLink,
results_link: $event->resultsLink,
registrationLink: $event->registrationLink,
resultsLink: $event->resultsLink,
),
$events,
);
Expand Down
4 changes: 2 additions & 2 deletions src/Api/V1/CompetitionRoundPuzzleResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ final class CompetitionRoundPuzzleResponse
public function __construct(
public string $id,
public string $name,
public int $pieces_count,
public int $piecesCount,
public null|string $image,
public null|string $manufacturer_name,
public null|string $manufacturerName,
) {
}
}
4 changes: 2 additions & 2 deletions src/Api/V1/CompetitionRoundResponse.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ final class CompetitionRoundResponse
public function __construct(
public string $id,
public string $name,
public null|string $starts_at,
public int $minutes_limit,
public null|string $startsAt,
public int $minutesLimit,
public string $category,
array $puzzles,
) {
Expand Down
2 changes: 1 addition & 1 deletion src/Api/V1/CreateCollectionProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
);

return new CollectionResponse(
collection_id: $collectionId,
collectionId: $collectionId,
name: $data->name,
description: $data->description,
visibility: $visibility->value,
Expand Down
10 changes: 5 additions & 5 deletions src/Api/V1/CreateSolvingTimeInput.php
Original file line number Diff line number Diff line change
Expand Up @@ -35,22 +35,22 @@
final class CreateSolvingTimeInput
{
#[Assert\NotBlank]
public string $puzzle_id = '';
public string $puzzleId = '';

#[Assert\NotBlank]
#[Assert\Regex(pattern: '/^\d{1,2}:\d{2}(:\d{2})?$/', message: 'Time must be in format HH:MM:SS or MM:SS')]
public string $time = '';

public null|string $comment = null;

public null|string $finished_at = null;
public null|string $finishedAt = null;

public bool $first_attempt = false;
public bool $firstAttempt = false;

public bool $unboxed = false;

public null|string $round_id = null;
public null|string $roundId = null;

/** @var array<string> */
public array $group_players = [];
public array $groupPlayers = [];
}
30 changes: 15 additions & 15 deletions src/Api/V1/CreateSolvingTimeProcessor.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,26 +53,26 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
// Validate the optional round here so an invalid/unknown id surfaces as 404
// (CompetitionRoundNotFound is a NotFoundHttpException). The handler re-resolves
// the round to wire it onto the entity.
if ($data->round_id !== null) {
$this->competitionRoundRepository->get($data->round_id);
if ($data->roundId !== null) {
$this->competitionRoundRepository->get($data->roundId);
}

$finishedAt = $data->finished_at !== null ? new DateTimeImmutable($data->finished_at) : null;
$finishedAt = $data->finishedAt !== null ? new DateTimeImmutable($data->finishedAt) : null;

$this->messageBus->dispatch(
new AddPuzzleSolvingTime(
timeId: $timeId,
userId: $userId,
puzzleId: $data->puzzle_id,
puzzleId: $data->puzzleId,
competitionId: null,
time: $data->time,
comment: $data->comment,
finishedPuzzlesPhoto: null,
groupPlayers: $data->group_players,
groupPlayers: $data->groupPlayers,
finishedAt: $finishedAt,
firstAttempt: $data->first_attempt,
firstAttempt: $data->firstAttempt,
unboxed: $data->unboxed,
roundId: $data->round_id,
roundId: $data->roundId,
),
);

Expand All @@ -81,14 +81,14 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
$timeSeconds = SolvingTime::fromUserInput($data->time)->seconds;

return new SolvingTimeResponse(
time_id: $timeId->toString(),
puzzle_id: $data->puzzle_id,
time_seconds: $timeSeconds,
finished_at: $finishedAt?->format('c'),
first_attempt: $data->first_attempt,
timeId: $timeId->toString(),
puzzleId: $data->puzzleId,
timeSeconds: $timeSeconds,
finishedAt: $finishedAt?->format('c'),
firstAttempt: $data->firstAttempt,
unboxed: $data->unboxed,
comment: $data->comment,
round_id: $data->round_id,
roundId: $data->roundId,
prediction: $this->predictionBefore($data, $timeSeconds, $timeId->toString()),
);
}
Expand All @@ -107,7 +107,7 @@ public function process(mixed $data, Operation $operation, array $uriVariables =
private function predictionBefore(CreateSolvingTimeInput $data, null|int $timeSeconds, string $timeId): null|TimePredictionResponse
{
// A non-empty group_players list always makes a duo/team time (or fails in the handler)
if ($data->group_players !== [] || $timeSeconds === null) {
if ($data->groupPlayers !== [] || $timeSeconds === null) {
return null;
}

Expand All @@ -122,7 +122,7 @@ private function predictionBefore(CreateSolvingTimeInput $data, null|int $timeSe
}

return TimePredictionResponse::fromResult(
$this->getPlayerPrediction->forPuzzle($profile->playerId, $data->puzzle_id, excludeTimeId: $timeId),
$this->getPlayerPrediction->forPuzzle($profile->playerId, $data->puzzleId, excludeTimeId: $timeId),
);
}
}
Loading