REST API Design Guide
Build robust, well-documented RESTful APIs for XOOPS 4.0 modules.
REST APIs enable external applications to interact with your XOOPS modules. This guide covers best practices for designing, implementing, and documenting APIs.
API Architecture Overview
Section titled “API Architecture Overview”flowchart TB subgraph Client["Client Applications"] WEB[Web App] MOB[Mobile App] CLI[CLI Tool] EXT[Third Party] end
subgraph API["XOOPS API Layer"] GW[API Gateway] AUTH[Authentication] RATE[Rate Limiting] ROUTE[Router] end
subgraph Handlers["Request Handlers"] CTRL[API Controllers] VAL[Validators] TRANS[Transformers] end
subgraph Core["Business Logic"] SVC[Services] REPO[Repositories] ENT[Entities] end
Client --> GW GW --> AUTH --> RATE --> ROUTE ROUTE --> Handlers Handlers --> CoreConfiguration
Section titled “Configuration”Enable API in module.json
Section titled “Enable API in module.json”{ "api": { "enabled": true, "version": "1.0", "prefix": "/api/v1/articles", "authentication": ["bearer", "api_key"], "rate_limit": { "requests": 100, "window": 60 }, "cors": { "origins": ["*"], "methods": ["GET", "POST", "PUT", "DELETE"], "headers": ["Authorization", "Content-Type"] } }}Route Definition
Section titled “Route Definition”<?phpdeclare(strict_types=1);
use Xoops\Routing\Router;
return static function (Router $router): void { $router->group([ 'prefix' => '/api/v1/articles', 'middleware' => ['api', 'throttle:100,60'], ], function (Router $router) {
// Collection routes $router->get('/', [ArticleApiController::class, 'index']); $router->post('/', [ArticleApiController::class, 'store']);
// Resource routes $router->get('/{id}', [ArticleApiController::class, 'show']); $router->put('/{id}', [ArticleApiController::class, 'update']); $router->delete('/{id}', [ArticleApiController::class, 'destroy']);
// Nested resources $router->get('/{id}/comments', [CommentApiController::class, 'index']); $router->post('/{id}/comments', [CommentApiController::class, 'store']);
// Actions $router->post('/{id}/publish', [ArticleApiController::class, 'publish']); $router->post('/{id}/archive', [ArticleApiController::class, 'archive']); });};RESTful URL Design
Section titled “RESTful URL Design”Resource Naming
Section titled “Resource Naming”flowchart LR subgraph Good["✅ Good URLs"] G1["/api/v1/articles"] G2["/api/v1/articles/123"] G3["/api/v1/articles/123/comments"] G4["/api/v1/users/456/articles"] end
subgraph Bad["❌ Bad URLs"] B1["/api/v1/getArticles"] B2["/api/v1/article/get/123"] B3["/api/v1/articleComments/123"] B4["/api/v1/get-user-articles"] endHTTP Methods
Section titled “HTTP Methods”| Method | URL | Action | Description |
|---|---|---|---|
| GET | /articles | index | List all articles |
| POST | /articles | store | Create new article |
| GET | /articles/{id} | show | Get single article |
| PUT | /articles/{id} | update | Update entire article |
| PATCH | /articles/{id} | update | Partial update |
| DELETE | /articles/{id} | destroy | Delete article |
URL Patterns
Section titled “URL Patterns”# CollectionGET /api/v1/articles # List articlesPOST /api/v1/articles # Create article
# Single resourceGET /api/v1/articles/123 # Get article 123PUT /api/v1/articles/123 # Update article 123DELETE /api/v1/articles/123 # Delete article 123
# Nested resourcesGET /api/v1/articles/123/comments # List comments for article 123POST /api/v1/articles/123/comments # Add comment to article 123
# Filtering & searchingGET /api/v1/articles?status=publishedGET /api/v1/articles?category=5&author=10GET /api/v1/articles?search=xoops
# PaginationGET /api/v1/articles?page=2&per_page=20
# SortingGET /api/v1/articles?sort=created_at&order=desc
# Including relationsGET /api/v1/articles?include=author,comments,tagsAPI Controllers
Section titled “API Controllers”Basic Controller
Section titled “Basic Controller”<?php
declare(strict_types=1);
namespace MyModule\Controller\Api;
use Psr\Http\Message\ResponseInterface;use Psr\Http\Message\ServerRequestInterface;use Xoops\Http\Controller\ApiController;
final class ArticleApiController extends ApiController{ public function __construct( private readonly ArticleService $service, private readonly ArticleTransformer $transformer, ) {}
public function index(ServerRequestInterface $request): ResponseInterface { $params = $request->getQueryParams();
$articles = $this->service->paginate( page: (int) ($params['page'] ?? 1), perPage: (int) ($params['per_page'] ?? 15), filters: $this->extractFilters($params), );
return $this->json([ 'data' => $this->transformer->collection($articles->items()), 'meta' => [ 'current_page' => $articles->currentPage(), 'per_page' => $articles->perPage(), 'total' => $articles->total(), 'last_page' => $articles->lastPage(), ], 'links' => [ 'first' => $this->buildPageUrl(1), 'last' => $this->buildPageUrl($articles->lastPage()), 'prev' => $articles->currentPage() > 1 ? $this->buildPageUrl($articles->currentPage() - 1) : null, 'next' => $articles->hasMorePages() ? $this->buildPageUrl($articles->currentPage() + 1) : null, ], ]); }
public function show(ServerRequestInterface $request, int $id): ResponseInterface { $article = $this->service->find($id);
if ($article === null) { return $this->notFound('Article not found'); }
return $this->json([ 'data' => $this->transformer->transform($article), ]); }
public function store(ServerRequestInterface $request): ResponseInterface { $data = $request->getParsedBody();
$validator = $this->validate($data, [ 'title' => 'required|string|min:3|max:255', 'content' => 'required|string', 'category_id' => 'required|integer|exists:categories,id', ]);
if ($validator->fails()) { return $this->validationError($validator->errors()); }
$article = $this->service->create( new CreateArticleCommand( title: $data['title'], content: $data['content'], categoryId: $data['category_id'], authorId: $request->getAttribute('user')->id, ) );
return $this->created([ 'data' => $this->transformer->transform($article), ]); }
public function update(ServerRequestInterface $request, int $id): ResponseInterface { $article = $this->service->find($id);
if ($article === null) { return $this->notFound('Article not found'); }
$data = $request->getParsedBody();
$validator = $this->validate($data, [ 'title' => 'string|min:3|max:255', 'content' => 'string', 'category_id' => 'integer|exists:categories,id', ]);
if ($validator->fails()) { return $this->validationError($validator->errors()); }
$article = $this->service->update($id, $data);
return $this->json([ 'data' => $this->transformer->transform($article), ]); }
public function destroy(ServerRequestInterface $request, int $id): ResponseInterface { $article = $this->service->find($id);
if ($article === null) { return $this->notFound('Article not found'); }
$this->service->delete($id);
return $this->noContent(); }}Response Formats
Section titled “Response Formats”Success Response
Section titled “Success Response”{ "data": { "id": 123, "type": "article", "attributes": { "title": "Getting Started with XOOPS 4.0", "slug": "getting-started-xoops-4.0", "content": "Article content here...", "status": "published", "created_at": "2026-01-29T10:30:00Z", "updated_at": "2026-01-29T14:45:00Z", "published_at": "2026-01-29T12:00:00Z" }, "relationships": { "author": { "data": {"type": "user", "id": 456} }, "category": { "data": {"type": "category", "id": 5} } } }, "included": [ { "id": 456, "type": "user", "attributes": { "name": "John Doe", "email": "john@example.com" } } ]}Collection Response
Section titled “Collection Response”{ "data": [ {"id": 1, "type": "article", "attributes": {...}}, {"id": 2, "type": "article", "attributes": {...}}, {"id": 3, "type": "article", "attributes": {...}} ], "meta": { "current_page": 1, "per_page": 15, "total": 47, "last_page": 4 }, "links": { "first": "/api/v1/articles?page=1", "last": "/api/v1/articles?page=4", "prev": null, "next": "/api/v1/articles?page=2" }}Error Response
Section titled “Error Response”{ "error": { "code": "VALIDATION_ERROR", "message": "The given data was invalid.", "details": [ { "field": "title", "message": "The title field is required." }, { "field": "category_id", "message": "The selected category is invalid." } ] }}HTTP Status Codes
Section titled “HTTP Status Codes”flowchart TB subgraph Success["2xx Success"] S200[200 OK] S201[201 Created] S204[204 No Content] end
subgraph ClientError["4xx Client Error"] E400[400 Bad Request] E401[401 Unauthorized] E403[403 Forbidden] E404[404 Not Found] E422[422 Validation Error] E429[429 Too Many Requests] end
subgraph ServerError["5xx Server Error"] E500[500 Internal Error] E503[503 Service Unavailable] end| Code | Meaning | Use Case |
|---|---|---|
| 200 | OK | Successful GET, PUT, PATCH |
| 201 | Created | Successful POST (resource created) |
| 204 | No Content | Successful DELETE |
| 400 | Bad Request | Malformed request |
| 401 | Unauthorized | Missing/invalid authentication |
| 403 | Forbidden | Authenticated but not authorized |
| 404 | Not Found | Resource doesn’t exist |
| 422 | Unprocessable Entity | Validation errors |
| 429 | Too Many Requests | Rate limit exceeded |
| 500 | Internal Server Error | Server-side error |
Authentication
Section titled “Authentication”Bearer Token
Section titled “Bearer Token”<?php// Middleware checks Authorization header// Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
final class BearerAuthMiddleware implements MiddlewareInterface{ public function process( ServerRequestInterface $request, RequestHandlerInterface $handler ): ResponseInterface { $header = $request->getHeaderLine('Authorization');
if (!str_starts_with($header, 'Bearer ')) { return $this->unauthorized('Missing bearer token'); }
$token = substr($header, 7); $user = $this->tokenService->validateAndGetUser($token);
if ($user === null) { return $this->unauthorized('Invalid or expired token'); }
return $handler->handle( $request->withAttribute('user', $user) ); }}API Key
Section titled “API Key”<?php// Header: X-API-Key: your-api-key-here
final class ApiKeyMiddleware implements MiddlewareInterface{ public function process( ServerRequestInterface $request, RequestHandlerInterface $handler ): ResponseInterface { $apiKey = $request->getHeaderLine('X-API-Key');
if (empty($apiKey)) { return $this->unauthorized('Missing API key'); }
$client = $this->apiKeyService->validate($apiKey);
if ($client === null) { return $this->unauthorized('Invalid API key'); }
return $handler->handle( $request ->withAttribute('api_client', $client) ->withAttribute('user', $client->getUser()) ); }}Authentication Flow
Section titled “Authentication Flow”sequenceDiagram participant C as Client participant API as API Gateway participant AUTH as Auth Service participant H as Handler
C->>API: POST /api/auth/login API->>AUTH: Validate credentials AUTH-->>API: JWT Token API-->>C: 200 {token: "eyJ..."}
C->>API: GET /api/articles (Bearer token) API->>AUTH: Validate token AUTH-->>API: User context API->>H: Forward request H-->>API: Response API-->>C: 200 {data: [...]}Data Transformers
Section titled “Data Transformers”Transformer Class
Section titled “Transformer Class”<?php
declare(strict_types=1);
namespace MyModule\Transformer;
use Xoops\Api\Transformer;
final class ArticleTransformer extends Transformer{ protected array $availableIncludes = [ 'author', 'category', 'comments', 'tags', ];
protected array $defaultIncludes = [ 'author', ];
public function transform(Article $article): array { return [ 'id' => $article->getId(), 'type' => 'article', 'attributes' => [ 'title' => $article->getTitle(), 'slug' => $article->getSlug(), 'excerpt' => $article->getExcerpt(), 'content' => $article->getContent(), 'status' => $article->getStatus()->value, 'views' => $article->getViews(), 'created_at' => $article->getCreatedAt()->format('c'), 'updated_at' => $article->getUpdatedAt()?->format('c'), 'published_at' => $article->getPublishedAt()?->format('c'), ], 'links' => [ 'self' => "/api/v1/articles/{$article->getId()}", 'web' => "/articles/{$article->getSlug()}", ], ]; }
public function includeAuthor(Article $article): array { $author = $article->getAuthor();
return [ 'data' => [ 'id' => $author->getId(), 'type' => 'user', 'attributes' => [ 'name' => $author->getName(), 'avatar' => $author->getAvatarUrl(), ], ], ]; }
public function includeComments(Article $article): array { $comments = $article->getComments(); $transformer = new CommentTransformer();
return [ 'data' => array_map( fn($c) => $transformer->transform($c), $comments->toArray() ), 'meta' => [ 'count' => $comments->count(), ], ]; }}Validation
Section titled “Validation”Request Validation
Section titled “Request Validation”<?php
declare(strict_types=1);
namespace MyModule\Request;
use Xoops\Http\Request\FormRequest;
final class CreateArticleRequest extends FormRequest{ public function rules(): array { return [ 'title' => [ 'required', 'string', 'min:3', 'max:255', ], 'content' => [ 'required', 'string', 'min:50', ], 'category_id' => [ 'required', 'integer', 'exists:categories,id', ], 'tags' => [ 'array', 'max:10', ], 'tags.*' => [ 'string', 'max:50', ], 'publish_at' => [ 'nullable', 'date', 'after:now', ], ]; }
public function messages(): array { return [ 'title.required' => 'Please provide an article title.', 'content.min' => 'Article content must be at least 50 characters.', 'category_id.exists' => 'The selected category does not exist.', ]; }}Rate Limiting
Section titled “Rate Limiting”Configuration
Section titled “Configuration”<?php
// Apply to routes$router->group([ 'middleware' => ['throttle:100,60'], // 100 requests per 60 seconds], function ($router) { // ...});
// Different limits for different endpoints$router->get('/articles', 'index')->middleware('throttle:200,60');$router->post('/articles', 'store')->middleware('throttle:10,60');Rate Limit Headers
Section titled “Rate Limit Headers”HTTP/1.1 200 OKX-RateLimit-Limit: 100X-RateLimit-Remaining: 95X-RateLimit-Reset: 1706529600flowchart LR subgraph Limiting["Rate Limit Flow"] REQ[Request] --> CHECK{Under Limit?} CHECK -->|Yes| PROCESS[Process Request] CHECK -->|No| REJECT[429 Too Many Requests] PROCESS --> DEC[Decrement Counter] REJECT --> RETRY[Retry-After Header] endAPI Versioning
Section titled “API Versioning”URL Versioning
Section titled “URL Versioning”/api/v1/articles/api/v2/articlesHeader Versioning
Section titled “Header Versioning”GET /api/articles HTTP/1.1Accept: application/vnd.xoops.v2+jsonVersion Handling
Section titled “Version Handling”<?php
$router->group(['prefix' => '/api/v1'], function ($router) { $router->get('/articles', [ArticleV1Controller::class, 'index']);});
$router->group(['prefix' => '/api/v2'], function ($router) { $router->get('/articles', [ArticleV2Controller::class, 'index']);});API Documentation
Section titled “API Documentation”OpenAPI Specification
Section titled “OpenAPI Specification”openapi: 3.0.3info: title: Articles API version: 1.0.0 description: API for managing articles
paths: /api/v1/articles: get: summary: List articles parameters: - name: page in: query schema: type: integer default: 1 - name: per_page in: query schema: type: integer default: 15 maximum: 100 responses: '200': description: Successful response content: application/json: schema: $ref: '#/components/schemas/ArticleCollection'
post: summary: Create article security: - bearerAuth: [] requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/CreateArticle' responses: '201': description: Article created '422': description: Validation error
components: schemas: Article: type: object properties: id: type: integer title: type: string content: type: string status: type: string enum: [draft, published, archived]
securitySchemes: bearerAuth: type: http scheme: bearer bearerFormat: JWT🔗 Related Documentation
Section titled “🔗 Related Documentation”- PSR-15 Middleware
- Dependency Injection
- Vision 2026 API
- Architecture Vision
#rest #api #http #json #xoops-4.0