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
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -458,7 +458,17 @@ Run local quality checks:
./shift qa
```

`shift lint` checks PHP syntax and basic file hygiene. `shift qa` runs Composer validation, lint checks, the test suite, and route listing.
`shift lint` checks PHP syntax and basic file hygiene. `shift qa` runs Composer validation, lint checks, the test suite, route listing, and OpenAPI generation.

Generate OpenAPI documentation from registered routes:

```sh
./shift openapi
./shift openapi --output=docs/openapi.json
./shift openapi --live
```

The generator reads module routes from the same router used by the HTTP runtime and emits OpenAPI 3.0 JSON. Live mode starts a local documentation server at `http://127.0.0.1:8088` by default. Use `--host=` and `--port=` to change the binding.

Run the example module command:

Expand Down
1 change: 1 addition & 0 deletions REFACTORING.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ ShiftPHP is moving toward an API-only modular monolith. View templates, compiled
- [x] Request id lifecycle with generated `X-Request-Id` response headers and log context.
- [x] Developer documentation organized into a Laravel-like guide.
- [x] CLI quality gate with `shift lint` and `shift qa`.
- [x] OpenAPI JSON generation from registered module routes with `shift openapi`.
- [x] Removal of view storage and example page assets from runtime.
- [x] Removal of legacy `application/controllers` and `application/routes.php`.
- [x] Domain-oriented framework namespaces:
Expand Down
22 changes: 20 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,7 @@ <h2>Architecture</h2>
<li><a href="#modules">Modules</a></li>
<li><a href="#service-container">Service Container</a></li>
<li><a href="#commands">Console Commands</a></li>
<li><a href="#openapi">OpenAPI</a></li>
</ul>

<h2>Database</h2>
Expand Down Expand Up @@ -687,6 +688,23 @@ <h2>Console Commands</h2>
./shift sync-billing</code></pre>
</section>

<section id="openapi">
<h2>OpenAPI</h2>
<p>The OpenAPI command generates an OpenAPI 3.0 JSON document from the routes registered in loaded modules.</p>

<pre><code>./shift openapi
./shift openapi --output=docs/openapi.json
./shift openapi --live</code></pre>

<p>The generator reads route paths, HTTP methods, controller handlers, path parameters, query parameters, request body attributes, request DTO rules, response status attributes, and response header attributes.</p>

<pre><code>./shift help openapi
./shift api:docs --output=storage/openapi.json
./shift openapi --live --host=127.0.0.1 --port=8088</code></pre>

<p>Live mode writes the generated JSON into a temporary directory and starts a local documentation server with a lightweight Swagger-like HTML viewer.</p>
</section>

<section id="database">
<h2>Database</h2>
<p>ShiftPHP uses native PDO and registers <code>Shift\Database\Database</code> and the <code>db</code> alias lazily in the service container.</p>
Expand Down Expand Up @@ -815,7 +833,7 @@ <h2>Quality Checks</h2>
<pre><code>./shift lint
./shift qa</code></pre>

<p><code>shift lint</code> checks PHP syntax and basic file hygiene, including trailing whitespace and missing final newlines. <code>shift qa</code> runs Composer validation, lint checks, the test suite, and the route list command.</p>
<p><code>shift lint</code> checks PHP syntax and basic file hygiene, including trailing whitespace and missing final newlines. <code>shift qa</code> runs Composer validation, lint checks, the test suite, route listing, and OpenAPI generation.</p>

<pre><code>./shift help lint
./shift help qa</code></pre>
Expand All @@ -837,7 +855,7 @@ <h2>Testing</h2>
<pre><code>composer test
./shift test</code></pre>

<p>The GitHub workflow validates Composer config, dumps autoload files, lints PHP files, runs tests, and verifies the route list command. Locally, <code>./shift qa</code> runs the same kind of pre-PR quality gate.</p>
<p>The GitHub workflow validates Composer config, dumps autoload files, lints PHP files, runs tests, and verifies the route list command. Locally, <code>./shift qa</code> runs the same kind of pre-PR quality gate and also checks OpenAPI generation.</p>
</section>

<section id="releases">
Expand Down
134 changes: 134 additions & 0 deletions src/Console/Commands/OpenApi.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

namespace Console\Commands;

use Shift\Console\Cli;
use Shift\Console\CommandInterface;
use Shift\Modules\ModuleLoader;
use Shift\OpenApi\OpenApiGenerator;
use Shift\OpenApi\OpenApiLivePage;
use Shift\Routing\Router\Router;

