diff --git a/Annotations/AnnotationGenerator.php b/Annotations/AnnotationGenerator.php index da5ec93..8d0b400 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']); @@ -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, ]; @@ -167,9 +168,37 @@ 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); + return str_replace(['{plugin}', '{method}'], [$plugin, $method], $virtualPathTemplate); } protected function buildParameterAnnotation(string $paramName, array $paramMetadata, array $paramDocInfo): array @@ -177,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) { @@ -197,7 +230,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, ]; } @@ -300,8 +333,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]; } @@ -319,16 +352,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); @@ -337,21 +364,61 @@ 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()); + if (!empty($returnType) && $returnType->isBuiltin()) { + $responseInfo['type'] = $this->getOpenApiTypeFromPhpType(strval($returnType)); + } $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) && strval($returnType) === '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... @@ -370,13 +437,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; @@ -434,7 +509,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 +523,32 @@ 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 - $schemaMap[] = 'default="' . $default . '"'; + if ($this->shouldIncludeDefault($type, $default)) { + $doubleQuote = '"'; + // Don't wrap with quotes for certain values + if (in_array($default, ['{}', 'false', 'true', "{$doubleQuote}{$doubleQuote}"])) { + $doubleQuote = ''; + } + $schemaMap[] = "default={$doubleQuote}{$default}{$doubleQuote}"; } 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 = []; @@ -493,7 +586,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'] . '")'; @@ -502,6 +596,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]; @@ -510,7 +607,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 af76b57..2e2f30b 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", example="200") + * ) + * + * 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") + * ) + * + * @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,81 @@ * 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\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="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", @@ -157,7 +210,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( @@ -166,8 +219,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( @@ -180,8 +232,7 @@ * type="string", * enum={"xml","json","csv","tsv","html","rss","original"}, * default="xml" - * ), - * example="xml" + * ) * ) * * @OA\Parameter( @@ -194,8 +245,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 @@ -206,8 +256,7 @@ * in="query", * description="Matomo site ID.", * required=true, - * @OA\Schema(type="integer"), - * example=1 + * @OA\Schema(type="integer", example=1) * ) * * @OA\Parameter( @@ -216,8 +265,7 @@ * in="query", * description="Matomo site ID.", * required=false, - * @OA\Schema(type="integer"), - * example=1 + * @OA\Schema(type="integer", example=1) * ) * * @OA\Parameter( @@ -226,8 +274,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( @@ -236,8 +283,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( @@ -246,8 +292,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( @@ -256,8 +301,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( @@ -281,14 +325,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")) @@ -301,14 +345,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")) @@ -321,15 +365,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; } }