AbstractController.php

PHP

Path: src/Controller/AbstractController.php

<?php

namespace mini\Controller;

use Psr\Http\Message\ServerRequestInterface;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Server\RequestHandlerInterface;

/**
 * Base controller class with routing and response helpers
 *
 * Controllers extend this class to handle HTTP requests using an internal router.
 * Route registration uses type-aware patterns - the router analyzes method signatures
 * to generate appropriate regex patterns for URL parameters.
 *
 * Example:
 * ```php
 * class UserController extends AbstractController
 * {
 *     public function __construct()
 *     {
 *         parent::__construct();
 *
 *         $this->router->get('/', $this->index(...));
 *         $this->router->get('/{id}/', $this->show(...));
 *         $this->router->post('/', $this->create(...));
 *     }
 *
 *     public function index(): ResponseInterface
 *     {
 *         $users = User::query()->limit(100);
 *         return $this->respond(iterator_to_array($users));
 *     }
 *
 *     public function show(int $id): ResponseInterface
 *     {
 *         $user = User::find($id);
 *         if (!$user) throw new \mini\Exceptions\NotFoundException();
 *         return $this->respond($user);
 *     }
 * }
 * ```
 *
 * Mount in Mini router:
 * ```php
 * // _routes/users/__DEFAULT__.php
 * return new UserController();
 * ```
 *
 * @package mini\Controller
 */
abstract class AbstractController implements RequestHandlerInterface
{
    /**
     * Internal router for this controller
     *
     * Use to register routes:
     * - $this->router->get($path, $handler)
     * - $this->router->post($path, $handler)
     * - $this->router->patch($path, $handler)
     * - $this->router->put($path, $handler)
     * - $this->router->delete($path, $handler)
     * - $this->router->any($path, $handler)
     */
    public readonly Router $router;

    public function __construct()
    {
        $this->router = new Router();
        $this->router->importRoutesFromAttributes($this);
    }

    /**
     * PSR-15 entry point
     *
     * Flow:
     * 1. Router::match() finds matching route and returns handler + params (or redirect)
     * 2. Enrich request with type-cast parameters as attributes
     * 3. Create ConverterHandler wrapping the matched controller method
     * 4. ConverterHandler invokes method and converts return value to ResponseInterface
     */
    public function handle(ServerRequestInterface $request): ResponseInterface
    {
        // Match route
        $match = $this->router->match($request);

        // Handle redirects (trailing slash normalization)
        if ($match instanceof ResponseInterface) {
            return $match;
        }

        // Enrich request with URL parameters as attributes
        foreach ($match['params'] as $name => $value) {
            $request = $request->withAttribute($name, $value);
        }

        // Create ConverterHandler and invoke controller method
        $converterHandler = new ConverterHandler($match['handler']);
        return $converterHandler->handle($request);
    }

    /**
     * Content negotiation response
     *
     * Checks Accept header to determine response format:
     * - application/json → JSON response
     * - text/html → Renders view if exists, otherwise JSON
     * - wildcard or empty → Prefers HTML if view exists, otherwise JSON
     *
     * This enables API-first development with progressive HTML enhancement.
     *
     * @param mixed $data Data to respond with
     * @param int $status HTTP status code
     * @param array $headers Additional headers ['Header-Name' => 'value']
     * @return ResponseInterface
     */
    protected function respond(mixed $data, int $status = 200, array $headers = []): ResponseInterface
    {
        // Check if client accepts HTML
        if ($this->acceptsHtml()) {
            // Try to find view for this route
            $view = $this->findViewForCurrentRoute();

            if ($view) {
                $html = \mini\render($view, ['data' => $data]);
                return $this->html($html, $status, $headers);
            }
        }

        // Fallback to JSON (also handles explicit application/json Accept)
        return $this->json($data, $status, $headers);
    }

    /**
     * Explicit JSON response
     *
     * Always returns JSON regardless of Accept header.
     *
     * @param mixed $data Data to encode as JSON
     * @param int $status HTTP status code
     * @param array $headers Additional headers
     * @return ResponseInterface
     */
    protected function json(mixed $data, int $status = 200, array $headers = []): ResponseInterface
    {
        $json = json_encode($data, JSON_THROW_ON_ERROR);
        $response = new \mini\Http\Message\Response($json, ['Content-Type' => 'application/json'], $status);

        foreach ($headers as $name => $value) {
            $response = $response->withHeader($name, $value);
        }

        return $response;
    }

    /**
     * Explicit HTML response
     *
     * @param string $body HTML content
     * @param int $status HTTP status code
     * @param array $headers Additional headers
     * @return ResponseInterface
     */
    protected function html(string $body, int $status = 200, array $headers = []): ResponseInterface
    {
        $response = new \mini\Http\Message\Response($body, ['Content-Type' => 'text/html; charset=utf-8'], $status);

        foreach ($headers as $name => $value) {
            $response = $response->withHeader($name, $value);
        }

        return $response;
    }

