diff --git a/.github/workflows/phplint.yml b/.github/workflows/phplint.yml index a82e951..fcc6b88 100644 --- a/.github/workflows/phplint.yml +++ b/.github/workflows/phplint.yml @@ -1,6 +1,6 @@ on: [push] -name: "CI PHP" +name: "CI PHP lint" jobs: test: @@ -24,7 +24,7 @@ jobs: coverage: pcov extensions: intl, gd, zip, pdo, sqlite, pdo_sqlite, dom, curl, libxml, mbstring, fileinfo, exif, iconv ini-values: memory_limit=-1,disable_functions="",pcov.exclude="~(vendor|tests|node_modules)~",pcov.directory="./" - php-version: 8.3 + php-version: 7.4 tools: composer:v2 - name: Composer Install diff --git a/.github/workflows/phptest.yml b/.github/workflows/phptest.yml index cd01d84..e45cf85 100644 --- a/.github/workflows/phptest.yml +++ b/.github/workflows/phptest.yml @@ -1,7 +1,7 @@ on: - push -name: CI PHP +name: "CI PHP test" jobs: test: diff --git a/composer.json b/composer.json index 6ee557f..888b0e6 100644 --- a/composer.json +++ b/composer.json @@ -26,10 +26,11 @@ "barryvdh/laravel-ide-helper": "^2.12|dev-master", "brianium/paratest": "^6.2|^7.4", "friendsofphp/php-cs-fixer": "^3.5", + "larastan/larastan": "^1.0|^2.0", "nunomaduro/collision": "^5.3|^6.0|^8.0", - "nunomaduro/larastan": "^1.0|^2.4", "orchestra/testbench": "^6.15|^7.0|^8.0|^9.0", "phpunit/phpunit": "^9.3|^10.5", + "slevomat/coding-standard": "^8.15", "spatie/laravel-ray": "^1.23", "squizlabs/php_codesniffer": "^3.6", "vimeo/psalm": "^4.8|^5.6" @@ -51,7 +52,10 @@ "test-coverage": "phpunit --coverage-html coverage" }, "config": { - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "dealerdirect/phpcodesniffer-composer-installer": true + } }, "extra": { "laravel": { diff --git a/phpcs.xml b/phpcs.xml index 9f4bd01..db4de98 100644 --- a/phpcs.xml +++ b/phpcs.xml @@ -1,15 +1,14 @@ - + Standard Based on PSR2 + src tests - - - - - - + tests/resources/database/migrations/*.php + + + @@ -18,4 +17,192 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon b/phpstan.neon index d27288b..5c790a7 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,5 +1,5 @@ includes: - - ./vendor/nunomaduro/larastan/extension.neon + - ./vendor/larastan/larastan/extension.neon parameters: @@ -7,13 +7,8 @@ parameters: - src - tests - # The level 8 is the highest level - level: 5 + # The level 9 is the highest level + level: 8 -# ignoreErrors: -# - '#PHPDoc tag @var#' -# -# excludePaths: -# - ./*/*/FileToBeExcluded.php - - checkMissingIterableValueType: false + excludePaths: + - ./tests/Stubs/** diff --git a/src/Commands/ExportRequestDocsCommand.php b/src/Commands/ExportRequestDocsCommand.php index a939af1..660810e 100644 --- a/src/Commands/ExportRequestDocsCommand.php +++ b/src/Commands/ExportRequestDocsCommand.php @@ -3,50 +3,49 @@ namespace Rakutentech\LaravelRequestDocs\Commands; use ErrorException; -use Exception; use Illuminate\Console\Command; use Illuminate\Support\Collection; use Rakutentech\LaravelRequestDocs\LaravelRequestDocs; use Rakutentech\LaravelRequestDocs\LaravelRequestDocsToOpenApi; +use Throwable; class ExportRequestDocsCommand extends Command { - private LaravelRequestDocs $laravelRequestDocs; - private LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi; - - public function __construct(LaravelRequestDocs $laravelRequestDoc, LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi) - { - parent::__construct(); - - $this->laravelRequestDocs = $laravelRequestDoc; - $this->laravelRequestDocsToOpenApi = $laravelRequestDocsToOpenApi; - } - /** * The name and signature of the console command. - * - * @var string */ + // phpcs:ignore protected $signature = 'laravel-request-docs:export {path? : Export file location} {--sort=default : Sort the data by route names} {--groupby=default : Group the data by API URI} {--force : Whether to overwrite existing file}'; - /** * The console command description. - * - * @var string */ + // phpcs:ignore protected $description = 'Generate OpenAPI collection as json file'; + private LaravelRequestDocs $laravelRequestDocs; + private string $exportFilePath; + private LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi; + + public function __construct(LaravelRequestDocs $laravelRequestDoc, LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi) + { + $this->laravelRequestDocsToOpenApi = $laravelRequestDocsToOpenApi; + + parent::__construct(); + + $this->laravelRequestDocs = $laravelRequestDoc; + } + /** * Execute the console command. */ - public function handle() + public function handle(): int { if (!$this->confirmFilePathAvailability()) { //silently stop command @@ -56,7 +55,7 @@ public function handle() try { //get the excluded methods list from config $excludedMethods = config('request-docs.open_api.exclude_http_methods', []); - $excludedMethods = array_map(fn($item) => strtolower($item), $excludedMethods); + $excludedMethods = array_map(static fn ($item) => strtolower($item), $excludedMethods); //filter while method apis to export $showGet = !in_array('get', $excludedMethods); @@ -78,13 +77,13 @@ public function handle() // Loop and split Doc by the `methods` property. $docs = $this->laravelRequestDocs->splitByMethods($docs); - $docs = $this->laravelRequestDocs->sortDocs($docs, $this->option('sort')); - $docs = $this->laravelRequestDocs->groupDocs($docs, $this->option('groupby')); + $docs = $this->laravelRequestDocs->sortDocs($docs, is_string($this->option('sort')) ? $this->option('sort') : 'default'); + $docs = $this->laravelRequestDocs->groupDocs($docs, is_string($this->option('groupby')) ? $this->option('groupby') : 'default'); if (!$this->writeApiDocsToFile($docs)) { throw new ErrorException("Failed to write on [{$this->exportFilePath}] file."); } - } catch (Exception $exception) { + } catch (Throwable $exception) { $this->error('Error : ' . $exception->getMessage()); return self::FAILURE; } @@ -92,9 +91,6 @@ public function handle() return self::SUCCESS; } - /** - * @return bool - */ private function confirmFilePathAvailability(): bool { $path = $this->argument('path'); @@ -109,10 +105,7 @@ private function confirmFilePathAvailability(): bool if (file_exists($this->exportFilePath)) { if (!$this->option('force')) { - if ($this->confirm("File exists on [{$path}]. Overwrite?", false) == true) { - return true; - } - return false; + return $this->confirm("File exists on [{$path}]. Overwrite?", false) === true; } } @@ -120,14 +113,13 @@ private function confirmFilePathAvailability(): bool } /** - * @param $docs - * @return bool + * @param \Illuminate\Support\Collection $docs */ private function writeApiDocsToFile(Collection $docs): bool { $content = json_encode( $this->laravelRequestDocsToOpenApi->openApi($docs->all())->toArray(), - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, ); $targetDirectory = dirname($this->exportFilePath); diff --git a/src/Controllers/LaravelRequestDocsController.php b/src/Controllers/LaravelRequestDocsController.php index 098c39a..974a6f5 100644 --- a/src/Controllers/LaravelRequestDocsController.php +++ b/src/Controllers/LaravelRequestDocsController.php @@ -8,17 +8,16 @@ use Illuminate\Routing\Controller; use Rakutentech\LaravelRequestDocs\LaravelRequestDocs; use Rakutentech\LaravelRequestDocs\LaravelRequestDocsToOpenApi; -use Symfony\Component\HttpFoundation\BinaryFileResponse; class LaravelRequestDocsController extends Controller { - private LaravelRequestDocs $laravelRequestDocs; + private LaravelRequestDocs $laravelRequestDocs; private LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi; public function __construct(LaravelRequestDocs $laravelRequestDoc, LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi) { - $this->laravelRequestDocs = $laravelRequestDoc; $this->laravelRequestDocsToOpenApi = $laravelRequestDocsToOpenApi; + $this->laravelRequestDocs = $laravelRequestDoc; } /** @@ -36,7 +35,7 @@ public function index(Request $request): Response public function api(Request $request): JsonResponse { $showGet = !$request->has('showGet') || $request->input('showGet') === 'true'; - $showPost = !$request->has('showPost') || $request->input('showPost') == 'true'; + $showPost = !$request->has('showPost') || $request->input('showPost') === 'true'; $showPut = !$request->has('showPut') || $request->input('showPut') === 'true'; $showPatch = !$request->has('showPatch') || $request->input('showPatch') === 'true'; $showDelete = !$request->has('showDelete') || $request->input('showDelete') === 'true'; @@ -65,9 +64,9 @@ public function api(Request $request): JsonResponse $this->laravelRequestDocsToOpenApi->openApi($docs->all())->toArray(), Response::HTTP_OK, [ - 'Content-type' => 'application/json; charset=utf-8' + 'Content-type' => 'application/json; charset=utf-8', ], - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, ); } @@ -77,13 +76,15 @@ public function api(Request $request): JsonResponse [ 'Content-type' => 'application/json; charset=utf-8', ], - JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, ); } /** * @codeCoverageIgnore - * @param \Illuminate\Http\Request $request + */ + + /** * @return \Symfony\Component\HttpFoundation\BinaryFileResponse|\Illuminate\Http\JsonResponse */ public function assets(Request $request) @@ -92,24 +93,31 @@ public function assets(Request $request) $path = end($path); // read js, css from dist folder $path = base_path() . "/vendor/rakutentech/laravel-request-docs/resources/dist/_astro/" . $path; + if (file_exists($path)) { $headers = ['Content-Type' => 'text/plain']; + // set MIME type to js module if (str_ends_with($path, '.js')) { $headers = ['Content-Type' => 'application/javascript']; } + if (str_ends_with($path, '.css')) { $headers = ['Content-Type' => 'text/css']; } + if (str_ends_with($path, '.woff')) { $headers = ['Content-Type' => 'font/woff']; } + if (str_ends_with($path, '.woff2')) { $headers = ['Content-Type' => 'font/woff2']; } + if (str_ends_with($path, '.png')) { $headers = ['Content-Type' => 'image/png']; } + if (str_ends_with($path, '.jpg')) { $headers = ['Content-Type' => 'image/jpg']; } @@ -119,19 +127,18 @@ public function assets(Request $request) $headers['Expires'] = gmdate('D, d M Y H:i:s \G\M\T', time() + 1800); return response()->file($path, $headers); } + return response()->json(['error' => 'file not found'], 404); } /** * @codeCoverageIgnore - * @param \Illuminate\Http\Request $request - * @return \Illuminate\Http\JsonResponse */ - public function config(Request $request) + public function config(Request $request): JsonResponse { $config = [ - 'title' => config('request-docs.title'), - 'default_headers' => config('request-docs.default_headers'), + 'title' => config('request-docs.title'), + 'default_headers' => config('request-docs.default_headers'), ]; return response()->json($config); } diff --git a/src/Doc.php b/src/Doc.php index 49a6f5c..b6ae167 100644 --- a/src/Doc.php +++ b/src/Doc.php @@ -11,8 +11,6 @@ class Doc implements Arrayable { /** * The URI pattern the route responds to. - * - * @var string */ private string $uri; @@ -35,31 +33,23 @@ class Doc implements Arrayable /** * The route controller short name. * Empty if the route action is a closure. - * - * @var string */ private string $controller; /** * The controller fully qualified name used for the route. * Empty if the route action is a closure. - * - * @var string */ private string $controllerFullPath; /** * The (Controller) method name of the route action. * Empty if the route action is a closure. - * - * @var string */ private string $method; /** * The HTTP method the route responds to. - * - * @var string */ private string $httpMethod; @@ -72,8 +62,6 @@ class Doc implements Arrayable /** * The additional description about this route. - * - * @var string */ private string $docBlock; @@ -87,34 +75,25 @@ class Doc implements Arrayable /** * A list of route path parameters, such as `/users/{id}`. * - * @var array + * @var array */ private array $pathParameters; /** * The group name of the route. - * - * @var string */ private string $group; /** * The group index of the group, determine the ordering. - * - * @var int */ private int $groupIndex; /** - * @param string $uri * @param string[] $methods * @param string[] $middlewares - * @param string $controller - * @param string $controllerFullPath - * @param string $method - * @param string $httpMethod + * @param array $pathParameters * @param array $rules - * @param string $docBlock */ public function __construct( string $uri, @@ -141,17 +120,11 @@ public function __construct( $this->responses = []; } - /** - * @return string - */ public function getUri(): string { return $this->uri; } - /** - * @param string $uri - */ public function setUri(string $uri): void { $this->uri = $uri; @@ -189,49 +162,31 @@ public function setMiddlewares(array $middlewares): void $this->middlewares = $middlewares; } - /** - * @return string - */ public function getController(): string { return $this->controller; } - /** - * @param string $controller - */ public function setController(string $controller): void { $this->controller = $controller; } - /** - * @return string - */ public function getControllerFullPath(): string { return $this->controllerFullPath; } - /** - * @param string $controllerFullPath - */ public function setControllerFullPath(string $controllerFullPath): void { $this->controllerFullPath = $controllerFullPath; } - /** - * @return string - */ public function getMethod(): string { return $this->method; } - /** - * @param string $method - */ public function setMethod(string $method): void { $this->method = $method; @@ -310,7 +265,7 @@ public function isClosure(): bool } /** - * @return array + * @return string[] */ public function getResponses(): array { @@ -318,7 +273,7 @@ public function getResponses(): array } /** - * @param array $responses + * @param string[] $responses */ public function setResponses(array $responses): void { @@ -326,18 +281,21 @@ public function setResponses(array $responses): void } /** - * @return array + * @return array */ public function getPathParameters(): array { return $this->pathParameters; } - public function clone(): Doc + public function clone(): self { return clone $this; } + /** + * @return array + */ public function toArray(): array { $result = [ diff --git a/src/LaravelRequestDocs.php b/src/LaravelRequestDocs.php index 85c27fe..8de677b 100644 --- a/src/LaravelRequestDocs.php +++ b/src/LaravelRequestDocs.php @@ -23,12 +23,6 @@ public function __construct(RoutePath $routePath) /** * Get a collection of {@see \Rakutentech\LaravelRequestDocs\Doc} with route and rules information. * - * @param bool $showGet - * @param bool $showPost - * @param bool $showPut - * @param bool $showPatch - * @param bool $showDelete - * @param bool $showHead * @return \Illuminate\Support\Collection * @throws \ReflectionException */ @@ -47,9 +41,8 @@ public function getDocs( Request::METHOD_PATCH => $showPatch, Request::METHOD_DELETE => $showDelete, Request::METHOD_HEAD => $showHead, - ], fn (bool $shouldShow) => $shouldShow); + ], static fn (bool $shouldShow) => $shouldShow); - /** @var string[] $methods */ $methods = array_keys($filteredMethods); $docs = $this->getControllersInfo($methods); @@ -66,7 +59,6 @@ public function getDocs( */ public function splitByMethods(Collection $docs): Collection { - /** @var \Illuminate\Support\Collection $splitDocs */ $splitDocs = collect(); foreach ($docs as $doc) { @@ -85,7 +77,6 @@ public function splitByMethods(Collection $docs): Collection * Sort by `$sortBy`. * * @param \Illuminate\Support\Collection $docs - * @param string|null $sortBy * @return \Illuminate\Support\Collection */ public function sortDocs(Collection $docs, ?string $sortBy = 'default'): Collection @@ -108,9 +99,7 @@ public function sortDocs(Collection $docs, ?string $sortBy = 'default'): Collect Request::METHOD_HEAD, ]; - $sorted = $docs->sortBy(function (Doc $doc) use ($methods) { - return array_search($doc->getHttpMethod(), $methods); - }, SORT_NUMERIC); + $sorted = $docs->sortBy(static fn (Doc $doc) => array_search($doc->getHttpMethod(), $methods), SORT_NUMERIC); return $sorted->values(); } @@ -137,9 +126,7 @@ public function groupDocs(Collection $docs, ?string $groupBy = 'default'): Colle } return $docs - ->sortBy(function (Doc $doc) { - return $doc->getGroup() . $doc->getGroupIndex(); - }, SORT_NATURAL) + ->sortBy(static fn (Doc $doc) => $doc->getGroup() . $doc->getGroupIndex(), SORT_NATURAL) ->values(); } @@ -150,10 +137,11 @@ public function groupDocs(Collection $docs, ?string $groupBy = 'default'): Colle * @return \Illuminate\Support\Collection * @throws \ReflectionException */ + // TODO Should reduce complexity + // phpcs:ignore public function getControllersInfo(array $onlyMethods): Collection { $docs = collect(); - /** @var \Illuminate\Support\Collection $docs */ $routes = Route::getRoutes()->getRoutes(); @@ -173,7 +161,7 @@ public function getControllersInfo(array $onlyMethods): Collection $routeMethods = array_intersect($route->methods, $onlyMethods); - if (empty($routeMethods)) { + if (count($routeMethods) === 0) { continue; } @@ -183,6 +171,7 @@ public function getControllersInfo(array $onlyMethods): Collection // `$route->action['uses']` value is either 'Class@method' string or Closure. if (is_string($route->action['uses']) && !RouteAction::containsSerializedClosure($route->action)) { + /** @var array{0: class-string<\Illuminate\Routing\Controller>, 1: string} $controllerCallback */ $controllerCallback = Str::parseCallback($route->action['uses']); $controllerFullPath = $controllerCallback[0]; $method = $controllerCallback[1]; @@ -191,15 +180,19 @@ public function getControllersInfo(array $onlyMethods): Collection $pathParameters = []; $pp = $this->routePath->getPathParameters($route); + // same format as rules foreach ($pp as $k => $v) { $pathParameters[$k] = [$v]; } + /** @var string[] $middlewares */ + $middlewares = $route->middleware(); + $doc = new Doc( $route->uri, $routeMethods, - config('request-docs.hide_meta_data') ? [] : $route->middleware(), + config('request-docs.hide_meta_data') ? [] : $middlewares, config('request-docs.hide_meta_data') ? '' : $controllerName, config('request-docs.hide_meta_data') ? '' : $controllerFullPath, config('request-docs.hide_meta_data') ? '' : $method, @@ -223,6 +216,8 @@ public function getControllersInfo(array $onlyMethods): Collection * @return \Illuminate\Support\Collection * @throws \ReflectionException */ + // TODO Should reduce complexity + // phpcs:ignore public function appendRequestRules(Collection $docs): Collection { foreach ($docs as $doc) { @@ -241,19 +236,30 @@ public function appendRequestRules(Collection $docs): Collection $doc->setResponses($this->customResponsesDocComment($controllerMethodDocComment)); $lrdDocComments = []; + foreach ($controllerReflectionMethod->getParameters() as $param) { - /** @var \ReflectionNamedType|\ReflectionUnionType|\ReflectionIntersectionType|null $namedType */ $namedType = $param->getType(); + if (!$namedType) { continue; } try { + if (!method_exists($namedType, 'getName')) { + continue; + } + $requestClassName = $namedType->getName(); - $reflectionClass = new ReflectionClass($requestClassName); + + if (!class_exists($requestClassName)) { + continue; + } + + $reflectionClass = new ReflectionClass($requestClassName); + try { $requestObject = $reflectionClass->newInstance(); - } catch (Throwable $th) { + } catch (Throwable $ex) { $requestObject = $reflectionClass->newInstanceWithoutConstructor(); } @@ -265,7 +271,7 @@ public function appendRequestRules(Collection $docs): Collection try { $doc->mergeRules($this->flattenRules($requestObject->$requestMethod())); $requestReflectionMethod = new ReflectionMethod($requestObject, $requestMethod); - } catch (Throwable $e) { + } catch (Throwable $ex) { $doc->mergeRules($this->rulesByRegex($requestClassName, $requestMethod)); $requestReflectionMethod = new ReflectionMethod($requestClassName, $requestMethod); } @@ -278,55 +284,59 @@ public function appendRequestRules(Collection $docs): Collection $lrdDocComments[] = $requestMethodLrdComment; $doc->mergeRules($requestMethodDocRules); } - } catch (Throwable $e) { + } catch (Throwable $ex) { // Do nothing. } } $lrdDocComments[] = $controllerMethodLrdComment; - $lrdDocComments = array_filter($lrdDocComments, fn($s) => $s !== ''); + $lrdDocComments = array_filter($lrdDocComments, static fn ($s) => $s !== ''); $doc->setDocBlock(join("\n", $lrdDocComments)); $doc->mergeRules($controllerMethodDocRules); } + return $docs; } /** * Get description in between @lrd:start and @lrd:end from the doc block. - * - * @param string $docComment - * @return string */ public function lrdDocComment(string $docComment): string { $lrdComment = ""; $counter = 0; + foreach (explode("\n", $docComment) as $comment) { $comment = trim($comment); + // check contains in string if (Str::contains($comment, '@lrd')) { $counter++; } - if ($counter == 1 && !Str::contains($comment, '@lrd')) { - if (Str::startsWith($comment, '*')) { - $comment = substr($comment, 1); - } - // remove first character from string - $lrdComment .= $comment . "\n"; + + if ($counter !== 1 || Str::contains($comment, '@lrd')) { + continue; } + + if (Str::startsWith($comment, '*')) { + $comment = substr($comment, 1); + } + + // remove first character from string + $lrdComment .= $comment . "\n"; } + return $lrdComment; } /** * Parse rules from the request. * - * @param array $mixedRules + * @param array|string> $mixedRules * @return array Key is attribute, value is a list of rules. */ public function flattenRules(array $mixedRules): array { - /** @var array $rules */ $rules = []; foreach ($mixedRules as $attribute => $rule) { @@ -336,7 +346,6 @@ public function flattenRules(array $mixedRules): array } if (is_array($rule)) { - /** @var string[] $rulesStrs */ $rulesStrs = []; foreach ($rule as $ruleItem) { @@ -362,45 +371,47 @@ public function flattenRules(array $mixedRules): array public function rulesByRegex(string $requestClassName, string $methodName): array { $data = new ReflectionMethod($requestClassName, $methodName); - $lines = file($data->getFileName()); + $lines = file((string) $data->getFileName()); + + if ($lines === false) { + return []; + } + $rules = []; for ($i = $data->getStartLine() - 1; $i <= $data->getEndLine() - 1; $i++) { // check if line is a comment $trimmed = trim($lines[$i]); + if (Str::startsWith($trimmed, '//') || Str::startsWith($trimmed, '#')) { continue; // @codeCoverageIgnore } + // check if => in string, only pick up rules that are coded on single line - if (Str::contains($lines[$i], '=>')) { - preg_match_all("/(?:'|\").*?(?:'|\")/", $lines[$i], $matches); - $rules[] = $matches; + if (!Str::contains($lines[$i], '=>')) { + continue; } + + preg_match_all("/(?:'|\").*?(?:'|\")/", $lines[$i], $matches); + $rules[] = $matches; } return collect($rules) - ->filter(function ($item) { - return count($item[0]) > 0; - }) - // @phpstan-ignore-next-line - ->transform(function ($item) { + ->filter(static fn ($item) => count($item[0]) > 0) + ->map(static function (array $item) { $fieldName = Str::of($item[0][0])->replace(['"', "'"], ''); - $definedFieldRules = collect(array_slice($item[0], 1))->transform(function ($rule) { - return Str::of($rule)->replace(['"', "'"], '')->__toString(); - })->toArray(); + $definedFieldRules = collect(array_slice($item[0], 1))->transform(static fn ($rule) => Str::of($rule)->replace(['"', "'"], '')->__toString())->toArray(); return ['key' => $fieldName, 'rules' => $definedFieldRules]; }) ->keyBy('key') - ->transform(function ($item) { - return $item['rules']; - })->toArray(); + ->map(static fn ($item) => $item['rules']) + ->toArray(); } /** * Get additional rules by parsing the doc block. * - * @param string $docComment * @return array */ private function customParamsDocComment(string $docComment): array @@ -416,9 +427,11 @@ private function customParamsDocComment(string $docComment): array $comments = $this->multiExplode([' ', '|'], $comment); - if (count($comments) > 0) { - $params[$comments[0]] = array_values(array_filter($comments, fn ($item) => $item !== $comments[0])); + if (count($comments) <= 0) { + continue; } + + $params[$comments[0]] = array_values(array_filter($comments, static fn ($item) => $item !== $comments[0])); } return $params; @@ -427,12 +440,10 @@ private function customParamsDocComment(string $docComment): array /** * Get responses by parsing the doc block. * - * @param string $docComment * @return string[] A list of responses. Will overwrite the default responses. */ private function customResponsesDocComment(string $docComment): array { - /** @var string[] $params */ $params = []; foreach (explode("\n", $docComment) as $comment) { @@ -453,7 +464,7 @@ private function customResponsesDocComment(string $docComment): array } /** - * @param string[] $delimiters + * @param array $delimiters * @return string[] */ private function multiExplode(array $delimiters, string $string): array @@ -474,7 +485,6 @@ private function groupDocsByAPIURI(Collection $docs): void $regex = count($patterns) > 0 ? '(' . implode('|', $patterns) . ')' : ''; // A collection to remember indexes with `group` => `index` pair. - /** @var \Illuminate\Support\Collection $groupIndexes */ $groupIndexes = collect(); foreach ($docs as $doc) { @@ -487,7 +497,7 @@ private function groupDocsByAPIURI(Collection $docs): void $group = $this->getGroupByURI($prefix ?? '', $doc->getUri()); $this->rememberGroupIndex($groupIndexes, $group); - $this->setGroupInfo($doc, $group, $groupIndexes->get($group)); + $this->setGroupInfo($doc, $group, (int) $groupIndexes->get($group)); } } @@ -503,7 +513,7 @@ private function getGroupByURI(string $prefix, string $uri): string } // Glue the prefix + "first path after prefix" to form a group. - $after = (Str::after($uri, $prefix)); + $after = Str::after($uri, $prefix); $paths = explode('/', $after); return $prefix . $paths[0]; } @@ -516,13 +526,12 @@ private function getGroupByURI(string $prefix, string $uri): string private function groupDocsByFQController(Collection $docs): void { // To remember group indexes with group => index pair. - /** @var \Illuminate\Support\Collection $groupIndexes */ $groupIndexes = collect(); foreach ($docs as $doc) { $group = $doc->getControllerFullPath(); $this->rememberGroupIndex($groupIndexes, $group); - $this->setGroupInfo($doc, $group, $groupIndexes->get($group)); + $this->setGroupInfo($doc, $group, (int) $groupIndexes->get($group)); } } @@ -550,10 +559,6 @@ private function setGroupInfo(Doc $doc, string $group, int $groupIndex): void $doc->setGroupIndex($groupIndex); } - /** - * @param \ReflectionMethod $reflectionMethod - * @return string - */ private function getDocComment(ReflectionMethod $reflectionMethod): string { $docComment = $reflectionMethod->getDocComment(); diff --git a/src/LaravelRequestDocsFacade.php b/src/LaravelRequestDocsFacade.php index c2640a6..011be85 100644 --- a/src/LaravelRequestDocsFacade.php +++ b/src/LaravelRequestDocsFacade.php @@ -10,6 +10,9 @@ */ class LaravelRequestDocsFacade extends Facade { + /** + * @inheritDoc + */ protected static function getFacadeAccessor() { return LaravelRequestDocs::class; diff --git a/src/LaravelRequestDocsMiddleware.php b/src/LaravelRequestDocsMiddleware.php index b7837b2..3821915 100644 --- a/src/LaravelRequestDocsMiddleware.php +++ b/src/LaravelRequestDocsMiddleware.php @@ -15,9 +15,27 @@ class LaravelRequestDocsMiddleware extends QueryLogger { - private array $queries = []; - private array $logs = []; - private array $models = []; + /** + * @var array + */ + private array $queries = []; + + /** + * @var array + * + * The object structure: + * object{level: string, message: string, context: array} + */ + private array $logs = []; + + /** + * @var array> + */ + private array $models = []; + + /** + * @var array + */ private array $modelsTimeline = []; /** @@ -32,30 +50,31 @@ public function handle(Request $request, Closure $next): Response } if (!config('app.debug') && $request->headers->has('X-Request-LRD')) { + /** @var \Illuminate\Http\JsonResponse $response */ $response = $next($request); $jsonContent = json_encode([ - // because php stan is not what it used to be - /** @phpstan-ignore-next-line */ - 'data' => $response->getData() + 'data' => $response->getData(), ]); - $response->setContent($jsonContent); + $response->setContent((string) $jsonContent); return $response; } if (!config('app.debug')) { return $next($request); } + if (!$request->headers->has('X-Request-LRD')) { return $next($request); } - if (!config('request-docs.hide_sql_data')) { $this->listenToDB(); } + if (!config('request-docs.hide_logs_data')) { $this->listenToLogs(); } + if (!config('request-docs.hide_models_data')) { $this->listenToModels(); } @@ -80,61 +99,65 @@ public function handle(Request $request, Closure $next): Response $jsonContent = json_encode($content); + if (!$jsonContent) { + return $next($request); + } + if (in_array('gzip', $request->getEncodings()) && function_exists('gzencode')) { $level = 9; // Best compression. $compressedContent = gzencode($jsonContent, $level); + if ($compressedContent === false) { + return $next($request); + } + // Create a new response object with compressed content. $response = new Response($compressedContent); // Add necessary headers. $response->headers->add([ - 'Content-Type' => 'application/json; charset=utf-8', - 'Content-Length' => strlen($compressedContent), + 'Content-Type' => 'application/json; charset=utf-8', + 'Content-Length' => strlen($compressedContent), 'Content-Encoding' => 'gzip', ]); return $response; // Return the response object directly. - } else { - // Fallback for clients that do not support gzip. - $response = new Response($jsonContent); - $response->headers->add([ - 'Content-Type' => 'application/json; charset=utf-8', - ]); - - return $response; } + + // Fallback for clients that do not support gzip. + $response = new Response($jsonContent); + $response->headers->add([ + 'Content-Type' => 'application/json; charset=utf-8', + ]); + + return $response; } public function listenToDB(): void { - DB::listen(function (QueryExecuted $query) { + DB::listen(function (QueryExecuted $query): void { $this->queries[] = $this->getMessages($query); }); } public function listenToLogs(): void { - Log::listen(function ($message) { + Log::listen(function ($message): void { $this->logs[] = $message; }); } public function listenToModels(): void { - Event::listen('eloquent.*', function ($event, $models) { + Event::listen('eloquent.*', function (string $event, array $models): void { foreach (array_filter($models) as $model) { + /** @var \Illuminate\Database\Eloquent\Model $model */ + // doing and booted ignore - if (Str::startsWith($event, 'eloquent.booting') - || Str::startsWith($event, 'eloquent.booted') - || Str::startsWith($event, 'eloquent.retrieving') - || Str::startsWith($event, 'eloquent.creating') - || Str::startsWith($event, 'eloquent.saving') - || Str::startsWith($event, 'eloquent.updating') - || Str::startsWith($event, 'eloquent.deleting') - ) { + if ($this->shouldIgnore($event)) { continue; } + // split $event by : and take first part $event = explode(':', $event)[0]; $event = Str::replace('eloquent.', '', $event); @@ -148,11 +171,27 @@ public function listenToModels(): void if (!isset($this->models[$class])) { $this->models[$class] = []; } + if (!isset($this->models[$class][$event])) { $this->models[$class][$event] = 0; } - $this->models[$class][$event] = $this->models[$class][$event] + 1; + + $this->models[$class][$event] += 1; } }); } + + /** + * Event of doing and booted ignore + */ + private function shouldIgnore(string $event): bool + { + return Str::startsWith($event, 'eloquent.booting') + || Str::startsWith($event, 'eloquent.booted') + || Str::startsWith($event, 'eloquent.retrieving') + || Str::startsWith($event, 'eloquent.creating') + || Str::startsWith($event, 'eloquent.saving') + || Str::startsWith($event, 'eloquent.updating') + || Str::startsWith($event, 'eloquent.deleting'); + } } diff --git a/src/LaravelRequestDocsServiceProvider.php b/src/LaravelRequestDocsServiceProvider.php index a174825..9a02bed 100644 --- a/src/LaravelRequestDocsServiceProvider.php +++ b/src/LaravelRequestDocsServiceProvider.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Route; use Rakutentech\LaravelRequestDocs\Commands\ExportRequestDocsCommand; +use Rakutentech\LaravelRequestDocs\Controllers\LaravelRequestDocsController; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -25,33 +26,34 @@ public function configurePackage(Package $package): void // ->hasAssets(); // publish resources/dist/_astro to public/ $this->publishes([ - __DIR__.'/../resources/dist/_astro' => public_path('request-docs/_astro'), - __DIR__.'/../resources/dist/index.html' => public_path('request-docs/index.html'), + __DIR__ . '/../resources/dist/_astro' => public_path('request-docs/_astro'), + __DIR__ . '/../resources/dist/index.html' => public_path('request-docs/index.html'), ], 'request-docs-assets'); } - public function packageBooted() + public function packageBooted(): void { parent::packageBooted(); + if (!config('request-docs.enabled')) { return; } // URL from which the docs will be served. - Route::get(config('request-docs.url'), [\Rakutentech\LaravelRequestDocs\Controllers\LaravelRequestDocsController::class, 'index']) + Route::get(config('request-docs.url'), [LaravelRequestDocsController::class, 'index']) ->name('request-docs.index') ->middleware(config('request-docs.middlewares')); // Following url for api and assets, donot change to config one. - Route::get("request-docs/api", [\Rakutentech\LaravelRequestDocs\Controllers\LaravelRequestDocsController::class, 'api']) + Route::get("request-docs/api", [LaravelRequestDocsController::class, 'api']) ->name('request-docs.api') ->middleware(config('request-docs.middlewares')); - Route::get("request-docs/config", [\Rakutentech\LaravelRequestDocs\Controllers\LaravelRequestDocsController::class, 'config']) + Route::get("request-docs/config", [LaravelRequestDocsController::class, 'config']) ->name('request-docs.config') ->middleware(config('request-docs.middlewares')); - Route::get("request-docs/_astro/{slug}", [\Rakutentech\LaravelRequestDocs\Controllers\LaravelRequestDocsController::class, 'assets']) + Route::get("request-docs/_astro/{slug}", [LaravelRequestDocsController::class, 'assets']) // where slug is either js or css ->where('slug', '.*js|.*css|.*png|.*jpg|.*jpeg|.*gif|.*svg|.*ico|.*woff|.*woff2|.*ttf|.*eot|.*otf|.*map') ->name('request-docs.assets') diff --git a/src/LaravelRequestDocsToOpenApi.php b/src/LaravelRequestDocsToOpenApi.php index 8f7767c..6857318 100644 --- a/src/LaravelRequestDocsToOpenApi.php +++ b/src/LaravelRequestDocsToOpenApi.php @@ -2,15 +2,59 @@ namespace Rakutentech\LaravelRequestDocs; +/** + * @phpstan-type OpenApi array{ + * openapi: string, + * info: array{ + * version: string, + * title: string, + * description: string, + * license: array{ + * name: string, + * url: string, + * }, + * }, + * servers: array{ + * url: string, + * } + * } + * + * @phpstan-type Parameter array{ + * name: string, + * description: string, + * in: string, + * style: string, + * required: bool, + * schema: array{ + * type: string + * } + * } + * + * @phpstan-type Property array{type: string, nullable: bool, format: string} + * + * @phpstan-type RequestBody array{ + * description: string, + * content: array + * } + * } + * > + * } + */ class LaravelRequestDocsToOpenApi { - private array $openApi = []; + /** + * @var OpenApi + */ + private array $openApi; /** - * @param \Rakutentech\LaravelRequestDocs\Doc[] $docs + * @param \Rakutentech\LaravelRequestDocs\Doc[] $docs * @return $this */ - public function openApi(array $docs): LaravelRequestDocsToOpenApi + public function openApi(array $docs): self { $this->openApi['openapi'] = config('request-docs.open_api.version', '3.0.0'); $this->openApi['info']['version'] = config('request-docs.open_api.document_version', '1.0.0'); @@ -19,7 +63,7 @@ public function openApi(array $docs): LaravelRequestDocsToOpenApi $this->openApi['info']['license']['name'] = config('request-docs.open_api.license', 'Apache 2.0'); $this->openApi['info']['license']['url'] = config('request-docs.open_api.license_url', 'https://www.apache.org/licenses/LICENSE-2.0.html'); $this->openApi['servers'][] = [ - 'url' => config('request-docs.open_api.server_url', config('app.url')) + 'url' => config('request-docs.open_api.server_url', config('app.url')), ]; $this->docsToOpenApi($docs); @@ -28,81 +72,48 @@ public function openApi(array $docs): LaravelRequestDocsToOpenApi } /** - * @param \Rakutentech\LaravelRequestDocs\Doc[] $docs - * @return void + * @codeCoverageIgnore */ - private function docsToOpenApi(array $docs): void + public function toJson(): string { - $this->openApi['paths'] = []; - $deleteWithBody = config('request-docs.open_api.delete_with_body', false); - $excludeHttpMethods = array_map(fn($item) => strtolower($item), config('request-docs.open_api.exclude_http_methods', [])); - - foreach ($docs as $doc) { - $httpMethod = strtolower($doc->getHttpMethod()); - - if (in_array($httpMethod, $excludeHttpMethods)) { - continue; - } - - $requestHasFile = false; - $isGet = $httpMethod == 'get'; - $isPost = $httpMethod == 'post'; - $isPut = $httpMethod == 'put'; - $isDelete = $httpMethod == 'delete'; - $uriLeadingSlash = '/' . $doc->getUri(); - - $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['description'] = $doc->getDocBlock(); - $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'] = []; - - foreach ($doc->getPathParameters() as $parameter => $rule) { - $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'][] = $this->makePathParameterItem($parameter, $rule); - } - - $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['responses'] = $this->setAndFilterResponses($doc); - - foreach ($doc->getRules() as $attribute => $rules) { - foreach ($rules as $rule) { - if ($isPost || $isPut || $isDelete) { - $requestHasFile = $this->attributeIsFile($rule); - - if ($requestHasFile) { - break 2; - } - } - } - } - - $contentType = $requestHasFile ? 'multipart/form-data' : 'application/json'; - - if ($isPost || $isPut || ($isDelete && $deleteWithBody)) { - $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['requestBody'] = $this->makeRequestBodyItem($contentType); - } + return collect($this->openApi)->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } - foreach ($doc->getRules() as $attribute => $rules) { - foreach ($rules as $rule) { - if ($isGet) { - $parameter = $this->makeQueryParameterItem($attribute, $rule); - $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'][] = $parameter; - } - if ($isPost || $isPut || ($isDelete && $deleteWithBody)) { - $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['requestBody']['content'][$contentType]['schema']['properties'][$attribute] = $this->makeRequestBodyContentPropertyItem($rule); - } - } - } - } + /** + * @return OpenApi + */ + public function toArray(): array + { + return $this->openApi; } + /** + * @return array + */ protected function setAndFilterResponses(Doc $doc): array { $docResponses = $doc->getResponses(); $configResponses = config('request-docs.open_api.responses', []); - if (empty($docResponses) || empty($configResponses)) { + + if (count($docResponses) === 0 || count($configResponses) === 0) { return $configResponses; } + $rtn = []; + foreach ($docResponses as $responseCode) { $rtn[$responseCode] = $configResponses[$responseCode] ?? $configResponses['default'] ?? []; } + return $rtn; } @@ -111,67 +122,74 @@ protected function attributeIsFile(string $rule): bool return str_contains($rule, 'file') || str_contains($rule, 'image'); } - protected function makeQueryParameterItem(string $attribute, $rule): array + /** + * @return Parameter + */ + protected function makeQueryParameterItem(string $attribute, string $rule): array { - if (is_array($rule)) { - $rule = implode('|', $rule); - } - $parameter = [ - 'name' => $attribute, + return [ + 'name' => $attribute, 'description' => $rule, - 'in' => 'query', - 'style' => 'form', - 'required' => str_contains($rule, 'required'), - 'schema' => [ + 'in' => 'query', + 'style' => 'form', + 'required' => str_contains($rule, 'required'), + 'schema' => [ 'type' => $this->getAttributeType($rule), ], ]; - return $parameter; } - protected function makePathParameterItem(string $attribute, $rule): array + /** + * @param string[] $rule + * @return Parameter + */ + protected function makePathParameterItem(string $attribute, array $rule): array { if (is_array($rule)) { $rule = implode('|', $rule); } - $parameter = [ - 'name' => $attribute, + return [ + 'name' => $attribute, 'description' => $rule, - 'in' => 'path', - 'style' => 'simple', - 'required' => str_contains($rule, 'required'), - 'schema' => [ + 'in' => 'path', + 'style' => 'simple', + 'required' => str_contains($rule, 'required'), + 'schema' => [ 'type' => $this->getAttributeType($rule), ], ]; - return $parameter; } + /** + * @return RequestBody + */ protected function makeRequestBodyItem(string $contentType): array { - $requestBody = [ + return [ 'description' => "Request body", - 'content' => [ + 'content' => [ $contentType => [ 'schema' => [ - 'type' => 'object', + 'type' => 'object', 'properties' => [], ], ], ], ]; - return $requestBody; } + /** + * @return Property + */ protected function makeRequestBodyContentPropertyItem(string $rule): array { $type = $this->getAttributeType($rule); return [ - 'type' => $type, + 'type' => $type, 'nullable' => str_contains($rule, 'nullable'), - 'format' => $this->attributeIsFile($rule) ? 'binary' : $type, + 'format' => $this->attributeIsFile($rule) ? 'binary' : $type, ]; } @@ -180,15 +198,19 @@ protected function getAttributeType(string $rule): string if (str_contains($rule, 'string') || $this->attributeIsFile($rule)) { return 'string'; } + if (str_contains($rule, 'array')) { return 'array'; } + if (str_contains($rule, 'integer')) { return 'integer'; } + if (str_contains($rule, 'boolean')) { return 'boolean'; } + return "object"; } @@ -196,56 +218,56 @@ protected function appendGlobalSecurityScheme(): void { $securityType = config('request-docs.open_api.security.type'); - if ($securityType == null) { + if ($securityType === null) { return; } switch ($securityType) { case 'bearer': $this->openApi['components']['securitySchemes']['bearerAuth'] = [ - 'type' => 'http', - 'name' => config('request-docs.open_api.security.name', 'Bearer Token'), + 'type' => 'http', + 'name' => config('request-docs.open_api.security.name', 'Bearer Token'), 'description' => 'Http Bearer Authorization Token', - 'scheme' => 'bearer' + 'scheme' => 'bearer', ]; $this->openApi['security'][] = [ - 'bearerAuth' => [] + 'bearerAuth' => [], ]; break; case 'basic': $this->openApi['components']['securitySchemes']['basicAuth'] = [ - 'type' => 'http', - 'name' => config('request-docs.open_api.security.name', 'Basic Username and Password'), + 'type' => 'http', + 'name' => config('request-docs.open_api.security.name', 'Basic Username and Password'), 'description' => 'Http Basic Authorization Username and Password', - 'scheme' => 'basic' + 'scheme' => 'basic', ]; $this->openApi['security'][] = [ - 'basicAuth' => [] + 'basicAuth' => [], ]; break; case 'apikey': $this->openApi['components']['securitySchemes']['apiKeyAuth'] = [ - 'type' => 'apiKey', - 'name' => config('request-docs.open_api.security.name', 'api_key'), - 'in' => config('request-docs.open_api.security.position', 'header'), - 'description' => config('app.name').' Provided Authorization Api Key', + 'type' => 'apiKey', + 'name' => config('request-docs.open_api.security.name', 'api_key'), + 'in' => config('request-docs.open_api.security.position', 'header'), + 'description' => config('app.name') . ' Provided Authorization Api Key', ]; $this->openApi['security'][] = ['apiKeyAuth' => []]; break; case 'jwt': $this->openApi['components']['securitySchemes']['bearerAuth'] = [ - 'type' => 'http', - 'scheme' => 'bearer', - 'name' => config('request-docs.open_api.security.name', 'Bearer JWT Token'), - 'in' => config('request-docs.open_api.security.position', 'header'), - 'description' => 'JSON Web Token', - 'bearerFormat' => 'JWT' + 'type' => 'http', + 'scheme' => 'bearer', + 'name' => config('request-docs.open_api.security.name', 'Bearer JWT Token'), + 'in' => config('request-docs.open_api.security.position', 'header'), + 'description' => 'JSON Web Token', + 'bearerFormat' => 'JWT', ]; $this->openApi['security'][] = [ - 'bearerAuth' => [] + 'bearerAuth' => [], ]; break; @@ -255,15 +277,74 @@ protected function appendGlobalSecurityScheme(): void } /** - * @codeCoverageIgnore + * @param \Rakutentech\LaravelRequestDocs\Doc[] $docs */ - public function toJson(): string + // TODO Should reduce complexity + // phpcs:ignore + private function docsToOpenApi(array $docs): void { - return collect($this->openApi)->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - } + $this->openApi['paths'] = []; + $deleteWithBody = config('request-docs.open_api.delete_with_body', false); + $excludeHttpMethods = array_map(static fn ($item) => strtolower($item), config('request-docs.open_api.exclude_http_methods', [])); - public function toArray(): array - { - return $this->openApi; + foreach ($docs as $doc) { + $httpMethod = strtolower($doc->getHttpMethod()); + + if (in_array($httpMethod, $excludeHttpMethods)) { + continue; + } + + $requestHasFile = false; + $isGet = $httpMethod === 'get'; + $isPost = $httpMethod === 'post'; + $isPut = $httpMethod === 'put'; + $isDelete = $httpMethod === 'delete'; + $uriLeadingSlash = '/' . $doc->getUri(); + + $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['description'] = $doc->getDocBlock(); + $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'] = []; + + foreach ($doc->getPathParameters() as $parameter => $rule) { + $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'][] = $this->makePathParameterItem($parameter, $rule); + } + + $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['responses'] = $this->setAndFilterResponses($doc); + + foreach ($doc->getRules() as $attribute => $rules) { + foreach ($rules as $rule) { + if (!$isPost && !$isPut && !$isDelete) { + continue; + } + + $requestHasFile = $this->attributeIsFile($rule); + + if ($requestHasFile) { + break 2; + } + } + } + + $contentType = $requestHasFile ? 'multipart/form-data' : 'application/json'; + + if ($isPost || $isPut || ($isDelete && $deleteWithBody)) { + $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['requestBody'] = $this->makeRequestBodyItem($contentType); + } + + foreach ($doc->getRules() as $attribute => $rules) { + foreach ($rules as $rule) { + if ($isGet) { + $parameter = $this->makeQueryParameterItem($attribute, $rule); + + $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'][] = $parameter; + } + + if (!$isPost && !$isPut && (!$isDelete || !$deleteWithBody)) { + continue; + } + + $this->openApi['paths'][$uriLeadingSlash][$httpMethod]['requestBody']['content'][$contentType]['schema']['properties'][$attribute] = $this->makeRequestBodyContentPropertyItem($rule); + } + } + } } } diff --git a/src/NotFoundWhenProduction.php b/src/NotFoundWhenProduction.php index d60db15..024479d 100644 --- a/src/NotFoundWhenProduction.php +++ b/src/NotFoundWhenProduction.php @@ -15,10 +15,11 @@ public function handle(Request $request, Closure $next): Response { if (app()->environment('prod', 'production')) { return response()->json([ - 'status' => 'forbidden', - 'status_code' => Response::HTTP_FORBIDDEN + 'status' => 'forbidden', + 'status_code' => Response::HTTP_FORBIDDEN, ], Response::HTTP_FORBIDDEN); } + return $next($request); } } diff --git a/src/RoutePath.php b/src/RoutePath.php index 474ea21..70f0354 100644 --- a/src/RoutePath.php +++ b/src/RoutePath.php @@ -38,7 +38,6 @@ public function getPathParameters(Route $route): array * Set route path parameter type. * This method will overwrite `$pathParameters` type with the real types found from route declaration. * - * @param \Illuminate\Routing\Route $route * @param array $pathParameters * @return array * @throws \ReflectionException @@ -47,6 +46,7 @@ private function setParameterType(Route $route, array $pathParameters): array { $bindableParameters = $this->getBindableParameters($route); + /** @var string $parameterName */ foreach ($route->parameterNames() as $position => $parameterName) { // Check `$bindableParameters` existence by comparing the position of route parameters. if (!isset($bindableParameters[$position])) { @@ -79,18 +79,25 @@ private function setParameterType(Route $route, array $pathParameters): array // Skip if user defined column except than default key. // Since we do not have the binding column type information, we set to string type. $bindingField = $route->bindingFieldFor($parameterName); + if ($bindingField !== null && $bindingField !== $model->getKeyName()) { continue; } // Try set type from model key type. - if ($model->getKeyName() === $model->getRouteKeyName()) { - $pathParameters[$parameterName] = self::TYPE_MAP[$model->getKeyType()] ?? $model->getKeyType(); + if ($model->getKeyName() !== $model->getRouteKeyName()) { + continue; } + + $pathParameters[$parameterName] = self::TYPE_MAP[$model->getKeyType()] ?? $model->getKeyType(); } + return $pathParameters; } + /** + * @return array + */ private function getOptionalParameterNames(string $uri): array { preg_match_all('/\{(\w+?)\?\}/', $uri, $matches); @@ -103,16 +110,15 @@ private function getOptionalParameterNames(string $uri): array * This method will filter {@see \Illuminate\Http\Request}. * The ordering of returned parameter should be maintained to match with route path parameter. * - * @param \Illuminate\Routing\Route $route - * @return array + * @return array|null}> * @throws \ReflectionException */ private function getBindableParameters(Route $route): array { - /** @var array $parameters */ $parameters = []; foreach ($route->signatureParameters() as $reflectionParameter) { + /** @var class-string<\Illuminate\Database\Eloquent\Model>|null $className */ $className = Reflector::getParameterClassName($reflectionParameter); // Is native type. @@ -126,6 +132,7 @@ private function getBindableParameters(Route $route): array // Check if the class name is a bindable objects, such as model. Skip if not. $reflectionClass = new ReflectionClass($className); + if (!$reflectionClass->implementsInterface(UrlRoutable::class)) { continue; } @@ -135,11 +142,11 @@ private function getBindableParameters(Route $route): array 'class' => $reflectionClass, ]; } + return $parameters; } /** - * @param \Illuminate\Routing\Route $route * @param array $pathParameters * @return array */ @@ -155,11 +162,11 @@ private function setOptional(Route $route, array $pathParameters): array $pathParameters[$parameter] .= '|required'; } + return $pathParameters; } /** - * @param \Illuminate\Routing\Route $route * @param array $pathParameters * @return array */ @@ -169,6 +176,7 @@ private function setRegex(Route $route, array $pathParameters): array if (!isset($route->wheres[$parameter])) { continue; } + $pathParameters[$parameter] .= '|regex:/' . $route->wheres[$parameter] . '/'; } @@ -178,7 +186,6 @@ private function setRegex(Route $route, array $pathParameters): array /** * Set and return route path parameters, with default string type. * - * @param \Illuminate\Routing\Route $route * @return array */ private function initAllParametersWithStringType(Route $route): array @@ -189,9 +196,6 @@ private function initAllParametersWithStringType(Route $route): array /** * Get type from method reflection parameter. * Return string if type is not declared. - * - * @param \ReflectionParameter $methodParameter - * @return string */ private function getParameterType(ReflectionParameter $methodParameter): string { @@ -210,7 +214,6 @@ private function getParameterType(ReflectionParameter $methodParameter): string } /** - * @param \Illuminate\Routing\Route $route * @param array $pathParameters * @return array */ @@ -218,6 +221,7 @@ private function mutateKeyNameWithBindingField(Route $route, array $pathParamete { $mutatedPath = []; + /** @var string $name */ foreach ($route->parameterNames() as $name) { $bindingName = $route->bindingFieldFor($name); diff --git a/tests/Controllers/LaravelRequestDocsControllerTest.php b/tests/Controllers/LaravelRequestDocsControllerTest.php index 4d67eef..8ffd5f2 100644 --- a/tests/Controllers/LaravelRequestDocsControllerTest.php +++ b/tests/Controllers/LaravelRequestDocsControllerTest.php @@ -4,6 +4,7 @@ use Illuminate\Http\Request; use Illuminate\Http\Response; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Config; use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\Route; @@ -16,7 +17,7 @@ class LaravelRequestDocsControllerTest extends TestCase { - public function testApi() + public function testApiMain(): void { $response = $this->get(route('request-docs.api')) ->assertStatus(Response::HTTP_OK); @@ -32,14 +33,14 @@ public function testApi() $this->assertSame($expected, $response->json()); } - public function testApiCanHideMetadata() + public function testApiCanHideMetadata(): void { Config::set('request-docs.hide_meta_data', true); $response = $this->get(route('request-docs.api')) ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); $this->assertEmpty($docs->pluck('middlewares')->flatten()->toArray()); $this->assertSame([''], $docs->pluck('controller')->flatten()->unique()->toArray()); @@ -49,12 +50,12 @@ public function testApiCanHideMetadata() $this->assertEmpty($docs->pluck('rules')->flatten()->toArray()); } - public function testAbleFetchAllMethods() + public function testAbleFetchAllMethods(): void { $response = $this->get(route('request-docs.api')) ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); $this->assertSame( [ @@ -70,11 +71,11 @@ public function testAbleFetchAllMethods() ->unique() ->sort() ->values() - ->toArray() + ->toArray(), ); } - public function testAbleFilterMethod() + public function testAbleFilterMethod(): void { $methodMap = [ 'showDelete' => Request::METHOD_DELETE, @@ -89,7 +90,7 @@ public function testAbleFilterMethod() $response = $this->get(route('request-docs.api') . '?' . $request . '=false') ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); $expected = array_filter([ Request::METHOD_DELETE, @@ -98,7 +99,7 @@ public function testAbleFilterMethod() Request::METHOD_PATCH, Request::METHOD_POST, Request::METHOD_PUT, - ], fn($expectedMethod) => $expectedMethod !== $method); + ], static fn ($expectedMethod) => $expectedMethod !== $method); $expected = array_values($expected); @@ -109,31 +110,31 @@ public function testAbleFilterMethod() ->unique() ->sort() ->values() - ->toArray() + ->toArray(), ); } } - public function testOnlyRouteURIStartWith() + public function testOnlyRouteURIStartWith(): void { Config::set('request-docs.only_route_uri_start_with', 'welcome'); $response = $this->get(route('request-docs.api')) ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); foreach ($docs as $doc) { $this->assertStringStartsWith('welcome', $doc['uri']); } } - public function testSortDocsByRouteNames() + public function testSortDocsByRouteNames(): void { $response = $this->get(route('request-docs.api')) ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); // Sort manually. $expected = $docs->pluck('uri')->unique()->sort()->values()->toArray(); @@ -141,19 +142,19 @@ public function testSortDocsByRouteNames() $response = $this->get(route('request-docs.api') . '?sort=route_names') ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); $sorted = $docs->pluck('uri')->unique()->values()->toArray(); $this->assertSame($expected, $sorted); } - public function testSortDocsByMethodNames() + public function testSortDocsByMethodNames(): void { $response = $this->get(route('request-docs.api') . '?sort=method_names') ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); $sorted = $docs->pluck('http_method')->unique()->values()->toArray(); $this->assertSame( @@ -165,11 +166,11 @@ public function testSortDocsByMethodNames() Request::METHOD_DELETE, Request::METHOD_HEAD, ], - $sorted + $sorted, ); } - public function testGroupByAPIURI() + public function testGroupByAPIURI(): void { Route::get('users', UserController::class); Route::post('users', UserController::class); @@ -185,88 +186,88 @@ public function testGroupByAPIURI() $response = $this->get(route('request-docs.api') . '?groupby=api_uri') ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); $expected = [ 'api/users' => [ [ 'uri' => 'api/users', 'group' => 'api/users', - 'group_index' => 0 + 'group_index' => 0, ], [ 'uri' => 'api/users/{id}', 'group' => 'api/users', - 'group_index' => 1 - ] + 'group_index' => 1, + ], ], 'api/users_roles' => [ [ 'uri' => 'api/users_roles/{id}', 'group' => 'api/users_roles', - 'group_index' => 0 - ] + 'group_index' => 0, + ], ], 'api/v1/users' => [ [ 'uri' => 'api/v1/users', 'group' => 'api/v1/users', - 'group_index' => 0 + 'group_index' => 0, ], [ 'uri' => 'api/v1/users/{id}/store', 'group' => 'api/v1/users', - 'group_index' => 1 - ] + 'group_index' => 1, + ], ], 'api/v2/users' => [ [ 'uri' => 'api/v2/users', 'group' => 'api/v2/users', - 'group_index' => 0 - ] + 'group_index' => 0, + ], ], 'api/v99/users' => [ [ 'uri' => 'api/v99/users', 'group' => 'api/v99/users', - 'group_index' => 0 - ] + 'group_index' => 0, + ], ], 'users' => [ [ 'uri' => 'users', 'group' => 'users', - 'group_index' => 0 + 'group_index' => 0, ], [ 'uri' => 'users', 'group' => 'users', - 'group_index' => 1 + 'group_index' => 1, ], [ 'uri' => 'users', 'group' => 'users', - 'group_index' => 2 + 'group_index' => 2, ], [ 'uri' => 'users/update', 'group' => 'users', - 'group_index' => 3 - ] - ] + 'group_index' => 3, + ], + ], ]; $grouped = $docs - ->filter(fn(array $item) => Str::startsWith($item['uri'], ['users', 'api'])) - ->map(fn(array $item) => collect($item)->only(['uri', 'group', 'group_index'])->toArray()) + ->filter(static fn (array $item) => Str::startsWith($item['uri'], ['users', 'api'])) + ->map(static fn (array $item) => (new Collection($item))->only(['uri', 'group', 'group_index'])->toArray()) ->groupBy('group') ->toArray(); $this->assertSame($expected, $grouped); } - public function testGroupDocsIsSortedByGroupAndGroupIndex() + public function testGroupDocsIsSortedByGroupAndGroupIndex(): void { // Define routes with random ordering. Route::post('api/v1/users/store', UserController::class); @@ -282,51 +283,65 @@ public function testGroupDocsIsSortedByGroupAndGroupIndex() $response = $this->get(route('request-docs.api') . '?groupby=api_uri') ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); - - $grouped = $docs - ->filter(fn(array $item) => Str::startsWith($item['uri'], ['api'])) - ->map(fn(array $item) => collect($item)->only(['group', 'group_index'])->toArray()) - ->values() - ->toArray(); + $docs = new Collection($response->json()); $expected = [ - [ - 'group' => 'api/v1/health', - 'group_index' => 0 - ], - [ - 'group' => 'api/v1/health', - 'group_index' => 1 - ], - [ - 'group' => 'api/v1/health', - 'group_index' => 2 - ], - [ - 'group' => 'api/v1/users', - 'group_index' => 0 - ], - [ - 'group' => 'api/v1/users', - 'group_index' => 1 - ], - [ - 'group' => 'api/v1/users', - 'group_index' => 2 + 'api/v1/health' => [ + [ + 'uri' => 'api/v1/health', + 'group' => 'api/v1/health', + 'group_index' => 0, + ], + [ + 'uri' => 'api/v1/health', + 'group' => 'api/v1/health', + 'group_index' => 1, + ], + [ + 'uri' => 'api/v1/health', + 'group' => 'api/v1/health', + 'group_index' => 2, + ], ], - [ - 'group' => 'api/v1/users', - 'group_index' => 3 + 'api/v1/users' => [ + [ + 'uri' => 'api/v1/users/store', + 'group' => 'api/v1/users', + 'group_index' => 0, + ], + [ + 'uri' => 'api/v1/users', + 'group' => 'api/v1/users', + 'group_index' => 1, + ], + [ + 'uri' => 'api/v1/users', + 'group' => 'api/v1/users', + 'group_index' => 2, + ], + [ + 'uri' => 'api/v1/users/update', + 'group' => 'api/v1/users', + 'group_index' => 3, + ], + [ + 'uri' => 'api/v1/users/destroy', + 'group' => 'api/v1/users', + 'group_index' => 4, + ], ], - [ - 'group' => 'api/v1/users', - 'group_index' => 4 - ] ]; + + $grouped = $docs + ->filter(static fn (array $item) => Str::startsWith($item['uri'], ['users', 'api'])) + ->map(static fn (array $item) => (new Collection($item))->only(['uri', 'group', 'group_index'])->toArray()) + ->groupBy('group') + ->toArray(); + + $this->assertSame($expected, $grouped); } - public function testGroupByURIBackwardCompatible() + public function testGroupByURIBackwardCompatible(): void { // Set to `null` to test backward compatibility. Config::set('request-docs.group_by.uri_patterns', []); @@ -335,7 +350,7 @@ public function testGroupByURIBackwardCompatible() ->assertStatus(Response::HTTP_OK); } - public function testGroupByControllerFullPath() + public function testGroupByControllerFullPath(): void { Route::post('api/group1', [Group1Controller::class, 'store']); Route::put('api/group1', [Group1Controller::class, 'update']); @@ -345,7 +360,7 @@ public function testGroupByControllerFullPath() $response = $this->get(route('request-docs.api') . '?groupby=controller_full_path') ->assertStatus(Response::HTTP_OK); - $docs = collect($response->json()); + $docs = new Collection($response->json()); $expected = [ 'Rakutentech\LaravelRequestDocs\Tests\Stubs\TestControllers\API\Group1Controller' => [ @@ -353,13 +368,13 @@ public function testGroupByControllerFullPath() 'method' => 'store', 'http_method' => 'POST', 'group' => 'Rakutentech\LaravelRequestDocs\Tests\Stubs\TestControllers\API\Group1Controller', - 'group_index' => 0 + 'group_index' => 0, ], [ 'method' => 'update', 'http_method' => 'PUT', 'group' => 'Rakutentech\LaravelRequestDocs\Tests\Stubs\TestControllers\API\Group1Controller', - 'group_index' => 1 + 'group_index' => 1, ], ], 'Rakutentech\LaravelRequestDocs\Tests\Stubs\TestControllers\API\Group2Controller' => [ @@ -367,39 +382,39 @@ public function testGroupByControllerFullPath() 'method' => 'show', 'http_method' => 'GET', 'group' => 'Rakutentech\LaravelRequestDocs\Tests\Stubs\TestControllers\API\Group2Controller', - 'group_index' => 0 + 'group_index' => 0, ], [ 'method' => 'show', 'http_method' => 'HEAD', 'group' => 'Rakutentech\LaravelRequestDocs\Tests\Stubs\TestControllers\API\Group2Controller', - 'group_index' => 1 + 'group_index' => 1, ], [ 'method' => 'destroy', 'http_method' => 'DELETE', 'group' => 'Rakutentech\LaravelRequestDocs\Tests\Stubs\TestControllers\API\Group2Controller', - 'group_index' => 2 - ] - ] + 'group_index' => 2, + ], + ], ]; $grouped = $docs - ->filter(fn(array $item) => Str::startsWith($item['uri'], ['api'])) - ->map(fn(array $item) => collect($item)->only(['method', 'http_method', 'group', 'group_index'])->toArray()) + ->filter(static fn (array $item) => Str::startsWith($item['uri'], ['api'])) + ->map(static fn (array $item) => (new Collection($item))->only(['method', 'http_method', 'group', 'group_index'])->toArray()) ->groupBy('group') ->toArray(); $this->assertSame($expected, $grouped); } - public function testOpenApi() + public function testOpenApi(): void { $this->get(route('request-docs.api') . '?openapi=true') ->assertStatus(Response::HTTP_OK); } - public function testPath() + public function testPath(): void { Route::get('user/{id}', [PathController::class, 'index']) ->where('id', '[0-9]+'); @@ -411,16 +426,16 @@ public function testPath() 'id' => ['integer|required|regex:/[0-9]+/'], ]; - $docs = collect($response->json()); + $docs = new Collection($response->json()); - $pathParameter = $docs->filter(fn(array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') + $pathParameter = $docs->filter(static fn (array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') ->pluck('path_parameters') ->first(); $this->assertSame($expected, $pathParameter); } - public function testPathWithOptional() + public function testPathWithOptional(): void { Route::get('user/{name?}', [PathController::class, 'optional']) ->where('name', '[A-Za-z]+'); @@ -432,16 +447,16 @@ public function testPathWithOptional() 'name' => ['string|nullable|regex:/[A-Za-z]+/'], ]; - $docs = collect($response->json()); + $docs = new Collection($response->json()); - $pathParameter = $docs->filter(fn(array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') + $pathParameter = $docs->filter(static fn (array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') ->pluck('path_parameters') ->first(); $this->assertSame($expected, $pathParameter); } - public function testPathWithModelBinding() + public function testPathWithModelBinding(): void { Route::get('user/{user}/{post}/{comment:name}', [PathController::class, 'model']); @@ -454,16 +469,16 @@ public function testPathWithModelBinding() 'comment:name' => ['string|required'], ]; - $docs = collect($response->json()); + $docs = new Collection($response->json()); - $pathParameter = $docs->filter(fn(array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') + $pathParameter = $docs->filter(static fn (array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') ->pluck('path_parameters') ->first(); $this->assertSame($expected, $pathParameter); } - public function testPathWithMethodParametersIsLesser() + public function testPathWithMethodParametersIsLesser(): void { Route::get('user/{id}/{user}/{valid?}', [PathController::class, 'index']) ->where('missing', '[A-Za-z]+') @@ -478,20 +493,20 @@ public function testPathWithMethodParametersIsLesser() 'valid' => ['string|nullable|regex:/[A-Za-z]+/'], ]; - $docs = collect($response->json()); + $docs = new Collection($response->json()); - $pathParameter = $docs->filter(fn(array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') + $pathParameter = $docs->filter(static fn (array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') ->pluck('path_parameters') ->first(); $this->assertSame($expected, $pathParameter); } - public function testPathWithGlobalPattern() + public function testPathWithGlobalPattern(): void { Route::pattern('id', '[0-9]+'); - Route::get('/user/{id}', function (string $id) { + Route::get('/user/{id}', static function (string $id): void { // Only executed if {id} is numeric... }); @@ -502,9 +517,9 @@ public function testPathWithGlobalPattern() 'id' => ['string|required|regex:/[0-9]+/'], ]; - $docs = collect($response->json()); + $docs = new Collection($response->json()); - $pathParameter = $docs->filter(fn(array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') + $pathParameter = $docs->filter(static fn (array $doc) => Str::startsWith($doc['uri'], 'user') && $doc['http_method'] === 'GET') ->pluck('path_parameters') ->first(); diff --git a/tests/LaravelRequestDocsMiddlewareTest.php b/tests/LaravelRequestDocsMiddlewareTest.php index b600ef7..af479d5 100644 --- a/tests/LaravelRequestDocsMiddlewareTest.php +++ b/tests/LaravelRequestDocsMiddlewareTest.php @@ -2,6 +2,7 @@ namespace Rakutentech\LaravelRequestDocs\Tests; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Route; @@ -9,38 +10,32 @@ class LaravelRequestDocsMiddlewareTest extends TestCase { - public function testMissingLRDHeader() + public function testMissingLRDHeader(): void { - Route::get('middleware', function () { - return ['test' => true]; - })->middleware(LaravelRequestDocsMiddleware::class); + Route::get('middleware', static fn () => ['test' => true])->middleware(LaravelRequestDocsMiddleware::class); $this->get('middleware') ->assertStatus(200) ->assertExactJson(['test' => true]); } - public function testNotJsonResponse() + public function testNotJsonResponse(): void { - Route::get('middleware', function () { - return 1; - })->middleware(LaravelRequestDocsMiddleware::class); + Route::get('middleware', static fn () => 1)->middleware(LaravelRequestDocsMiddleware::class); - $response = $this->get('middleware', ['X-Request-LRD' => true]) + $this->get('middleware', ['X-Request-LRD' => true]) ->assertStatus(200) ->assertExactJson([1]); } - public function testJsonResponseIsObject() + public function testJsonResponseIsObject(): void { - Route::get('middleware', function () { - return response()->json(['test' => true]); - })->middleware(LaravelRequestDocsMiddleware::class); + Route::get('middleware', static fn () => response()->json(['test' => true]))->middleware(LaravelRequestDocsMiddleware::class); $response = $this->get('middleware', ['X-Request-LRD' => true]) ->assertStatus(200); - $content = collect($response->json()); + $content = new Collection($response->json()); $this->assertSame(['test' => true], $content->get('data')); @@ -53,16 +48,14 @@ public function testJsonResponseIsObject() $this->assertArrayHasKey('memory', $lrd); } - public function testJsonResponseIsNotObject() + public function testJsonResponseIsNotObject(): void { - Route::get('middleware', function () { - return response()->json('abc'); - })->middleware(LaravelRequestDocsMiddleware::class); + Route::get('middleware', static fn () => response()->json('abc'))->middleware(LaravelRequestDocsMiddleware::class); $response = $this->get('middleware', ['X-Request-LRD' => true]) ->assertStatus(200); - $content = collect($response->json()); + $content = new Collection($response->json()); $this->assertSame('abc', $content->get('data')); @@ -70,25 +63,23 @@ public function testJsonResponseIsNotObject() $this->assertCount(5, $lrd); } - public function testResponseIsGzipable() + public function testResponseIsGzipable(): void { - Route::get('middleware', function () { - return response()->json(['test' => true]); - })->middleware(LaravelRequestDocsMiddleware::class); + Route::get('middleware', static fn () => response()->json(['test' => true]))->middleware(LaravelRequestDocsMiddleware::class); $this->get( 'middleware', [ 'X-Request-LRD' => true, 'Accept-Encoding' => 'gzip', - ] + ], ) ->assertStatus(200); } - public function testLogListenerIsWorking() + public function testLogListenerIsWorking(): void { - Route::get('middleware', function () { + Route::get('middleware', static function () { Log::info('aaa'); return response()->json(['test' => true]); })->middleware(LaravelRequestDocsMiddleware::class); @@ -96,7 +87,7 @@ public function testLogListenerIsWorking() $response = $this->get('middleware', ['X-Request-LRD' => true]) ->assertStatus(200); - $content = collect($response->json()); + $content = new Collection($response->json()); $lrd = $content->get('_lrd'); @@ -109,9 +100,9 @@ public function testLogListenerIsWorking() ], $lrd['logs']); } - public function testDBListenerIsWorking() + public function testDBListenerIsWorking(): void { - Route::get('middleware', function () { + Route::get('middleware', static function () { DB::select('SELECT 1'); return response()->json(['test' => true]); })->middleware(LaravelRequestDocsMiddleware::class); @@ -119,7 +110,7 @@ public function testDBListenerIsWorking() $response = $this->get('middleware', ['X-Request-LRD' => true]) ->assertStatus(200); - $content = collect($response->json()); + $content = new Collection($response->json()); $lrd = $content->get('_lrd'); diff --git a/tests/Models/Post.php b/tests/Models/Post.php index 62b66fc..9f2b01a 100644 --- a/tests/Models/Post.php +++ b/tests/Models/Post.php @@ -8,8 +8,6 @@ class Post extends Model { /** * Test with different route key name. - * - * @return string */ public function getRouteKeyName(): string { diff --git a/tests/NotFoundWhenProductionTest.php b/tests/NotFoundWhenProductionTest.php index 4a102f0..5d13265 100644 --- a/tests/NotFoundWhenProductionTest.php +++ b/tests/NotFoundWhenProductionTest.php @@ -7,13 +7,11 @@ class NotFoundWhenProductionTest extends TestCase { - public function testForbiddenInProduction() + public function testForbiddenInProduction(): void { foreach (['prod', 'production'] as $production) { app()['env'] = $production; - Route::get('middleware', function () { - return 1; - })->middleware(NotFoundWhenProduction::class); + Route::get('middleware', static fn () => 1)->middleware(NotFoundWhenProduction::class); $this->get('middleware') ->assertStatus(403) @@ -21,11 +19,9 @@ public function testForbiddenInProduction() } } - public function testHandle() + public function testHandle(): void { - Route::get('middleware', function () { - return response()->json([1]); - })->middleware(NotFoundWhenProduction::class); + Route::get('middleware', static fn () => response()->json([1]))->middleware(NotFoundWhenProduction::class); $this->get('middleware') ->assertStatus(200) diff --git a/tests/Stubs/TestControllers/CommentsOnRequestRulesMethodController.php b/tests/Stubs/TestControllers/CommentsOnRequestRulesMethodController.php index d98f148..708a2d2 100644 --- a/tests/Stubs/TestControllers/CommentsOnRequestRulesMethodController.php +++ b/tests/Stubs/TestControllers/CommentsOnRequestRulesMethodController.php @@ -13,7 +13,6 @@ class CommentsOnRequestRulesMethodController * # Controller * ## Index Method Comment * @lrd:end - * * @LRDparam extra_index_field_1 string|max:32 * // either space or pipe * @LRDparam extra_index_field_2 string|nullable|max:32 @@ -24,7 +23,7 @@ class CommentsOnRequestRulesMethodController * * After */ - public function index(CommentsOnRequestRulesMethodRequest $request) + public function index(CommentsOnRequestRulesMethodRequest $request): int { return 1; } diff --git a/tests/Stubs/TestControllers/PathController.php b/tests/Stubs/TestControllers/PathController.php index a5b7c48..7e34209 100644 --- a/tests/Stubs/TestControllers/PathController.php +++ b/tests/Stubs/TestControllers/PathController.php @@ -21,6 +21,7 @@ public function index(Request $request, int $differentNameIsOkay): Response /** * `$name` has no type hint, test generate with default string type. */ + // phpcs:ignore public function optional(Request $request, $name = null): Response { return response('content'); diff --git a/tests/Stubs/TestControllers/SingleActionController.php b/tests/Stubs/TestControllers/SingleActionController.php index 4067b1c..abc74da 100644 --- a/tests/Stubs/TestControllers/SingleActionController.php +++ b/tests/Stubs/TestControllers/SingleActionController.php @@ -9,7 +9,7 @@ */ class SingleActionController { - public function __invoke() + public function __invoke(): int { return 1; } diff --git a/tests/Stubs/TestControllers/TelescopeController.php b/tests/Stubs/TestControllers/TelescopeController.php index 9b6e897..02bd401 100644 --- a/tests/Stubs/TestControllers/TelescopeController.php +++ b/tests/Stubs/TestControllers/TelescopeController.php @@ -6,10 +6,8 @@ class TelescopeController { /** * For `config('request-docs.hide_matching')` test. - * - * @return int */ - public function index() + public function index(): int { return 1; } diff --git a/tests/Stubs/TestControllers/WelcomeController.php b/tests/Stubs/TestControllers/WelcomeController.php index 6f4bef0..b572332 100644 --- a/tests/Stubs/TestControllers/WelcomeController.php +++ b/tests/Stubs/TestControllers/WelcomeController.php @@ -12,18 +12,19 @@ class WelcomeController { /** * Before + * * @lrd:start * #Hello markdown * ## Documentation for /my route * @lrd:end * After */ - public function index(WelcomeIndexRequest $request) + public function index(WelcomeIndexRequest $request): int { return 1; } - public function show() + public function show(): int { return 1; } @@ -35,17 +36,17 @@ public function show() * @LRDparam search_boolean boolean * @LRDresponses 200|400|401 */ - public function edit(WelcomeEditRequest $request) + public function edit(WelcomeEditRequest $request): int { return 1; } - public function store(int $id, WelcomeStoreRequest $request) + public function store(int $id, WelcomeStoreRequest $request): int { return 1; } - public function destroy(WelcomeDeleteRequest $request) + public function destroy(WelcomeDeleteRequest $request): int { return 1; } @@ -53,12 +54,15 @@ public function destroy(WelcomeDeleteRequest $request) /** * Test request without `rules` method */ - public function noRules(RequestWithoutRules $request) + public function noRules(RequestWithoutRules $request): int { return 1; } - public function health($unknown) + /** + * @param mixed $unknown + */ + public function health($unknown): int { return 1; } diff --git a/tests/Stubs/TestRequests/CommentsOnRequestRulesMethodRequest.php b/tests/Stubs/TestRequests/CommentsOnRequestRulesMethodRequest.php index 86b8400..3b86169 100644 --- a/tests/Stubs/TestRequests/CommentsOnRequestRulesMethodRequest.php +++ b/tests/Stubs/TestRequests/CommentsOnRequestRulesMethodRequest.php @@ -13,16 +13,14 @@ class CommentsOnRequestRulesMethodRequest extends FormRequest * # Request * ## Rules Method Comment * @lrd:end - * * @LRDparam extra_rules_field_1 string|max:32 * // either space or pipe * @LRDparam extra_rules_field_2 string|nullable|max:32 * // duplicate param in controller * @LRDparam this_is_a_duplicate_param request description - * - * @return array + * @return array */ - public function rules() + public function rules(): array { return [ 'message_param' => 'nullable|string', diff --git a/tests/Stubs/TestRequests/RequestWithEmptyRules.php b/tests/Stubs/TestRequests/RequestWithEmptyRules.php index 50a8d9a..8755d32 100644 --- a/tests/Stubs/TestRequests/RequestWithEmptyRules.php +++ b/tests/Stubs/TestRequests/RequestWithEmptyRules.php @@ -8,24 +8,18 @@ class RequestWithEmptyRules extends FormRequest { /** * Determine if the user is authorized to make this request. - * - * @return bool */ - public function authorize() + public function authorize(): bool { return true; } - protected function prepareForValidation() - { - } - /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ - public function rules() + public function rules(): array { return []; } diff --git a/tests/Stubs/TestRequests/RequestWithoutRules.php b/tests/Stubs/TestRequests/RequestWithoutRules.php index 5bd559f..f758253 100644 --- a/tests/Stubs/TestRequests/RequestWithoutRules.php +++ b/tests/Stubs/TestRequests/RequestWithoutRules.php @@ -8,15 +8,13 @@ class RequestWithoutRules extends FormRequest { /** * Determine if the user is authorized to make this request. - * - * @return bool */ - public function authorize() + public function authorize(): bool { return true; } - protected function prepareForValidation() + protected function prepareForValidation(): void { } } diff --git a/tests/Stubs/TestRequests/WelcomeDeleteRequest.php b/tests/Stubs/TestRequests/WelcomeDeleteRequest.php index fa64056..ee90b41 100644 --- a/tests/Stubs/TestRequests/WelcomeDeleteRequest.php +++ b/tests/Stubs/TestRequests/WelcomeDeleteRequest.php @@ -8,24 +8,18 @@ class WelcomeDeleteRequest extends FormRequest { /** * Determine if the user is authorized to make this request. - * - * @return bool */ - public function authorize() + public function authorize(): bool { return true; } - protected function prepareForValidation() - { - } - /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ - public function rules() + public function rules(): array { return [ 'message_param' => 'nullable|string', diff --git a/tests/Stubs/TestRequests/WelcomeEditRequest.php b/tests/Stubs/TestRequests/WelcomeEditRequest.php index 30e9109..50c8b35 100644 --- a/tests/Stubs/TestRequests/WelcomeEditRequest.php +++ b/tests/Stubs/TestRequests/WelcomeEditRequest.php @@ -8,24 +8,18 @@ class WelcomeEditRequest extends FormRequest { /** * Determine if the user is authorized to make this request. - * - * @return bool */ - public function authorize() + public function authorize(): bool { return true; } - protected function prepareForValidation() - { - } - /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ - public function rules() + public function rules(): array { return [ 'message_param' => 'nullable|string', diff --git a/tests/Stubs/TestRequests/WelcomeIndexRequest.php b/tests/Stubs/TestRequests/WelcomeIndexRequest.php index 4989f08..9266b23 100644 --- a/tests/Stubs/TestRequests/WelcomeIndexRequest.php +++ b/tests/Stubs/TestRequests/WelcomeIndexRequest.php @@ -9,32 +9,26 @@ class WelcomeIndexRequest extends FormRequest { /** * Determine if the user is authorized to make this request. - * - * @return bool */ - public function authorize() + public function authorize(): bool { return true; } - protected function prepareForValidation() - { - } - /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ - public function rules() + public function rules(): array { return [ - 'name' => ['nullable', 'string', 'min:5', 'max:255'], - 'title' => new Uppercase(), - 'file' => 'file', - 'image' => 'image', - 'page' => 'nullable|integer|min:1', - 'per_page' => 'nullable|integer|min:1|max:100', + 'name' => ['nullable', 'string', 'min:5', 'max:255'], + 'title' => new Uppercase(), + 'file' => 'file', + 'image' => 'image', + 'page' => 'nullable|integer|min:1', + 'per_page' => 'nullable|integer|min:1|max:100', ]; } } diff --git a/tests/Stubs/TestRequests/WelcomeStoreRequest.php b/tests/Stubs/TestRequests/WelcomeStoreRequest.php index 4b71206..2ce7b5d 100644 --- a/tests/Stubs/TestRequests/WelcomeStoreRequest.php +++ b/tests/Stubs/TestRequests/WelcomeStoreRequest.php @@ -8,24 +8,18 @@ class WelcomeStoreRequest extends FormRequest { /** * Determine if the user is authorized to make this request. - * - * @return bool */ - public function authorize() + public function authorize(): bool { return true; } - protected function prepareForValidation() - { - } - /** * Get the validation rules that apply to the request. * - * @return array + * @return array */ - public function rules() + public function rules(): array { return [ 'error' => ['string', 'exists:' . $this->user->id], diff --git a/tests/Stubs/TestRules/Uppercase.php b/tests/Stubs/TestRules/Uppercase.php index 222ea9a..79dd12a 100644 --- a/tests/Stubs/TestRules/Uppercase.php +++ b/tests/Stubs/TestRules/Uppercase.php @@ -7,21 +7,14 @@ class Uppercase implements Rule { /** - * Create a new rule instance. - * - * @return void + * @inheritDoc */ public function __construct() { - // } /** - * Determine if the validation rule passes. - * - * @param string $attribute - * @param mixed $value - * @return bool + * @inheritDoc */ public function passes($attribute, $value) { @@ -29,9 +22,8 @@ public function passes($attribute, $value) } /** - * Get the validation error message. - * - * @return string + * @inheritDoc + * @return string|string[] */ public function message() { diff --git a/tests/TestCase.php b/tests/TestCase.php index e39961c..f420a41 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -9,29 +9,14 @@ class TestCase extends Orchestra { - public function setUp(): void { parent::setUp(); - $this->registerRoutes(); - } - protected function getPackageProviders($app) - { - return [ - LaravelRequestDocsServiceProvider::class, - ]; - } - - public function getEnvironmentSetUp($app) - { - app()->setBasePath(__DIR__ . '/../'); - - $app['config']->set('database.default', 'testing'); - $app['config']->set('app.debug', true); + $this->registerRoutes(); } - public function registerRoutes() + public function registerRoutes(): void { Route::get('/', [TestControllers\WelcomeController::class, 'index']); Route::get('welcome', [TestControllers\WelcomeController::class, 'index']); @@ -45,9 +30,7 @@ public function registerRoutes() Route::delete('welcome/no-rules', [TestControllers\WelcomeController::class, 'noRules']); Route::post('comments-on-request-rules-method', [TestControllers\CommentsOnRequestRulesMethodController::class, 'index']); - Route::get('closure', function () { - return true; - }); + Route::get('closure', static fn () => true); Route::apiResource('accounts', TestControllers\AccountController::class); @@ -59,9 +42,28 @@ public function registerRoutes() // Expected to be skipped Route::get('telescope', [TestControllers\TelescopeController::class, 'index']); - Route::options('options_is_not_included', function () { - return false; - }); + Route::options('options_is_not_included', static fn () => false); + } + + /** + * @inheritDoc + */ + protected function getEnvironmentSetUp($app) + { + app()->setBasePath(__DIR__ . '/../'); + + $app['config']->set('database.default', 'testing'); + $app['config']->set('app.debug', true); + } + + /** + * @inheritDoc + */ + protected function getPackageProviders($app) + { + return [ + LaravelRequestDocsServiceProvider::class, + ]; } protected function countRoutesWithLRDDoc(): int