Author: Shivam Mathur (shivammathur)
Date: 2025-11-10T11:37:03+05:30
Commit:
https://github.com/php/web-downloads/commit/daab2fa752eadce4f936b52577c2d972a728b4df
Raw diff:
https://github.com/php/web-downloads/commit/daab2fa752eadce4f936b52577c2d972a728b4df.diff
Add endpoint to list builds
Changed paths:
A src/Http/Controllers/ListBuildsController.php
A tests/Http/Controllers/ListBuildsControllerTest.php
M API.md
M routes.php
Diff:
diff --git a/API.md b/API.md
index 7056060..46917be 100644
--- a/API.md
+++ b/API.md
@@ -25,6 +25,26 @@
---
+### GET /api/list-builds
+
+- Auth: Required
+- Purpose: Enumerate the files under `BUILDS_DIRECTORY` so operators can
inspect available build artifacts.
+- Request body: none (GET request).
+- Success: `200 OK` with JSON payload `{ "builds": [ { "path":
"relative/path", "size": 1234, "modified": "2025-09-30T12:34:56+00:00" }, ... ]
}`.
+- Errors:
+ - `401` if the bearer token is missing or invalid.
+ - `500` with `{ "error": "Builds directory not configured or missing." }`
if `BUILDS_DIRECTORY` is unset or the directory does not exist.
+
+Example
+
+```bash
+curl -i -X GET \
+ -H "Authorization: Bearer $AUTH_TOKEN" \
+ https://downloads.php.net/api/list-builds
+```
+
+---
+
### POST /api/php
- Auth: Required
diff --git a/routes.php b/routes.php
index bdf6001..9b707a4 100644
--- a/routes.php
+++ b/routes.php
@@ -2,6 +2,7 @@
declare(strict_types=1);
use App\Http\Controllers\IndexController;
+use App\Http\Controllers\ListBuildsController;
use App\Http\Controllers\PeclController;
use App\Http\Controllers\PhpController;
use App\Http\Controllers\SeriesDeleteController;
@@ -12,6 +13,7 @@
$router = new Router();
$router->registerRoute('/api', 'GET', IndexController::class);
+$router->registerRoute('/api/list-builds', 'GET', ListBuildsController::class,
true);
$router->registerRoute('/api/pecl', 'POST', PeclController::class, true);
$router->registerRoute('/api/winlibs', 'POST', WinlibsController::class, true);
$router->registerRoute('/api/php', 'POST', PhpController::class, true);
diff --git a/src/Http/Controllers/ListBuildsController.php
b/src/Http/Controllers/ListBuildsController.php
new file mode 100644
index 0000000..3be3732
--- /dev/null
+++ b/src/Http/Controllers/ListBuildsController.php
@@ -0,0 +1,79 @@
+<?php
+declare(strict_types=1);
+
+namespace App\Http\Controllers;
+
+use App\Http\ControllerInterface;
+use FilesystemIterator;
+use JsonException;
+use RecursiveDirectoryIterator;
+use RecursiveIteratorIterator;
+
+class ListBuildsController implements ControllerInterface
+{
+ public function __construct(private ?string $buildsDirectory = null)
+ {
+ if ($this->buildsDirectory === null) {
+ $this->buildsDirectory = getenv('BUILDS_DIRECTORY') ?: '';
+ }
+ }
+
+ public function handle(): void
+ {
+ if ($this->buildsDirectory === '' || !is_dir($this->buildsDirectory)) {
+ http_response_code(500);
+ $this->outputJson(['error' => 'Builds directory not configured or
missing.']);
+ return;
+ }
+
+ $builds = $this->collectBuilds($this->buildsDirectory);
+
+ http_response_code(200);
+ $this->outputJson(['builds' => $builds]);
+ }
+
+ private function collectBuilds(string $root): array
+ {
+ $entries = [];
+
+ $normalizedRoot = rtrim($root, DIRECTORY_SEPARATOR);
+
+ $iterator = new RecursiveIteratorIterator(
+ new RecursiveDirectoryIterator($normalizedRoot,
FilesystemIterator::SKIP_DOTS),
+ RecursiveIteratorIterator::LEAVES_ONLY
+ );
+
+ foreach ($iterator as $fileInfo) {
+ if (!$fileInfo->isFile()) {
+ continue;
+ }
+
+ $relativePath = substr($fileInfo->getPathname(),
strlen($normalizedRoot) + 1);
+
+ $entries[] = [
+ 'path' => $relativePath,
+ 'size' => $fileInfo->getSize(),
+ 'modified' => gmdate('c', $fileInfo->getMTime()),
+ ];
+ }
+
+ usort($entries, static fn (array $a, array $b): int =>
strcmp($a['path'], $b['path']));
+
+ return $entries;
+ }
+
+ private function outputJson(array $payload): void
+ {
+ try {
+ $json = json_encode($payload, JSON_THROW_ON_ERROR |
JSON_PRETTY_PRINT);
+ } catch (JsonException) {
+ http_response_code(500);
+ header('Content-Type: application/json');
+ echo '{"error":"Failed to encode response."}';
+ return;
+ }
+
+ header('Content-Type: application/json');
+ echo $json;
+ }
+}
diff --git a/tests/Http/Controllers/ListBuildsControllerTest.php
b/tests/Http/Controllers/ListBuildsControllerTest.php
new file mode 100644
index 0000000..9b4077e
--- /dev/null
+++ b/tests/Http/Controllers/ListBuildsControllerTest.php
@@ -0,0 +1,90 @@
+<?php
+declare(strict_types=1);
+
+namespace Http\Controllers;
+
+use App\Http\Controllers\ListBuildsController;
+use JsonException;
+use PHPUnit\Framework\TestCase;
+
+class ListBuildsControllerTest extends TestCase
+{
+ public function testHandleOutputsBuildListing(): void
+ {
+ $tempDir = $this->createTempBuildDirectory();
+
+ $controller = new ListBuildsController($tempDir);
+
+ http_response_code(200);
+ ob_start();
+ $controller->handle();
+ $output = ob_get_clean();
+
+ static::assertNotFalse($output);
+
+ $data = $this->decodeJson($output);
+
+ static::assertSame(200, http_response_code());
+ static::assertArrayHasKey('builds', $data);
+ static::assertCount(2, $data['builds']);
+ static::assertSame('php/build-one.zip', $data['builds'][0]['path']);
+ static::assertSame('winlibs/run/info.json',
$data['builds'][1]['path']);
+
+ $this->removeDirectory($tempDir);
+ }
+
+ public function testHandleReturnsErrorWhenDirectoryMissing(): void
+ {
+ $controller = new ListBuildsController('/path/to/missing/builds');
+
+ http_response_code(200);
+ ob_start();
+ $controller->handle();
+ $output = ob_get_clean();
+
+ $data = $this->decodeJson($output);
+
+ static::assertSame(500, http_response_code());
+ static::assertSame('Builds directory not configured or missing.',
$data['error']);
+ }
+
+ private function decodeJson(string $json): array
+ {
+ try {
+ return json_decode($json, true, 512, JSON_THROW_ON_ERROR);
+ } catch (JsonException $exception) {
+ static::fail('Response is not valid JSON: ' .
$exception->getMessage());
+ }
+
+ return [];
+ }
+
+ private function createTempBuildDirectory(): string
+ {
+ $base = sys_get_temp_dir() . '/list-builds-' . uniqid();
+ mkdir($base . '/php', 0755, true);
+ mkdir($base . '/winlibs/run', 0755, true);
+
+ file_put_contents($base . '/php/build-one.zip', 'fake-zip-data');
+ touch($base . '/php/build-one.zip', 1730000000);
+
+ file_put_contents($base . '/winlibs/run/info.json', '{}');
+ touch($base . '/winlibs/run/info.json', 1730003600);
+
+ return $base;
+ }
+
+ private function removeDirectory(string $path): void
+ {
+ $items = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($path,
\FilesystemIterator::SKIP_DOTS),
+ \RecursiveIteratorIterator::CHILD_FIRST
+ );
+
+ foreach ($items as $item) {
+ $item->isDir() ? rmdir($item->getPathname()) :
unlink($item->getPathname());
+ }
+
+ rmdir($path);
+ }
+}