Skip to content

Commit a29c31b

Browse files
authored
feat: API backward compatibility for userName field rename (#310)
- ApiResponseTransformInterface: declares sinceVersion, routes, transform() - ApiCompatTransformRegistry: collects transforms, filters by client version and route name, returns applicable transforms sorted newest-first - ApiCompatSubscriber: applies transforms from registry on kernel.response - ClientVersionDetector: now returns semver string instead of bool - RenameUserNameTransform: first concrete transform (userName → username for clients < 1.0.1 on user/bike/report endpoints) Adding a future breaking change = creating one new class implementing ApiResponseTransformInterface. Auto-discovered via tagged services.
1 parent ec38b6d commit a29c31b

10 files changed

Lines changed: 611 additions & 0 deletions

config/services.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@
4646
$services->instanceof(MailSenderInterface::class)->tag('mailSender');
4747
$services->instanceof(SmsConnectorInterface::class)->tag('smsConnector');
4848
$services->instanceof(SmsCommandInterface::class)->tag('smsCommand');
49+
$services->instanceof(\BikeShare\App\Api\Compat\ApiResponseTransformInterface::class)
50+
->tag('app.api_compat_transform');
51+
52+
$services->set(\BikeShare\App\Api\Compat\ApiCompatTransformRegistry::class)
53+
->args([tagged_iterator('app.api_compat_transform')]);
4954

5055
$services->alias('logger', 'monolog.logger');
5156

@@ -74,6 +79,7 @@
7479
'../src/Event',
7580
'../src/Command/LoadFixturesCommand.php',
7681
'../src/SmsCommand/*Command.php',
82+
'../src/App/Api/Compat/ApiCompatTransformRegistry.php',
7783
'../src/Rent/DTO',
7884
'../src/Rent/Enum',
7985
]);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BikeShare\App\Api;
6+
7+
use Symfony\Component\HttpFoundation\Request;
8+
9+
class ClientVersionDetector
10+
{
11+
// Pattern: AppName-Android/versionName (versionCode)
12+
private const ANDROID_UA_PATTERN = '/^.+-Android\/(\d+\.\d+\.\d+)\s*\(\d+\)$/';
13+
14+
// Clients at this version get no transforms applied
15+
private const VERSION_LATEST = '999.0.0';
16+
17+
// Clients at this version get all transforms applied
18+
private const VERSION_OLDEST = '0.0.0';
19+
20+
/**
21+
* Returns the detected client version as a semver string.
22+
*
23+
* - Android with custom UA: parsed version (e.g. "1.0.0")
24+
* - Old Android (okhttp/*): "0.0.0" (all transforms apply)
25+
* - Browsers, web admin, etc.: "999.0.0" (no transforms apply)
26+
*/
27+
public function getClientVersion(Request $request): string
28+
{
29+
$userAgent = $request->headers->get('User-Agent', '');
30+
31+
if (preg_match(self::ANDROID_UA_PATTERN, $userAgent, $matches)) {
32+
return $matches[1];
33+
}
34+
35+
// Old Android app without custom UA sends okhttp/*
36+
if (str_starts_with($userAgent, 'okhttp/')) {
37+
return self::VERSION_OLDEST;
38+
}
39+
40+
// Browsers, web admin, curl, etc.
41+
return self::VERSION_LATEST;
42+
}
43+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BikeShare\App\Api\Compat;
6+
7+
class ApiCompatTransformRegistry
8+
{
9+
/** @var ApiResponseTransformInterface[] */
10+
private array $transforms;
11+
12+
/**
13+
* @param iterable<ApiResponseTransformInterface> $transforms
14+
*/
15+
public function __construct(iterable $transforms)
16+
{
17+
$this->transforms = $transforms instanceof \Traversable
18+
? iterator_to_array($transforms)
19+
: (array) $transforms;
20+
21+
// Sort newest first so transforms compose correctly (latest change reversed first)
22+
usort($this->transforms, static function (
23+
ApiResponseTransformInterface $a,
24+
ApiResponseTransformInterface $b
25+
): int {
26+
return version_compare($b->getSinceVersion(), $a->getSinceVersion());
27+
});
28+
}
29+
30+
/**
31+
* @return ApiResponseTransformInterface[]
32+
*/
33+
public function getTransformsFor(string $clientVersion, string $routeName): array
34+
{
35+
$applicable = [];
36+
37+
foreach ($this->transforms as $transform) {
38+
if (version_compare($clientVersion, $transform->getSinceVersion(), '>=')) {
39+
continue;
40+
}
41+
42+
$routes = $transform->getRoutes();
43+
if (!empty($routes) && !in_array($routeName, $routes, true)) {
44+
continue;
45+
}
46+
47+
$applicable[] = $transform;
48+
}
49+
50+
return $applicable;
51+
}
52+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BikeShare\App\Api\Compat;
6+
7+
interface ApiResponseTransformInterface
8+
{
9+
/**
10+
* The app version that introduced this breaking change (semver).
11+
* Clients with version < this will receive the transformed (old) response.
12+
*/
13+
public function getSinceVersion(): string;
14+
15+
/**
16+
* Symfony route names this transform applies to.
17+
* Return empty array to apply to all API routes.
18+
*
19+
* @return string[]
20+
*/
21+
public function getRoutes(): array;
22+
23+
/**
24+
* Transform new-format response data to old-format for legacy clients.
25+
*/
26+
public function transform(array $data): array;
27+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BikeShare\App\Api\Compat;
6+
7+
class RenameUserNameTransform implements ApiResponseTransformInterface
8+
{
9+
private const RENAMES = [
10+
'userName' => 'username',
11+
];
12+
13+
public function getSinceVersion(): string
14+
{
15+
return '1.0.1';
16+
}
17+
18+
public function getRoutes(): array
19+
{
20+
return [
21+
'api_v1_admin_users',
22+
'api_v1_admin_user_item',
23+
'api_v1_admin_bikes',
24+
'api_v1_bike_item',
25+
'api_v1_bike_last_usage',
26+
'api_v1_admin_report_users',
27+
];
28+
}
29+
30+
public function transform(array $data): array
31+
{
32+
return $this->renameKeysRecursive($data);
33+
}
34+
35+
private function renameKeysRecursive(array $data): array
36+
{
37+
$result = [];
38+
foreach ($data as $key => $value) {
39+
$newKey = self::RENAMES[$key] ?? $key;
40+
$result[$newKey] = is_array($value) ? $this->renameKeysRecursive($value) : $value;
41+
}
42+
43+
return $result;
44+
}
45+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BikeShare\App\EventListener;
6+
7+
use BikeShare\App\Api\ClientVersionDetector;
8+
use BikeShare\App\Api\Compat\ApiCompatTransformRegistry;
9+
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
10+
use Symfony\Component\HttpFoundation\JsonResponse;
11+
use Symfony\Component\HttpFoundation\Response;
12+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
13+
use Symfony\Component\HttpKernel\KernelEvents;
14+
15+
class ApiCompatSubscriber implements EventSubscriberInterface
16+
{
17+
public function __construct(
18+
private readonly ClientVersionDetector $clientVersionDetector,
19+
private readonly ApiCompatTransformRegistry $registry,
20+
) {
21+
}
22+
23+
public static function getSubscribedEvents(): array
24+
{
25+
return [
26+
KernelEvents::RESPONSE => ['onResponse', 110],
27+
];
28+
}
29+
30+
public function onResponse(ResponseEvent $event): void
31+
{
32+
if (!$event->isMainRequest()) {
33+
return;
34+
}
35+
36+
$request = $event->getRequest();
37+
if (!str_starts_with($request->getPathInfo(), '/api/v1')) {
38+
return;
39+
}
40+
41+
$response = $event->getResponse();
42+
if (!$response instanceof JsonResponse) {
43+
return;
44+
}
45+
46+
if ($response->getStatusCode() >= Response::HTTP_BAD_REQUEST) {
47+
return;
48+
}
49+
50+
$clientVersion = $this->clientVersionDetector->getClientVersion($request);
51+
$routeName = $request->attributes->get('_route', '');
52+
$transforms = $this->registry->getTransformsFor($clientVersion, $routeName);
53+
54+
if (empty($transforms)) {
55+
return;
56+
}
57+
58+
$content = $response->getContent();
59+
if ($content === false || $content === '') {
60+
return;
61+
}
62+
63+
try {
64+
$data = json_decode($content, true, 512, JSON_THROW_ON_ERROR);
65+
} catch (\JsonException) {
66+
return;
67+
}
68+
69+
if (!is_array($data)) {
70+
return;
71+
}
72+
73+
foreach ($transforms as $transform) {
74+
$data = $transform->transform($data);
75+
}
76+
77+
$response->setData($data);
78+
}
79+
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace BikeShare\Test\Unit\App\Api;
6+
7+
use BikeShare\App\Api\ClientVersionDetector;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\TestCase;
10+
use Symfony\Component\HttpFoundation\Request;
11+
12+
class ClientVersionDetectorTest extends TestCase
13+
{
14+
private ClientVersionDetector $detector;
15+
16+
protected function setUp(): void
17+
{
18+
$this->detector = new ClientVersionDetector();
19+
}
20+
21+
#[DataProvider('userAgentProvider')]
22+
public function testGetClientVersion(string $userAgent, string $expectedVersion): void
23+
{
24+
$request = Request::create('/api/v1/admin/users');
25+
$request->headers->set('User-Agent', $userAgent);
26+
27+
$this->assertSame($expectedVersion, $this->detector->getClientVersion($request));
28+
}
29+
30+
public static function userAgentProvider(): array
31+
{
32+
return [
33+
'Android 1.0.1' => ['BikeShare-Android/1.0.1 (2)', '1.0.1'],
34+
'Android 2.0.0' => ['BikeShare-Android/2.0.0 (5)', '2.0.0'],
35+
'Android 1.0.0' => ['BikeShare-Android/1.0.0 (1)', '1.0.0'],
36+
'Android 0.9.0' => ['BikeShare-Android/0.9.0 (1)', '0.9.0'],
37+
'custom app name' => ['WhiteBikes-Android/1.2.3 (4)', '1.2.3'],
38+
'old Android okhttp' => ['okhttp/4.12.0', '0.0.0'],
39+
'browser' => ['Mozilla/5.0 (Linux; Android 14) AppleWebKit/537.36', '999.0.0'],
40+
'empty UA' => ['', '999.0.0'],
41+
'curl' => ['curl/8.5.0', '999.0.0'],
42+
];
43+
}
44+
45+
public function testNoUserAgentHeader(): void
46+
{
47+
$request = Request::create('/api/v1/admin/users');
48+
$request->headers->remove('User-Agent');
49+
50+
$this->assertSame('999.0.0', $this->detector->getClientVersion($request));
51+
}
52+
}

0 commit comments

Comments
 (0)