    /**
     * Plain text response
     *
     * @param string $body Response body
     * @param int $status HTTP status code
     * @param array $headers Additional headers
     * @return ResponseInterface
     */
    protected function text(string $body, int $status = 200, array $headers = []): ResponseInterface
    {
        $response = new \mini\Http\Message\Response($body, ['Content-Type' => 'text/plain; charset=utf-8'], $status);

        foreach ($headers as $name => $value) {
            $response = $response->withHeader($name, $value);
        }

        return $response;
    }

    /**
     * Empty response (for 204 No Content, etc.)
     *
     * @param int $status HTTP status code
     * @param array $headers Additional headers
     * @return ResponseInterface
     */
    protected function empty(int $status = 204, array $headers = []): ResponseInterface
    {
        $response = new \mini\Http\Message\Response('', [], $status);

        foreach ($headers as $name => $value) {
            $response = $response->withHeader($name, $value);
        }

        return $response;
    }

    /**
     * Redirect response
     *
     * @param string $url Redirect target URL
     * @param int $status HTTP status code (301 permanent, 302 temporary, 303 see other)
     * @return ResponseInterface
     */
    protected function redirect(string $url, int $status = 302): ResponseInterface
    {
        return new \mini\Http\Message\Response('', ['Location' => $url], $status);
    }

    /**
     * Check if client accepts HTML
     *
     * @return bool
     */
    private function acceptsHtml(): bool
    {
        $accept = \mini\request()->getHeaderLine('Accept');

        // No Accept header or */* - assume HTML preference
        if (empty($accept) || $accept === '*/*') {
            return true;
        }

        // Parse Accept header for quality values
        $types = $this->parseAcceptHeader($accept);

        // Check if text/html is accepted and preferred over application/json
        $htmlQuality = $types['text/html'] ?? 0;
        $jsonQuality = $types['application/json'] ?? 0;

        return $htmlQuality > 0 && $htmlQuality >= $jsonQuality;
    }

    /**
     * Parse Accept header into quality-weighted array
     *
     * @param string $accept Accept header value
     * @return array Type => quality mapping
     */
    private function parseAcceptHeader(string $accept): array
    {
        $types = [];

        foreach (explode(',', $accept) as $part) {
            $part = trim($part);

            // Parse "type/subtype;q=0.8"
            if (preg_match('/^([^;]+)(?:;q=([0-9.]+))?$/', $part, $matches)) {
                $type = trim($matches[1]);
                $quality = isset($matches[2]) ? (float)$matches[2] : 1.0;

                $types[$type] = $quality;

                // Handle wildcards
                if ($type === '*/*') {
                    $types['text/html'] = max($types['text/html'] ?? 0, $quality);
                    $types['application/json'] = max($types['application/json'] ?? 0, $quality);
                }
            }
        }

        return $types;
    }

    /**
     * Find view template for current route
     *
     * Maps controller route to view path:
     * - UserController::index() → users/index.php
     * - UserController::show() → users/show.php
     * - PostController::showPost() → posts/show.php
     *
     * @return string|null View path or null if not found
     */
    private function findViewForCurrentRoute(): ?string
    {
        // Get controller class name
        $className = get_class($this);
        $shortName = (new \ReflectionClass($this))->getShortName();

        // Strip "Controller" suffix: UserController → User
        $resource = preg_replace('/Controller$/', '', $shortName);
        $resource = strtolower($resource);

        // Get current method being called from backtrace
        $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10);
        $method = null;
        foreach ($trace as $frame) {
            if (isset($frame['class']) && $frame['class'] === $className && $frame['function'] !== 'handle') {
                $method = $frame['function'];
                break;
            }
        }

        if (!$method) {
            return null;
        }

        // Map method names to view names
        // index → index, show → show, showPost → show, createComment → create, etc.
        $viewName = $this->methodToViewName($method);

        // Try to find view: users/index.php, users/show.php, etc.
        $viewPath = $resource . '/' . $viewName . '.php';

        $foundPath = \mini\Mini::$mini->paths->views->findFirst($viewPath);

        return $foundPath ? $viewPath : null;
    }

    /**
     * Convert controller method name to view name
     *
     * @param string $method Controller method name
     * @return string View name
     */
    private function methodToViewName(string $method): string
    {
        // Common mappings
        $map = [
            'index' => 'index',
            'show' => 'show',
            'create' => 'create',
            'update' => 'update',
            'edit' => 'edit',
            'delete' => 'delete',
        ];

        if (isset($map[$method])) {
            return $map[$method];
        }

        // Try to extract action from method name
        // showPost → show, createComment → create, listPosts → list
        foreach (['show', 'create', 'update', 'edit', 'delete', 'list'] as $action) {
            if (str_starts_with($method, $action)) {
                return $action;
            }
        }

        // Fallback: use method name as-is
        return $method;
    }
}