Enable Discovery debug logging for production troubleshooting

- Add DISCOVERY_LOG_LEVEL=debug
- Add DISCOVERY_SHOW_PROGRESS=true
- Temporary changes for debugging InitializerProcessor fixes on production
This commit is contained in:
2025-08-11 20:13:26 +02:00
parent 59fd3dd3b1
commit 55a330b223
3683 changed files with 2956207 additions and 16948 deletions

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi\Attributes;
use Attribute;
/**
* Marks a controller method as an API endpoint for OpenAPI documentation
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class ApiEndpoint
{
public function __construct(
public string $summary,
public string $description = '',
public array $tags = [],
public bool $deprecated = false,
public ?string $operationId = null,
) {
}
}

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi\Attributes;
use Attribute;
/**
* Describes a parameter for an API endpoint
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class ApiParameter
{
public function __construct(
public string $name,
public string $in = 'query', // query, path, header, cookie
public string $description = '',
public bool $required = false,
public string $type = 'string',
public ?string $format = null,
public mixed $example = null,
public array $enum = [],
) {
}
}

View File

@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi\Attributes;
use Attribute;
/**
* Describes the request body for an API endpoint
*/
#[Attribute(Attribute::TARGET_METHOD)]
final readonly class ApiRequestBody
{
public function __construct(
public string $description,
public bool $required = true,
public string $contentType = 'application/json',
public ?string $schemaRef = null,
public ?array $example = null,
) {
}
}

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi\Attributes;
use Attribute;
/**
* Describes a response for an API endpoint
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::IS_REPEATABLE)]
final readonly class ApiResponse
{
public function __construct(
public int $statusCode,
public string $description,
public ?string $contentType = 'application/json',
public ?string $schemaRef = null,
public ?array $example = null,
public array $headers = [],
) {
}
}

View File

@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi\Attributes;
use Attribute;
/**
* Describes security requirements for an API endpoint
*/
#[Attribute(Attribute::TARGET_METHOD | Attribute::TARGET_CLASS | Attribute::IS_REPEATABLE)]
final readonly class ApiSecurity
{
public function __construct(
public string $scheme,
public array $scopes = [],
) {
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\OpenApi\MarkdownGenerator;
use App\Framework\OpenApi\OpenApiGenerator;
use App\Framework\OpenApi\OpenApiInfo;
/**
* Console command to generate Markdown documentation
*/
final readonly class GenerateMarkdownDocsCommand
{
public function __construct(
private OpenApiGenerator $generator,
private MarkdownGenerator $markdownGenerator,
) {
}
#[ConsoleCommand('docs:markdown', 'Generate Markdown documentation from OpenAPI specification')]
public function __invoke(ConsoleInput $input, ConsoleOutput $output): void
{
$output->writeLine('Generating Markdown documentation...');
$info = new OpenApiInfo(
title: 'Michael Schiemer API',
version: '1.0.0',
description: 'API documentation for Michael Schiemer\'s custom PHP framework',
);
$spec = $this->generator->generate($info);
$markdown = $this->markdownGenerator->generate($spec);
$outputFile = $input->getOption('output') ?? 'docs/api.md';
$format = $input->getOption('format') ?? 'markdown';
if (! is_dir(dirname($outputFile))) {
mkdir(dirname($outputFile), 0755, true);
}
if ($format === 'html') {
$renderer = new \App\Framework\Markdown\MarkdownRenderer(
new \App\Framework\Markdown\MarkdownConverter()
);
$html = $renderer->render($markdown, 'api', [
'title' => 'API Documentation',
'syntaxHighlighting' => true,
]);
$outputFile = str_replace('.md', '.html', $outputFile);
file_put_contents($outputFile, $html);
} else {
file_put_contents($outputFile, $markdown);
}
$output->writeSuccess("Documentation generated: {$outputFile}");
}
}

View File

@@ -0,0 +1,72 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi\Commands;
use App\Framework\Console\ConsoleCommand;
use App\Framework\Console\ConsoleInput;
use App\Framework\Console\ConsoleOutput;
use App\Framework\OpenApi\OpenApiContact;
use App\Framework\OpenApi\OpenApiGenerator;
use App\Framework\OpenApi\OpenApiInfo;
use App\Framework\OpenApi\OpenApiLicense;
/**
* Console command to generate OpenAPI specification
*/
final readonly class GenerateOpenApiCommand
{
public function __construct(
private OpenApiGenerator $generator,
) {
}
#[ConsoleCommand('openapi:generate', 'Generate OpenAPI specification from controller attributes')]
public function __invoke(ConsoleInput $input, ConsoleOutput $output): void
{
$output->writeLine('Generating OpenAPI specification...');
$info = new OpenApiInfo(
title: 'Michael Schiemer API',
version: '1.0.0',
description: 'API documentation for Michael Schiemer\'s custom PHP framework',
contact: new OpenApiContact(
name: 'Michael Schiemer',
email: 'contact@michaelschiemer.dev',
),
license: new OpenApiLicense(
name: 'MIT',
url: 'https://opensource.org/licenses/MIT',
),
);
$servers = [
[
'url' => 'http://localhost',
'description' => 'Local development server (HTTP)',
],
[
'url' => 'https://localhost',
'description' => 'Local development server (HTTPS)',
],
[
'url' => 'https://api.michaelschiemer.dev',
'description' => 'Production server',
],
];
$spec = $this->generator->generate($info, $servers);
$outputFile = $input->getOption('output') ?? 'public/openapi.json';
if (! is_dir(dirname($outputFile))) {
mkdir(dirname($outputFile), 0755, true);
}
file_put_contents($outputFile, $spec->toJson());
$output->writeSuccess("OpenAPI specification generated: {$outputFile}");
$output->writeLine("View at: https://editor.swagger.io/?url=" . urlencode("https://localhost/{$outputFile}"));
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi;
/**
* Generates Markdown documentation from OpenAPI specification
*/
final readonly class MarkdownGenerator
{
public function generate(OpenApiSpec $spec): string
{
$markdown = "# {$spec->info->title}\n\n";
$markdown .= "{$spec->info->description}\n\n";
$markdown .= "**Version:** {$spec->info->version}\n\n";
if (! empty($spec->servers)) {
$markdown .= "## Servers\n\n";
foreach ($spec->servers as $server) {
$markdown .= "- **{$server['description']}**: `{$server['url']}`\n";
}
$markdown .= "\n";
}
$markdown .= "## Endpoints\n\n";
foreach ($spec->paths as $path => $pathItem) {
foreach ($pathItem as $method => $operation) {
$markdown .= "### " . strtoupper($method) . " " . $path . "\n\n";
$markdown .= $operation['summary'] . "\n\n";
if (! empty($operation['description'])) {
$markdown .= $operation['description'] . "\n\n";
}
if (! empty($operation['parameters'])) {
$markdown .= "**Parameters:**\n\n";
foreach ($operation['parameters'] as $param) {
$required = $param['required'] ? '**required**' : 'optional';
$markdown .= "- `{$param['name']}` ({$param['in']}) - {$param['description']} ({$required})\n";
}
$markdown .= "\n";
}
if (! empty($operation['responses'])) {
$markdown .= "**Responses:**\n\n";
foreach ($operation['responses'] as $code => $response) {
$markdown .= "- `{$code}` - {$response['description']}\n";
}
$markdown .= "\n";
}
$markdown .= "---\n\n";
}
}
return $markdown;
}
}

View File

@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi;
final readonly class OpenApiContact
{
public function __construct(
public ?string $name = null,
public ?string $url = null,
public ?string $email = null,
) {
}
/**
* @return array<string, string>
*/
public function toArray(): array
{
$contact = [];
if ($this->name !== null) {
$contact['name'] = $this->name;
}
if ($this->url !== null) {
$contact['url'] = $this->url;
}
if ($this->email !== null) {
$contact['email'] = $this->email;
}
return $contact;
}
}

View File

@@ -0,0 +1,277 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi;
use App\Framework\Core\ValueObjects\ClassName;
use App\Framework\OpenApi\Attributes\ApiEndpoint;
use App\Framework\OpenApi\Attributes\ApiParameter;
use App\Framework\OpenApi\Attributes\ApiRequestBody;
use App\Framework\OpenApi\Attributes\ApiResponse;
use App\Framework\OpenApi\Attributes\ApiSecurity;
use App\Framework\Reflection\ReflectionProvider;
use App\Framework\Router\CompiledRoutes;
use ReflectionMethod;
/**
* Generates OpenAPI specification from controller attributes
*/
final readonly class OpenApiGenerator
{
public function __construct(
private CompiledRoutes $compiledRoutes,
private ReflectionProvider $reflectionProvider,
) {
}
public function generate(OpenApiInfo $info, array $servers = []): OpenApiSpec
{
$paths = [];
$tags = [];
$components = [
'securitySchemes' => [
'bearerAuth' => [
'type' => 'http',
'scheme' => 'bearer',
'bearerFormat' => 'JWT',
],
'apiKey' => [
'type' => 'apiKey',
'in' => 'header',
'name' => 'X-API-Key',
],
],
];
$staticRoutes = $this->compiledRoutes->getStaticRoutes();
foreach ($staticRoutes as $method => $methodRoutes) {
foreach ($methodRoutes as $path => $route) {
$className = ClassName::create($route->controller);
$reflectionMethod = $this->reflectionProvider->getNativeMethod($className, $route->action);
$apiEndpoint = $this->getApiEndpointAttribute($reflectionMethod);
if ($apiEndpoint === null) {
continue; // Skip non-API routes
}
$pathItem = $this->generatePathItem($route, $reflectionMethod, $apiEndpoint);
// Group paths by path pattern
$pathPattern = $route->path;
if (! isset($paths[$pathPattern])) {
$paths[$pathPattern] = [];
}
$paths[$pathPattern][strtolower($method)] = $pathItem;
// Collect tags
foreach ($apiEndpoint->tags as $tag) {
if (! in_array($tag, array_column($tags, 'name'), true)) {
$tags[] = ['name' => $tag];
}
}
}
}
return new OpenApiSpec(
info: $info,
servers: $servers,
paths: $paths,
components: $components,
tags: $tags,
);
}
private function getApiEndpointAttribute(ReflectionMethod $method): ?ApiEndpoint
{
$attributes = $method->getAttributes(ApiEndpoint::class);
return ! empty($attributes) ? $attributes[0]->newInstance() : null;
}
private function generatePathItem(object $route, ReflectionMethod $method, ApiEndpoint $apiEndpoint): array
{
$operation = [
'summary' => $apiEndpoint->summary,
'operationId' => $apiEndpoint->operationId ?? $method->getDeclaringClass()->getShortName() . '_' . $method->getName(),
];
if ($apiEndpoint->description !== '') {
$operation['description'] = $apiEndpoint->description;
}
if (! empty($apiEndpoint->tags)) {
$operation['tags'] = $apiEndpoint->tags;
}
if ($apiEndpoint->deprecated) {
$operation['deprecated'] = true;
}
// Parameters
$parameters = $this->extractParameters($method);
if (! empty($parameters)) {
$operation['parameters'] = $parameters;
}
// Request body
$requestBody = $this->extractRequestBody($method);
if ($requestBody !== null) {
$operation['requestBody'] = $requestBody;
}
// Responses
$responses = $this->extractResponses($method);
$operation['responses'] = $responses;
// Security
$security = $this->extractSecurity($method);
if (! empty($security)) {
$operation['security'] = $security;
}
return $operation;
}
private function extractParameters(ReflectionMethod $method): array
{
$parameters = [];
foreach ($method->getAttributes(ApiParameter::class) as $attribute) {
$param = $attribute->newInstance();
$parameter = [
'name' => $param->name,
'in' => $param->in,
'required' => $param->required,
'schema' => [
'type' => $param->type,
],
];
if ($param->description !== '') {
$parameter['description'] = $param->description;
}
if ($param->format !== null) {
$parameter['schema']['format'] = $param->format;
}
if ($param->example !== null) {
$parameter['example'] = $param->example;
}
if (! empty($param->enum)) {
$parameter['schema']['enum'] = $param->enum;
}
$parameters[] = $parameter;
}
return $parameters;
}
private function extractRequestBody(ReflectionMethod $method): ?array
{
$attributes = $method->getAttributes(ApiRequestBody::class);
if (empty($attributes)) {
return null;
}
$requestBody = $attributes[0]->newInstance();
$body = [
'required' => $requestBody->required,
'content' => [
$requestBody->contentType => [],
],
];
if ($requestBody->description !== '') {
$body['description'] = $requestBody->description;
}
if ($requestBody->schemaRef !== null) {
$body['content'][$requestBody->contentType]['schema'] = [
'$ref' => '#/components/schemas/' . $requestBody->schemaRef,
];
}
if ($requestBody->example !== null) {
$body['content'][$requestBody->contentType]['example'] = $requestBody->example;
}
return $body;
}
private function extractResponses(ReflectionMethod $method): array
{
$responses = [];
foreach ($method->getAttributes(ApiResponse::class) as $attribute) {
$response = $attribute->newInstance();
$responseData = [
'description' => $response->description,
];
if ($response->contentType !== null) {
$content = [];
if ($response->schemaRef !== null) {
$content['schema'] = [
'$ref' => '#/components/schemas/' . $response->schemaRef,
];
}
if ($response->example !== null) {
$content['example'] = $response->example;
}
if (! empty($content)) {
$responseData['content'] = [
$response->contentType => $content,
];
}
}
if (! empty($response->headers)) {
$responseData['headers'] = $response->headers;
}
$responses[(string) $response->statusCode] = $responseData;
}
// Ensure at least one response exists
if (empty($responses)) {
$responses['200'] = [
'description' => 'Successful response',
];
}
return $responses;
}
private function extractSecurity(ReflectionMethod $method): array
{
$security = [];
// Check method-level security
foreach ($method->getAttributes(ApiSecurity::class) as $attribute) {
$sec = $attribute->newInstance();
$security[] = [$sec->scheme => $sec->scopes];
}
// Check class-level security if no method-level security
if (empty($security)) {
foreach ($method->getDeclaringClass()->getAttributes(ApiSecurity::class) as $attribute) {
$sec = $attribute->newInstance();
$security[] = [$sec->scheme => $sec->scopes];
}
}
return $security;
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi;
/**
* Represents the info object in OpenAPI specification
*/
final readonly class OpenApiInfo
{
public function __construct(
public string $title,
public string $version,
public string $description = '',
public ?string $termsOfService = null,
public ?OpenApiContact $contact = null,
public ?OpenApiLicense $license = null,
) {
}
public function toArray(): array
{
$info = [
'title' => $this->title,
'version' => $this->version,
];
if ($this->description !== '') {
$info['description'] = $this->description;
}
if ($this->termsOfService !== null) {
$info['termsOfService'] = $this->termsOfService;
}
if ($this->contact !== null) {
$info['contact'] = $this->contact->toArray();
}
if ($this->license !== null) {
$info['license'] = $this->license->toArray();
}
return $info;
}
}

View File

@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi;
final readonly class OpenApiLicense
{
public function __construct(
public string $name,
public ?string $url = null,
) {
}
public function toArray(): array
{
$license = ['name' => $this->name];
if ($this->url !== null) {
$license['url'] = $this->url;
}
return $license;
}
}

View File

@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi;
/**
* Service for programmatic access to OpenAPI specification
*/
final readonly class OpenApiService
{
public function __construct(
private OpenApiGenerator $generator,
) {
}
public function getSpecification(): OpenApiSpec
{
$info = new OpenApiInfo(
title: 'API',
version: '1.0.0',
);
return $this->generator->generate($info);
}
public function getEndpoints(): array
{
$spec = $this->getSpecification();
$endpoints = [];
foreach ($spec->paths as $path => $pathItem) {
foreach ($pathItem as $method => $operation) {
$endpoints[] = [
'method' => strtoupper($method),
'path' => $path,
'summary' => $operation['summary'] ?? '',
'tags' => $operation['tags'] ?? [],
];
}
}
return $endpoints;
}
public function getEndpointsByTag(string $tag): array
{
return array_filter(
$this->getEndpoints(),
fn ($endpoint) => in_array($tag, $endpoint['tags'])
);
}
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
namespace App\Framework\OpenApi;
/**
* Represents a complete OpenAPI specification
*/
final readonly class OpenApiSpec
{
public function __construct(
public OpenApiInfo $info,
public string $openapi = '3.0.3',
public array $servers = [],
public array $paths = [],
public array $components = [],
public array $security = [],
public array $tags = [],
) {
}
public function toArray(): array
{
$spec = [
'openapi' => $this->openapi,
'info' => $this->info->toArray(),
];
if (! empty($this->servers)) {
$spec['servers'] = $this->servers;
}
if (! empty($this->paths)) {
$spec['paths'] = $this->paths;
}
if (! empty($this->components)) {
$spec['components'] = $this->components;
}
if (! empty($this->security)) {
$spec['security'] = $this->security;
}
if (! empty($this->tags)) {
$spec['tags'] = $this->tags;
}
return $spec;
}
public function toJson(): string
{
return json_encode($this->toArray(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
}
}