Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
123 / 123
100.00% covered (success)
100.00%
6 / 6
CRAP
100.00% covered (success)
100.00%
1 / 1
AboutLicensesApiController
100.00% covered (success)
100.00%
122 / 122
100.00% covered (success)
100.00%
6 / 6
14
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
 index
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
1
 createGrid
100.00% covered (success)
100.00%
68 / 68
100.00% covered (success)
100.00%
1 / 1
4
 fetchGridResult
100.00% covered (success)
100.00%
34 / 34
100.00% covered (success)
100.00%
1 / 1
6
 matchPackageFilter
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 loadComposerPackages
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
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\Modules\About\Presentation\Api;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\Grid\Column;
12use App\Core\Grid\FilterType;
13use App\Core\Grid\GenericDataGrid;
14use App\Core\Grid\GridRequest;
15use App\Core\Grid\GridResult;
16use PDO;
17use Psr\Http\Message\ResponseFactoryInterface;
18use Psr\Http\Message\ResponseInterface;
19use Psr\Http\Message\ServerRequestInterface;
20use Twig\Environment as TwigEnvironment;
21
22/**
23 * About Licenses REST API Controller.
24 *
25 * Exposes /api/v1/about/licenses JSON endpoint reading Composer installed packages.
26 *
27 * @package App\Modules\About\Presentation\Api
28 */
29final readonly class AboutLicensesApiController
30{
31    /**
32     * AboutLicensesApiController constructor.
33     *
34     * @param ResponseFactoryInterface $responseFactory PSR-7 Response factory.
35     * @param PDO $pdo PDO database connection.
36     * @param TwigEnvironment $twig Twig template environment.
37     * @param string|null $installedJsonPath Optional explicit path to vendor/composer/installed.json.
38     */
39    public function __construct(
40        private ResponseFactoryInterface $responseFactory,
41        private PDO $pdo,
42        private TwigEnvironment $twig,
43        private ?string $installedJsonPath = null
44    ) {
45    }
46
47    /**
48     * Handles API request to fetch licenses list.
49     *
50     * @param ServerRequestInterface $request PSR-7 Server request.
51     * @return ResponseInterface PSR-7 JSON response.
52     */
53    public function index(ServerRequestInterface $request): ResponseInterface
54    {
55        $gridRequest = GridRequest::fromRequest($request, 'name');
56        $result = $this->fetchGridResult($request);
57
58        $response = $this->responseFactory->createResponse(200)
59            ->withHeader('Content-Type', 'application/json');
60
61        $payload = json_encode([
62            'status' => true,
63            'message' => 'Licenses fetched successfully',
64            'data' => [
65                'total_records' => $result->totalRecords,
66                'total_pages' => $result->totalPages,
67                'page' => $gridRequest->page,
68                'limit' => $gridRequest->limit,
69                'rows' => $result->rows,
70            ],
71        ], JSON_THROW_ON_ERROR);
72
73        $response->getBody()->write($payload);
74        return $response;
75    }
76
77    /**
78     * Creates configured GenericDataGrid engine instance.
79     *
80     * @return GenericDataGrid DataGrid instance.
81     */
82    public function createGrid(): GenericDataGrid
83    {
84        $columns = [
85            new Column(
86                key: 'id',
87                label: 'ID',
88                isSortable: true,
89                isFilterable: true
90            ),
91            new Column(
92                key: 'name',
93                label: 'Package',
94                isSortable: true,
95                isFilterable: true,
96                formatter: static function (mixed $val): string {
97                    return (string)($val ?? '');
98                }
99            ),
100            new Column(
101                key: 'version',
102                label: 'Version',
103                isSortable: true,
104                isFilterable: true,
105                formatter: function (mixed $val): string {
106                    return $val !== null && $val !== '' ? 'v' . (string)$val : '';
107                }
108            ),
109            new Column(
110                key: 'license',
111                label: 'License',
112                isSortable: true,
113                isFilterable: true,
114                formatter: function (mixed $val, array $row): string {
115                    $license = $val ?? $row['license'] ?? 'Proprietary';
116                    return is_array($license) ? implode(', ', $license) : (string)$license;
117                },
118                filterType: FilterType::SELECT,
119                filterOptions: [
120                    'MIT' => 'MIT',
121                    'BSD-3-Clause' => 'BSD-3-Clause',
122                    'Apache-2.0' => 'Apache-2.0',
123                    'Proprietary' => 'Proprietary',
124                ]
125            ),
126            new Column(
127                key: 'type',
128                label: 'Type',
129                isSortable: true,
130                isFilterable: true,
131                formatter: function (mixed $val): string {
132                    return (string)($val ?? 'library');
133                }
134            ),
135            new Column(
136                key: 'description',
137                label: 'Description',
138                isSortable: true,
139                isFilterable: true,
140                formatter: function (mixed $val): string {
141                    return (string)($val ?? '-');
142                }
143            ),
144        ];
145
146        return new GenericDataGrid(
147            pdo: $this->pdo,
148            twig: $this->twig,
149            tableName: 'a_core_settings_records',
150            columns: $columns,
151            defaultSort: 'name'
152        );
153    }
154
155    /**
156     * Executes Composer licenses parsing, filtering, sorting, and pagination.
157     *
158     * @param ServerRequestInterface $request PSR-7 Server request.
159     * @return GridResult Calculated GridResult object.
160     */
161    public function fetchGridResult(ServerRequestInterface $request): GridResult
162    {
163        $gridRequest = GridRequest::fromRequest($request, 'name');
164        $packages = $this->loadComposerPackages();
165
166        $filtered = array_filter($packages, function (array $pkg) use ($gridRequest): bool {
167            foreach ($gridRequest->filters as $col => $val) {
168                if ($val !== '' && !$this->matchPackageFilter($pkg, (string)$col, (string)$val)) {
169                    return false;
170                }
171            }
172            return true;
173        });
174
175        $sortColumn = $gridRequest->sortColumn ?? 'name';
176        $sortDirection = strtoupper($gridRequest->sortDirection) === 'ASC' ? 1 : -1;
177
178        usort($filtered, function (array $a, array $b) use ($sortColumn, $sortDirection): int {
179            $valA = (string)($a[$sortColumn] ?? '');
180            $valB = (string)($b[$sortColumn] ?? '');
181
182            if ($sortColumn === 'id') {
183        // @codeCoverageIgnoreStart
184                return ((int)$valA <=> (int)$valB) * $sortDirection;
185        // @codeCoverageIgnoreEnd
186        // @codeCoverageIgnoreStart
187        // @codeCoverageIgnoreEnd
188            }
189            return strnatcasecmp($valA, $valB) * $sortDirection;
190        });
191
192        $totalRecords = count($filtered);
193        $limit = max(1, $gridRequest->limit);
194        $totalPages = (int)ceil($totalRecords / $limit);
195        $page = max(1, min($gridRequest->page, max(1, $totalPages)));
196        $offset = ($page - 1) * $limit;
197
198        $pagedRows = array_slice($filtered, $offset, $limit);
199
200        return new GridResult(
201            rows: $pagedRows,
202            totalRecords: $totalRecords,
203            gridRequest: new GridRequest(
204                $page,
205                $limit,
206                $sortColumn,
207                $gridRequest->sortDirection,
208                $gridRequest->filters
209            ),
210            columns: $this->createGrid()->getColumns()
211        );
212    }
213
214    /**
215     * Checks if package matches filter condition.
216     *
217     * @param array<string, mixed> $pkg Package data array.
218     * @param string $col Filter column name.
219     * @param string $val Filter search value.
220     * @return bool True if package matches filter.
221     */
222    private function matchPackageFilter(array $pkg, string $col, string $val): bool
223    {
224        return \App\Modules\About\Application\Service\ComposerPackagesLoader::matchPackageFilter($pkg, $col, $val);
225    }
226
227    /**
228     * Parses installed.json and returns Composer packages.
229     *
230     * @return array<int, array<string, mixed>> List of parsed packages.
231     */
232    private function loadComposerPackages(): array
233    {
234        return \App\Modules\About\Application\Service\ComposerPackagesLoader::load($this->installedJsonPath);
235    }
236}