API Architecture Overview

The JekCMS REST API is a single PHP file (api/v1/index.php) with a class-based router. All requests go through .htaccess rewrite rules to this entry point, which parses the URL, authenticates the request, and routes to the appropriate handler method.

// .htaccess
RewriteRule ^api/v1/(.*)$ api/v1/index.php [QSA,L]

// api/v1/index.php
class Api
{
    public function handleRequest(): void
    {
        $method = $_SERVER['REQUEST_METHOD'];
        $path = trim($_SERVER['PATH_INFO'] ?? '', '/');
        $segments = explode('/', $path);

        $resource = $segments[0] ?? '';
        $id = $segments[1] ?? null;
        $action = $segments[2] ?? null;

        // Route to handler
        match ($resource) {
            'posts' => $this->handlePosts($method, $id, $action),
            'media' => $this->handleMedia($method, $id),
            'categories' => $this->handleCategories($method, $id),
            'custom' => $this->handleCustom($method, $action),
            default => $this->error('Unknown resource', 404),
        };
    }
}

Creating a Custom Endpoint

To add a new endpoint, you add a handler method and a route case. For example, adding a /api/v1/stats endpoint that returns site statistics:

// Add to the match statement:
'stats' => $this->handleStats($method),

// Add the handler method:
private function handleStats(string $method): void
{
    $this->requireAuth(); // Require API key

    if ($method !== 'GET') {
        $this->error('Method not allowed', 405);
        return;
    }

    $stats = [
        'total_posts' => $this->db->fetch("SELECT COUNT(*) as c FROM posts")['c'],
        'published_posts' => $this->db->fetch("SELECT COUNT(*) as c FROM posts WHERE status='published'")['c'],
        'total_views' => $this->db->fetch("SELECT SUM(views) as v FROM posts")['v'] ?? 0,
        'total_media' => $this->db->fetch("SELECT COUNT(*) as c FROM media")['c'],
        'categories' => $this->db->fetchAll("SELECT name, slug, (SELECT COUNT(*) FROM posts WHERE category_id=c.id) as post_count FROM categories c ORDER BY name"),
    ];

    $this->success($stats);
}

Authentication

All custom endpoints should use API key authentication. The requireAuth() method checks for the X-API-Key header or Bearer token:

private function requireAuth(): void
{
    if (!$this->authenticate()) {
        $this->error('Unauthorized', 401);
        exit;
    }
}

private function authenticate(): bool
{
    $apiKey = $_SERVER['HTTP_X_API_KEY'] ?? null;

    $authHeader = $_SERVER['HTTP_AUTHORIZATION'] ?? '';
    if (preg_match('/Bearers+(.+)/', $authHeader, $matches)) {
        $apiKey = $matches[1];
    }

    if (!$apiKey) return false;

    $token = $this->db->fetch(
        "SELECT * FROM api_tokens WHERE token = ? AND is_active = 1",
        [$apiKey]
    );

    return $token !== false;
}

Pagination

private function paginate(string $query, array $params, int $page, int $perPage): array
{
    $countQuery = preg_replace('/SELECT .+ FROM/i', 'SELECT COUNT(*) as total FROM', $query);
    $countQuery = preg_replace('/ORDER BY .+$/i', '', $countQuery);
    $total = $this->db->fetch($countQuery, $params)['total'];

    $offset = ($page - 1) * $perPage;
    $query .= " LIMIT {$perPage} OFFSET {$offset}";
    $items = $this->db->fetchAll($query, $params);

    return [
        'items' => $items,
        'pagination' => [
            'page' => $page,
            'per_page' => $perPage,
            'total' => (int)$total,
            'total_pages' => ceil($total / $perPage),
        ],
    ];
}

Filtering

Query parameters map to WHERE clauses with parameterized queries (never string concatenation):

$where = ['1=1'];
$params = [];

if (!empty($_GET['category'])) {
    $where[] = 'c.slug = ?';
    $params[] = $_GET['category'];
}

if (!empty($_GET['status'])) {
    $where[] = 'p.status = ?';
    $params[] = $_GET['status'];
}

$query = "SELECT p.* FROM posts p LEFT JOIN categories c ON p.category_id = c.id WHERE " . implode(' AND ', $where);

Webhook Triggers

Custom endpoints can trigger webhooks after operations. For example, after creating a new resource, notify external systems:

private function triggerWebhook(string $event, array $data): void
{
    $webhooks = $this->db->fetchAll(
        "SELECT url, secret FROM webhooks WHERE event = ? AND is_active = 1",
        [$event]
    );

    foreach ($webhooks as $webhook) {
        $payload = json_encode(['event' => $event, 'data' => $data, 'timestamp' => time()]);
        $signature = hash_hmac('sha256', $payload, $webhook['secret']);

        // Fire-and-forget using non-blocking socket
        $this->asyncPost($webhook['url'], $payload, [
            'X-Webhook-Signature: sha256=' . $signature,
            'X-Webhook-Event: ' . $event,
        ]);
    }
}

The async approach ensures webhook delivery does not slow down the API response. If the external system is down, the webhook fails silently — we log the failure for debugging but do not retry (retries belong in a queue system).