Files
citrus-cms/app/Console/Commands/GenerateEndpointsDocs.php
T
2026-04-28 21:14:25 +03:00

451 lines
15 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Str;
class GenerateEndpointsDocs extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'docs:endpoints
{--append : Append to existing OpenAPI spec}
{--output= : Output file path}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Generate API documentation for admin-ajax endpoints and append to Scribe OpenAPI spec';
/**
* Execute the console command.
*/
public function handle()
{
$this->info('Scanning admin-ajax endpoints...');
$endpoints = $this->scanEndpoints();
$this->info("Found " . count($endpoints) . " endpoints");
if ($this->option('append')) {
$this->appendToOpenApiSpec($endpoints);
} else {
$this->generateStandaloneSpec($endpoints);
}
$this->info('✅ Endpoints documentation generated successfully!');
return Command::SUCCESS;
}
/**
* Scan admin-ajax directory for blade files
*/
protected function scanEndpoints(): array
{
$endpoints = [];
$path = resource_path('views/admin-ajax');
if (!File::isDirectory($path)) {
$this->error('admin-ajax directory not found');
return $endpoints;
}
// Scan files recursively
$this->scanDirectory($path, '', $endpoints);
// Sort alphabetically
usort($endpoints, fn($a, $b) => strcmp($a['name'], $b['name']));
return $endpoints;
}
/**
* Recursively scan directory for blade files
* SECURITY: Only includes endpoints marked with @api-readonly annotation
*/
protected function scanDirectory(string $basePath, string $prefix, array &$endpoints): void
{
$items = File::files($basePath);
foreach ($items as $file) {
$filename = $file->getFilename();
// Only process .blade.php files
if (!Str::endsWith($filename, '.blade.php')) {
continue;
}
// Extract endpoint name
$name = str_replace('.blade.php', '', $filename);
$fullName = $prefix ? "{$prefix}/{$name}" : $name;
// Analyze file for response type hints
$content = File::get($file->getPathname());
// SECURITY: Only include endpoints marked as @api-readonly
if (!Str::contains($content, '@api-readonly')) {
continue;
}
$responseType = $this->detectResponseType($content);
$description = $this->extractDescription($content, $name);
$category = $this->categorizeEndpoint($fullName);
$endpoints[] = [
'name' => $fullName,
'path' => str_replace(base_path(), '', $file->getPathname()),
'response_type' => $responseType,
'description' => $description,
'category' => $category,
];
}
// Scan subdirectories
$directories = File::directories($basePath);
foreach ($directories as $dir) {
$dirName = basename($dir);
$newPrefix = $prefix ? "{$prefix}/{$dirName}" : $dirName;
$this->scanDirectory($dir, $newPrefix, $endpoints);
}
}
/**
* Detect response type from blade content
*/
protected function detectResponseType(string $content): string
{
// Check for JSON indicators
if (
Str::contains($content, 'json_encode') ||
Str::contains($content, 'json_encode_tr') ||
Str::contains($content, 'response()->json') ||
Str::contains($content, "'status' =>")
) {
return 'json';
}
// Check for HTML indicators
if (
Str::contains($content, '<html') ||
Str::contains($content, '<div') ||
Str::contains($content, '@extends') ||
Str::contains($content, '@include')
) {
return 'html';
}
return 'auto';
}
/**
* Extract description from blade file comments
*/
protected function extractDescription(string $content, string $name): string
{
// Try to find PHPDoc style comment at the start
if (preg_match('/^<\?php\s*\/\*\*?\s*\n?\s*\*?\s*(.+?)(?:\n|\*\/)/s', $content, $matches)) {
return trim($matches[1]);
}
// Try to find HTML comment
if (preg_match('/<!--\s*(.+?)\s*-->/s', $content, $matches)) {
return trim($matches[1]);
}
// Generate description from name
return 'Execute ' . Str::title(str_replace(['-', '_'], ' ', $name)) . ' endpoint';
}
/**
* Categorize endpoint based on name
*/
protected function categorizeEndpoint(string $name): string
{
$prefixes = [
'ndt-' => 'NDT Operations',
'welder-' => 'Welder Management',
'weld' => 'Welding',
'register-' => 'Register Creator',
'test-package' => 'Test Packages',
'tp-' => 'Test Packages',
'document-' => 'Document Management',
'report-' => 'Reports',
'summary' => 'Summaries',
'excel-' => 'Excel Operations',
'pdf' => 'PDF Operations',
'user' => 'User Management',
'permission' => 'Permissions',
'content-' => 'Content Management',
'spool-' => 'Spool Operations',
'material-' => 'Materials',
'paint-' => 'Paint Operations',
'repair-' => 'Repair Operations',
'rfi-' => 'RFI Operations',
'punch-' => 'Punch Lists',
'mto' => 'MTO Operations',
'ai-' => 'AI Features',
'cron-' => 'Cron Jobs',
'si-' => 'System Info',
];
$baseName = Str::contains($name, '/') ? Str::afterLast($name, '/') : $name;
foreach ($prefixes as $prefix => $category) {
if (Str::startsWith($baseName, $prefix)) {
return $category;
}
}
return 'General';
}
/**
* Append endpoints to existing OpenAPI spec
*/
protected function appendToOpenApiSpec(array $endpoints): void
{
$specPath = storage_path('app/scribe/openapi.yaml');
if (!File::exists($specPath)) {
$this->warn('OpenAPI spec not found at ' . $specPath);
$this->info('Run "php artisan scribe:generate" first, then run this command with --append');
return;
}
// Load existing spec
$spec = yaml_parse_file($specPath);
if (!$spec) {
$this->error('Failed to parse OpenAPI spec');
return;
}
// Add endpoint paths
foreach ($endpoints as $endpoint) {
$pathKey = '/api/endpoints/' . $endpoint['name'];
$spec['paths'][$pathKey] = $this->generatePathSpec($endpoint);
}
// Add Endpoints tag if not exists
$hasEndpointsTag = false;
foreach ($spec['tags'] ?? [] as $tag) {
if ($tag['name'] === 'Endpoints') {
$hasEndpointsTag = true;
break;
}
}
if (!$hasEndpointsTag) {
$spec['tags'][] = [
'name' => 'Endpoints',
'description' => 'Admin-ajax endpoints served via API'
];
}
// Save updated spec
$yaml = yaml_emit($spec, YAML_UTF8_ENCODING);
File::put($specPath, $yaml);
$this->info('Updated OpenAPI spec at ' . $specPath);
}
/**
* Generate standalone endpoints spec file
*/
protected function generateStandaloneSpec(array $endpoints): void
{
$outputPath = $this->option('output') ?: storage_path('app/scribe/endpoints.json');
// Group endpoints by category
$grouped = [];
foreach ($endpoints as $endpoint) {
$category = $endpoint['category'];
if (!isset($grouped[$category])) {
$grouped[$category] = [];
}
$grouped[$category][] = $endpoint;
}
$output = [
'generated_at' => now()->toIso8601String(),
'total_endpoints' => count($endpoints),
'categories' => array_keys($grouped),
'endpoints' => $endpoints,
'grouped' => $grouped,
];
File::ensureDirectoryExists(dirname($outputPath));
File::put($outputPath, json_encode($output, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$this->info('Generated endpoints spec at ' . $outputPath);
// Generate Scribe response file for dynamic documentation
$this->generateScribeResponseFile($endpoints);
// Also generate markdown documentation
$this->generateMarkdownDocs($grouped);
}
/**
* Generate response file for Scribe documentation
*/
protected function generateScribeResponseFile(array $endpoints): void
{
$responsePath = storage_path('app/scribe/endpoints-response.json');
// Format endpoints with url and methods for API response
$formattedEndpoints = array_map(function ($endpoint) {
return [
'name' => $endpoint['name'],
'url' => '/api/endpoints/' . $endpoint['name'],
'methods' => ['GET', 'POST'],
'response_type' => $endpoint['response_type'],
'description' => $endpoint['description'],
'category' => $endpoint['category'],
];
}, $endpoints);
$response = [
'status' => 'success',
'data' => [
'endpoints' => $formattedEndpoints,
'total' => count($formattedEndpoints)
]
];
File::ensureDirectoryExists(dirname($responsePath));
File::put($responsePath, json_encode($response, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$this->info('Generated Scribe response file at ' . $responsePath);
}
/**
* Generate OpenAPI path specification for an endpoint
*/
protected function generatePathSpec(array $endpoint): array
{
$responseSchema = $endpoint['response_type'] === 'json'
? ['type' => 'object', 'properties' => ['status' => ['type' => 'string'], 'data' => ['type' => 'object']]]
: ['type' => 'object', 'properties' => ['status' => ['type' => 'string'], 'html' => ['type' => 'string']]];
return [
'get' => [
'tags' => ['Endpoints'],
'summary' => $endpoint['description'],
'description' => "Endpoint: {$endpoint['name']}\nResponse Type: {$endpoint['response_type']}\nCategory: {$endpoint['category']}",
'operationId' => 'endpoint_' . Str::slug($endpoint['name'], '_'),
'security' => [['bearerAuth' => []]],
'responses' => [
'200' => [
'description' => 'Successful response',
'content' => [
'application/json' => [
'schema' => $responseSchema
]
]
],
'401' => [
'description' => 'Unauthorized'
],
'404' => [
'description' => 'Endpoint not found'
]
]
],
'post' => [
'tags' => ['Endpoints'],
'summary' => $endpoint['description'],
'description' => "Endpoint: {$endpoint['name']}\nResponse Type: {$endpoint['response_type']}\nCategory: {$endpoint['category']}",
'operationId' => 'endpoint_' . Str::slug($endpoint['name'], '_') . '_post',
'security' => [['bearerAuth' => []]],
'requestBody' => [
'content' => [
'application/json' => [
'schema' => [
'type' => 'object',
'additionalProperties' => true
]
]
]
],
'responses' => [
'200' => [
'description' => 'Successful response',
'content' => [
'application/json' => [
'schema' => $responseSchema
]
]
],
'401' => [
'description' => 'Unauthorized'
],
'404' => [
'description' => 'Endpoint not found'
]
]
]
];
}
/**
* Generate markdown documentation
*/
protected function generateMarkdownDocs(array $grouped): void
{
$markdown = "# Available Endpoints\n\n";
$markdown .= "This document lists all available admin-ajax endpoints accessible via the API.\n\n";
$markdown .= "**Base URL:** `/api/endpoints/{endpoint}`\n\n";
$markdown .= "**Authentication:** Bearer Token required\n\n";
$markdown .= "---\n\n";
foreach ($grouped as $category => $endpoints) {
$markdown .= "## {$category}\n\n";
$markdown .= "| Endpoint | Description | Response Type |\n";
$markdown .= "|----------|-------------|---------------|\n";
foreach ($endpoints as $endpoint) {
$markdown .= "| `{$endpoint['name']}` | {$endpoint['description']} | {$endpoint['response_type']} |\n";
}
$markdown .= "\n";
}
$markdown .= "---\n\n";
$markdown .= "## Usage Examples\n\n";
$markdown .= "### cURL\n\n";
$markdown .= "```bash\n";
$markdown .= "curl -X GET \\\n";
$markdown .= " '{base_url}/api/endpoints/users' \\\n";
$markdown .= " -H 'Authorization: Bearer {your_token}' \\\n";
$markdown .= " -H 'Accept: application/json'\n";
$markdown .= "```\n\n";
$markdown .= "### JavaScript\n\n";
$markdown .= "```javascript\n";
$markdown .= "const response = await fetch('/api/endpoints/users', {\n";
$markdown .= " headers: {\n";
$markdown .= " 'Authorization': 'Bearer ' + token,\n";
$markdown .= " 'Accept': 'application/json'\n";
$markdown .= " }\n";
$markdown .= "});\n";
$markdown .= "const data = await response.json();\n";
$markdown .= "```\n";
$docsPath = resource_path('views/guide/api-endpoints.md');
File::ensureDirectoryExists(dirname($docsPath));
File::put($docsPath, $markdown);
$this->info('Generated markdown docs at ' . $docsPath);
}
}