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->request as a Pair\Api\Request
  • the internal MiddlewarePipeline
  • the default ThrottleMiddleware when PAIR_API_RATE_LIMIT_ENABLED=true

By default, the throttle reads:

  • PAIR_API_RATE_LIMIT_MAX_ATTEMPTS
  • PAIR_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-Type must include application/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/whatsappWebhook
  • POST /api/whatsappWebhook

The Pair runtime allows this single action without sid or bearer auth because Meta authenticates it with:

  • the hub.* verification challenge on GET
  • the X-Hub-Signature-256 header on POST

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(): ?User returns the loaded current user or null.
  • getJsonBody(): mixed is a small convenience wrapper around $this->request->json().

Common pitfalls

  • Overriding _init() without parent::_init().
  • Forgetting that the default throttle is already mounted when rate limiting is enabled.
  • Using requireJsonPost() for PUT or PATCH; it is intentionally POST-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_TOKEN and WHATSAPP_CLOUD_APP_SECRET.

See also: API, Request, ApiResponse, TextResponse, ThrottleMiddleware, RateLimiter, WhatsAppCloudClient.