Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ A framework-agnostic PHP Swagger/OpenAPI generator that uses static analysis (AS

- **AST-based Static Analysis**: No need to run your application.
- **Modern PHP Support**: Handles namespaces, use aliases, and complex types.
- **Global API Metadata Discovery**: Automatically extracts `@title`, `@version`, `@description`, `@contact.*`, `@license.*`, and `@host` from any file.
- **Global API Metadata Discovery**: Automatically extracts `@title`, `@version`, `@description`, `@contact.*`, `@license.*`, `@host`, and `@server` from any file.
- **Security & Authentication**: Define global security schemes (ApiKey, JWT) and apply them to endpoints or globally.
- **Comprehensive Schema Validation**: Support for `minimum`, `maximum`, `minLength`, `maxLength`, `pattern`, `format`, and `example` directly in PHPDoc.
- **Auto-inference**: Automatically resolve route parameters and request bodies from method signatures.
Expand Down Expand Up @@ -76,12 +76,15 @@ You can define your API information in a top-level PHPDoc block in any of your s
* @license.name MIT
* @license.url https://opensource.org/licenses/MIT
* @host https://api.example.com
* @server https://api.production.com Production Server
* @server https://api.staging.com Staging Server
*
* @tag.name Auth Authentication endpoints
* @tag.name Users User management endpoints
*/
```

- **Multiple Servers**: You can define multiple servers using `@server [URL] [Description]`. If `@server` tags are defined, they will take precedence over `@host`.
- **Global Tag Ordering**: Explicitly define tags using `@tag.name [name] [description]` at the global level. The generated OpenAPI spec will preserve the order and descriptions of these tags. Any other tags found on endpoints that are not declared at the global level will be sorted alphabetically and appended to the end of the list.

### Controller-level Metadata & Inheritance
Expand Down Expand Up @@ -404,6 +407,7 @@ When both PHPDoc and PHP 8 Attributes are present:
- `@license.name [TEXT]`
- `@license.url [TEXT]`
- `@host [URL]`
- `@server [URL] [Description]`
- **Security**:
- `@securityDefinitions.apikey [NAME] [IN: header|query|cookie] [KEY_NAME]`
- `@securityDefinitions.jwt [NAME]`
Expand Down
10 changes: 9 additions & 1 deletion src/Bridges/Laravel/Commands/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,17 @@ public function handle(): int
if (!empty($config['description'])) {
$core->setDescription($config['description']);
}
if (!empty($config['host'])) {
if (!empty($config['servers'])) {
$core->setServers($config['servers']);
} elseif (!empty($config['host'])) {
$core->setServers([['url' => $config['host']]]);
}
if (!empty($config['contact']) && is_array($config['contact'])) {
$core->setContact($config['contact']);
}
if (!empty($config['license']) && is_array($config['license'])) {
$core->setLicense($config['license']);
}

// Apply Cache configuration
if (!empty($config['cache'])) {
Expand Down
15 changes: 15 additions & 0 deletions src/Bridges/Laravel/config/phpswag.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,21 @@
'version' => '1.0.0',
'description' => 'Laravel API Documentation generated by phpswag',
'host' => env('APP_URL', 'http://localhost'),
'servers' => [
// [
// 'url' => env('APP_URL', 'http://localhost'),
// 'description' => 'Local Environment'
// ]
],
'contact' => [
// 'name' => 'Support Team',
// 'email' => 'support@example.com',
// 'url' => 'https://example.com/support',
],
'license' => [
// 'name' => 'MIT',
// 'url' => 'https://opensource.org/licenses/MIT',
],

/*
|--------------------------------------------------------------------------
Expand Down
22 changes: 21 additions & 1 deletion src/Bridges/Symfony/Command/GenerateCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,31 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$description = $this->parameterBag->get('phpswag.description');
$core->setDescription($description);
}
if ($this->parameterBag->has('phpswag.host')) {
if ($this->parameterBag->has('phpswag.servers')) {
$servers = $this->parameterBag->get('phpswag.servers');
if (is_array($servers) && !empty($servers)) {
/** @var array<int, array<string, mixed>> $servers */
$core->setServers($servers);
}
} elseif ($this->parameterBag->has('phpswag.host')) {
/** @var string $host */
$host = $this->parameterBag->get('phpswag.host');
$core->setServers([['url' => $host]]);
}
if ($this->parameterBag->has('phpswag.contact')) {
$contact = $this->parameterBag->get('phpswag.contact');
if (is_array($contact) && !empty($contact)) {
/** @var array<string, mixed> $contact */
$core->setContact($contact);
}
}
if ($this->parameterBag->has('phpswag.license')) {
$license = $this->parameterBag->get('phpswag.license');
if (is_array($license) && !empty($license)) {
/** @var array<string, mixed> $license */
$core->setLicense($license);
}
}

// Set cache if enabled
if ($this->parameterBag->get('phpswag.cache')) {
Expand Down
21 changes: 21 additions & 0 deletions src/Bridges/Symfony/DependencyInjection/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,27 @@ public function getConfigTreeBuilder(): TreeBuilder
->scalarNode('host')
->defaultValue('http://localhost')
->end()
->arrayNode('servers')
->prototype('array')
->children()
->scalarNode('url')->isRequired()->cannotBeEmpty()->end()
->scalarNode('description')->end()
->end()
->end()
->end()
->arrayNode('contact')
->children()
->scalarNode('name')->end()
->scalarNode('email')->end()
->scalarNode('url')->end()
->end()
->end()
->arrayNode('license')
->children()
->scalarNode('name')->end()
->scalarNode('url')->end()
->end()
->end()
->booleanNode('cache')
->defaultFalse()
->end()
Expand Down
3 changes: 3 additions & 0 deletions src/Bridges/Symfony/DependencyInjection/PhpSwagExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ public function load(array $configs, ContainerBuilder $container): void
$container->setParameter('phpswag.version', $config['version']);
$container->setParameter('phpswag.description', $config['description']);
$container->setParameter('phpswag.host', $config['host']);
$container->setParameter('phpswag.servers', $config['servers'] ?? []);
$container->setParameter('phpswag.contact', $config['contact'] ?? []);
$container->setParameter('phpswag.license', $config['license'] ?? []);
$container->setParameter('phpswag.cache', $config['cache']);
$container->setParameter('phpswag.cache_file', $config['cache_file']);
}
Expand Down
59 changes: 37 additions & 22 deletions src/Core.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ class Core
private array $globalSecurity = [];
/** @var array<string, array{name: string, description?: string}> */
private array $globalTags = [];
/** @var array<int, array<string, mixed>> */
private array $globalServers = [];

public function __construct(
?Scanner $scanner = null,
Expand Down Expand Up @@ -336,6 +338,7 @@ private function discoverFile(string $filePath): void
$this->securitySchemes = array_merge($this->securitySchemes, $discovered['securitySchemes']);
$this->globalSecurity = array_merge($this->globalSecurity, $discovered['globalSecurity']);
$this->globalTags = array_merge($this->globalTags, $discovered['globalTags']);
$this->globalServers = array_merge($this->globalServers, $discovered['globalServers']);

$nameResolver = new NameResolver();
$traverser = new NodeTraverser();
Expand Down Expand Up @@ -371,34 +374,47 @@ private function applyGlobalMetadata(): void
$this->generator->setDescription($description);
}

$contact = [];
if (isset($this->globalMetadata['@contact.name'])) {
$contact['name'] = $this->globalMetadata['@contact.name'];
}
if (isset($this->globalMetadata['@contact.email'])) {
$contact['email'] = $this->globalMetadata['@contact.email'];
}
if (isset($this->globalMetadata['@contact.url'])) {
$contact['url'] = $this->globalMetadata['@contact.url'];
$contact = $this->cliOverrides['contact'] ?? null;
if ($contact === null) {
$contact = [];
if (isset($this->globalMetadata['@contact.name'])) {
$contact['name'] = $this->globalMetadata['@contact.name'];
}
if (isset($this->globalMetadata['@contact.email'])) {
$contact['email'] = $this->globalMetadata['@contact.email'];
}
if (isset($this->globalMetadata['@contact.url'])) {
$contact['url'] = $this->globalMetadata['@contact.url'];
}
}
if (!empty($contact)) {
$this->generator->setContact($contact);
}

$license = [];
if (isset($this->globalMetadata['@license.name'])) {
$license['name'] = $this->globalMetadata['@license.name'];
}
if (isset($this->globalMetadata['@license.url'])) {
$license['url'] = $this->globalMetadata['@license.url'];
$license = $this->cliOverrides['license'] ?? null;
if ($license === null) {
$license = [];
if (isset($this->globalMetadata['@license.name'])) {
$license['name'] = $this->globalMetadata['@license.name'];
}
if (isset($this->globalMetadata['@license.url'])) {
$license['url'] = $this->globalMetadata['@license.url'];
}
}
if (!empty($license)) {
$this->generator->setLicense($license);
}

$host = $this->cliOverrides['host'] ?? $this->globalMetadata['@host'] ?? null;
if ($host) {
$this->generator->setServers([['url' => $host]]);
$servers = $this->cliOverrides['servers'] ?? null;
if ($servers !== null) {
$this->generator->setServers($servers);
} elseif (!empty($this->globalServers)) {
$this->generator->setServers($this->globalServers);
} else {
$host = $this->cliOverrides['host'] ?? $this->globalMetadata['@host'] ?? null;
if ($host) {
$this->generator->setServers([['url' => $host]]);
}
}

if (!empty($this->securitySchemes)) {
Expand Down Expand Up @@ -980,26 +996,25 @@ public function setDescription(?string $description): void
*/
public function setContact(?array $contact): void
{
$this->generator->setContact($contact);
$this->cliOverrides['contact'] = $contact;
}

/**
* @param array<string, mixed>|null $license
*/
public function setLicense(?array $license): void
{
$this->generator->setLicense($license);
$this->cliOverrides['license'] = $license;
}

/**
* @param array<int, array<string, mixed>> $servers
*/
public function setServers(array $servers): void
{
$this->cliOverrides['servers'] = $servers;
if (isset($servers[0]['url'])) {
$this->cliOverrides['host'] = $servers[0]['url'];
} else {
$this->generator->setServers($servers);
}
}

Expand Down
30 changes: 28 additions & 2 deletions src/Metadata/GlobalMetadataDiscoverer.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ public function __construct(DocBlockCollector $docCollector)
* metadataSources: array<string, string>,
* securitySchemes: array<string, array<string, mixed>>,
* globalSecurity: array<int, array<string, array<int, string>>>,
* globalTags: array<string, array{name: string, description?: string}>
* globalTags: array<string, array{name: string, description?: string}>,
* globalServers: array<int, array<string, mixed>>
* }
*/
public function discover(
Expand All @@ -39,6 +40,7 @@ public function discover(
$securitySchemes = [];
$globalSecurity = [];
$globalTags = [];
$globalServers = [];

$tokens = token_get_all($code);
foreach ($tokens as $token) {
Expand All @@ -58,7 +60,7 @@ public function discover(
if (!$hasRouteOrProperty) {
foreach ($tags as $tag) {
if (
in_array($tag['name'], ['@title', '@version', '@description', '@host']) ||
in_array($tag['name'], ['@title', '@version', '@description', '@host', '@server']) ||
str_starts_with($tag['name'], '@contact.') ||
str_starts_with($tag['name'], '@license.') ||
str_starts_with($tag['name'], '@securityDefinitions.') ||
Expand Down Expand Up @@ -97,6 +99,29 @@ public function discover(
}
$globalMetadata[$tagName] = $val;
$metadataSources[$tagName] = $filePath;
} elseif ($tagName === '@server') {
$parts = preg_split('/\s+/', $tag['value'] ?? '', 2);
if (is_array($parts) && isset($parts[0]) && trim($parts[0]) !== '') {
$url = $parts[0];
$desc = isset($parts[1]) ? trim($parts[1]) : null;
$serverData = ['url' => $url];
if ($desc !== null && $desc !== '') {
$serverData['description'] = $desc;
}
$globalServers[] = $serverData;
} else {
throw new DiagnosticException(
sprintf(
"Invalid syntax for tag '@server': "
. "expected format is '@server URL [description]', got '%s'",
$tag['value'] ?? ''
),
0,
null,
$tag['file'] ?? $filePath,
$tag['line'] ?? null
);
}
} elseif ($tagName === '@securityDefinitions.apikey') {
if (preg_match('/^(\S+)\s+(header|query|cookie)\s+(\S+)/', $tag['value'], $matches)) {
$securitySchemes[$matches[1]] = [
Expand Down Expand Up @@ -190,6 +215,7 @@ public function discover(
'securitySchemes' => $securitySchemes,
'globalSecurity' => $globalSecurity,
'globalTags' => $globalTags,
'globalServers' => $globalServers,
];
}
}
10 changes: 8 additions & 2 deletions tests/FrameworkBridgesTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,10 @@ public function testSymfonyGenerateCommand()
['phpswag.title', true],
['phpswag.version', true],
['phpswag.description', true],
['phpswag.host', true]
['phpswag.host', true],
['phpswag.servers', false],
['phpswag.contact', false],
['phpswag.license', false]
]);
$bag->method('get')->willReturnMap([
['phpswag.paths', ['examples/App']],
Expand Down Expand Up @@ -105,7 +108,10 @@ public function testSymfonyGenerateCommandWithValidation()
['phpswag.title', true],
['phpswag.version', true],
['phpswag.description', true],
['phpswag.host', true]
['phpswag.host', true],
['phpswag.servers', false],
['phpswag.contact', false],
['phpswag.license', false]
]);
$bag->method('get')->willReturnMap([
['phpswag.paths', ['examples/App']],
Expand Down
48 changes: 48 additions & 0 deletions tests/GlobalMetadataTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -112,4 +112,52 @@ public function testCliOverridePrioritized(): void
$this->assertStringContainsString('title: CLI Title', str_replace("'", "", $yaml));
$this->assertStringNotContainsString('title: Doc Title', str_replace("'", "", $yaml));
}

public function testMultipleServersDiscovery(): void
{
$code = <<<'PHP'
<?php
/**
* @title API with multiple servers
* @server https://api.production.com Production Server
* @server https://api.staging.com Staging Server
*/
PHP;
file_put_contents($this->fixtureDir . '/api.php', $code);

$core = new Core();
$yaml = $core->generateYaml([$this->fixtureDir]);

$yamlClean = str_replace("'", "", $yaml);
$this->assertStringContainsString('servers:', $yamlClean);
$this->assertStringContainsString('url: https://api.production.com', $yamlClean);
$this->assertStringContainsString('description: Production Server', $yamlClean);
$this->assertStringContainsString('url: https://api.staging.com', $yamlClean);
$this->assertStringContainsString('description: Staging Server', $yamlClean);
}

public function testProgrammaticMultipleServers(): void
{
$code = <<<'PHP'
<?php
/**
* @title API Title
*/
PHP;
file_put_contents($this->fixtureDir . '/api.php', $code);

$core = new Core();
$core->setServers([
['url' => 'https://api.dev.com', 'description' => 'Development Server'],
['url' => 'https://api.test.com', 'description' => 'Testing Server']
]);
$yaml = $core->generateYaml([$this->fixtureDir]);

$yamlClean = str_replace("'", "", $yaml);
$this->assertStringContainsString('servers:', $yamlClean);
$this->assertStringContainsString('url: https://api.dev.com', $yamlClean);
$this->assertStringContainsString('description: Development Server', $yamlClean);
$this->assertStringContainsString('url: https://api.test.com', $yamlClean);
$this->assertStringContainsString('description: Testing Server', $yamlClean);
}
}
Loading