Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
39 / 39
100.00% covered (success)
100.00%
5 / 5
CRAP
100.00% covered (success)
100.00%
1 / 1
OpenApiDocController
100.00% covered (success)
100.00%
38 / 38
100.00% covered (success)
100.00%
5 / 5
19
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ui
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 jsonSpec
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 filterClientSpec
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
7
 isAdminOnlyPath
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\Api\Presentation\Web;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Profile\Domain\Model\Profile;
12use Psr\Http\Message\ResponseFactoryInterface;
13use Psr\Http\Message\ResponseInterface;
14use Twig\Environment as TwigEnvironment;
15
16/**
17 * OpenAPI Interactive Documentation Controller.
18 *
19 * Serves Swagger UI (/api) and OpenAPI 3.0 JSON specification (/api/v1/openapi.json).
20 *
21 * @package App\Core\Api\Presentation\Web
22 */
23final readonly class OpenApiDocController
24{
25    private const string SPEC_PATH = __DIR__ . '/../../../../../resources/openapi/openapi.json';
26
27    /**
28     * OpenApiDocController constructor.
29     *
30     * @param ResponseFactoryInterface $responseFactory PSR-7 Response factory.
31     * @param TwigEnvironment          $twig            Twig template engine.
32     * @param Profile|null              $profile         Active application profile.
33     */
34    public function __construct(
35        private ResponseFactoryInterface $responseFactory,
36        private TwigEnvironment $twig,
37        private ?Profile $profile = null
38    ) {
39    }
40
41    /**
42     * Serves interactive Swagger UI HTML page.
43     *
44     * @return ResponseInterface PSR-7 HTML response.
45     */
46    public function ui(): ResponseInterface
47    {
48        $response = $this->responseFactory->createResponse(200);
49        $html = $this->twig->render('api/swagger.twig', [
50            'specUrl' => '/api/v1/openapi.json',
51        ]);
52        $response->getBody()->write($html);
53
54        return $response->withHeader('Content-Type', 'text/html; charset=UTF-8');
55    }
56
57    /**
58     * Serves OpenAPI 3.0 JSON Specification filtered according to active profile.
59     *
60     * @return ResponseInterface PSR-7 JSON response.
61     */
62    public function jsonSpec(): ResponseInterface
63    {
64        $specJson = (string) file_get_contents(self::SPEC_PATH);
65
66        if ($this->profile !== null && $this->profile->name === 'client') {
67            $specJson = $this->filterClientSpec($specJson);
68        }
69
70        $response = $this->responseFactory->createResponse(200);
71        $response->getBody()->write($specJson);
72
73        return $response->withHeader('Content-Type', 'application/json');
74    }
75
76    /**
77     * Filters OpenAPI spec removing administrative-only routes and updating metadata for client profile.
78     *
79     * @param string $specJson Raw JSON spec.
80     * @return string Filtered JSON spec.
81     */
82    private function filterClientSpec(string $specJson): string
83    {
84        /** @var array<string, mixed> $spec */
85        $spec = json_decode($specJson, true, 512, JSON_THROW_ON_ERROR);
86
87        if (isset($spec['info']) && is_array($spec['info'])) {
88            $spec['info']['title'] = 'Ammonly Client Portal API v1';
89            $spec['info']['description'] =
90                'Official API documentation for Ammonly Client Portal (API-First Architecture).';
91        }
92
93        $spec['servers'] = [
94            [
95                'url' => 'https://app-client.ammonly.com',
96                'description' => 'Client Portal HTTPS Server',
97            ],
98        ];
99
100        if (isset($spec['paths']) && is_array($spec['paths'])) {
101            $filteredPaths = [];
102            foreach ($spec['paths'] as $path => $definition) {
103                if (!$this->isAdminOnlyPath((string) $path)) {
104                    $filteredPaths[$path] = $definition;
105                }
106            }
107            $spec['paths'] = $filteredPaths;
108        }
109
110        return json_encode($spec, JSON_THROW_ON_ERROR | JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
111    }
112
113    /**
114     * Determines whether an OpenAPI path belongs strictly to administrative operations.
115     *
116     * @param string $path OpenAPI endpoint path.
117     * @return bool True if admin-only.
118     */
119    private function isAdminOnlyPath(string $path): bool
120    {
121        return str_starts_with($path, '/api/v1/system/context')
122            || str_starts_with($path, '/api/v1/instance')
123            || str_starts_with($path, '/api/v1/about')
124            || str_starts_with($path, '/api/v1/audit')
125            || str_starts_with($path, '/api/v1/cron')
126            || str_starts_with($path, '/api/v1/layout')
127            || str_starts_with($path, '/api/v1/settings');
128    }
129}