Skip to content
Viames Marino edited this page Mar 26, 2026 · 6 revisions

Pair framework: API

Pair includes a native API layer designed for JSON endpoints with authentication helpers, middleware pipeline, automatic CRUD resources, throttling, CORS, idempotency, and OpenAPI generation helpers.

This page is the overview. Use the dedicated pages when you need full method-level reference:

  • ApiController for controller lifecycle, auth helpers and middleware execution
  • Request for payload parsing, validation and transport metadata
  • ApiResponse for JSON success/error responses
  • CrudController for automatic REST resources

Core classes

  • Pair\Api\ApiController
  • Pair\Api\CrudController
  • Pair\Api\Request
  • Pair\Api\ApiResponse
  • Pair\Api\Middleware + Pair\Api\MiddlewarePipeline
  • Pair\Api\CorsMiddleware
  • Pair\Api\ThrottleMiddleware + Pair\Api\RateLimiter + Pair\Api\RateLimitResult
  • Pair\Api\Idempotency
  • Pair\Api\Resource
  • Pair\Api\QueryFilter
  • Pair\Api\PasskeyController
  • Pair\Services\PasskeyAuth
  • Pair\Models\UserPasskey

Minimal API controller

<?php

namespace App\Modules\Api;

use Pair\Api\ApiController as BaseApiController;
use Pair\Api\ApiResponse;

class ApiController extends BaseApiController {

    protected function _init(): void
    {
        // Always keep the base initialization: request + pipeline + default middleware.
        parent::_init();
    }

    public function healthAction(): void
    {
        // Returns a JSON response body and exits.
        ApiResponse::respond(['ok' => true]);
    }

}

parent::_init() now initializes the request object, the middleware pipeline, and the default throttle middleware when PAIR_API_RATE_LIMIT_ENABLED=true.

Automatic CRUD with CrudController

CrudController can register ActiveRecord models and expose REST-style endpoints automatically.

<?php

namespace App\Modules\Api;

use Pair\Api\CrudController as BaseCrudController;
use App\Models\Faq;

class ApiController extends BaseCrudController {

    protected function _init(): void
    {
        parent::_init();

        // Registers one REST resource handled by CrudController.
        $this->crud('faqs', Faq::class);
    }

}

Generated endpoints (for faqs):

  • GET /api/faqs
  • GET /api/faqs/{id}
  • POST /api/faqs
  • PUT /api/faqs/{id} (also PATCH)
  • DELETE /api/faqs/{id}

Request and response helpers

Request provides method/content-type/body/query access:

// Reads transport metadata and parsed payloads.
$method = $this->request->method();
$isJson = $this->request->isJson();
$body = $this->request->json();
$status = $this->request->query('status');
$clientIp = $this->request->ip();

ApiResponse provides consistent JSON output:

// Returns a generic JSON payload.
ApiResponse::respond(['saved' => true], 201);

// Returns a normalized error payload.
ApiResponse::error('BAD_REQUEST', ['detail' => 'Invalid payload']);

// Returns an API payload with pagination metadata.
ApiResponse::paginated($rows, $page, $perPage, $total);

If you are documenting or implementing a concrete endpoint, keep the method details in the dedicated pages above and use this page as the entry point.

Rate limiting

Pair API throttling is enabled out of the box for ApiController through ThrottleMiddleware.

Current behavior:

  • Redis is the primary backend when REDIS_HOST is configured and ext-redis is available.
  • File storage in TEMP_PATH/rate_limits/ is used automatically as fallback.
  • Limits are evaluated with an atomic sliding window.
  • Clients receive X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, and Retry-After (when blocked).
  • Keys are resolved by identity in this order: sid, bearer token, authenticated user, then client IP.

Example .env:

PAIR_API_RATE_LIMIT_ENABLED=true
PAIR_API_RATE_LIMIT_MAX_ATTEMPTS=60
PAIR_API_RATE_LIMIT_DECAY_SECONDS=60
PAIR_API_RATE_LIMIT_REDIS_PREFIX="pair:rate_limit:"
PAIR_TRUSTED_PROXIES=127.0.0.1,10.0.0.0/8
REDIS_HOST=127.0.0.1
REDIS_PORT=6379
REDIS_PASSWORD=
REDIS_DB=0
REDIS_TIMEOUT=1

Example 429 response handling on a client:

// Executes one API request that may be throttled.
const response = await fetch('/api/orders?sid=SESSION_ID');

if (response.status === 429) {
  // Use Retry-After to decide when to retry.
  console.log(response.headers.get('Retry-After'));

  // X-RateLimit-Reset gives the reset timestamp.
  console.log(response.headers.get('X-RateLimit-Reset'));
}

Middleware pipeline

ApiController includes a middleware pipeline.

protected function _init(): void
{
    parent::_init();

    // default throttle is already registered by parent::_init()
    $this->middleware(new \Pair\Api\CorsMiddleware());
}

The method you usually call in actions is runMiddleware(), because it executes the stack and only then runs your action callback.

public function meAction(): void
{
    $this->runMiddleware(function () {
        // Require an authenticated user after middleware checks.
        $user = $this->requireAuth();

        \Pair\Api\ApiResponse::respond(['id' => $user->getId()]);
    });
}

Important: the default stack currently mounts the throttle first inside parent::_init(). Any middleware added later in _init() runs after it.

If you need a specific order, override registerDefaultMiddleware().

