ApiController - viames/pair GitHub Wiki
Pair framework: ApiController
Pair\Api\ApiController is the base class for JSON API modules. It extends Controller and adds:
- a parsed Request object
- auth helpers for session and bearer-token flows
- a middleware pipeline
- default rate limiting when enabled by env
- a ready-to-use
whatsappWebhookAction()endpoint for Meta webhooks
When to use
Use ApiController for endpoints that return JSON only and need centralized request, auth, and middleware behavior.
Lifecycle
When overriding _init(), call parent::_init() first:
protected function _init(): void
{
parent::_init();
// custom API setup
}
parent::_init() creates:
$this->requestas aPair\Api\Request- the internal
MiddlewarePipeline - the default
ThrottleMiddlewarewhenPAIR_API_RATE_LIMIT_ENABLED=true
By default, the throttle reads:
PAIR_API_RATE_LIMIT_MAX_ATTEMPTSPAIR_API_RATE_LIMIT_DECAY_SECONDS
Main methods (deep dive)
1) Auth context: getUser(), requireAuthOrResponse(), requireAuth(), setBearerToken(), requireBearerOrResponse(), requireBearer(), setSession()
getUser() reads the current authenticated user from Application::getInstance()->currentUser and returns null if there is no loaded user.
requireAuth() is the usual guard for session-authenticated endpoints:
public function profileAction(): \Pair\Http\ResponseInterface
{
$user = $this->requireAuth();
return new \Pair\Http\JsonResponse($user->toArray());
}
If you are moving an endpoint to the explicit v4 flow, requireAuthOrResponse() returns either the loaded user or an ApiErrorResponse:
public function profileAction(): \Pair\Http\ResponseInterface
{
$result = $this->requireAuthOrResponse();
if ($result instanceof \Pair\Api\ApiErrorResponse) {
return $result;
}
return new \Pair\Http\JsonResponse(['id' => $result->id]);
}
setBearerToken() and setSession() are normally called by the Pair runtime during API bootstrap. requireBearer() is useful when an endpoint must reject calls that are not authenticated via bearer token:
public function tokenInfoAction(): \Pair\Http\ResponseInterface
{
$token = $this->requireBearer();
return new \Pair\Http\JsonResponse(['token' => $token]);
}
If you need the same check in an explicit response flow, requireBearerOrResponse() returns either the bearer token string or an ApiErrorResponse.
2) JSON request helpers: getJsonBody() and requireJsonPost()
getJsonBody() is just a convenience proxy to $this->request->json().
requireJsonPost() enforces three things:
- method must be
POST Content-Typemust includeapplication/json- the parsed JSON body must be valid and non-empty
public function loginAction(): \Pair\Http\ResponseInterface
{
$payload = $this->requireJsonPostOrResponse();
if ($payload instanceof \Pair\Api\ApiErrorResponse) {
return $payload;
}
$data = $this->request->validateOrResponse([
'email' => 'required|email',
'password' => 'required|string|min:8',
]);
if ($data instanceof \Pair\Api\ApiErrorResponse) {
return $data;
}
return \Pair\Api\ApiResponse::jsonResponse([
'email' => $data['email'],
'received' => $payload,
]);
}
requireJsonPost() keeps the legacy terminate-on-error behavior. New explicit v4 actions should prefer requireJsonPostOrResponse().
Important: both JSON POST helpers are intentionally POST-only. For JSON PUT or PATCH endpoints, validate the content type and body explicitly:
public function updateOrderAction(): \Pair\Http\ResponseInterface
{
if (!$this->request->isJson()) {
return \Pair\Api\ApiResponse::errorResponse('UNSUPPORTED_MEDIA_TYPE', [
'expected' => 'application/json',
]);
}
$data = $this->request->json();
if (is_null($data)) {
return \Pair\Api\ApiResponse::errorResponse('BAD_REQUEST', [
'detail' => 'Invalid or empty JSON body',
]);
}
// handle PUT/PATCH payload
return \Pair\Api\ApiResponse::jsonResponse(['updated' => true]);
}
3) Middleware pipeline: middleware(), registerDefaultMiddleware(), runMiddleware()
middleware() appends a middleware instance to the pipeline. During normal API requests, the Pair runtime executes that pipeline automatically before calling the action. runMiddleware() remains available for focused tests and advanced manual flows; when used directly, it executes the chain and returns the first explicit value produced by the middleware stack or destination callable.
protected function _init(): void
{
parent::_init();
// the default throttle is already attached here
$this->middleware(new \Pair\Api\CorsMiddleware());
}
If you need a custom order, override registerDefaultMiddleware():
protected function registerDefaultMiddleware(): void
{
$this->middleware(new \Pair\Api\CorsMiddleware());
$this->middleware(new \Pair\Api\ThrottleMiddleware(20, 60));
}
Sensitive-endpoint example with an additional stricter limiter:
protected function _init(): void
{
parent::_init();
$this->middleware(new \Pair\Api\ThrottleMiddleware(10, 60));
}
When you use runMiddleware() manually, it can now propagate an explicit response back to the caller, even when that response is returned by a middleware that short-circuits the chain:
public function healthAction(): \Pair\Http\ResponseInterface
{
return $this->runMiddleware(function () {
return new \Pair\Http\JsonResponse(['ok' => true]);
});
}
4) Built-in WhatsApp webhook: whatsappWebhookAction() and handleWhatsAppWebhook()
ApiController now includes a ready-to-use unauthenticated Meta webhook endpoint at:
GET /api/whatsappWebhookPOST /api/whatsappWebhook
The Pair runtime allows this single action without sid or bearer auth because Meta authenticates it with:
- the
hub.*verification challenge onGET - the
X-Hub-Signature-256header onPOST
The endpoint uses WHATSAPP_CLOUD_WEBHOOK_VERIFY_TOKEN and WHATSAPP_CLOUD_APP_SECRET from .env.
The GET challenge path now returns an explicit TextResponse, while the POST delivery path returns an explicit JSON response or API error response.
Override handleWhatsAppWebhook() in the application controller to process normalized events:
class ApiController extends \Pair\Api\CrudController {
protected function handleWhatsAppWebhook(array $events, array $payload): ?array
{
foreach ($events as $event) {
if ($event['event'] === 'message') {
// handle inbound message
}
if ($event['event'] === 'status') {
// handle sent/delivered/read/failed updates
}
}
return [
'received' => true,
'events' => count($events),
];
}
}
If you do not override the hook, Pair still acknowledges the webhook with 200 OK.
5) __call(mixed $name, mixed $arguments): mixed
If an API action is missing, ApiController now returns an explicit ApiErrorResponse with NOT_FOUND instead of falling back to MVC behavior.
That makes it safe to expose JSON-only modules without HTML redirects or view rendering.
Full minimal controller example
<?php
namespace App\Modules\Api;
use Pair\Api\ApiController as BaseApiController;
class ApiController extends BaseApiController {
protected function _init(): void
{
parent::_init();
$this->middleware(new \Pair\Api\CorsMiddleware());
}
public function healthAction(): \Pair\Http\ResponseInterface
{
return new \Pair\Http\JsonResponse(['ok' => true]);
}
}
Secondary methods (short reference)
setBearerToken(string $bearerToken)stores the bearer token resolved by the Pair API bootstrap.setSession(Session $session)stores the current session object for SID-based API auth flows.getUser(): ?Userreturns the loaded current user ornull.getJsonBody(): mixedis a small convenience wrapper around$this->request->json().
Common pitfalls
- Overriding
_init()withoutparent::_init(). - Forgetting that the default throttle is already mounted when rate limiting is enabled.
- Using
requireJsonPost()forPUTorPATCH; it is intentionallyPOST-only. - Mixing HTML redirects or views inside API actions.
- Calling
requireBearer()before the runtime has populated the bearer token. - Enabling the WhatsApp endpoint without setting
WHATSAPP_CLOUD_WEBHOOK_VERIFY_TOKENandWHATSAPP_CLOUD_APP_SECRET.
See also: API, Request, ApiResponse, TextResponse, ThrottleMiddleware, RateLimiter, WhatsAppCloudClient.