-
Notifications
You must be signed in to change notification settings - Fork 2
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
- built-in mobile auth endpoints backed by ApiToken
- a ready-to-use
whatsappWebhookAction()endpoint for Meta webhooks
Use ApiController for endpoints that return JSON only and need centralized request, auth, and middleware behavior.
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
1) Auth context: getUser(), requireAuthOrResponse(), requireAuth(), setBearerToken(), setApiToken(), 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.
setApiToken() is called by the runtime when the Bearer token matches an api_tokens row. Application code normally does not call it directly.
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]);
}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]);
});
}ApiController includes a default mobile auth endpoint for native clients:
POST /api/auth/loginPOST /api/auth/registerPOST /api/auth/refreshGET /api/auth/me-
POST /api/auth/logoutorDELETE /api/auth/logout POST /api/auth/passkey/optionsPOST /api/auth/passkey/verifyGET /api/auth/passkeysPOST /api/auth/passkeys/optionsPOST /api/auth/passkeys/verifyDELETE /api/auth/passkeys/{id}
Login verifies credentials with User::doTokenLogin() without creating a PHP session. Successful login and registration issue short-lived access tokens plus optional refresh tokens through ApiToken. Refresh rotates the refresh token atomically. Logout accepts POST or DELETE, revokes the current token row, and can also revoke a row by refresh_token when the JSON body includes it.
Native passkey login uses a cookie-free opaque challenge flow and issues the same bearer session as password login. Only the two singular /auth/passkey/* login operations are public; passkey listing, registration, and revocation require the current bearer-authenticated user. See Mobile auth app setup for schema, relying-party, and signed-app association requirements.
Applications should override these hooks instead of copying the auth action:
use Pair\Api\ApiErrorResponse;
use Pair\Api\ApiController as BaseApiController;
use Pair\Models\User;
class ApiController extends BaseApiController {
/**
* Return the public mobile user payload.
*/
protected function mobileAuthUserSnapshot(User $user): array {
return [
'id' => (int)$user->id,
'email' => $user->email,
'name' => $user->fullName(),
];
}
/**
* Return optional tenant or city context for native startup.
*/
protected function mobileAuthContext(User $user): ?array {
return ['tenant' => 'demo'];
}
/**
* Create a user for mobile registration after app-specific validation.
*/
protected function mobileAuthRegisterUser(array $body): User|ApiErrorResponse {
return $this->errorResponse('NOT_IMPLEMENTED');
}
}The default registration hook returns NOT_IMPLEMENTED because signup fields, privacy consent, and tenant bootstrap are application-specific. See Mobile auth app setup for the application migration, registration override, OpenAPI, and revocation guidance.
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.
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.
<?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]);
}
}-
setBearerToken(string $bearerToken)stores the bearer token resolved by the Pair API bootstrap. -
setApiToken(ApiToken $apiToken)stores the mobile/API token row resolved by bearer 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().
- 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. - Reimplementing mobile login, refresh, or logout in every app instead of overriding the dedicated mobile auth hooks.
- Enabling the WhatsApp endpoint without setting
WHATSAPP_CLOUD_WEBHOOK_VERIFY_TOKENandWHATSAPP_CLOUD_APP_SECRET.
See also: API, Request, AuthorizationHeader, ApiResponse, ApiToken, AuthAttemptLimiter, Mobile iOS stack, Mobile Android stack, TextResponse, ThrottleMiddleware, RateLimiter, WhatsAppCloudClient.