protected function registerDefaultMiddleware(): void
{
    $this->middleware(new \Pair\Api\CorsMiddleware());
    $this->middleware(new \Pair\Api\ThrottleMiddleware(60, 60));
}

If you need a second stricter limiter for a specific controller, add another ThrottleMiddleware after parent::_init().

protected function _init(): void
{
    parent::_init();
    $this->middleware(new \Pair\Api\ThrottleMiddleware(10, 60));
}

Authentication helpers

Inside ApiController:

  • requireAuth() requires an authenticated user.
  • requireBearer() requires a bearer token.
  • getUser() returns current user or null.
  • requireJsonPost() validates method + JSON content-type + body for POST requests only.

For PUT, PATCH and DELETE endpoints, read and validate the payload through Request directly instead of forcing requireJsonPost().

Idempotency for safe retries

Useful for replayed requests (mobile retries, offline queue replays, flaky networks):

use Pair\Api\Idempotency;
use Pair\Api\ApiResponse;

// Immediately returns the stored response if the same key was already processed.
Idempotency::respondIfDuplicate($this->request, 'orders:create');

$result = ['orderId' => 123, 'saved' => true];

// Stores the successful response for future safe retries.
Idempotency::storeResponse($this->request, 'orders:create', $result, 201);
ApiResponse::respond($result, 201);

Passkey/WebAuthn endpoints

Pair provides PasskeyController for ready-to-use endpoints:

  • POST /api/passkey/login/options
  • POST /api/passkey/login/verify
  • POST /api/passkey/register/options (requires authenticated session)
  • POST /api/passkey/register/verify (requires authenticated session)
  • GET /api/passkey/list (requires authenticated session)
  • DELETE /api/passkey/revoke/{id} (requires authenticated session)

Minimal setup:

// Inherit the ready-to-use passkey endpoints directly.
class ApiController extends \Pair\Api\PasskeyController {}

Frontend integration is usually done with PairPasskey.js.

End-to-end recipes

Authenticated JSON endpoint with validation

public function createOrderAction(): void
{
    $this->runMiddleware(function () {
        // Requires an authenticated application user.
        $this->requireAuth();

        // Requires POST + application/json + valid JSON body.
        $this->requireJsonPost();

        // Validates the expected payload shape.
        $data = $this->request->validate([
            'customerId' => 'required|int',
            'amount' => 'required|numeric|min:0.01',
            'currency' => 'required|string|max:3',
        ]);

        // Maps payload data into the domain object.
        $order = new \App\Orm\Order();
        $order->customerId = (int)$data['customerId'];
        $order->amount = (float)$data['amount'];
        $order->currency = strtoupper((string)$data['currency']);

        if (!$order->store()) {
            // Returns a normalized API error with object validation errors.
            ApiResponse::error('INVALID_OBJECT_DATA', ['errors' => $order->getErrors()]);
        }

        // Returns the created id to the client.
        ApiResponse::respond(['id' => $order->getId()], 201);
    });
}

Idempotent create endpoint

public function createPaymentAction(): void
{
    // Auth and JSON validation happen before idempotency storage.
    $this->requireAuth();
    $this->requireJsonPost();

    // Returns the previous response immediately if this request is a replay.
    Idempotency::respondIfDuplicate($this->request, 'payments:create');

    try {
        // Validates the expected payload.
        $payload = $this->request->validate([
            'orderId' => 'required|int',
            'amount' => 'required|numeric|min:0.01',
        ]);

        // Builds and stores the response for safe retries.
        $result = ['paymentId' => 9911, 'orderId' => (int)$payload['orderId']];
        Idempotency::storeResponse($this->request, 'payments:create', $result, 201);
        ApiResponse::respond($result, 201);
    } catch (\Throwable $e) {
        // Clears the "processing" lock so the client can retry cleanly.
        Idempotency::clearProcessing($this->request, 'payments:create');
        throw $e;
    }
}

Read headers after a throttled request

curl -i "https://example.test/api/orders?sid=SESSION_ID"

Expected headers when the limit is reached:

HTTP/1.1 429 Too Many Requests
Retry-After: 32
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1710842892

Common pitfalls

  • Overriding ApiController::_init() and forgetting parent::_init().
  • Adding a second throttle middleware without realizing the default one is already active.
  • Describing CORS-before-throttle as the default order when the real default stack mounts throttle first.
  • Returning non-JSON output (echo/print) in API actions.
  • Running lockForUpdate() logic without transaction control.
  • Using idempotency key without clearProcessing() in failure paths.
  • Trusting forwarded proxy headers without configuring PAIR_TRUSTED_PROXIES.

OpenAPI helpers

Pair provides generators under:

  • Pair\Api\OpenApi\SpecGenerator
  • Pair\Api\OpenApi\SchemaGenerator

Use them to build machine-readable API docs from your resource/controller metadata.

Secondary building blocks worth knowing

These classes are used frequently around the API layer even if they are not the first ones you touch:

  • Pair\Api\Middleware and Pair\Api\MiddlewarePipeline Define and execute reusable request guards.
  • Pair\Api\CorsMiddleware Adds CORS handling to API endpoints.
  • Pair\Api\ThrottleMiddleware, Pair\Api\RateLimiter, Pair\Api\RateLimitResult Implement the default API throttling system.
  • Pair\Api\Resource Shapes normalized API output for CRUD resources.
  • Pair\Api\QueryFilter Helps transform request filters into query constraints.

Related pages

Clone this wiki locally