Skip to content

API Design

RESTful API design principles and implementation in the Gold Standard Module.

Overview

The Gold Standard Module provides a fully-featured REST API following modern best practices.

flowchart LR
    Client --> Auth[Authentication]
    Auth --> Router
    Router --> Controller
    Controller --> Service
    Service --> Repository
    Repository --> Database

    style Auth fill:#f9f,stroke:#333
    style Controller fill:#bbf,stroke:#333
    style Service fill:#bfb,stroke:#333

API Design Principles

Principle Implementation
RESTful Resource-based URLs, HTTP methods
JSON:API Consistent response format
Versioned URL-based versioning (/api/v1/)
Authenticated JWT or API key authentication
Documented OpenAPI 3.0 specification

URL Structure

Resource Naming

GET    /api/v1/articles          # List articles
GET    /api/v1/articles/{id}     # Get single article
POST   /api/v1/articles          # Create article
PUT    /api/v1/articles/{id}     # Update article
DELETE /api/v1/articles/{id}     # Delete article

GET    /api/v1/articles/{id}/comments     # Article comments
POST   /api/v1/articles/{id}/comments     # Add comment

URL Conventions

  • Use plural nouns: /articles not /article
  • Use hyphens: /api-keys not /api_keys
  • Lowercase only
  • No trailing slashes
  • No file extensions

Request/Response Format

Request Headers

Content-Type: application/json
Accept: application/json
Authorization: Bearer {token}
X-API-Version: 2026-01-01

Success Response

{
  "data": {
    "type": "article",
    "id": "01HX1234567890ABCDEFGHIJK",
    "attributes": {
      "title": "Article Title",
      "content": "Article content...",
      "status": "published",
      "created_at": "2026-01-29T12:00:00Z"
    },
    "relationships": {
      "author": {
        "data": { "type": "user", "id": "01HX..." }
      },
      "category": {
        "data": { "type": "category", "id": "01HX..." }
      }
    }
  },
  "meta": {
    "version": "1.0.0"
  }
}

Error Response

{
  "errors": [
    {
      "status": "422",
      "code": "validation_error",
      "title": "Validation Failed",
      "detail": "Title must be at least 5 characters",
      "source": {
        "pointer": "/data/attributes/title"
      }
    }
  ]
}

Collection Response

{
  "data": [
    { "type": "article", "id": "01HX...", "attributes": {} },
    { "type": "article", "id": "01HX...", "attributes": {} }
  ],
  "meta": {
    "total": 150,
    "page": 1,
    "per_page": 20,
    "total_pages": 8
  },
  "links": {
    "self": "/api/v1/articles?page=1",
    "first": "/api/v1/articles?page=1",
    "prev": null,
    "next": "/api/v1/articles?page=2",
    "last": "/api/v1/articles?page=8"
  }
}

HTTP Status Codes

Code Meaning Usage
200 OK Successful GET, PUT
201 Created Successful POST
204 No Content Successful DELETE
400 Bad Request Invalid JSON
401 Unauthorized Missing/invalid auth
403 Forbidden Insufficient permissions
404 Not Found Resource doesn't exist
422 Unprocessable Validation failed
429 Too Many Requests Rate limit exceeded
500 Server Error Unexpected error

Authentication

JWT Authentication

POST /api/v1/auth/token
Content-Type: application/json

{
  "username": "user@example.com",
  "password": "password123"
}

Response:

{
  "data": {
    "access_token": "eyJhbGciOiJIUzI1NiIs...",
    "token_type": "Bearer",
    "expires_in": 3600,
    "refresh_token": "dGhpcyBpcyBhIHJlZnJl..."
  }
}

API Key Authentication

GET /api/v1/articles
X-API-Key: gs_live_abc123xyz789

Controller Implementation

<?php

declare(strict_types=1);

namespace Xoops\GoldStandard\Presentation\Api;

use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;