#[\Shift\Console\Attributes\Command('openapi', aliases: ['api:docs'], group: 'documentation')]
class OpenApi implements CommandInterface
{
public function execute(mixed ...$args): void
{
$router = new Router();
(new ModuleLoader())->load()->registerRoutes($router);

$document = (new OpenApiGenerator())->generate($router);
$json = json_encode($document, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);

if ($json === false) {
(new Cli())->error('Unable to encode OpenAPI document.');
exit(1);
}

$outputPath = $this->outputPath($args);
$live = $this->hasOption($args, '--live');

if ($outputPath === null && !$live) {
echo $json . PHP_EOL;
return;
}

if ($outputPath !== null) {
$this->writeFile($outputPath, $json . PHP_EOL);
(new Cli())->success('OpenAPI document written to ' . $outputPath);
}

if ($live) {
$this->serveLiveDocumentation($json, $args);
}
}

public function getHelp(): string
{
return 'Usage: ./shift openapi [--output=docs/openapi.json] [--live] [--host=127.0.0.1] [--port=8088]';
}

public function getDescription(): string
{
return 'Generate an OpenAPI JSON document from registered routes.';
}

private function outputPath(array $args): ?string
{
foreach ($args as $arg) {
if (!is_string($arg) || !str_starts_with($arg, '--output=')) {
continue;
}

$path = trim(substr($arg, 9));

if ($path === '') {
return null;
}

return str_starts_with($path, '/') ? $path : APP_ROOT . '/' . $path;
}

return null;
}

private function serveLiveDocumentation(string $json, array $args): void
{
$host = $this->optionValue($args, '--host=') ?? '127.0.0.1';
$port = $this->optionValue($args, '--port=') ?? '8088';
$root = sys_get_temp_dir() . '/shift-openapi-live-' . bin2hex(random_bytes(6));

mkdir($root, 0775, true);
$this->writeFile($root . '/openapi.json', $json . PHP_EOL);
$this->writeFile($root . '/index.html', (new OpenApiLivePage())->render());

$url = 'http://' . $host . ':' . $port;
$cli = new Cli();
$cli->info('OpenAPI live documentation: ' . $url);
$cli->debug('Press Ctrl+C to stop the server.');

passthru(escapeshellarg(PHP_BINARY) . ' -S ' . escapeshellarg($host . ':' . $port) . ' -t ' . escapeshellarg($root), $exitCode);

if ($exitCode !== 0) {
$cli->error('OpenAPI live server stopped with exit code ' . $exitCode . '.');
exit($exitCode);
}
}

private function writeFile(string $path, string $contents): void
{
$directory = dirname($path);

if (!is_dir($directory)) {
mkdir($directory, 0775, true);
}

file_put_contents($path, $contents);
}

private function hasOption(array $args, string $option): bool
{
foreach ($args as $arg) {
if ($arg === $option) {
return true;
}
}

return false;
}

private function optionValue(array $args, string $prefix): ?string
{
foreach ($args as $arg) {
if (!is_string($arg) || !str_starts_with($arg, $prefix)) {
continue;
}

$value = trim(substr($arg, strlen($prefix)));

return $value !== '' ? $value : null;
}

return null;
}
}
33 changes: 33 additions & 0 deletions src/Console/Quality/QualityChecks.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public function qa(): array
$this->fileHygiene(),
$this->testSuite(),
$this->routeList(),
$this->openApiDocument(),
];
}

Expand Down Expand Up @@ -93,6 +94,38 @@ public function routeList(): CheckResult
return $this->runShellCheck('Route list', $command, './shift route:list passed');
}

public function openApiDocument(): CheckResult
{
$command = escapeshellarg(PHP_BINARY) . ' ' . escapeshellarg($this->projectRoot() . '/shift') . ' openapi 2>&1';
$previousDirectory = getcwd();

if ($previousDirectory !== false) {
chdir($this->projectRoot());
}

try {
exec($command, $output, $exitCode);
} finally {
if ($previousDirectory !== false) {
chdir($previousDirectory);
}
}

if ($exitCode !== 0) {
$details = trim(implode(' ', array_slice($output, -3)));

return CheckResult::fail('OpenAPI document', $details !== '' ? $details : 'Command failed with exit code ' . $exitCode);
}

$document = json_decode(implode("\n", $output), true);

if (!is_array($document) || ($document['openapi'] ?? null) !== '3.0.3') {
return CheckResult::fail('OpenAPI document', 'Generated document is not valid OpenAPI JSON');
}

return CheckResult::ok('OpenAPI document', './shift openapi passed');
}

private function runShellCheck(string $name, string $command, string $successDetails): CheckResult
{
$previousDirectory = getcwd();
Expand Down
Loading
Loading