Skip to content

Commit b0a018f

Browse files
committed
Upgraded api documentation system
1 parent 166d35d commit b0a018f

13 files changed

Lines changed: 175 additions & 27 deletions

File tree

doc/contracts/documentor-contracts.md

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Documentor Contracts (Swagger + Postman)
1+
# Documentor Contracts (Swagger + OpenAPI + Postman)
22

33
## Scope
44
- `src/controller/DocumentorController.php`
@@ -15,24 +15,30 @@
1515
- Required keys: `info`, `variable`, `item`.
1616
- `info.schema` must be `https://schema.getpostman.com/json/collection/v2.1.0/collection.json`.
1717

18-
3. Download mode
18+
3. `GET /{domain}/api/doc?type=openapi`
19+
- Returns OpenAPI 3.1 JSON.
20+
- Required keys: `openapi`, `info`, `servers`, `paths`, `components.schemas`.
21+
22+
4. Download mode
1923
- `download=1&type=swagger` returns `swagger.json`.
2024
- `download=1&type=postman` returns `postman.collection.json`.
25+
- `download=1&type=openapi` returns `openapi.json`.
2126

22-
4. Filtering rules
27+
5. Filtering rules
2328
- Endpoints under `/admin/*` and `/api/*` are excluded from generated docs.
2429
- Route visibility/deprecation behavior remains unchanged.
2530

26-
5. Postman request shaping
31+
6. Postman request shaping
2732
- URL path params are emitted using `:param` syntax.
2833
- Query/header metadata is projected from endpoint metadata.
2934
- For `POST`/`PUT`, payload is exported as raw JSON with known fields.
3035

3136
## Compatibility
3237
- No public route changes.
33-
- Swagger behavior kept as-is.
34-
- Postman support is additive (replaces previous `Pending...` placeholder only).
38+
- Swagger behavior kept as-is (legacy).
39+
- OpenAPI support is additive and recommended for new integrations.
40+
- Postman support remains additive.
3541

3642
## Test Evidence
37-
- `tests/services/DocumentorServiceTest.php` validates Swagger + Postman contract skeleton.
38-
43+
- `tests/services/DocumentorServiceTest.php` validates Swagger + OpenAPI + Postman contract skeleton.
44+
- `tests/base/controller/CoverageControllersTest.php` validates controller paths, downloads and Swagger UI route.