#[Route('/api/v1/articles')]
final readonly class ArticleApiController
{
    public function __construct(
        private ArticleService $articleService,
        private ArticleQueryService $queryService,
        private ResponseFactory $responseFactory,
    ) {}

    #[Route('', methods: ['GET'])]
    public function index(ServerRequestInterface $request): ResponseInterface
    {
        $page = (int) ($request->getQueryParams()['page'] ?? 1);
        $perPage = (int) ($request->getQueryParams()['per_page'] ?? 20);

        $result = $this->queryService->findPublished($page, $perPage);

        return $this->responseFactory->collection(
            data: $result->items,
            meta: [
                'total' => $result->total,
                'page' => $page,
                'per_page' => $perPage,
            ],
        );
    }

    #[Route('/{id}', methods: ['GET'])]
    public function show(string $id): ResponseInterface
    {
        $article = $this->articleService->getArticle($id);

        if ($article === null) {
            return $this->responseFactory->notFound('Article not found');
        }

        return $this->responseFactory->item($article);
    }

    #[Route('', methods: ['POST'])]
    #[RequireAuth]
    public function store(ServerRequestInterface $request): ResponseInterface
    {
        $data = $request->getParsedBody();

        $command = new CreateArticleCommand(
            title: $data['title'] ?? '',
            content: $data['content'] ?? '',
            authorId: $request->getAttribute('user_id'),
        );

        try {
            $article = $this->articleService->createArticle($command);
            return $this->responseFactory->created($article);
        } catch (ValidationException $e) {
            return $this->responseFactory->validationError($e->errors());
        }
    }

    #[Route('/{id}', methods: ['PUT'])]
    #[RequireAuth]
    #[RequirePermission('article_edit')]
    public function update(string $id, ServerRequestInterface $request): ResponseInterface
    {
        $data = $request->getParsedBody();

        $command = new UpdateArticleCommand(
            id: $id,
            title: $data['title'] ?? null,
            content: $data['content'] ?? null,
        );

        try {
            $article = $this->articleService->updateArticle($command);
            return $this->responseFactory->item($article);
        } catch (NotFoundException $e) {
            return $this->responseFactory->notFound($e->getMessage());
        }
    }

    #[Route('/{id}', methods: ['DELETE'])]
    #[RequireAuth]
    #[RequirePermission('article_delete')]
    public function destroy(string $id): ResponseInterface
    {
        try {
            $this->articleService->deleteArticle($id);
            return $this->responseFactory->noContent();
        } catch (NotFoundException $e) {
            return $this->responseFactory->notFound($e->getMessage());
        }
    }
}

Query Parameters

Filtering

GET /api/v1/articles?filter[status]=published
GET /api/v1/articles?filter[category]=01HX...
GET /api/v1/articles?filter[author]=01HX...

Sorting

GET /api/v1/articles?sort=created_at
GET /api/v1/articles?sort=-created_at  # Descending
GET /api/v1/articles?sort=title,-created_at  # Multiple

Pagination

GET /api/v1/articles?page=2&per_page=50

Including Relations

GET /api/v1/articles?include=author,category
GET /api/v1/articles/{id}?include=comments

Sparse Fieldsets

GET /api/v1/articles?fields[article]=title,status
GET /api/v1/articles?fields[author]=name

Rate Limiting

HTTP/1.1 200 OK
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 45
X-RateLimit-Reset: 1706540400

When exceeded:

HTTP/1.1 429 Too Many Requests
Retry-After: 30

{
  "errors": [{
    "status": "429",
    "code": "rate_limit_exceeded",
    "title": "Rate Limit Exceeded",
    "detail": "You have exceeded 60 requests per minute"
  }]
}


Versioning Strategy

flowchart TB
    subgraph "Version Strategy"
        V1["/api/v1/"] --> |"Stable"| S1[Current]
        V2["/api/v2/"] --> |"Beta"| S2[Next Version]
        V0["/api/"] --> |"Redirects"| V1
    end

Deprecation Headers

Sunset: Sat, 31 Dec 2026 23:59:59 GMT
Deprecation: true
Link: </api/v2/articles>; rel="successor-version"

OpenAPI Specification

openapi: 3.0.3
info:
  title: Gold Standard API
  version: 1.0.0
  description: REST API for the Gold Standard Module

servers:
  - url: https://example.com/api/v1

paths:
  /articles:
    get:
      summary: List articles
      parameters:
        - name: page
          in: query
          schema:
            type: integer
            default: 1
        - name: per_page
          in: query
          schema:
            type: integer
            default: 20
      responses:
        '200':
          description: Success
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ArticleCollection'

components:
  schemas:
    Article:
      type: object
      properties:
        type:
          type: string
          example: article
        id:
          type: string
          example: 01HX1234567890ABCDEFGHIJK
        attributes:
          type: object
          properties:
            title:
              type: string
            content:
              type: string
            status:
              type: string
              enum: [draft, published, archived]


goldstandard #api #rest #design