forked from fa/breadcrumb-the-shire
feat(api-docs): extract API docs viewer into standalone module
Move the Swagger UI viewer from core (pages/admin/api-docs/) into modules/api-docs/ as a self-contained module. Create module manifest with routes, permission, sidebar.admin_nav_item slot, asset group, and authorization policy. Remove API docs ability from core OperationsAuthorizationPolicy and UiCapabilityMap. Remove hardcoded sidebar nav item — now contributed via module slot. Add admin-automation group meta to sidebar template for module nav items. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
5
modules/api-docs/i18n/default_de.json
Normal file
5
modules/api-docs/i18n/default_de.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"API docs": "API-Dokumentation",
|
||||
"API documentation": "API-Dokumentation",
|
||||
"Can view API documentation": "Kann API-Dokumentation anzeigen"
|
||||
}
|
||||
5
modules/api-docs/i18n/default_en.json
Normal file
5
modules/api-docs/i18n/default_en.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"API docs": "API docs",
|
||||
"API documentation": "API documentation",
|
||||
"Can view API documentation": "Can view API documentation"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\ApiDocs;
|
||||
|
||||
use MintyPHP\Service\Access\AuthorizationDecision;
|
||||
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
|
||||
final class ApiDocsAuthorizationPolicy implements AuthorizationPolicyInterface
|
||||
{
|
||||
public const ABILITY_VIEW = 'api_docs.view';
|
||||
public const PERMISSION_KEY = 'api_docs.view';
|
||||
|
||||
public function __construct(
|
||||
private readonly PermissionService $permissionService
|
||||
) {
|
||||
}
|
||||
|
||||
public function supports(string $ability): bool
|
||||
{
|
||||
return $ability === self::ABILITY_VIEW;
|
||||
}
|
||||
|
||||
public function authorize(string $ability, array $context = []): AuthorizationDecision
|
||||
{
|
||||
$actorUserId = (int) ($context['actor_user_id'] ?? 0);
|
||||
if ($actorUserId <= 0) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
if (!$this->permissionService->userHas($actorUserId, self::PERMISSION_KEY)) {
|
||||
return AuthorizationDecision::deny(403, 'forbidden');
|
||||
}
|
||||
|
||||
return AuthorizationDecision::allow();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace MintyPHP\Module\ApiDocs;
|
||||
|
||||
use MintyPHP\App\AppContainer;
|
||||
use MintyPHP\App\Container\ContainerRegistrar;
|
||||
use MintyPHP\Service\Access\PermissionService;
|
||||
|
||||
final class ApiDocsContainerRegistrar implements ContainerRegistrar
|
||||
{
|
||||
public function register(AppContainer $container): void
|
||||
{
|
||||
$container->set(ApiDocsAuthorizationPolicy::class, static fn (AppContainer $c): ApiDocsAuthorizationPolicy => new ApiDocsAuthorizationPolicy(
|
||||
$c->get(PermissionService::class)
|
||||
));
|
||||
}
|
||||
}
|
||||
66
modules/api-docs/module.php
Normal file
66
modules/api-docs/module.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* API Docs module manifest.
|
||||
*
|
||||
* Provides a Swagger UI viewer for the project's OpenAPI specification.
|
||||
*/
|
||||
return [
|
||||
'id' => 'api-docs',
|
||||
'version' => '1.0.0',
|
||||
'enabled_by_default' => false,
|
||||
'load_order' => 20,
|
||||
'requires' => [],
|
||||
|
||||
'routes' => [
|
||||
['path' => 'admin/api-docs', 'target' => 'api-docs'],
|
||||
['path' => 'admin/api-docs/spec', 'target' => 'api-docs/spec'],
|
||||
],
|
||||
'public_paths' => [],
|
||||
'container_registrars' => [
|
||||
\MintyPHP\Module\ApiDocs\ApiDocsContainerRegistrar::class,
|
||||
],
|
||||
|
||||
'ui_slots' => [
|
||||
'sidebar.admin_nav_item' => [
|
||||
[
|
||||
'key' => 'api-docs',
|
||||
'group' => 'admin-automation',
|
||||
'label' => 'API docs',
|
||||
'path' => 'admin/api-docs',
|
||||
'permission' => 'api_docs.view',
|
||||
'order' => 300,
|
||||
],
|
||||
],
|
||||
],
|
||||
|
||||
'authorization_policies' => [
|
||||
\MintyPHP\Module\ApiDocs\ApiDocsAuthorizationPolicy::class,
|
||||
],
|
||||
|
||||
'search_resources' => [],
|
||||
|
||||
'asset_groups' => [
|
||||
'api-docs' => [
|
||||
'modules/api-docs/css/vendor-overrides/swagger-ui.css',
|
||||
],
|
||||
],
|
||||
|
||||
'scheduler_jobs' => [],
|
||||
'layout_context_providers' => [],
|
||||
'session_providers' => [],
|
||||
|
||||
'permissions' => [
|
||||
[
|
||||
'key' => 'api_docs.view',
|
||||
'description' => 'Can view API documentation',
|
||||
'active' => true,
|
||||
'is_system' => true,
|
||||
],
|
||||
],
|
||||
|
||||
'event_listeners' => [],
|
||||
'deactivation_handler' => null,
|
||||
'migrations_path' => null,
|
||||
'i18n_path' => 'i18n',
|
||||
];
|
||||
11
modules/api-docs/pages/api-docs/index().php
Normal file
11
modules/api-docs/pages/api-docs/index().php
Normal file
@@ -0,0 +1,11 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Buffer;
|
||||
use MintyPHP\Module\ApiDocs\ApiDocsAuthorizationPolicy;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(ApiDocsAuthorizationPolicy::ABILITY_VIEW);
|
||||
|
||||
Buffer::set('title', t('API docs'));
|
||||
Buffer::set('style_groups', json_encode(['api-docs']));
|
||||
15
modules/api-docs/pages/api-docs/index(default).phtml
Normal file
15
modules/api-docs/pages/api-docs/index(default).phtml
Normal file
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
$breadcrumbs = [
|
||||
['label' => t('Home'), 'path' => 'admin'],
|
||||
['label' => t('API docs')],
|
||||
];
|
||||
$specUrl = lurl('admin/api-docs/spec');
|
||||
require templatePath('partials/app-breadcrumb.phtml');
|
||||
?>
|
||||
|
||||
<div class="app-swagger-container">
|
||||
<div id="swagger-ui" data-spec-url="<?php e($specUrl); ?>"></div>
|
||||
</div>
|
||||
|
||||
<script src="<?php e(assetVersion('vendor/swagger-ui/swagger-ui-bundle.js')); ?>"></script>
|
||||
<script type="module" src="<?php e(assetVersion('modules/api-docs/js/pages/admin-api-docs-index.js')); ?>"></script>
|
||||
29
modules/api-docs/pages/api-docs/spec().php
Normal file
29
modules/api-docs/pages/api-docs/spec().php
Normal file
@@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
use MintyPHP\Module\ApiDocs\ApiDocsAuthorizationPolicy;
|
||||
use MintyPHP\Support\Guard;
|
||||
|
||||
define('MINTY_ALLOW_OUTPUT', true);
|
||||
|
||||
Guard::requireLogin();
|
||||
Guard::requireAbilityOrForbidden(ApiDocsAuthorizationPolicy::ABILITY_VIEW);
|
||||
|
||||
$specPath = defined('APP_ROOT') ? APP_ROOT . '/docs/openapi.yaml' : dirname(__DIR__, 4) . '/docs/openapi.yaml';
|
||||
|
||||
if (!is_file($specPath)) {
|
||||
http_response_code(404);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!is_readable($specPath)) {
|
||||
http_response_code(500);
|
||||
return;
|
||||
}
|
||||
|
||||
header('Content-Type: application/yaml; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: private, max-age=60');
|
||||
$bytes = @readfile($specPath);
|
||||
if ($bytes === false) {
|
||||
http_response_code(500);
|
||||
}
|
||||
176
modules/api-docs/web/css/vendor-overrides/swagger-ui.css
Normal file
176
modules/api-docs/web/css/vendor-overrides/swagger-ui.css
Normal file
@@ -0,0 +1,176 @@
|
||||
@import url("../../../../vendor/swagger-ui/swagger-ui.css") layer(vendor);
|
||||
|
||||
@layer vendor-overrides {
|
||||
.app-swagger-container {
|
||||
background: var(--app-background-color);
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
.swagger-ui {
|
||||
color: var(--app-color);
|
||||
font-family: var(--app-font-family);
|
||||
}
|
||||
|
||||
.swagger-ui .info .title,
|
||||
.swagger-ui .info h1,
|
||||
.swagger-ui .info h2,
|
||||
.swagger-ui .info h3,
|
||||
.swagger-ui h1,
|
||||
.swagger-ui h2,
|
||||
.swagger-ui h3,
|
||||
.swagger-ui h4 {
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
.swagger-ui .info a,
|
||||
.swagger-ui a.nostyle,
|
||||
.swagger-ui a.nostyle:visited {
|
||||
color: var(--app-primary);
|
||||
}
|
||||
|
||||
.swagger-ui .scheme-container {
|
||||
background: var(--app-card-background-color);
|
||||
box-shadow: none;
|
||||
border: var(--app-border-width) solid var(--app-form-element-border-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock {
|
||||
border-color: var(--app-form-element-border-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
background: var(--app-card-background-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary {
|
||||
border-color: var(--app-form-element-border-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock-tag {
|
||||
border-color: var(--app-form-element-border-color);
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
.swagger-ui .responses-inner h4,
|
||||
.swagger-ui .responses-inner h5,
|
||||
.swagger-ui .response-col_status,
|
||||
.swagger-ui .response-col_description__inner p,
|
||||
.swagger-ui .tab li,
|
||||
.swagger-ui .model-title {
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
.swagger-ui .parameter__name,
|
||||
.swagger-ui .parameter__type,
|
||||
.swagger-ui .parameter__deprecated,
|
||||
.swagger-ui .parameter__in {
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
.swagger-ui input[type="text"],
|
||||
.swagger-ui input[type="password"],
|
||||
.swagger-ui input[type="search"],
|
||||
.swagger-ui input[type="email"],
|
||||
.swagger-ui textarea,
|
||||
.swagger-ui select {
|
||||
background: var(--app-form-element-background-color);
|
||||
color: var(--app-form-element-color);
|
||||
border: var(--app-border-width) solid var(--app-form-element-border-color);
|
||||
border-radius: var(--app-border-radius);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.swagger-ui input[type="text"]:focus,
|
||||
.swagger-ui input[type="password"]:focus,
|
||||
.swagger-ui input[type="search"]:focus,
|
||||
.swagger-ui input[type="email"]:focus,
|
||||
.swagger-ui textarea:focus,
|
||||
.swagger-ui select:focus {
|
||||
border-color: var(--app-primary);
|
||||
box-shadow: 0 0 0 var(--app-outline-width) var(--app-primary-focus);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.swagger-ui .btn {
|
||||
border-radius: var(--app-border-radius);
|
||||
}
|
||||
|
||||
.swagger-ui .btn.authorize {
|
||||
color: var(--app-primary);
|
||||
border-color: var(--app-primary);
|
||||
}
|
||||
|
||||
.swagger-ui .btn.authorize svg {
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.swagger-ui .model-box,
|
||||
.swagger-ui .highlight-code,
|
||||
.swagger-ui .microlight,
|
||||
.swagger-ui .responses-table .response-col_description__inner {
|
||||
background: var(--app-card-background-color);
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
/* Reset all Pico CSS button styles that bleed into Swagger UI */
|
||||
.swagger-ui button {
|
||||
cursor: pointer;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
line-height: inherit;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
/* Reset CSS custom properties set by app base styles on all buttons */
|
||||
--app-background-color: initial;
|
||||
--app-border-color: initial;
|
||||
--app-color: initial;
|
||||
--app-box-shadow: initial;
|
||||
}
|
||||
|
||||
/* Restore sizing/appearance for Swagger UI action buttons (.btn) */
|
||||
.swagger-ui .btn {
|
||||
padding: 6px 23px;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
border: 2px solid transparent;
|
||||
border-radius: var(--app-border-radius);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-summary-description {
|
||||
color: var(--app-color);
|
||||
}
|
||||
|
||||
.swagger-ui .opblock .opblock-section-header {
|
||||
background: var(--app-accordion-border-color);
|
||||
}
|
||||
|
||||
.swagger-ui section {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.swagger-ui .servers > label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
pre.version {
|
||||
all: inherit;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.swagger-ui .info .title small {
|
||||
top: 0;
|
||||
}
|
||||
|
||||
select#servers {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
16
modules/api-docs/web/js/pages/admin-api-docs-index.js
Normal file
16
modules/api-docs/web/js/pages/admin-api-docs-index.js
Normal file
@@ -0,0 +1,16 @@
|
||||
const container = document.getElementById('swagger-ui');
|
||||
|
||||
if (container && window.SwaggerUIBundle) {
|
||||
window.SwaggerUIBundle({
|
||||
url: container.dataset.specUrl || '',
|
||||
dom_id: '#swagger-ui',
|
||||
deepLinking: false,
|
||||
displayRequestDuration: true,
|
||||
filter: true,
|
||||
docExpansion: 'list',
|
||||
supportedSubmitMethods: [],
|
||||
tryItOutEnabled: false,
|
||||
presets: [window.SwaggerUIBundle.presets.apis],
|
||||
layout: 'BaseLayout',
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user