From 60fed3f948b30e7517a0b509d2faaba8e386caa0 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Mon, 8 Sep 2025 15:46:01 +1200 Subject: [PATCH 1/9] Improve global components --- Annotations/AnnotationGenerator.php | 4 +- Annotations/GlobalApiComponents.php | 93 ++++++++++++++++++++++------- 2 files changed, 72 insertions(+), 25 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index da5ec93..b46b185 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -300,8 +300,8 @@ protected function getApplicableDemoExampleUrls(string $pluginName, string $meth protected function getExampleIfAvailable(string $url): array { - // Simply return the URL for TSV - if (stripos($url, 'format=tsv') !== false) { + // Simply return the URL for anything other than JSON until we figure out how to better format those examples + if (stripos($url, 'format=json') === false) { return ['externalValue' => $url]; } diff --git a/Annotations/GlobalApiComponents.php b/Annotations/GlobalApiComponents.php index af76b57..89859a1 100644 --- a/Annotations/GlobalApiComponents.php +++ b/Annotations/GlobalApiComponents.php @@ -11,7 +11,9 @@ /** * Global components for generating OpenAPI specs for the Matomo Reporting API. - * + */ + +/** * @OA\OpenApi( * openapi="3.1.0", * security={{"MatomoToken": {}}}, @@ -51,14 +53,46 @@ * * Generic Error object * @OA\Schema( + * schema="GenericSuccess", + * type="object", + * description="Generic Matomo success payload.", + * required={"result","message"}, + * additionalProperties=true, + * @OA\Property(property="result", type="string", enum={"success"}, example="success"), + * @OA\Property(property="message", type="string", example="ok"), + * @OA\Property(property="code", type="integer", nullable=true, default=null) + * ) + * + * Generic Error object + * @OA\Schema( * schema="Error", * type="object", * description="Generic Matomo error payload.", * required={"result","message"}, * additionalProperties=true, * @OA\Property(property="result", type="string", enum={"error"}, example="error"), - * @OA\Property(property="message", type="string", example="You can't access this resource"), - * @OA\Property(property="code", type="integer", nullable=true, example=401) + * @OA\Property(property="message", type="string", example="There was an error"), + * @OA\Property(property="code", type="integer", nullable=true, default=null) + * ) + * + * @OA\Schema( + * schema="ErrorXml", + * type="object", + * description="Generic Matomo error payload in XML.", + * @OA\Xml( + * name="result" + * ), + * @OA\Property( + * property="error", + * type="object", + * @OA\Xml(name="error"), + * @OA\Property( + * property="message", + * type="string", + * xml=@OA\Xml(attribute=true), + * example="There was an error" + * ) + * ) * ) * * Common responses which should be used by each API endpoint @@ -66,62 +100,75 @@ * response="BadRequest", * description="Bad request (validation or missing parameters).", * @OA\JsonContent(ref="#/components/schemas/Error"), - * @OA\XmlContent(ref="#/components/schemas/Error"), - * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string")), - * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string")) + * @OA\XmlContent(ref="#/components/schemas/ErrorXml"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Error: There was an error."), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="There was an error.") * ) * * @OA\Response( * response="Unauthorized", * description="Authentication failed or missing token.", - * @OA\JsonContent(ref="#/components/schemas/Error"), - * @OA\XmlContent(ref="#/components/schemas/Error"), - * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string")), - * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string")) + * @OA\JsonContent( + * ref="#/components/schemas/Error", + * example={"result":"error","message":"You must be logged in to access this functionality."} + * ), + * @OA\XmlContent(ref="#/components/schemas/ErrorXml"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Error: You must be logged in to access this functionality."), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="You must be logged in to access this functionality.") * ) * * @OA\Response( * response="Forbidden", * description="Authenticated but not allowed to access the resource.", * @OA\JsonContent(ref="#/components/schemas/Error"), - * @OA\XmlContent(ref="#/components/schemas/Error"), - * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string")), - * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string")) + * @OA\XmlContent(ref="#/components/schemas/ErrorXml"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Error: Not authorised."), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="Not authorised.") * ) * * @OA\Response( * response="NotFound", * description="Resource not found.", * @OA\JsonContent(ref="#/components/schemas/Error"), - * @OA\XmlContent(ref="#/components/schemas/Error"), - * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string")), - * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string")) + * @OA\XmlContent(ref="#/components/schemas/ErrorXml"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Error: The method is not available."), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="The method is not available.") * ) * * @OA\Response( * response="ServerError", * description="Unexpected server error.", * @OA\JsonContent(ref="#/components/schemas/Error"), - * @OA\XmlContent(ref="#/components/schemas/Error"), - * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string")), - * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string")) + * @OA\XmlContent(ref="#/components/schemas/ErrorXml"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Error: There was an error."), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="There was an error.") * ) * * @OA\Response( * response="DefaultError", * description="Default error response (any non-2xx).", * @OA\JsonContent(ref="#/components/schemas/Error"), - * @OA\XmlContent(ref="#/components/schemas/Error"), - * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string")), - * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string")) + * @OA\XmlContent(ref="#/components/schemas/ErrorXml"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Error: There was an error."), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="There was an error.") * ) * * Generic responses which can be used by endpoints * @OA\Response( - * response="GenericSuccess", + * response="GenericSuccessNoBody", * description="Generic 200 response with no body" * ) * + * Generic responses which can be used by endpoints + * @OA\Response( + * response="GenericSuccess", + * description="Generic 200 response" + * @OA\JsonContent(ref="#/components/schemas/GenericSuccess"), + * @OA\XmlContent(ref="#/components/schemas/GenericSuccess"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Result: success"), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="success") + * ) + * * @OA\Response( * response="GenericBoolean", * description="Generic 200 response with only true or false as the body", From 05c00e5731865f59f932a97ab963d0bb6ed6e519 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Mon, 8 Sep 2025 16:00:43 +1200 Subject: [PATCH 2/9] Fix missing comma --- Annotations/GlobalApiComponents.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Annotations/GlobalApiComponents.php b/Annotations/GlobalApiComponents.php index 89859a1..d9687ad 100644 --- a/Annotations/GlobalApiComponents.php +++ b/Annotations/GlobalApiComponents.php @@ -162,7 +162,7 @@ * Generic responses which can be used by endpoints * @OA\Response( * response="GenericSuccess", - * description="Generic 200 response" + * description="Generic 200 response", * @OA\JsonContent(ref="#/components/schemas/GenericSuccess"), * @OA\XmlContent(ref="#/components/schemas/GenericSuccess"), * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Result: success"), From 70f983844bcfa964264391e4fda78faa07213172 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Mon, 8 Sep 2025 17:43:16 +1200 Subject: [PATCH 3/9] Write spec to file and more cleanup --- Annotations/AnnotationGenerator.php | 3 +- Annotations/GlobalApiComponents.php | 48 +++++++++++------------------ Commands/GenerateSpecFile.php | 20 ++++++++++-- Specs/SpecGenerator.php | 18 +++++++++-- 4 files changed, 52 insertions(+), 37 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index b46b185..fba33e9 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -510,7 +510,8 @@ protected function compileOperationLines(string $path, string $opId, string $plu $operationValuesMap[] = ['@OA\Response' => $responsePropertyArray]; } } - $operationValuesMap[] = 'x={"runtime"={"entry":"index.php","query":{"module":"API","method":"' . $plugin . '.' . $method . '"}}}'; + // TODO - Remove this if it's determined that we won't ever use it + //$operationValuesMap[] = 'x={"runtime"={"entry":"index.php","query":{"module":"API","method":"' . $plugin . '.' . $method . '"}}}'; $lines = $this->buildLinesForAnnotationObject('@OA\\' . ($isPost ? 'Post' : 'Get'), $operationValuesMap); diff --git a/Annotations/GlobalApiComponents.php b/Annotations/GlobalApiComponents.php index d9687ad..94847f4 100644 --- a/Annotations/GlobalApiComponents.php +++ b/Annotations/GlobalApiComponents.php @@ -108,10 +108,7 @@ * @OA\Response( * response="Unauthorized", * description="Authentication failed or missing token.", - * @OA\JsonContent( - * ref="#/components/schemas/Error", - * example={"result":"error","message":"You must be logged in to access this functionality."} - * ), + * @OA\JsonContent(ref="#/components/schemas/Error"), * @OA\XmlContent(ref="#/components/schemas/ErrorXml"), * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Error: You must be logged in to access this functionality."), * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="You must be logged in to access this functionality.") @@ -204,7 +201,7 @@ * in="query", * description="Always `API` for Reporting API requests.", * required=true, - * @OA\Schema(type="string", default="API", example="API") + * @OA\Schema(type="string", default="API") * ) * * @OA\Parameter( @@ -213,8 +210,7 @@ * in="query", * description="API method, e.g. `VisitsSummary.get` or `CustomAlerts.getAlert`.", * required=true, - * @OA\Schema(type="string"), - * example="CustomAlerts.getAlert" + * @OA\Schema(type="string", example="CustomAlerts.getAlert") * ) * * @OA\Parameter( @@ -227,8 +223,7 @@ * type="string", * enum={"xml","json","csv","tsv","html","rss","original"}, * default="xml" - * ), - * example="xml" + * ) * ) * * @OA\Parameter( @@ -241,8 +236,7 @@ * type="string", * enum={"xml","json","csv","tsv","html","rss","original"}, * default="xml" - * ), - * example="xml" + * ) * ) * * Commonly used parameters. If there are parameters not required by every endpoint, it will be declared as both @@ -253,8 +247,7 @@ * in="query", * description="Matomo site ID.", * required=true, - * @OA\Schema(type="integer"), - * example=1 + * @OA\Schema(type="integer", example=1) * ) * * @OA\Parameter( @@ -263,8 +256,7 @@ * in="query", * description="Matomo site ID.", * required=false, - * @OA\Schema(type="integer"), - * example=1 + * @OA\Schema(type="integer", example=1) * ) * * @OA\Parameter( @@ -273,8 +265,7 @@ * in="query", * description="Reporting period.", * required=true, - * @OA\Schema(type="string", enum={"day","week","month","year","range"}), - * example="day" + * @OA\Schema(type="string", enum={"day","week","month","year","range"}, example="day") * ) * * @OA\Parameter( @@ -283,8 +274,7 @@ * in="query", * description="Reporting period.", * required=false, - * @OA\Schema(type="string", enum={"day","week","month","year","range"}), - * example="day" + * @OA\Schema(type="string", enum={"day","week","month","year","range"}, example="day") * ) * * @OA\Parameter( @@ -293,8 +283,7 @@ * in="query", * description="Date or range (e.g. `2025-08-01`, `yesterday`, `last30`, or `2025-08-01,2025-08-11`).", * required=true, - * @OA\Schema(type="string"), - * example="yesterday" + * @OA\Schema(type="string", example="today") * ) * * @OA\Parameter( @@ -303,8 +292,7 @@ * in="query", * description="Date or range (e.g. `2025-08-01`, `yesterday`, `last30`, or `2025-08-01,2025-08-11`).", * required=false, - * @OA\Schema(type="string"), - * example="yesterday" + * @OA\Schema(type="string", example="today") * ) * * @OA\Parameter( @@ -328,14 +316,14 @@ * Parameters specific to DataTables and Views * @OA\Parameter(parameter="flatOptional", name="flat", in="query", * description="Flatten subtables into the parent table.", required=false, - * @OA\Schema(type="integer", enum={0,1}), example=0) + * @OA\Schema(type="integer", enum={0,1}, example=0)) * * @OA\Parameter(parameter="filter_patternOptional", name="filter_pattern", in="query", * description="Regex to keep matching rows.", required=false, @OA\Schema(type="string")) * * @OA\Parameter(parameter="filter_columnOptional", name="filter_column", in="query", * description="Column to apply the regex to (e.g., `label`).", required=false, - * @OA\Schema(type="string"), example="label") + * @OA\Schema(type="string", example="label")) * * @OA\Parameter(parameter="filter_pattern_recursiveOptional", name="filter_pattern_recursive", in="query", * description="Recursive regex filter.", required=false, @OA\Schema(type="string")) @@ -348,14 +336,14 @@ * * @OA\Parameter(parameter="filter_excludelowpop_valueOptional", name="filter_excludelowpop_value", in="query", * description="Minimum value threshold for `filter_excludelowpop`.", required=false, - * @OA\Schema(type="number"), example=0) + * @OA\Schema(type="number", example=0)) * * @OA\Parameter(parameter="filter_sort_columnOptional", name="filter_sort_column", in="query", * description="Column to sort by.", required=false, @OA\Schema(type="string")) * * @OA\Parameter(parameter="filter_sort_orderOptional", name="filter_sort_order", in="query", * description="Sort direction.", required=false, - * @OA\Schema(type="string", enum={"asc","desc"}), example="desc") + * @OA\Schema(type="string", enum={"asc","desc"}, example="desc")) * * @OA\Parameter(parameter="filter_truncateOptional", name="filter_truncate", in="query", * description="Row index after which rows are removed.", required=false, @OA\Schema(type="integer")) @@ -368,15 +356,15 @@ * * @OA\Parameter(parameter="keep_summary_rowOptional", name="keep_summary_row", in="query", * description="Keep the summary row.", required=false, - * @OA\Schema(type="integer", enum={0,1}), example=1) + * @OA\Schema(type="integer", enum={0,1}, example=1)) * * @OA\Parameter(parameter="disable_generic_filtersOptional", name="disable_generic_filters", in="query", * description="Disable generic filters (those above).", required=false, - * @OA\Schema(type="integer", enum={0,1}), example=0) + * @OA\Schema(type="integer", enum={0,1}, example=0)) * * @OA\Parameter(parameter="disable_queued_filtersOptional", name="disable_queued_filters", in="query", * description="Skip queued filters.", required=false, - * @OA\Schema(type="integer", enum={0,1}), example=0) + * @OA\Schema(type="integer", enum={0,1}, example=0)) * * @OA\Parameter(parameter="hideColumnsOptional", name="hideColumns", in="query", * description="Comma-separated list of columns to hide.", required=false, @OA\Schema(type="string")) diff --git a/Commands/GenerateSpecFile.php b/Commands/GenerateSpecFile.php index c89940c..3b13b46 100644 --- a/Commands/GenerateSpecFile.php +++ b/Commands/GenerateSpecFile.php @@ -29,7 +29,10 @@ protected function configure() { $this->setName('openapidocs:generate-spec-file'); $this->setDescription('Generate the OpenAPI documentation file for the Matomo APIs.'); - $this->addRequiredValueOption('plugin', null, 'Name of the plugin to document'); + $this->addRequiredValueOption('plugin', 'p', 'Name of the plugin to document'); + $this->addRequiredValueOption('format', 'f', 'Format of the spec file (JSON or YAML). Default is JSON'); + $this->addRequiredValueOption('api-version', null, 'Version of the spec file. Default is 1.0.0'); + $this->addNoValueOption('not-dry-run', null, 'Flag to allow writing to file instead of outputting a dry run.'); } /** @@ -71,13 +74,24 @@ protected function doExecute(): int $output = $this->getOutput(); $plugin = $input->getOption('plugin') ?: 'Matomo'; + $format = $input->getOption('format') ?: 'json'; + $version = $input->getOption('version') ?: '1.0.0'; + $notDryRun = $input->getOption('not-dry-run') ?: false; $message = sprintf('Generating documentation for: %s', $plugin); $output->writeln($message); - $output->writeln((new SpecGenerator())->generatePluginDoc($plugin)); + $result = (new SpecGenerator())->generatePluginDoc($plugin, $format, $version, $notDryRun); - return self::SUCCESS; + if ($notDryRun) { + $output->writeln('Results written to ' . $plugin . ' plugin\'s /OpenApi/Specs directory.'); + + return $result ? self::SUCCESS : self::FAILURE; + } + + $output->writeln($result); + + return $result ? self::SUCCESS : self::FAILURE; } } diff --git a/Specs/SpecGenerator.php b/Specs/SpecGenerator.php index bc33cc6..0229699 100644 --- a/Specs/SpecGenerator.php +++ b/Specs/SpecGenerator.php @@ -30,13 +30,19 @@ public function __construct() } } - public function generatePluginDoc(string $pluginName, string $format = 'json', bool $writeToFile = false): string + public function generatePluginDoc(string $pluginName, string $format = 'json', string $version = '1.0.0', bool $writeToFile = false): string { BaseValidator::check('plugin', $pluginName, [new NotEmpty()]); Manager::getInstance()->checkIsPluginActivated($pluginName); $currentPluginDir = Manager::getInstance()::getPluginDirectory('OpenApiDocs'); $pluginDir = Manager::getInstance()::getPluginDirectory($pluginName); + $pluginSpecDir = $pluginDir . '/OpenApi/Specs'; + $pluginSpecPath = $pluginSpecDir . '/' . $pluginName . '_v' . $version . '.' . strtolower($format); + // If the directory doesn't exist yet, create it + if ($writeToFile && !is_dir($pluginSpecDir)) { + mkdir($pluginSpecDir, 0777, true); + } // Check if the API class has been annotated and use the generated annotations file if it hasn't $pluginAnnotationsSource = $pluginDir . '/API.php'; @@ -54,7 +60,6 @@ public function generatePluginDoc(string $pluginName, string $format = 'json', b } $generator = new Generator(StaticContainer::get(LoggerInterface::class)); - $generator->setVersion(OpenApi::DEFAULT_VERSION); $openapi = $generator->generate([ $currentPluginDir . '/Annotations/GlobalApiComponents.php', @@ -64,12 +69,19 @@ public function generatePluginDoc(string $pluginName, string $format = 'json', b // Update title with plugin name $openapi->info->title .= ' for ' . $pluginName . ' plugin'; + $openapi->info->version = $version ?: '1.0.0'; + // Remove the current server so that it isn't used when saving the spec file. It should only leave demo if ($writeToFile && is_array($openapi->servers) && count($openapi->servers) > 1) { unset($openapi->servers[0]); $openapi->servers = array_values($openapi->servers); } - return strtolower($format) === 'yaml' ? $openapi->toYaml() : $openapi->toJson(); + $specContents = strtolower($format) === 'yaml' ? $openapi->toYaml() : $openapi->toJson(); + if ($writeToFile) { + file_put_contents($pluginSpecPath, $specContents); + } + + return $specContents; } } From 64c872415d4be3449b0eb3a2f7a6693eba9c7db5 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 9 Sep 2025 11:51:21 +1200 Subject: [PATCH 4/9] A few more minor improvements --- Annotations/AnnotationGenerator.php | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index fba33e9..4547da0 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -159,7 +159,8 @@ protected function getParamInfoFromDocBlock(string $docBlock): array $name = ltrim($param->parameterName, '$'); $params[$name] = [ 'type' => (string) $param->type, - 'desc' => $param->description, + // Normalise the description. E.g. remove linebreaks and indentation + 'desc' => trim(preg_replace(['/^\h+/m', '/\R+/u',], ['', ' '], $param->description)), 'byRef' => $param->isReference, 'variadic' => $param->isVariadic, ]; @@ -169,7 +170,7 @@ protected function getParamInfoFromDocBlock(string $docBlock): array protected function buildVirtualPath(string $virtualPathTemplate, string $plugin, string $method): string { - return str_replace([ '{plugin}', '{method}' ], [ $plugin, $method ], $virtualPathTemplate); + return str_replace(['{plugin}', '{method}'], [$plugin, $method], $virtualPathTemplate); } protected function buildParameterAnnotation(string $paramName, array $paramMetadata, array $paramDocInfo): array @@ -197,7 +198,7 @@ protected function buildParameterAnnotation(string $paramName, array $paramMetad 'types' => $typesMap, 'description' => $paramDocInfo['desc'] ?? '', 'required' => $isRequired ? 'true' : 'false', - 'default' => !$isRequired ? json_encode($paramMetadata['default']) : '', + 'default' => !$isRequired ? json_encode($paramMetadata['default']) : NoDefaultValue::class, ]; } @@ -434,7 +435,7 @@ protected function buildLinesForAnnotationObject(string $objectName, array $obje return array_merge([$indentString . $objectName . $openingCharacter], $lines, [$indentString . $closingCharacter . ',']); } - protected function buildSchemaObjectArray(string $type, string $subType = '', string $default = ''): array + protected function buildSchemaObjectArray(string $type, string $subType = '', string $default = NoDefaultValue::class): array { $schemaMap = ['type="' . $type . '"']; $subTypeString = ''; @@ -448,14 +449,26 @@ protected function buildSchemaObjectArray(string $type, string $subType = '', st } } - if ($default !== '') { - // TODO - Add some logic to only add default if it matches the type. E.g. false isn't a good default for string + if ($this->shouldIncludeDefault($type, $default)) { $schemaMap[] = 'default="' . $default . '"'; } return ['@OA\Schema' => $schemaMap]; } + protected function shouldIncludeDefault(string $type, string $default = NoDefaultValue::class): bool { + if ($default === NoDefaultValue::class) { + return false; + } + + // Don't use true or false for default if it's not a boolean type + if ($type !== 'boolean' && in_array(strtolower($default), ['false', 'true'])) { + return false; + } + + return true; + } + protected function buildSchemaObjectArrays(array $typesMap, string $default = ''): array { $schemas = []; From c09d3cf9d25447c42bfca5feac496059d7b12704 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 9 Sep 2025 11:56:13 +1200 Subject: [PATCH 5/9] Fixing code style issue --- Annotations/AnnotationGenerator.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 4547da0..2cf2aa0 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -456,7 +456,8 @@ protected function buildSchemaObjectArray(string $type, string $subType = '', st return ['@OA\Schema' => $schemaMap]; } - protected function shouldIncludeDefault(string $type, string $default = NoDefaultValue::class): bool { + protected function shouldIncludeDefault(string $type, string $default = NoDefaultValue::class): bool + { if ($default === NoDefaultValue::class) { return false; } From b2a4f7c2ef06d1f362bf56ff3edca69ae75c05b8 Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 9 Sep 2025 17:38:24 +1200 Subject: [PATCH 6/9] Making a few more improvements --- Annotations/AnnotationGenerator.php | 99 +++++++++++++++++++++++++---- Annotations/GlobalApiComponents.php | 9 +++ 2 files changed, 96 insertions(+), 12 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 2cf2aa0..4c41ca1 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -138,7 +138,7 @@ protected function buildAnnotationForMethod(array $rules, string $pluginName, \R ); $params = $this->determineParameters($rules, $pluginName, $methodName, $reflectionMethod); - $responses = $this->determineResponses($rules, $pluginName, $methodName); + $responses = $this->determineResponses($rules, $pluginName, $methodName, $reflectionMethod); $isPost = !empty($rules['plugins'][$pluginName]['methodsRequiringPost']) && in_array($methodName, $rules['plugins'][$pluginName]['methodsRequiringPost']); @@ -168,6 +168,34 @@ protected function getParamInfoFromDocBlock(string $docBlock): array return $params; } + protected function getResponseInfoFromDocBlock(string $docBlock): array + { + $lexer = new Lexer(); + $tokens = $lexer->tokenize($docBlock); + $expressionParser = new ConstExprParser(); + $parser = new PhpDocParser(new TypeParser($expressionParser), $expressionParser); + $node = $parser->parse(new TokenIterator($tokens)); + + $responseInfo = ['type' => null]; + $returnTags = $node->getReturnTagValues(); + if (empty($returnTags)) { + return $responseInfo; + } + + $returnTag = $returnTags[0]; + $tagValue = strval($returnTag->type); + $responseInfo['type'] = $this->getOpenApiTypeFromPhpType($tagValue); + if ($responseInfo['type'] === 'string' && !empty($tagValue) && strtolower($tagValue) !== 'string') { + $responseInfo['type'] = ''; + $responseInfo['description'] = 'Response of unknown type'; + } + if (!empty($returnTag->description)) { + $responseInfo['description'] = $returnTag->description; + } + + return $responseInfo; + } + protected function buildVirtualPath(string $virtualPathTemplate, string $plugin, string $method): string { return str_replace(['{plugin}', '{method}'], [$plugin, $method], $virtualPathTemplate); @@ -320,16 +348,10 @@ protected function getExampleIfAvailable(string $url): array curl_close($ch); // If the example didn't load or is too big, simply include the URL instead of the string value - if ($body === false || $status !== 200 || strlen($body) > 1000 || strpos($body, 'Error: ') === 0) { + if ($body === false || $status !== 200 || strlen($body) > 2000 || strpos($body, 'Error: ') === 0) { return ['externalValue' => $url]; } - // Clean up XML formatting a bit - $body = trim($body); - if (stripos($url, 'format=xml') !== false) { - $body = str_replace(['', "\n", "\t", '"'], ['', '', '', '\"'], $body); - } - // The annotation expects an objects and not arrays if (stripos($url, 'format=json') !== false && stripos($body, '[') === 0) { $body = str_replace(['[', ']'], ['{', '}'], $body); @@ -338,21 +360,62 @@ protected function getExampleIfAvailable(string $url): array return ['value' => $body]; } - protected function determineResponses(array $rules, string $plugin, string $method): array + protected function determineResponses(array $rules, string $plugin, string $method, \ReflectionMethod $reflectionMethod): array { $responses = []; - // TODO - Try to determine the success response using the return type and/or doc-block return type + // Try to determine the success response using the return type and/or doc-block return type + $returnType = $reflectionMethod->getReturnType(); + $responseInfo = $this->getResponseInfoFromDocBlock($reflectionMethod->getDocComment()); + $commentType = $responseInfo['type']; + if (!empty($returnType) && $returnType->isBuiltin()) { + $responseInfo['type'] = $this->getOpenApiTypeFromPhpType($returnType->getName()); + } $successRef = null; $successArray = ['code' => 200]; if (isset($rules['plugins'][$plugin]['successResponseByMethod'][$method])) { $successRef = $rules['plugins'][$plugin]['successResponseByMethod'][$method]; } + // TODO - See if there's a way to auto-handle custom objects, especially common stuff like DataTable\DataTableInterface if ($successRef) { $successArray['ref'] = $successRef; } + // If the return type is void, use the generic response type + if (empty($successArray['ref']) && !empty($returnType) && $returnType->getName() === 'void') { + $successArray['ref'] = '#/components/responses/GenericSuccessNoBody'; + } + + // If it's a generic type and there's no custom description, use one of the global generic responses + if (empty($successArray['ref']) && !empty($responseInfo['type']) && empty($responseInfo['description'])) { + $ref = ''; + switch ($responseInfo['type']) { + case 'array': + $ref = '#/components/responses/GenericArray'; + break; + case 'integer': + $ref = '#/components/responses/GenericInteger'; + break; + case 'boolean': + $ref = '#/components/responses/GenericBoolean'; + break; + case 'string': + $ref = '#/components/responses/GenericString'; + break; + } + + if (!empty($ref)) { + $successArray['ref'] = $ref; + } + } + + if (!empty($responseInfo['description'])) { + $successArray['desc'] = $responseInfo['description']; + } + + $responseSchema = !empty($responseInfo['type']) ? $this->buildSchemaObjectArray($responseInfo['type']) : []; + $mediaTypes = []; // This simply reuses the example URLs used by the current documentation, but some endpoints don't work because authentication is required // TODO - Come up with a way to demo examples for endpoints which require authentication. E.g. hit a live endpoint server-side and replace any potentially sensitive data... @@ -371,13 +434,21 @@ protected function determineResponses(array $rules, string $plugin, string $meth $value = substr($value, 1, -1); } $exampleProperties[] = $valueKey . '=' . $value; - $mediaTypes[] = [ + $mediaType = [ 'mediaType="' . $contentType . '"', '@OA\Examples' => $exampleProperties, ]; + // If a type was found, add it as a schema to the media type + if (!empty($responseSchema)) { + $mediaType = array_merge($mediaType, $responseSchema); + } + $mediaTypes[] = $mediaType; } if (!empty($mediaTypes)) { $successArray['mediaTypes'] = $mediaTypes; + } else { + // Make sure the schema is included in there are no examples + $successArray['schema'] = $responseSchema; } $responses[] = $successArray; @@ -507,7 +578,8 @@ protected function compileOperationLines(string $path, string $opId, string $plu $operationValuesMap[] = ['@OA\Parameter' => $paramMap]; } foreach ($responses as $response) { - if (isset($response['ref'])) { + // Don't use the reference if there are media type examples + if (isset($response['ref']) && empty($response['mediaTypes'])) { $code = $response['code']; $codeFormatted = is_numeric($code) ? (string)$code : '"' . $code . '"'; $operationValuesMap[] = '@OA\Response(response=' . $codeFormatted . ', ref="' . $response['ref'] . '")'; @@ -516,6 +588,9 @@ protected function compileOperationLines(string $path, string $opId, string $plu 'response=200', 'description="' . ($response['desc'] ?? 'OK') . '"', ]; + if (!empty($response['schema'])) { + $responsePropertyArray = array_merge($responsePropertyArray, $response['schema']); + } if (isset($response['mediaTypes']) && is_array($response['mediaTypes'])) { foreach ($response['mediaTypes'] as $mediaType) { $responsePropertyArray[] = ['@OA\MediaType' => $mediaType]; diff --git a/Annotations/GlobalApiComponents.php b/Annotations/GlobalApiComponents.php index 94847f4..e3b9211 100644 --- a/Annotations/GlobalApiComponents.php +++ b/Annotations/GlobalApiComponents.php @@ -167,6 +167,15 @@ * ) * * @OA\Response( + * response="GenericString", + * description="Generic 200 response with only a string body", + * @OA\JsonContent(type="string"), + * @OA\XmlContent(type="string"), + * @OA\MediaType(mediaType="text/plain", @OA\Schema(type="string"), example="Result: success"), + * @OA\MediaType(mediaType="text/html", @OA\Schema(type="string"), example="success") + * ) + * + * @OA\Response( * response="GenericBoolean", * description="Generic 200 response with only true or false as the body", * @OA\JsonContent(type="boolean"), From 80a1575e27fb528fe0b1ab93f95924db8168b09f Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Tue, 9 Sep 2025 17:41:59 +1200 Subject: [PATCH 7/9] Minor correction --- Annotations/AnnotationGenerator.php | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index 4c41ca1..d6ed26e 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -367,9 +367,8 @@ protected function determineResponses(array $rules, string $plugin, string $meth // Try to determine the success response using the return type and/or doc-block return type $returnType = $reflectionMethod->getReturnType(); $responseInfo = $this->getResponseInfoFromDocBlock($reflectionMethod->getDocComment()); - $commentType = $responseInfo['type']; if (!empty($returnType) && $returnType->isBuiltin()) { - $responseInfo['type'] = $this->getOpenApiTypeFromPhpType($returnType->getName()); + $responseInfo['type'] = $this->getOpenApiTypeFromPhpType(strval($returnType)); } $successRef = null; @@ -383,7 +382,7 @@ protected function determineResponses(array $rules, string $plugin, string $meth } // If the return type is void, use the generic response type - if (empty($successArray['ref']) && !empty($returnType) && $returnType->getName() === 'void') { + if (empty($successArray['ref']) && !empty($returnType) && strval($returnType) === 'void') { $successArray['ref'] = '#/components/responses/GenericSuccessNoBody'; } From a2cb7a5d806177bacf6791607af22c488ca4a66f Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Wed, 10 Sep 2025 11:59:24 +1200 Subject: [PATCH 8/9] Improved array parameter and default value formatting --- Annotations/AnnotationGenerator.php | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index d6ed26e..f48acb4 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -206,6 +206,10 @@ protected function buildParameterAnnotation(string $paramName, array $paramMetad $docType = strtolower(trim($paramDocInfo['type'] ?? '')); $metaType = strtolower(trim($paramMetadata['type'] ?? $docType)); $type = $metaType === 'string' && $docType !== 'string' ? $docType : $metaType; + // If the signature type is array, but the type hinting provides more, use that instead + if ($type === 'array' && strpos($docType, '[]') !== false && strpos($docType, '|') === false) { + $type = $docType; + } $typesMap = []; // Check for pipes and try to list possible types foreach (explode('|', $type) as $typePart) { @@ -520,7 +524,12 @@ protected function buildSchemaObjectArray(string $type, string $subType = '', st } if ($this->shouldIncludeDefault($type, $default)) { - $schemaMap[] = 'default="' . $default . '"'; + $doubleQuote = '"'; + // Don't wrap with quotes for certain values + if (in_array($default, ['{}', "{$doubleQuote}{$doubleQuote}"])) { + $doubleQuote = ''; + } + $schemaMap[] = "default={$doubleQuote}{$default}{$doubleQuote}"; } return ['@OA\Schema' => $schemaMap]; From a8b8dd0f7a4453989df5e2dc4be20079feb748ec Mon Sep 17 00:00:00 2001 From: Jacob Ransom Date: Wed, 10 Sep 2025 12:21:50 +1200 Subject: [PATCH 9/9] A few more minor adjustments --- Annotations/AnnotationGenerator.php | 2 +- Annotations/GlobalApiComponents.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index f48acb4..8d0b400 100644 --- a/Annotations/AnnotationGenerator.php +++ b/Annotations/AnnotationGenerator.php @@ -526,7 +526,7 @@ protected function buildSchemaObjectArray(string $type, string $subType = '', st if ($this->shouldIncludeDefault($type, $default)) { $doubleQuote = '"'; // Don't wrap with quotes for certain values - if (in_array($default, ['{}', "{$doubleQuote}{$doubleQuote}"])) { + if (in_array($default, ['{}', 'false', 'true', "{$doubleQuote}{$doubleQuote}"])) { $doubleQuote = ''; } $schemaMap[] = "default={$doubleQuote}{$default}{$doubleQuote}"; diff --git a/Annotations/GlobalApiComponents.php b/Annotations/GlobalApiComponents.php index e3b9211..2e2f30b 100644 --- a/Annotations/GlobalApiComponents.php +++ b/Annotations/GlobalApiComponents.php @@ -60,7 +60,7 @@ * additionalProperties=true, * @OA\Property(property="result", type="string", enum={"success"}, example="success"), * @OA\Property(property="message", type="string", example="ok"), - * @OA\Property(property="code", type="integer", nullable=true, default=null) + * @OA\Property(property="code", type="integer", example="200") * ) * * Generic Error object @@ -72,7 +72,7 @@ * additionalProperties=true, * @OA\Property(property="result", type="string", enum={"error"}, example="error"), * @OA\Property(property="message", type="string", example="There was an error"), - * @OA\Property(property="code", type="integer", nullable=true, default=null) + * @OA\Property(property="code", type="integer") * ) * * @OA\Schema(