Skip to content

Commit 05b380b

Browse files
committed
Add proxy api endpoint for the composer versions file
1 parent aaaa01b commit 05b380b

2 files changed

Lines changed: 65 additions & 0 deletions

File tree

src/Controller/ApiController.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
use Symfony\Component\HttpFoundation\JsonResponse;
3232
use Symfony\Component\HttpFoundation\Request;
3333
use Symfony\Component\HttpFoundation\Response;
34+
use Symfony\Component\HttpKernel\EventListener\AbstractSessionListener;
3435
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
3536
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
3637
use Symfony\Component\Routing\Attribute\Route;
@@ -49,6 +50,8 @@ enum ApiType
4950
*/
5051
class ApiController extends Controller
5152
{
53+
private const COMPOSER_VERSIONS_URL = 'https://getcomposer.org/versions';
54+
5255
private const REGEXES = [
5356
'gitlab' => '{^(?:ssh://git@|https?://|git://|git@)?(?P<host>[a-z0-9.-]+)(?::[0-9]+/|[:/])(?P<path>[\w.-]+(?:/[\w.-]+?)+)(?:\.git|/)?$}i',
5457
'any' => '{^(?:ssh://git@|https?://|git://|git@)?(?P<host>[a-z0-9.-]+)(?::[0-9]+/|[:/])(?P<path>[\w.-]+(?:/[\w.-]+?)*)(?:\.git|/)?$}i',
@@ -82,6 +85,34 @@ public function packagesAction(string $webDir): Response
8285
return new Response('Horrible misconfiguration or the dumper script messed up, you need to use bin/console packagist:dump-v2', 404);
8386
}
8487

88+
/**
89+
* Proxies https://getcomposer.org/versions so that systems which can only reach
90+
* packagist.org are still able to read Composer's version metadata.
91+
*
92+
* The response is cached at the CDN edge via s-maxage as this data changes rarely.
93+
*/
94+
#[Route(path: '/api/composer-versions', name: 'composer_versions', defaults: ['_format' => 'json'], methods: ['GET'])]
95+
public function composerVersionsAction(): Response
96+
{
97+
try {
98+
$upstream = $this->httpClient->request('GET', self::COMPOSER_VERSIONS_URL);
99+
if ($upstream->getStatusCode() !== 200) {
100+
throw new \RuntimeException('Unexpected status code '.$upstream->getStatusCode());
101+
}
102+
$contents = $upstream->getContent();
103+
} catch (\Throwable $e) {
104+
$this->logger->error('Failed to proxy '.self::COMPOSER_VERSIONS_URL, ['exception' => $e]);
105+
106+
return new JsonResponse(['status' => 'error', 'message' => 'Failed to fetch upstream version data, please try again later.'], 502);
107+
}
108+
109+
$response = new Response($contents, 200, ['Content-Type' => 'application/json']);
110+
$response->setSharedMaxAge(900);
111+
$response->headers->set(AbstractSessionListener::NO_AUTO_CACHE_CONTROL_HEADER, 'true');
112+
113+
return $response;
114+
}
115+
85116
#[Route(path: '/api/create-package', name: 'generic_create', defaults: ['_format' => 'json'], methods: ['POST'])]
86117
public function createPackageAction(Request $request, ProviderManager $providerManager, GitHubUserMigrationWorker $githubUserMigrationWorker, RouterInterface $router, ValidatorInterface $validator): JsonResponse
87118
{

tests/Controller/ApiControllerTest.php

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
use App\Tests\IntegrationTestCase;
2020
use PHPUnit\Framework\Attributes\DataProvider;
2121
use PHPUnit\Framework\Attributes\Depends;
22+
use Symfony\Component\HttpClient\MockHttpClient;
23+
use Symfony\Component\HttpClient\Response\MockResponse;
2224

2325
class ApiControllerTest extends IntegrationTestCase
2426
{
@@ -282,4 +284,36 @@ public function testBearerTokenAuthenticationOverridesQuery(): void
282284

283285
$this->assertEquals(202, $this->client->getResponse()->getStatusCode(), $this->client->getResponse()->getContent());
284286
}
287+
288+
public function testComposerVersionsProxiesUpstream(): void
289+
{
290+
$body = '{"stable":[{"path":"/download/2.7.1/composer.phar","version":"2.7.1"}]}';
291+
$mockClient = new MockHttpClient(function (string $method, string $url) use ($body) {
292+
$this->assertSame('GET', $method);
293+
$this->assertSame('https://getcomposer.org/versions', $url);
294+
295+
return new MockResponse($body, ['http_code' => 200, 'response_headers' => ['Content-Type' => 'application/json']]);
296+
});
297+
self::getContainer()->set('http_client', $mockClient);
298+
299+
$this->client->request('GET', '/api/composer-versions');
300+
$response = $this->client->getResponse();
301+
302+
$this->assertSame(200, $response->getStatusCode(), (string) $response->getContent());
303+
$this->assertSame($body, $response->getContent());
304+
$this->assertStringContainsString('application/json', (string) $response->headers->get('Content-Type'));
305+
$this->assertStringContainsString('s-maxage=900', (string) $response->headers->get('Cache-Control'));
306+
}
307+
308+
public function testComposerVersionsReturns502OnUpstreamFailure(): void
309+
{
310+
$mockClient = new MockHttpClient(new MockResponse('nope', ['http_code' => 500]));
311+
self::getContainer()->set('http_client', $mockClient);
312+
313+
$this->client->request('GET', '/api/composer-versions');
314+
$response = $this->client->getResponse();
315+
316+
$this->assertSame(502, $response->getStatusCode());
317+
$this->assertJsonStringEqualsJsonString('{"status":"error","message":"Failed to fetch upstream version data, please try again later."}', (string) $response->getContent());
318+
}
285319
}

0 commit comments

Comments
 (0)