Skip to content
Viames Marino edited this page Feb 26, 2026 · 4 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.

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\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
    {
        parent::_init();
    }

    public function healthAction(): void
    {
        ApiResponse::respond(['ok' => 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();
        $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:

$method = $this->request->method();
$isJson = $this->request->isJson();
$body = $this->request->json();
$status = $this->request->query('status');

ApiResponse provides consistent JSON output:

ApiResponse::respond(['saved' => true], 201);
ApiResponse::error('BAD_REQUEST', ['detail' => 'Invalid payload']);
ApiResponse::paginated($rows, $page, $perPage, $total);

Middleware pipeline

ApiController includes a middleware pipeline:

$this->middleware(new \Pair\Api\CorsMiddleware());
$this->middleware(new \Pair\Api\ThrottleMiddleware(60, 60));

$this->runMiddleware(function () {
    // action code
});

Production order suggestion:

  1. CORS middleware first (preflight short-circuit).
  2. Throttle middleware second.
  3. Authentication/authorization middleware after transport checks.
  4. Endpoint action last.

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.

Idempotency for safe retries

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

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

Idempotency::respondIfDuplicate($this->request, 'orders:create');

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

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:

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 () {
        $this->requireAuth();
        $this->requireJsonPost();

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

        $order = new \App\Orm\Order();
        $order->customerId = (int)$data['customerId'];
        $order->amount = (float)$data['amount'];
        $order->currency = strtoupper((string)$data['currency']);

        if (!$order->store()) {
            ApiResponse::error('INVALID_OBJECT_DATA', ['errors' => $order->getErrors()]);
        }

        ApiResponse::respond(['id' => $order->getId()], 201);
    });
}

Idempotent create endpoint

public function createPaymentAction(): void
{
    $this->requireAuth();
    $this->requireJsonPost();

    Idempotency::respondIfDuplicate($this->request, 'payments:create');

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

        $result = ['paymentId' => 9911, 'orderId' => (int)$payload['orderId']];
        Idempotency::storeResponse($this->request, 'payments:create', $result, 201);
        ApiResponse::respond($result, 201);
    } catch (\Throwable $e) {
        Idempotency::clearProcessing($this->request, 'payments:create');
        throw $e;
    }
}

Auto-CRUD + custom endpoint in same controller

protected function _init(): void
{
    parent::_init();
    $this->crud('users', \App\Orm\User::class);
}

public function meAction(): void
{
    $user = $this->requireAuth();
    ApiResponse::respond($user->toArray());
}

Common pitfalls

  • Overriding ApiController::_init() and forgetting parent::_init().
  • Returning non-JSON output (echo/print) in API actions.
  • Running lockForUpdate() logic without transaction control.
  • Using idempotency key without clearProcessing() in failure paths.

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.

Related pages

Clone this wiki locally