Adding REST API to Your Module
This tutorial extends the Notes module from Getting-Started-with-XOOPS-4.0-Module-Development by adding a complete REST API. You’ll learn API design patterns, JSON responses, authentication, and error handling.
What We’re Building
Section titled “What We’re Building”A RESTful API for the Notes module with:
- CRUD endpoints for notes
- JSON request/response handling
- JWT-based authentication
- Proper HTTP status codes
- API versioning
- Rate limiting
- OpenAPI documentation
API Design Overview
Section titled “API Design Overview”Base URL: /modules/notes/api/v1
Endpoints:GET /notes List user's notesPOST /notes Create a new noteGET /notes/{id} Get a single notePUT /notes/{id} Update a notePATCH /notes/{id} Partial updateDELETE /notes/{id} Delete a notePOST /notes/{id}/archive Archive a notePOST /notes/{id}/restore Restore from archiveGET /notes/search Search notesPart 1: API Infrastructure
Section titled “Part 1: API Infrastructure”Step 1: Create the API Router
Section titled “Step 1: Create the API Router”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api;
/** * Router - Simple REST API router. * * Routes incoming requests to appropriate handlers based on * HTTP method and URL pattern. */final class Router{ /** @var array<string, array<string, callable>> */ private array $routes = [];
/** * Register a GET route. */ public function get(string $pattern, callable $handler): self { return $this->addRoute('GET', $pattern, $handler); }
/** * Register a POST route. */ public function post(string $pattern, callable $handler): self { return $this->addRoute('POST', $pattern, $handler); }
/** * Register a PUT route. */ public function put(string $pattern, callable $handler): self { return $this->addRoute('PUT', $pattern, $handler); }
/** * Register a PATCH route. */ public function patch(string $pattern, callable $handler): self { return $this->addRoute('PATCH', $pattern, $handler); }
/** * Register a DELETE route. */ public function delete(string $pattern, callable $handler): self { return $this->addRoute('DELETE', $pattern, $handler); }
/** * Dispatch the current request. */ public function dispatch(string $method, string $path): mixed { $method = strtoupper($method);
if (!isset($this->routes[$method])) { throw new HttpException(405, 'Method Not Allowed'); }
foreach ($this->routes[$method] as $pattern => $handler) { $params = $this->match($pattern, $path);
if ($params !== null) { return $handler(...$params); } }
throw new HttpException(404, 'Not Found'); }
private function addRoute(string $method, string $pattern, callable $handler): self { $this->routes[$method][$pattern] = $handler; return $this; }
/** * Match a URL pattern against a path. * * Patterns use {param} syntax for path parameters. * Returns array of matched parameters or null if no match. */ private function match(string $pattern, string $path): ?array { // Convert {param} to named capture groups $regex = preg_replace('/\{(\w+)\}/', '(?P<$1>[^/]+)', $pattern); $regex = '#^' . $regex . '$#';
if (preg_match($regex, $path, $matches)) { // Return only named captures return array_filter($matches, 'is_string', ARRAY_FILTER_USE_KEY); }
return null; }}Step 2: Create HTTP Exception Classes
Section titled “Step 2: Create HTTP Exception Classes”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api;
/** * HttpException - Represents an HTTP error response. */class HttpException extends \RuntimeException{ public function __construct( public readonly int $statusCode, string $message, public readonly array $errors = [], ?\Throwable $previous = null ) { parent::__construct($message, $statusCode, $previous); }
/** * Convert to API response array. */ public function toArray(): array { $response = [ 'error' => [ 'code' => $this->statusCode, 'message' => $this->message, ], ];
if (!empty($this->errors)) { $response['error']['details'] = $this->errors; }
return $response; }}
/** * ValidationException - Thrown when request validation fails. */final class ValidationException extends HttpException{ public function __construct(array $errors) { parent::__construct( statusCode: 422, message: 'Validation failed', errors: $errors ); }}
/** * UnauthorizedException - Thrown when authentication fails. */final class UnauthorizedException extends HttpException{ public function __construct(string $message = 'Unauthorized') { parent::__construct(401, $message); }}
/** * ForbiddenException - Thrown when user lacks permission. */final class ForbiddenException extends HttpException{ public function __construct(string $message = 'Forbidden') { parent::__construct(403, $message); }}
/** * NotFoundException - Thrown when resource is not found. */final class NotFoundException extends HttpException{ public function __construct(string $resource = 'Resource') { parent::__construct(404, "{$resource} not found"); }}Step 3: Create the JSON Request Handler
Section titled “Step 3: Create the JSON Request Handler”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api;
/** * Request - Wraps the incoming HTTP request. */final class Request{ private ?array $parsedBody = null;
public function __construct( public readonly string $method, public readonly string $path, public readonly array $query, public readonly array $headers ) {}
/** * Create from PHP globals. */ public static function fromGlobals(): self { $path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH);
// Remove module base path $basePath = '/modules/notes/api/v1'; if (str_starts_with($path, $basePath)) { $path = substr($path, strlen($basePath)) ?: '/'; }
return new self( method: $_SERVER['REQUEST_METHOD'] ?? 'GET', path: $path, query: $_GET, headers: self::parseHeaders() ); }
/** * Get parsed JSON body. */ public function getBody(): array { if ($this->parsedBody === null) { $input = file_get_contents('php://input');
if (empty($input)) { $this->parsedBody = []; } else { $this->parsedBody = json_decode($input, true);
if (json_last_error() !== JSON_ERROR_NONE) { throw new HttpException(400, 'Invalid JSON: ' . json_last_error_msg()); } } }
return $this->parsedBody; }
/** * Get a specific body field. */ public function input(string $key, mixed $default = null): mixed { return $this->getBody()[$key] ?? $default; }
/** * Get a query parameter. */ public function query(string $key, mixed $default = null): mixed { return $this->query[$key] ?? $default; }
/** * Get a header value. */ public function header(string $name): ?string { $name = strtolower($name); return $this->headers[$name] ?? null; }
/** * Get the Bearer token from Authorization header. */ public function getBearerToken(): ?string { $auth = $this->header('authorization');
if ($auth && str_starts_with($auth, 'Bearer ')) { return substr($auth, 7); }
return null; }
private static function parseHeaders(): array { $headers = [];
foreach ($_SERVER as $key => $value) { if (str_starts_with($key, 'HTTP_')) { $name = strtolower(str_replace('_', '-', substr($key, 5))); $headers[$name] = $value; } }
return $headers; }}Step 4: Create the JSON Response Handler
Section titled “Step 4: Create the JSON Response Handler”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api;
/** * Response - JSON API response builder. */final class Response{ private function __construct( private readonly mixed $data, private readonly int $statusCode, private readonly array $headers ) {}
/** * Create a success response. */ public static function json(mixed $data, int $status = 200): self { return new self($data, $status, []); }
/** * Create a created response (201). */ public static function created(mixed $data, ?string $location = null): self { $headers = $location ? ['Location' => $location] : []; return new self($data, 201, $headers); }
/** * Create a no content response (204). */ public static function noContent(): self { return new self(null, 204, []); }
/** * Create an error response. */ public static function error(HttpException $e): self { return new self($e->toArray(), $e->statusCode, []); }
/** * Send the response to the client. */ public function send(): never { http_response_code($this->statusCode);
header('Content-Type: application/json; charset=utf-8'); header('X-Content-Type-Options: nosniff');
foreach ($this->headers as $name => $value) { header("{$name}: {$value}"); }
if ($this->data !== null) { echo json_encode($this->data, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE); }
exit; }}Part 2: Authentication
Section titled “Part 2: Authentication”Step 5: Create JWT Authentication
Section titled “Step 5: Create JWT Authentication”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api\Auth;
/** * JwtAuth - Simple JWT authentication. * * In production, use a library like firebase/php-jwt. * This is a simplified implementation for learning. */final class JwtAuth{ private const ALGORITHM = 'HS256';
public function __construct( private readonly string $secretKey, private readonly int $ttl = 3600 // 1 hour ) {}
/** * Generate a JWT token for a user. */ public function generateToken(int $userId, array $claims = []): string { $header = [ 'typ' => 'JWT', 'alg' => self::ALGORITHM, ];
$payload = array_merge($claims, [ 'sub' => $userId, 'iat' => time(), 'exp' => time() + $this->ttl, ]);
$headerEncoded = $this->base64UrlEncode(json_encode($header)); $payloadEncoded = $this->base64UrlEncode(json_encode($payload));
$signature = $this->sign("{$headerEncoded}.{$payloadEncoded}"); $signatureEncoded = $this->base64UrlEncode($signature);
return "{$headerEncoded}.{$payloadEncoded}.{$signatureEncoded}"; }
/** * Verify and decode a JWT token. * * @return array The payload claims * @throws UnauthorizedException If token is invalid */ public function verifyToken(string $token): array { $parts = explode('.', $token);
if (count($parts) !== 3) { throw new UnauthorizedException('Invalid token format'); }
[$headerEncoded, $payloadEncoded, $signatureEncoded] = $parts;
// Verify signature $expectedSignature = $this->sign("{$headerEncoded}.{$payloadEncoded}");
if (!hash_equals($this->base64UrlEncode($expectedSignature), $signatureEncoded)) { throw new UnauthorizedException('Invalid token signature'); }
// Decode payload $payload = json_decode($this->base64UrlDecode($payloadEncoded), true);
if (!$payload) { throw new UnauthorizedException('Invalid token payload'); }
// Check expiration if (isset($payload['exp']) && $payload['exp'] < time()) { throw new UnauthorizedException('Token has expired'); }
return $payload; }
/** * Get user ID from token. */ public function getUserId(string $token): int { $payload = $this->verifyToken($token); return (int) ($payload['sub'] ?? 0); }
private function sign(string $data): string { return hash_hmac('sha256', $data, $this->secretKey, true); }
private function base64UrlEncode(string $data): string { return rtrim(strtr(base64_encode($data), '+/', '-_'), '='); }
private function base64UrlDecode(string $data): string { return base64_decode(strtr($data, '-_', '+/')); }}Step 6: Create Authentication Middleware
Section titled “Step 6: Create Authentication Middleware”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api\Middleware;
use Notes\Infrastructure\Api\Request;use Notes\Infrastructure\Api\Auth\JwtAuth;use Notes\Infrastructure\Api\UnauthorizedException;
/** * AuthMiddleware - Validates JWT tokens. */final class AuthMiddleware{ private ?int $authenticatedUserId = null;
public function __construct( private readonly JwtAuth $jwt ) {}
/** * Authenticate the request. * * @throws UnauthorizedException If authentication fails */ public function authenticate(Request $request): int { $token = $request->getBearerToken();
if (!$token) { throw new UnauthorizedException('No token provided'); }
$this->authenticatedUserId = $this->jwt->getUserId($token);
return $this->authenticatedUserId; }
/** * Get the authenticated user ID. */ public function getUserId(): ?int { return $this->authenticatedUserId; }}Part 3: API Controllers
Section titled “Part 3: API Controllers”Step 7: Create the Base API Controller
Section titled “Step 7: Create the Base API Controller”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api\Controller;
use Notes\Infrastructure\Api\Request;use Notes\Infrastructure\Api\Response;use Notes\Infrastructure\Api\ValidationException;
/** * BaseController - Common API controller functionality. */abstract class BaseController{ protected ?int $userId = null;
/** * Set the authenticated user ID. */ public function setUserId(int $userId): void { $this->userId = $userId; }
/** * Validate request data against rules. * * @param array $data The data to validate * @param array $rules Validation rules ['field' => 'rule1|rule2'] * @throws ValidationException If validation fails */ protected function validate(array $data, array $rules): array { $errors = []; $validated = [];
foreach ($rules as $field => $ruleString) { $fieldRules = explode('|', $ruleString); $value = $data[$field] ?? null; $isRequired = in_array('required', $fieldRules, true);
// Check required if ($isRequired && ($value === null || $value === '')) { $errors[$field][] = "The {$field} field is required"; continue; }
// Skip other validations if not required and empty if ($value === null || $value === '') { continue; }
// Apply other rules foreach ($fieldRules as $rule) { if ($rule === 'required') { continue; }
$error = $this->applyRule($field, $value, $rule); if ($error) { $errors[$field][] = $error; } }
if (!isset($errors[$field])) { $validated[$field] = $value; } }
if (!empty($errors)) { throw new ValidationException($errors); }
return $validated; }
/** * Apply a single validation rule. */ private function applyRule(string $field, mixed $value, string $rule): ?string { // Parse rule with parameters (e.g., "max:200") $parts = explode(':', $rule, 2); $ruleName = $parts[0]; $param = $parts[1] ?? null;
return match ($ruleName) { 'string' => is_string($value) ? null : "The {$field} must be a string", 'int', 'integer' => is_numeric($value) ? null : "The {$field} must be an integer", 'bool', 'boolean' => is_bool($value) || in_array($value, [0, 1, '0', '1'], true) ? null : "The {$field} must be a boolean", 'min' => mb_strlen((string) $value) >= (int) $param ? null : "The {$field} must be at least {$param} characters", 'max' => mb_strlen((string) $value) <= (int) $param ? null : "The {$field} cannot exceed {$param} characters", 'ulid' => preg_match('/^[0-9A-HJKMNP-TV-Z]{26}$/', (string) $value) ? null : "The {$field} must be a valid ULID", default => null, }; }
/** * Format pagination metadata. */ protected function paginate(array $items, int $total, int $page, int $perPage): array { return [ 'data' => $items, 'meta' => [ 'current_page' => $page, 'per_page' => $perPage, 'total' => $total, 'total_pages' => (int) ceil($total / $perPage), ], ]; }}Step 8: Create the Notes API Controller
Section titled “Step 8: Create the Notes API Controller”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api\Controller;
use Notes\Application\Command\CreateNoteCommand;use Notes\Application\Command\CreateNoteHandler;use Notes\Application\Command\UpdateNoteCommand;use Notes\Application\Command\UpdateNoteHandler;use Notes\Application\Command\DeleteNoteCommand;use Notes\Application\Command\DeleteNoteHandler;use Notes\Application\Command\ArchiveNoteCommand;use Notes\Application\Command\ArchiveNoteHandler;use Notes\Application\Query\GetNoteQuery;use Notes\Application\Query\GetNoteHandler;use Notes\Application\Query\GetUserNotesQuery;use Notes\Application\Query\GetUserNotesHandler;use Notes\Application\Query\SearchNotesQuery;use Notes\Application\Query\SearchNotesHandler;use Notes\Domain\Exception\NoteException;use Notes\Domain\Exception\NoteNotFound;use Notes\Infrastructure\Api\Request;use Notes\Infrastructure\Api\Response;use Notes\Infrastructure\Api\NotFoundException;use Notes\Infrastructure\Api\ForbiddenException;
/** * NotesApiController - REST API endpoints for notes. */final class NotesApiController extends BaseController{ public function __construct( private readonly CreateNoteHandler $createHandler, private readonly UpdateNoteHandler $updateHandler, private readonly DeleteNoteHandler $deleteHandler, private readonly ArchiveNoteHandler $archiveHandler, private readonly GetNoteHandler $getNoteHandler, private readonly GetUserNotesHandler $getUserNotesHandler, private readonly SearchNotesHandler $searchHandler ) {}
/** * GET /notes - List user's notes. * * Query params: * - page: int (default 1) * - per_page: int (default 20, max 100) * - archived: bool (default false) * - sort: string (created_at|updated_at, default updated_at) * - order: string (asc|desc, default desc) */ public function index(Request $request): Response { $page = max(1, (int) $request->query('page', 1)); $perPage = min(100, max(1, (int) $request->query('per_page', 20))); $includeArchived = filter_var($request->query('archived', false), FILTER_VALIDATE_BOOLEAN);
$query = new GetUserNotesQuery( userId: $this->userId, includeArchived: $includeArchived, limit: $perPage, offset: ($page - 1) * $perPage );
$notes = $this->getUserNotesHandler->handle($query); $total = $this->getUserNotesHandler->count($query);
return Response::json($this->paginate( items: array_map([$this, 'formatNote'], $notes), total: $total, page: $page, perPage: $perPage )); }
/** * POST /notes - Create a new note. * * Body: * - title: string (required, 1-200 chars) * - content: string (optional, max 50000 chars) */ public function store(Request $request): Response { $data = $this->validate($request->getBody(), [ 'title' => 'required|string|min:1|max:200', 'content' => 'string|max:50000', ]);
$command = new CreateNoteCommand( userId: $this->userId, title: $data['title'], content: $data['content'] ?? '' );
try { $note = $this->createHandler->handle($command);
return Response::created( data: ['data' => $this->formatNote($note)], location: "/api/v1/notes/{$note->getId()}" ); } catch (NoteException $e) { throw new \Notes\Infrastructure\Api\HttpException(422, $e->getMessage()); } }
/** * GET /notes/{id} - Get a single note. */ public function show(string $id): Response { $this->validate(['id' => $id], ['id' => 'required|ulid']);
try { $query = new GetNoteQuery( noteId: $id, userId: $this->userId );
$note = $this->getNoteHandler->handle($query);
return Response::json(['data' => $this->formatNote($note)]); } catch (NoteNotFound) { throw new NotFoundException('Note'); } catch (\DomainException $e) { throw new ForbiddenException($e->getMessage()); } }
/** * PUT /notes/{id} - Full update of a note. * * Body: * - title: string (required) * - content: string (required) */ public function update(Request $request, string $id): Response { $this->validate(['id' => $id], ['id' => 'required|ulid']);
$data = $this->validate($request->getBody(), [ 'title' => 'required|string|min:1|max:200', 'content' => 'required|string|max:50000', ]);
return $this->doUpdate($id, $data['title'], $data['content']); }
/** * PATCH /notes/{id} - Partial update of a note. * * Body: * - title: string (optional) * - content: string (optional) */ public function patch(Request $request, string $id): Response { $this->validate(['id' => $id], ['id' => 'required|ulid']);
$data = $this->validate($request->getBody(), [ 'title' => 'string|min:1|max:200', 'content' => 'string|max:50000', ]);
return $this->doUpdate( $id, $data['title'] ?? null, $data['content'] ?? null ); }
/** * DELETE /notes/{id} - Delete a note. */ public function destroy(string $id): Response { $this->validate(['id' => $id], ['id' => 'required|ulid']);
try { $command = new DeleteNoteCommand( noteId: $id, userId: $this->userId );
$this->deleteHandler->handle($command);
return Response::noContent(); } catch (NoteNotFound) { throw new NotFoundException('Note'); } catch (\DomainException $e) { throw new ForbiddenException($e->getMessage()); } }
/** * POST /notes/{id}/archive - Archive a note. */ public function archive(string $id): Response { $this->validate(['id' => $id], ['id' => 'required|ulid']);
try { $command = new ArchiveNoteCommand( noteId: $id, userId: $this->userId, archive: true );
$note = $this->archiveHandler->handle($command);
return Response::json(['data' => $this->formatNote($note)]); } catch (NoteNotFound) { throw new NotFoundException('Note'); } catch (\DomainException $e) { throw new ForbiddenException($e->getMessage()); } }
/** * POST /notes/{id}/restore - Restore from archive. */ public function restore(string $id): Response { $this->validate(['id' => $id], ['id' => 'required|ulid']);
try { $command = new ArchiveNoteCommand( noteId: $id, userId: $this->userId, archive: false );
$note = $this->archiveHandler->handle($command);
return Response::json(['data' => $this->formatNote($note)]); } catch (NoteNotFound) { throw new NotFoundException('Note'); } catch (\DomainException $e) { throw new ForbiddenException($e->getMessage()); } }
/** * GET /notes/search - Search notes. * * Query params: * - q: string (required, search term) * - in: string (title|content|all, default all) */ public function search(Request $request): Response { $term = $request->query('q', '');
if (mb_strlen($term) < 2) { return Response::json(['data' => [], 'meta' => ['total' => 0]]); }
$query = new SearchNotesQuery( userId: $this->userId, term: $term, searchIn: $request->query('in', 'all') );
$notes = $this->searchHandler->handle($query);
return Response::json([ 'data' => array_map([$this, 'formatNote'], $notes), 'meta' => ['total' => count($notes)], ]); }
/** * Common update logic for PUT and PATCH. */ private function doUpdate(string $id, ?string $title, ?string $content): Response { try { $command = new UpdateNoteCommand( noteId: $id, userId: $this->userId, title: $title, content: $content );
$note = $this->updateHandler->handle($command);
return Response::json(['data' => $this->formatNote($note)]); } catch (NoteNotFound) { throw new NotFoundException('Note'); } catch (\DomainException $e) { throw new ForbiddenException($e->getMessage()); } catch (NoteException $e) { throw new \Notes\Infrastructure\Api\HttpException(422, $e->getMessage()); } }
/** * Format a note for API output. */ private function formatNote($note): array { return [ 'id' => $note->getId()->toString(), 'type' => 'note', 'attributes' => [ 'title' => $note->getTitle()->toString(), 'content' => $note->getContent()->toString(), 'word_count' => $note->getContent()->getWordCount(), 'is_archived' => $note->isArchived(), 'created_at' => $note->getCreatedAt()->format(\DateTimeInterface::RFC3339), 'updated_at' => $note->getUpdatedAt()->format(\DateTimeInterface::RFC3339), ], 'links' => [ 'self' => "/api/v1/notes/{$note->getId()}", ], ]; }}Part 4: Additional Commands and Handlers
Section titled “Part 4: Additional Commands and Handlers”Step 9: Create Delete and Archive Commands
Section titled “Step 9: Create Delete and Archive Commands”<?php
declare(strict_types=1);
namespace Notes\Application\Command;
/** * DeleteNoteCommand - Request to permanently delete a note. */final readonly class DeleteNoteCommand{ public function __construct( public string $noteId, public int $userId ) {}}
/** * DeleteNoteHandler - Executes the delete use case. */final readonly class DeleteNoteHandler{ public function __construct( private NoteRepositoryInterface $repository ) {}
public function handle(DeleteNoteCommand $command): void { $noteId = NoteId::fromString($command->noteId); $note = $this->repository->findById($noteId);
if (!$note->canBeEditedBy($command->userId)) { throw new \DomainException('You cannot delete this note'); }
$this->repository->delete($note); }}<?php
declare(strict_types=1);
namespace Notes\Application\Command;
/** * ArchiveNoteCommand - Request to archive or restore a note. */final readonly class ArchiveNoteCommand{ public function __construct( public string $noteId, public int $userId, public bool $archive = true ) {}}
/** * ArchiveNoteHandler - Executes the archive/restore use case. */final readonly class ArchiveNoteHandler{ public function __construct( private NoteRepositoryInterface $repository ) {}
public function handle(ArchiveNoteCommand $command): Note { $noteId = NoteId::fromString($command->noteId); $note = $this->repository->findById($noteId);
if (!$note->canBeEditedBy($command->userId)) { throw new \DomainException('You cannot modify this note'); }
if ($command->archive) { $note->archive(); } else { $note->restore(); }
$this->repository->save($note);
return $note; }}Part 5: Wiring the API
Section titled “Part 5: Wiring the API”Step 10: Create the API Entry Point
Section titled “Step 10: Create the API Entry Point”Create api/v1/index.php:
<?php
declare(strict_types=1);
/** * Notes Module - REST API v1 Entry Point * * All API requests are routed through this file. * URL: /modules/notes/api/v1/* */
use Notes\Infrastructure\Api\Router;use Notes\Infrastructure\Api\Request;use Notes\Infrastructure\Api\Response;use Notes\Infrastructure\Api\HttpException;use Notes\Infrastructure\Api\Auth\JwtAuth;use Notes\Infrastructure\Api\Middleware\AuthMiddleware;use Notes\Infrastructure\Api\Controller\NotesApiController;use Notes\Infrastructure\Api\Controller\AuthController;use Notes\Infrastructure\Xoops\Container;
// Bootstrap XOOPS (minimal, no template engine)require_once dirname(__DIR__, 4) . '/mainfile.php';
// Set JSON content type earlyheader('Content-Type: application/json; charset=utf-8');
// CORS headers (adjust for production)header('Access-Control-Allow-Origin: *');header('Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS');header('Access-Control-Allow-Headers: Content-Type, Authorization');
// Handle preflightif ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit;}
try { // Initialize services $container = new Container($GLOBALS['xoopsDB']); $jwt = new JwtAuth($GLOBALS['xoopsConfig']['secret_key'] ?? 'change-this-secret'); $auth = new AuthMiddleware($jwt);
// Create request $request = Request::fromGlobals();
// Create router $router = new Router();
// Public routes (no auth required) $router->post('/auth/login', fn() => (new AuthController($jwt))->login($request)); $router->post('/auth/refresh', fn() => (new AuthController($jwt))->refresh($request));
// Protected routes $notesController = $container->getNotesApiController();
// Middleware wrapper for authenticated routes $authenticated = function (callable $handler) use ($auth, $request, $notesController) { $userId = $auth->authenticate($request); $notesController->setUserId($userId); return $handler(); };
// Notes CRUD $router->get('/notes', fn() => $authenticated( fn() => $notesController->index($request) ));
$router->post('/notes', fn() => $authenticated( fn() => $notesController->store($request) ));
$router->get('/notes/search', fn() => $authenticated( fn() => $notesController->search($request) ));
$router->get('/notes/{id}', fn(array $params) => $authenticated( fn() => $notesController->show($params['id']) ));
$router->put('/notes/{id}', fn(array $params) => $authenticated( fn() => $notesController->update($request, $params['id']) ));
$router->patch('/notes/{id}', fn(array $params) => $authenticated( fn() => $notesController->patch($request, $params['id']) ));
$router->delete('/notes/{id}', fn(array $params) => $authenticated( fn() => $notesController->destroy($params['id']) ));
$router->post('/notes/{id}/archive', fn(array $params) => $authenticated( fn() => $notesController->archive($params['id']) ));
$router->post('/notes/{id}/restore', fn(array $params) => $authenticated( fn() => $notesController->restore($params['id']) ));
// Dispatch and send response $response = $router->dispatch($request->method, $request->path); $response->send();
} catch (HttpException $e) { Response::error($e)->send();} catch (\Throwable $e) { // Log the error in production error_log($e->getMessage() . "\n" . $e->getTraceAsString());
// Return generic error (don't expose details in production) Response::error(new HttpException(500, 'Internal Server Error'))->send();}Step 11: Create .htaccess for Clean URLs
Section titled “Step 11: Create .htaccess for Clean URLs”Create api/v1/.htaccess:
# Enable rewritingRewriteEngine On
# Set base pathRewriteBase /modules/notes/api/v1/
# Skip existing files and directoriesRewriteCond %{REQUEST_FILENAME} !-fRewriteCond %{REQUEST_FILENAME} !-d
# Route everything to index.phpRewriteRule ^(.*)$ index.php [QSA,L]
# Security headersHeader set X-Content-Type-Options "nosniff"Header set X-Frame-Options "DENY"Part 6: Rate Limiting
Section titled “Part 6: Rate Limiting”Step 12: Add Rate Limiting Middleware
Section titled “Step 12: Add Rate Limiting Middleware”<?php
declare(strict_types=1);
namespace Notes\Infrastructure\Api\Middleware;
use Notes\Infrastructure\Api\HttpException;
/** * RateLimitMiddleware - Prevents API abuse. * * Uses a simple sliding window algorithm with file-based storage. * In production, use Redis for distributed rate limiting. */final class RateLimitMiddleware{ public function __construct( private readonly int $maxRequests = 60, private readonly int $windowSeconds = 60, private readonly string $storagePath = '/tmp/rate_limits' ) { if (!is_dir($this->storagePath)) { mkdir($this->storagePath, 0755, true); } }
/** * Check if request should be rate limited. * * @throws HttpException 429 Too Many Requests */ public function check(string $identifier): void { $file = $this->getStorageFile($identifier); $now = time(); $windowStart = $now - $this->windowSeconds;
// Read existing timestamps $timestamps = $this->readTimestamps($file);
// Filter to current window $timestamps = array_filter($timestamps, fn($t) => $t > $windowStart);
// Check limit if (count($timestamps) >= $this->maxRequests) { $retryAfter = min($timestamps) + $this->windowSeconds - $now;
throw new HttpException( statusCode: 429, message: 'Too Many Requests', errors: [ 'retry_after' => $retryAfter, 'limit' => $this->maxRequests, 'window' => $this->windowSeconds, ] ); }
// Add current timestamp $timestamps[] = $now; $this->writeTimestamps($file, $timestamps); }
/** * Get remaining requests in current window. */ public function getRemaining(string $identifier): int { $file = $this->getStorageFile($identifier); $windowStart = time() - $this->windowSeconds;
$timestamps = $this->readTimestamps($file); $timestamps = array_filter($timestamps, fn($t) => $t > $windowStart);
return max(0, $this->maxRequests - count($timestamps)); }
private function getStorageFile(string $identifier): string { return $this->storagePath . '/' . md5($identifier) . '.json'; }
private function readTimestamps(string $file): array { if (!file_exists($file)) { return []; }
$content = file_get_contents($file); return json_decode($content, true) ?? []; }
private function writeTimestamps(string $file, array $timestamps): void { file_put_contents($file, json_encode(array_values($timestamps))); }}Part 7: OpenAPI Documentation
Section titled “Part 7: OpenAPI Documentation”Step 13: Create OpenAPI Specification
Section titled “Step 13: Create OpenAPI Specification”Create api/v1/openapi.yaml:
openapi: 3.1.0info: title: Notes Module API description: RESTful API for managing personal notes in XOOPS version: 1.0.0 contact: name: XOOPS Development Team url: https://xoops.org
servers: - url: /modules/notes/api/v1 description: Local development
security: - bearerAuth: []
tags: - name: Authentication description: User authentication endpoints - name: Notes description: Note management endpoints
paths: /auth/login: post: tags: [Authentication] summary: Authenticate user security: [] requestBody: required: true content: application/json: schema: type: object required: [username, password] properties: username: type: string example: admin password: type: string format: password responses: '200': description: Authentication successful content: application/json: schema: $ref: '#/components/schemas/AuthResponse' '401': $ref: '#/components/responses/Unauthorized'
/notes: get: tags: [Notes] summary: List user's notes parameters: - name: page in: query schema: type: integer default: 1 - name: per_page in: query schema: type: integer default: 20 maximum: 100 - name: archived in: query schema: type: boolean default: false responses: '200': description: List of notes content: application/json: schema: $ref: '#/components/schemas/NoteListResponse' '401': $ref: '#/components/responses/Unauthorized'
post: tags: [Notes] summary: Create a new note requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateNoteRequest' responses: '201': description: Note created headers: Location: schema: type: string description: URL of created note content: application/json: schema: $ref: '#/components/schemas/NoteResponse' '401': $ref: '#/components/responses/Unauthorized' '422': $ref: '#/components/responses/ValidationError'
/notes/{id}: parameters: - name: id in: path required: true schema: type: string pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' description: Note ULID
get: tags: [Notes] summary: Get a note responses: '200': description: Note details content: application/json: schema: $ref: '#/components/schemas/NoteResponse' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound'
put: tags: [Notes] summary: Update a note (full) requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/UpdateNoteRequest' responses: '200': description: Note updated content: application/json: schema: $ref: '#/components/schemas/NoteResponse' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound' '422': $ref: '#/components/responses/ValidationError'
patch: tags: [Notes] summary: Update a note (partial) requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/PatchNoteRequest' responses: '200': description: Note updated content: application/json: schema: $ref: '#/components/schemas/NoteResponse' '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound'
delete: tags: [Notes] summary: Delete a note responses: '204': description: Note deleted '401': $ref: '#/components/responses/Unauthorized' '404': $ref: '#/components/responses/NotFound'
/notes/{id}/archive: post: tags: [Notes] summary: Archive a note parameters: - name: id in: path required: true schema: type: string responses: '200': description: Note archived content: application/json: schema: $ref: '#/components/schemas/NoteResponse'
/notes/{id}/restore: post: tags: [Notes] summary: Restore a note from archive parameters: - name: id in: path required: true schema: type: string responses: '200': description: Note restored content: application/json: schema: $ref: '#/components/schemas/NoteResponse'
/notes/search: get: tags: [Notes] summary: Search notes parameters: - name: q in: query required: true schema: type: string minLength: 2 - name: in in: query schema: type: string enum: [title, content, all] default: all responses: '200': description: Search results content: application/json: schema: $ref: '#/components/schemas/NoteListResponse'
components: securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT
schemas: Note: type: object properties: id: type: string pattern: '^[0-9A-HJKMNP-TV-Z]{26}$' example: 01HV8X5Z0KDMVR8SDPY62J9ACP type: type: string enum: [note] attributes: type: object properties: title: type: string maxLength: 200 content: type: string maxLength: 50000 word_count: type: integer is_archived: type: boolean created_at: type: string format: date-time updated_at: type: string format: date-time links: type: object properties: self: type: string
CreateNoteRequest: type: object required: [title] properties: title: type: string minLength: 1 maxLength: 200 content: type: string maxLength: 50000 default: ''
UpdateNoteRequest: type: object required: [title, content] properties: title: type: string minLength: 1 maxLength: 200 content: type: string maxLength: 50000
PatchNoteRequest: type: object properties: title: type: string minLength: 1 maxLength: 200 content: type: string maxLength: 50000
NoteResponse: type: object properties: data: $ref: '#/components/schemas/Note'
NoteListResponse: type: object properties: data: type: array items: $ref: '#/components/schemas/Note' meta: type: object properties: current_page: type: integer per_page: type: integer total: type: integer total_pages: type: integer
AuthResponse: type: object properties: token: type: string expires_at: type: string format: date-time
Error: type: object properties: error: type: object properties: code: type: integer message: type: string details: type: object
responses: Unauthorized: description: Authentication required content: application/json: schema: $ref: '#/components/schemas/Error' example: error: code: 401 message: Unauthorized
NotFound: description: Resource not found content: application/json: schema: $ref: '#/components/schemas/Error' example: error: code: 404 message: Note not found
ValidationError: description: Validation failed content: application/json: schema: $ref: '#/components/schemas/Error' example: error: code: 422 message: Validation failed details: title: ["The title field is required"]Summary
Section titled “Summary”You’ve now added a complete REST API to the Notes module:
What You’ve Built
Section titled “What You’ve Built”-
API Infrastructure
- Router with pattern matching
- Request/Response handlers
- HTTP exception hierarchy
-
Authentication
- JWT token generation and validation
- Auth middleware for protected routes
-
CRUD Endpoints
- Full RESTful resource operations
- Pagination support
- Search functionality
-
Best Practices
- Input validation
- Proper HTTP status codes
- JSON:API-inspired response format
- Rate limiting
- OpenAPI documentation
API Usage Example
Section titled “API Usage Example”# Logincurl -X POST /modules/notes/api/v1/auth/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"secret"}'
# Create notecurl -X POST /modules/notes/api/v1/notes \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"title":"My Note","content":"Hello world"}'
# List notescurl /modules/notes/api/v1/notes \ -H "Authorization: Bearer <token>"
# Searchcurl "/modules/notes/api/v1/notes/search?q=hello" \ -H "Authorization: Bearer <token>"Related Documentation
Section titled “Related Documentation”- Getting-Started-with-XOOPS-4.0-Module-Development
- ../../10-Vision2026-Module/API/REST-Endpoints (Vision 2026 reference)
- ../Implementation-Guides/Domain-Exception-Handling-Guide