JSON API
Expose the product catalog through JSON without creating a second data-access path. Reuse ProductRepository, the authenticated session, and the current-role lookup from the previous projects.
This project uses one explicit front controller:
GET /api.php/products
GET /api.php/products/{id}
POST /api.php/products
The /api.php path keeps the example runnable with php -S localhost:8000 -t public without requiring web-server rewrite rules.
Make Every Response JSON
Put response helpers in src/JsonApi.php:
<?php
declare(strict_types=1);
function jsonResponse(array $payload, int $status = 200, array $headers = []): never
{
try {
$body = json_encode(
$payload,
JSON_THROW_ON_ERROR | JSON_INVALID_UTF8_SUBSTITUTE | JSON_UNESCAPED_SLASHES,
) . PHP_EOL;
} catch (JsonException) {
$status = 500;
$body = "{\"error\":{\"code\":\"encoding_failed\",\"message\":\"Response encoding failed.\"}}\n";
}
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
foreach ($headers as $name => $value) {
header($name . ': ' . $value);
}
echo $body;
exit;
}
function errorResponse(int $status, string $code, string $message, array $fields = []): never
{
$error = ['code' => $code, 'message' => $message];
if ($fields !== []) {
$error['fields'] = $fields;
}
jsonResponse(['error' => $error], $status);
}
Encoding happens before headers are committed. Invalid UTF-8 in database text is replaced rather than leaking an uncaught JsonException; other encoding failures become a stable 500 response.
Bound And Decode The Request Body
Do not read an unlimited body and do not assume every valid JSON value is a product object:
<?php
declare(strict_types=1);
function readJsonObject(int $maximumBytes = 32_768): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
$mediaType = strtolower(trim(explode(';', $contentType, 2)[0]));
if ($mediaType !== 'application/json') {
errorResponse(415, 'unsupported_media_type', 'Use Content-Type: application/json.');
}
$declaredLength = $_SERVER['CONTENT_LENGTH'] ?? null;
if (is_string($declaredLength) && ctype_digit($declaredLength)
&& (int) $declaredLength > $maximumBytes) {
errorResponse(413, 'body_too_large', 'Request body is too large.');
}
$stream = fopen('php://input', 'rb');
$raw = $stream === false ? false : stream_get_contents($stream, $maximumBytes + 1);
if (is_resource($stream)) {
fclose($stream);
}
if (!is_string($raw)) {
errorResponse(400, 'body_unavailable', 'Request body could not be read.');
}
if (strlen($raw) > $maximumBytes) {
errorResponse(413, 'body_too_large', 'Request body is too large.');
}
try {
$decoded = json_decode($raw, false, 64, JSON_THROW_ON_ERROR);
} catch (JsonException) {
errorResponse(400, 'invalid_json', 'Request body is not valid JSON.');
}
if (!$decoded instanceof stdClass) {
errorResponse(400, 'object_required', 'Request body must be a JSON object.');
}
return get_object_vars($decoded);
}
function validateProductPayload(array $payload): array
{
$allowed = ['name', 'price_cents', 'status'];
$unknown = array_diff(array_keys($payload), $allowed);
$errors = [];
if ($unknown !== []) {
$errors['_request'] = 'Unknown fields: ' . implode(', ', $unknown) . '.';
}
if (!is_string($payload['name'] ?? null)
|| trim($payload['name']) === ''
|| strlen(trim($payload['name'])) > 100) {
$errors['name'] = 'Enter a name up to 100 bytes.';
}
if (!is_int($payload['price_cents'] ?? null)
|| $payload['price_cents'] < 0
|| $payload['price_cents'] > 99_999_999) {
$errors['price_cents'] = 'Use an integer from 0 to 99999999.';
}
if (!is_string($payload['status'] ?? null)
|| !in_array($payload['status'], ['draft', 'published'], true)) {
$errors['status'] = 'Choose draft or published.';
}
return $errors;
}
json_decode() can successfully return null, a boolean, a number, a string, or a list. Requiring stdClass distinguishes the API's object contract from those other valid JSON values. Strict is_int() validation also prevents quoted prices and large values decoded as floats from reaching PDO.
Stabilize Resource Types
SQLite/PDO values should be normalized at the representation boundary:
<?php
declare(strict_types=1);
function productResource(array $row): array
{
return [
'id' => (int) $row['id'],
'name' => (string) $row['name'],
'price_cents' => (int) $row['price_cents'],
'status' => (string) $row['status'],
];
}
Clients now receive integers consistently even if a different PDO driver returns numeric columns as strings.
Route Reads And Writes Deliberately
public/api.php parses only the paths this project supports. A session cookie authenticates writes, so a write also requires the session CSRF token in X-CSRF-Token:
<?php
declare(strict_types=1);
// no-execute: requires application bootstrap, repository, PDO, and active session.
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '', PHP_URL_PATH);
try {
if ($method === 'GET' && $path === '/api.php/products') {
$page = filter_var($_GET['page'] ?? 1, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 1_000_000],
]);
$limit = filter_var($_GET['limit'] ?? 20, FILTER_VALIDATE_INT, [
'options' => ['min_range' => 1, 'max_range' => 100],
]);
if ($page === false || $limit === false) {
errorResponse(400, 'invalid_pagination', 'Page and limit are out of range.');
}
$rows = $repository->page($limit, ($page - 1) * $limit);
jsonResponse([
'data' => array_map('productResource', $rows),
'meta' => ['page' => $page, 'limit' => $limit],
]);
}
if ($method === 'GET'
&& is_string($path)
&& preg_match('#\A/api\.php/products/([1-9][0-9]*)\z#', $path, $matches) === 1) {
$product = $repository->find((int) $matches[1]);
if ($product === null) {
errorResponse(404, 'product_not_found', 'Product not found.');
}
jsonResponse(['data' => productResource($product)]);
}
if ($path === '/api.php/products' && $method === 'POST') {
$currentUser = currentUser($pdo, $_SESSION['user_id'] ?? null);
if ($currentUser === null) {
errorResponse(401, 'authentication_required', 'Authentication required.');
}
if (($currentUser['role'] ?? null) !== 'admin') {
errorResponse(403, 'forbidden', 'Administrator access required.');
}
$token = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? null;
if (!is_string($token) || !hash_equals($_SESSION['csrf'], $token)) {
errorResponse(403, 'invalid_csrf', 'Request token is invalid.');
}
$payload = readJsonObject();
$errors = validateProductPayload($payload);
if ($errors !== []) {
errorResponse(422, 'validation_failed', 'Product data is invalid.', $errors);
}
$id = $repository->insert(
trim($payload['name']),
$payload['price_cents'],
$payload['status'],
);
$product = $repository->find($id);
if ($product === null) {
throw new RuntimeException('Created product could not be reloaded.');
}
jsonResponse(
['data' => productResource($product)],
201,
['Location' => '/api.php/products/' . $id],
);
}
if ($path === '/api.php/products') {
header('Allow: GET, POST');
errorResponse(405, 'method_not_allowed', 'Method not allowed.');
}
errorResponse(404, 'route_not_found', 'API route not found.');
} catch (Throwable $exception) {
error_log('API request failed: ' . $exception::class);
errorResponse(500, 'internal_error', 'An internal error occurred.');
}
The exception boundary logs only the exception class in this small project and never returns exception messages, SQL, paths, or stack traces. A production logger can attach a request ID and protected diagnostic context.
Pagination is bounded to 100 records per request. Apply a separate write-rate limiter before decoding and inserting if this endpoint is exposed beyond a trusted local exercise; use a shared limiter for multi-server deployment rather than a process-local counter.
Verify As A Client
Inspect headers and bodies rather than checking only whether JSON parses:
curl -i 'http://localhost:8000/api.php/products?page=1&limit=20'
curl -i 'http://localhost:8000/api.php/products/999999'
curl -i -X POST -H 'Content-Type: application/json' --data 'null' http://localhost:8000/api.php/products
curl -i -X POST -H 'Content-Type: application/json' --data '{broken' http://localhost:8000/api.php/products
For authenticated creation, reuse the session cookie from login and send its CSRF token in X-CSRF-Token. Confirm 201, Location, integer JSON fields, and the stable response envelope. Also test wrong media type, oversized body, list instead of object, unknown fields, quoted price_cents, unauthorized user, bad CSRF token, invalid pagination, unsupported method, and forced repository failure.
Practice
Practice: Build A Product JSON API
Implement the complete product API front controller from the lesson using the existing repository and authenticated session.
Requirements
- Expose bounded list, find, and create routes through
public/api.php. - Return only JSON with a stable data or error envelope.
- Normalize PDO values into explicit JSON scalar types.
- Validate
pageandlimitas bounded positive integers. - Return
404for missing products and routes, and405withAllowfor unsupported collection methods. - Require
application/jsonand read at most 32 KiB for create requests. - Distinguish malformed JSON, non-object JSON, unknown fields, and invalid product fields.
- Require current administrator authorization and session CSRF for writes.
- Return the created resource with
201andLocation. - Prevent invalid UTF-8 or encoding failures from leaking PHP errors.
- Catch unexpected failures and return a generic JSON
500response. - Do not expose exception messages, SQL, paths, or stack traces.
- Identify where a shared write-rate limiter runs before expensive work.
Record complete curl requests, response status, relevant headers, and JSON body for every normal and rejected route listed in the lesson.
Show solution
Use jsonResponse(), errorResponse(), readJsonObject(), validateProductPayload(), and productResource() from the lesson. The front controller must bootstrap PDO, the repository, and the authenticated session before entering the route try block.
The create route follows this exact order:
- Match both path and POST method.
- Load the current session user and require the current
adminrole. - Verify
X-CSRF-Tokenwithhash_equals(). - Reserve any configured write-rate-limit capacity.
- Enforce media type and bounded body reading.
- Decode a JSON object and reject unknown or incorrectly typed fields.
- Insert through
ProductRepositoryand reload the stored row. - Return the typed resource with
201andLocation.
A successful request using a previously established login session looks like:
curl -i \
-b cookies.txt \
-H 'Content-Type: application/json' \
-H 'X-CSRF-Token: replace-with-session-token' \
--data '{"name":"Notebook","price_cents":1299,"status":"draft"}' \
http://localhost:8000/api.php/products
Expected response shape:
{"data":{"id":1,"name":"Notebook","price_cents":1299,"status":"draft"}}
The status is 201 Created, and Location identifies /api.php/products/1. Repeat the request with price_cents as "1299", an array, a float, and an oversized integer; every case must return 422 without inserting. Submit null, [], and a JSON string; each must return 400 object_required. Force a PDO exception and confirm the response remains generic JSON with status 500.