-
Notifications
You must be signed in to change notification settings - Fork 2
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
Pair\Api\ApiControllerPair\Api\CrudControllerPair\Api\RequestPair\Api\ApiResponse-
Pair\Api\Middleware+Pair\Api\MiddlewarePipeline Pair\Api\CorsMiddleware-
Pair\Api\ThrottleMiddleware+Pair\Api\RateLimiter+Pair\Api\RateLimitResult Pair\Api\IdempotencyPair\Api\ResourcePair\Api\QueryFilterPair\Api\PasskeyControllerPair\Services\PasskeyAuthPair\Models\UserPasskey
<?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.
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/faqsGET /api/faqs/{id}POST /api/faqs-
PUT /api/faqs/{id}(alsoPATCH) DELETE /api/faqs/{id}
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.
Pair API throttling is enabled out of the box for ApiController through ThrottleMiddleware.
Current behavior:
- Redis is the primary backend when
REDIS_HOSTis configured andext-redisis 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, andRetry-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=1Example 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'));
}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));
}Inside ApiController:
-
requireAuth()requires an authenticated user. -
requireBearer()requires a bearer token. -
getUser()returns current user ornull. -
requireJsonPost()validates method + JSON content-type + body forPOSTrequests only.
For PUT, PATCH and DELETE endpoints, read and validate the payload through Request directly instead of forcing requireJsonPost().
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);Pair provides PasskeyController for ready-to-use endpoints:
POST /api/passkey/login/optionsPOST /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.
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);
});
}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;
}
}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
- Overriding
ApiController::_init()and forgettingparent::_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.
Pair provides generators under:
Pair\Api\OpenApi\SpecGeneratorPair\Api\OpenApi\SchemaGenerator
Use them to build machine-readable API docs from your resource/controller metadata.
These classes are used frequently around the API layer even if they are not the first ones you touch:
-
Pair\Api\MiddlewareandPair\Api\MiddlewarePipelineDefine and execute reusable request guards. -
Pair\Api\CorsMiddlewareAdds CORS handling to API endpoints. -
Pair\Api\ThrottleMiddleware,Pair\Api\RateLimiter,Pair\Api\RateLimitResultImplement the default API throttling system. -
Pair\Api\ResourceShapes normalized API output for CRUD resources. -
Pair\Api\QueryFilterHelps transform request filters into query constraints.