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);
+    }
+}

Reply via email to