src/base/extension/AssetsParser.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,11 @@ public function addFile($filename)
9797
$this->files[] = $localFilename;
9898
} elseif (!empty($this->domains)) {
9999
foreach ($this->domains as $domain => $paths) {
100-
$domainFilename = str_replace($domain, $paths["public"], (string)$filename);
100+
$publicPath = $paths['public'] ?? null;
101+
if (!is_string($publicPath) || $publicPath === '') {
102+
continue;
103+
}
104+
$domainFilename = str_replace($domain, $publicPath, (string)$filename);
101105
if (file_exists($domainFilename) && preg_match('/\.' . $this->type . '$/i', (string)$domainFilename)) {
102106
$this->files[] = $domainFilename;
103107
}

src/base/extension/TemplateFunctions.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -221,7 +221,11 @@ private static function extractPathname(string $path, $domains): string|array
221221
$filenamePath = $path;
222222
if (!empty($domains) && !file_exists($path)) {
223223
foreach ($domains as $domain => $paths) {
224-
$domainFilename = str_replace($domain, $paths['public'], $path);
224+
$publicPath = $paths['public'] ?? null;
225+
if (!is_string($publicPath) || $publicPath === '') {
226+
continue;
227+
}
228+
$domainFilename = str_replace($domain, $publicPath, $path);
225229
if (file_exists($domainFilename)) {
226230
$filenamePath = $domainFilename;
227231
break;

src/base/types/helpers/AssetsHelper.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ public static function findDomainPath($string, string $filePath): ?string
4444
$filenamePath = null;
4545
if (!file_exists($filePath) && 0 < count($domains)) {
4646
foreach ($domains as $domain => $paths) {
47-
$domainFilename = str_replace($domain, $paths["public"], $string);
47+
$publicPath = $paths['public'] ?? null;
48+
if (!is_string($publicPath) || $publicPath === '') {
49+
continue;
50+
}
51+
$domainFilename = str_replace($domain, $publicPath, $string);
4852
if (file_exists($domainFilename)) {
4953
$filenamePath = $domainFilename;
5054
break;

src/base/types/traits/Api/PostmanFormaterTrait.php

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ public function postmanFormatter(array $module, array $endpoints): array
2020
{
2121
$collection = [
2222
'info' => [
23-
'_postman_id' => uniqid('psfs-', true),
23+
'_postman_id' => $this->buildPostmanCollectionId($module),
2424
'name' => t('Module API collection ') . $module['name'],
2525
'schema' => 'https://schema.getpostman.com/json/collection/v2.1.0/collection.json',
2626
],
@@ -64,6 +64,17 @@ public function postmanFormatter(array $module, array $endpoints): array
6464
return $collection;
6565
}
6666

67+
/**
68+
* Build a stable identifier so generated collections are deterministic.
69+
*
70+
* @param array $module
71+
* @return string
72+
*/
73+
private function buildPostmanCollectionId(array $module): string
74+
{
75+
return 'psfs-' . substr(hash('sha256', (string)($module['name'] ?? 'module')), 0, 24);
76+
}
77+
6778
private function buildPostmanItem(array $module, array $endpoint): array
6879
{
6980
$urlPath = preg_replace('/\/' . $module['name'] . '\/api/i', '', $endpoint['url']);
@@ -146,4 +157,3 @@ private function buildPostmanBody(array $endpoint, string $method): array
146157
];
147158
}
148159
}
149-

src/base/types/traits/Api/SwaggerFormaterTrait.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ trait SwaggerFormaterTrait
1515
use SwaggerDtoComposerTrait;
1616
use ApiEndpointExtractorTrait;
1717
use PostmanFormaterTrait;
18+
use OpenApiFormaterTrait;
1819

1920
/**
2021
* @return array
@@ -135,7 +136,7 @@ private function appendEndpointToSwagger(array $module, array $endpoint, array &
135136
}
136137
}
137138

138-
private function shouldIncludeSwaggerEndpoint(array $endpoint): bool
139+
protected function shouldIncludeSwaggerEndpoint(array $endpoint): bool
139140
{
140141
return !preg_match('/^\/(admin|api)\//i', $endpoint['url']) && strlen($endpoint['url']) > 0;
141142
}

src/controller/ApiController.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ class ApiController extends AuthAdminController
2020
const PSFS_DOC = 'psfs';
2121
const SWAGGER_DOC = 'swagger';
2222
const POSTMAN_DOC = 'postman';
23+
const OPENAPI_DOC = 'openapi';
2324
const HTML_DOC = 'html';
2425

2526
/**
@@ -49,6 +50,7 @@ public function documentorHome()
4950
self::PSFS_DOC,
5051
self::SWAGGER_DOC,
5152
self::POSTMAN_DOC,
53+
self::OPENAPI_DOC,
5254
self::HTML_DOC,
5355
]
5456
]);

src/controller/DocumentorController.php

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PSFS\base\exception\RouterException;
66
use PSFS\base\Router;
7+
use PSFS\base\config\Config;
78
use PSFS\base\types\Controller;
89
use PSFS\base\types\helpers\attributes\Cacheable;
910
use PSFS\base\types\helpers\attributes\HttpMethod;
@@ -20,6 +21,10 @@
2021
class DocumentorController extends Controller
2122
{
2223
const DOMAIN = 'ROOT';
24+
/**
25+
* @var array<string, array{doc:array, expires:int}>
26+
*/
27+
private static array $docsCache = [];
2328

2429
/**
2530
* @Injectable
@@ -46,30 +51,56 @@ public function createApiDocs($domain)
4651
ini_set('memory_limit', -1);
4752
ini_set('max_execution_time', -1);
4853

49-
$type = $this->getRequest()->get('type') ?: ApiController::PSFS_DOC;
54+
$type = strtolower((string)($this->getRequest()->get('type') ?: ApiController::PSFS_DOC));
5055
$download = $this->getRequest()->get('download') ?: false;
56+
$cacheVersion = (string)Config::getParam('cache.var', 'v1');
57+
$cacheTtl = (int)Config::getParam('api.doc.cache.ttl', 300);
58+
$cacheKey = implode(':', [$domain, $type, $cacheVersion]);
59+
60+
if ($cacheTtl > 0 && !$download && isset(self::$docsCache[$cacheKey])) {
61+
$entry = self::$docsCache[$cacheKey];
62+
if ($entry['expires'] >= time()) {
63+
return $this->json($entry['doc'], 200);
64+
}
65+
unset(self::$docsCache[$cacheKey]);
66+
}
5167

52-
$module = $this->srv->getModules($domain);
68+
$module = $this->srv->getModules((string)$domain);
5369
if (empty($module)) {
5470
return ResponseHelper::httpNotFound(null, true);
5571
}
56-
$doc = $this->srv->extractApiEndpoints($module);
57-
switch (strtolower($type)) {
72+
$doc = $this->srv->buildEndpointSpec($module);
73+
switch ($type) {
5874
case ApiController::SWAGGER_DOC:
5975
$doc = $this->srv->swaggerFormatter($module, $doc);
6076
break;
6177
case ApiController::POSTMAN_DOC:
6278
$doc = $this->srv->postmanFormatter($module, $doc);
6379
break;
80+
case ApiController::OPENAPI_DOC:
81+
$doc = $this->srv->openApiFormatter($module, $doc);
82+
break;
6483
}
6584

66-
if ($download && in_array($type, [ApiController::SWAGGER_DOC, ApiController::POSTMAN_DOC], true)) {
67-
$filename = $type === ApiController::POSTMAN_DOC ? 'postman.collection.json' : 'swagger.json';
85+
if ($download && in_array($type, [ApiController::SWAGGER_DOC, ApiController::POSTMAN_DOC, ApiController::OPENAPI_DOC], true)) {
86+
if ($type === ApiController::POSTMAN_DOC) {
87+
$filename = 'postman.collection.json';
88+
} elseif ($type === ApiController::OPENAPI_DOC) {
89+
$filename = 'openapi.json';
90+
} else {
91+
$filename = 'swagger.json';
92+
}
6893
return $this->download(json_encode($doc), 'application/json', $filename);
6994
}
7095
if ($type === ApiController::HTML_DOC) {
7196
return $this->render('documentation.html.twig', ["data" => json_encode($doc)]);
7297
}
98+
if ($cacheTtl > 0 && !in_array($type, [ApiController::PSFS_DOC, ApiController::HTML_DOC], true)) {
99+
self::$docsCache[$cacheKey] = [
100+
'doc' => $doc,
101+
'expires' => time() + $cacheTtl,
102+
];
103+
}
73104
return $this->json($doc, 200);
74105
}
75106

src/services/DocumentorService.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,19 @@ public function extractApiEndpoints(array $module)
6464
return $endpoints;
6565
}
6666

67+
/**
68+
* Build a normalized endpoint specification model used by all output formatters.
69+
* Current v1 model reuses extracted endpoint metadata and keeps legacy shape.
70+
*
71+
* @param array $module
72+
* @return array
73+
* @throws ReflectionException
74+
*/
75+
public function buildEndpointSpec(array $module): array
76+
{
77+
return $this->extractApiEndpoints($module);
78+
}
79+
6780
/**
6881
* @param $namespace
6982
* @param $module

src/templates/api.home.html.twig

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,19 @@
1414
<md-list-item class="noright" ng-click="null">
1515
<p>{{ _domain }}</p>
1616
{% set swagger_uri = path('domain-api-doc', true, {'domain':_domain}) ~ '?type=swagger' %}
17-
<md-button ng-href="/admin/{{ _domain }}/swagger-ui" class="md-icon-button" target="_blank">
17+
{% set openapi_uri = path('domain-api-doc', true, {'domain':_domain}) ~ '?type=openapi' %}
18+
<md-button ng-href="/admin/{{ _domain }}/swagger-ui?type=openapi" class="md-icon-button" target="_blank">
1819
<md-tooltip md-direction="top">{{ "View documentation in HTML"|trans }}</md-tooltip>
1920
<md-icon class="fa fa-book-spells"></md-icon>
2021
</md-button>
2122
<md-button ng-href="{{ swagger_uri }}" class="md-icon-button" target="_blank">
2223
<md-tooltip md-direction="top">{{ "View documentation in Swagger format"|trans }}</md-tooltip>
2324
<md-icon class="fal fa-file-check"></md-icon>
2425
</md-button>
26+
<md-button ng-href="{{ openapi_uri }}" class="md-icon-button" target="_blank">
27+
<md-tooltip md-direction="top">{{ "View documentation in OpenAPI 3.1 format"|trans }}</md-tooltip>
28+
<md-icon class="fal fa-file-code"></md-icon>
29+
</md-button>
2530
<md-button ng-href="{{ path('domain-api-doc', true, {'domain':_domain}) }}?type=postman" class="md-icon-button" target="_blank">
2631
<md-tooltip md-direction="top">{{ "View documentation in Postman format"|trans }}</md-tooltip>
2732
<md-icon class="fal fa-rocket"></md-icon>

0 commit comments

Comments
 (0)