The API answers 201 with Location, 401 with WWW-Authenticate, 405 with Allow, and 422 for well-formed but invalid data. Errors use RFC 9457 application/problem+json. The body comes from php://input (Raw Request Bodies).
<?php
require __DIR__ . '/Product.php';
$catalog = [1 => new Product(1, 'BK-PHP-01', 'Modern PHP in Practice', 3990, 25),
2 => new Product(2, 'BK-SQL-01', 'SQL Queries That Scale', 4450, 12)];
function send(int $status, array|JsonSerializable $body, string ...$headers): never {
http_response_code($status);
header('Content-Type: application/' . ($status < 400 ? 'json' : 'problem+json'));
foreach ($headers as $header) header($header);
exit(json_encode($body, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES) . "\n");
}
function createOrder(array $catalog): never {
if (($_SERVER['HTTP_AUTHORIZATION'] ?? '') !== 'Bearer shop-demo-token')
send(401, ['title' => 'Missing or wrong token'], 'WWW-Authenticate: Bearer');
$raw = file_get_contents('php://input');
$in = json_validate($raw, 8) ? json_decode($raw, true) : null;
if (!is_array($in)) send(400, ['title' => 'Body must be a JSON object']);
$errors = array_filter([
'product_id' => isset($catalog[$in['product_id'] ?? 0]) ? null : 'unknown',
'qty' => in_array($in['qty'] ?? 0, range(1, 10), true) ? null : '1 to 10']);
if ($errors) send(422, ['title' => 'Invalid order', 'errors' => $errors]);
send(201, ['id' => 5001, 'product_id' => $in['product_id'], 'qty' => $in['qty']],
'Location: /orders/5001'); // a real API INSERTs first (4.16.10)
}
$path = Uri\Rfc3986\Uri::parse('http://api' . $_SERVER['REQUEST_URI'])?->getPath() ?? '';
$id = (int) basename($path);
match ($_SERVER['REQUEST_METHOD'] . ' ' . preg_replace('#/\d+$#', '/{id}', $path)) {
'GET /products' => send(200, ['data' => array_values($catalog)]),
'GET /products/{id}' => send(200, $catalog[$id] ?? send(404, ['title' => "No product $id"])),
'GET /slow' => send(200, ['pid' => getmypid(), 'waited' => time_nanosleep(0, 800_000_000)]),
'POST /orders' => createOrder($catalog),
default => preg_match('#^/(products|orders)#', $path, $m)
? send(405, ['title' => 'Wrong method'], 'Allow: ' . ($m[1] === 'orders' ? 'POST' : 'GET'))
: send(404, ['title' => 'No route']),
};send() returns never, so it serves as a value and an early exit. php -S 127.0.0.1:8218 api.php suits development, but it closes every connection and its workers accept greedily (three parallel calls took 1.6 s, not 0.8 s). The clients above therefore used Apache 129 with FallbackResource /api.php and CGIPassAuth On (Apache).