123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429 |
- <?php
- namespace Knuckles\Scribe\Extracting;
- use Faker\Factory;
- use Illuminate\Http\UploadedFile;
- use Illuminate\Routing\Route;
- use Illuminate\Support\Arr;
- use Illuminate\Support\Str;
- use Knuckles\Scribe\Extracting\Strategies\Strategy;
- use Knuckles\Scribe\Tools\DocumentationConfig;
- use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
- use Knuckles\Scribe\Tools\Utils as u;
- use ReflectionClass;
- use ReflectionFunctionAbstract;
- class Generator
- {
- /**
- * @var DocumentationConfig
- */
- private $config;
- /**
- * @var Route|null
- */
- private static $routeBeingProcessed = null;
- public function __construct(DocumentationConfig $config = null)
- {
- // If no config is injected, pull from global
- $this->config = $config ?: new DocumentationConfig(config('scribe'));
- }
- /**
- * External interface that allows users to know what route is currently being processed
- */
- public static function getRouteBeingProcessed(): ?Route
- {
- return self::$routeBeingProcessed;
- }
- /**
- * @param Route $route
- *
- * @return mixed
- */
- public function getUri(Route $route)
- {
- return $route->uri();
- }
- /**
- * @param Route $route
- *
- * @return mixed
- */
- public function getMethods(Route $route)
- {
- $methods = $route->methods();
- // Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
- // but not if there's only one method (means it was intentional)
- if (count($methods) === 1) {
- return $methods;
- }
- return array_diff($methods, ['HEAD']);
- }
- /**
- * @param \Illuminate\Routing\Route $route
- * @param array $routeRules Rules to apply when generating documentation for this route
- *
- * @return array
- * @throws \ReflectionException
- *
- */
- public function processRoute(Route $route, array $routeRules = [])
- {
- self::$routeBeingProcessed = $route;
- [$controllerName, $methodName] = u::getRouteClassAndMethodNames($route);
- $controller = new ReflectionClass($controllerName);
- $method = u::getReflectedRouteMethod([$controllerName, $methodName]);
- $parsedRoute = [
- 'id' => md5($this->getUri($route) . ':' . implode($this->getMethods($route))),
- 'methods' => $this->getMethods($route),
- 'uri' => $this->getUri($route),
- ];
- $metadata = $this->fetchMetadata($controller, $method, $route, $routeRules, $parsedRoute);
- $parsedRoute['metadata'] = $metadata;
- $urlParameters = $this->fetchUrlParameters($controller, $method, $route, $routeRules, $parsedRoute);
- $parsedRoute['urlParameters'] = $urlParameters;
- $parsedRoute['cleanUrlParameters'] = self::cleanParams($urlParameters);
- $parsedRoute['boundUri'] = u::getUrlWithBoundParameters($route, $parsedRoute['cleanUrlParameters']);
- $parsedRoute = $this->addAuthField($parsedRoute);
- $queryParameters = $this->fetchQueryParameters($controller, $method, $route, $routeRules, $parsedRoute);
- $parsedRoute['queryParameters'] = $queryParameters;
- $parsedRoute['cleanQueryParameters'] = self::cleanParams($queryParameters);
- $headers = $this->fetchRequestHeaders($controller, $method, $route, $routeRules, $parsedRoute);
- $parsedRoute['headers'] = $headers;
- $bodyParameters = $this->fetchBodyParameters($controller, $method, $route, $routeRules, $parsedRoute);
- $parsedRoute['bodyParameters'] = $bodyParameters;
- $parsedRoute['cleanBodyParameters'] = self::cleanParams($bodyParameters);
- if (count($parsedRoute['cleanBodyParameters']) && !isset($parsedRoute['headers']['Content-Type'])) {
- // Set content type if the user forgot to set it
- $parsedRoute['headers']['Content-Type'] = 'application/json';
- }
- [$files, $regularParameters] = collect($parsedRoute['cleanBodyParameters'])->partition(function ($example) {
- return $example instanceof UploadedFile;
- });
- if (count($files)) {
- $parsedRoute['headers']['Content-Type'] = 'multipart/form-data';
- }
- $parsedRoute['fileParameters'] = $files->toArray();
- $parsedRoute['cleanBodyParameters'] = $regularParameters->toArray();
- $responses = $this->fetchResponses($controller, $method, $route, $routeRules, $parsedRoute);
- $parsedRoute['responses'] = $responses;
- $parsedRoute['showresponse'] = !empty($responses);
- $responseFields = $this->fetchResponseFields($controller, $method, $route, $routeRules, $parsedRoute);
- $parsedRoute['responseFields'] = $responseFields;
- $parsedRoute['nestedBodyParameters'] = $this->nestArrayAndObjectFields($parsedRoute['bodyParameters']);
- self::$routeBeingProcessed = null;
- return $parsedRoute;
- }
- protected function fetchMetadata(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
- {
- $context['metadata'] = [
- 'groupName' => $this->config->get('default_group', ''),
- 'groupDescription' => '',
- 'title' => '',
- 'description' => '',
- 'authenticated' => false,
- ];
- return $this->iterateThroughStrategies('metadata', $context, [$route, $controller, $method, $rulesToApply]);
- }
- protected function fetchUrlParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
- {
- return $this->iterateThroughStrategies('urlParameters', $context, [$route, $controller, $method, $rulesToApply]);
- }
- protected function fetchQueryParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
- {
- return $this->iterateThroughStrategies('queryParameters', $context, [$route, $controller, $method, $rulesToApply]);
- }
- protected function fetchBodyParameters(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
- {
- return $this->iterateThroughStrategies('bodyParameters', $context, [$route, $controller, $method, $rulesToApply]);
- }
- protected function fetchResponses(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
- {
- $responses = $this->iterateThroughStrategies('responses', $context, [$route, $controller, $method, $rulesToApply]);
- if (count($responses)) {
- return array_filter($responses, function ($response) {
- return $response['content'] != null;
- });
- }
- return [];
- }
- protected function fetchResponseFields(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
- {
- return $this->iterateThroughStrategies('responseFields', $context, [$route, $controller, $method, $rulesToApply]);
- }
- protected function fetchRequestHeaders(ReflectionClass $controller, ReflectionFunctionAbstract $method, Route $route, array $rulesToApply, array $context = [])
- {
- $headers = $this->iterateThroughStrategies('headers', $context, [$route, $controller, $method, $rulesToApply]);
- return array_filter($headers);
- }
- protected function iterateThroughStrategies(string $stage, array $extractedData, array $arguments)
- {
- $defaultStrategies = [
- 'metadata' => [
- \Knuckles\Scribe\Extracting\Strategies\Metadata\GetFromDocBlocks::class,
- ],
- 'urlParameters' => [
- \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromLaravelAPI::class,
- \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromLumenAPI::class,
- \Knuckles\Scribe\Extracting\Strategies\UrlParameters\GetFromUrlParamTag::class,
- ],
- 'queryParameters' => [
- \Knuckles\Scribe\Extracting\Strategies\QueryParameters\GetFromQueryParamTag::class,
- ],
- 'headers' => [
- \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromRouteRules::class,
- \Knuckles\Scribe\Extracting\Strategies\Headers\GetFromHeaderTag::class,
- ],
- 'bodyParameters' => [
- \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromFormRequest::class,
- \Knuckles\Scribe\Extracting\Strategies\BodyParameters\GetFromBodyParamTag::class,
- ],
- 'responses' => [
- \Knuckles\Scribe\Extracting\Strategies\Responses\UseTransformerTags::class,
- \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseTag::class,
- \Knuckles\Scribe\Extracting\Strategies\Responses\UseResponseFileTag::class,
- \Knuckles\Scribe\Extracting\Strategies\Responses\UseApiResourceTags::class,
- \Knuckles\Scribe\Extracting\Strategies\Responses\ResponseCalls::class,
- ],
- 'responseFields' => [
- \Knuckles\Scribe\Extracting\Strategies\ResponseFields\GetFromResponseFieldTag::class,
- ],
- ];
- // Use the default strategies for the stage, unless they were explicitly set
- $strategies = $this->config->get("strategies.$stage", $defaultStrategies[$stage]);
- $extractedData[$stage] = $extractedData[$stage] ?? [];
- foreach ($strategies as $strategyClass) {
- /** @var Strategy $strategy */
- $strategy = new $strategyClass($this->config);
- $strategyArgs = $arguments;
- $strategyArgs[] = $extractedData;
- $results = $strategy(...$strategyArgs);
- if (!is_null($results)) {
- foreach ($results as $index => $item) {
- if ($stage == 'responses') {
- // Responses from different strategies are all added, not overwritten
- $extractedData[$stage][] = $item;
- continue;
- }
- // We're using a for loop rather than array_merge or +=
- // so it does not renumber numeric keys and also allows values to be overwritten
- // Don't allow overwriting if an empty value is trying to replace a set one
- if (!in_array($extractedData[$stage], [null, ''], true) && in_array($item, [null, ''], true)) {
- continue;
- } else {
- $extractedData[$stage][$index] = $item;
- }
- }
- }
- }
- return $extractedData[$stage];
- }
- /**
- * This method prepares and simplifies request parameters for use in example requests and response calls.
- * It takes in an array with rich details about a parameter eg
- * ['age' => [
- * 'description' => 'The age',
- * 'value' => 12,
- * 'required' => false,
- * ]]
- * And transforms them into key-example pairs : ['age' => 12]
- * It also filters out parameters which have null values and have 'required' as false.
- * It converts all file params that have string examples to actual files (instances of UploadedFile).
- *
- * @param array $parameters
- *
- * @return array
- */
- public static function cleanParams(array $parameters): array
- {
- $cleanParameters = [];
- foreach ($parameters as $paramName => $details) {
- // Remove params which have no examples and are optional.
- if (is_null($details['value']) && $details['required'] === false) {
- continue;
- }
- if (($details['type'] ?? '') === 'file' && is_string($details['value'])) {
- $details['value'] = self::convertStringValueToUploadedFileInstance($details['value']);
- }
- if (Str::contains($paramName, '.')) { // Object field (or array of objects)
- self::setObject($cleanParameters, $paramName, $details['value'], $parameters, ($details['required'] ?? false));
- } else {
- $cleanParameters[$paramName] = $details['value'];
- }
- }
- return $cleanParameters;
- }
- public static function setObject(array &$results, string $path, $value, array $source, bool $isRequired)
- {
- $parts = array_reverse(explode('.', $path));
- array_shift($parts); // Get rid of the field name
- $baseName = join('.', array_reverse($parts));
- // The type should be indicated in the source object by now; we don't need it in the name
- $normalisedBaseName = Str::replaceLast('[]', '', $baseName);
- $parentData = Arr::get($source, $normalisedBaseName);
- if ($parentData) {
- // Path we use for data_set
- $dotPath = str_replace('[]', '.0', $path);
- if ($parentData['type'] === 'object') {
- if (!Arr::has($results, $dotPath)) {
- Arr::set($results, $dotPath, $value);
- }
- } else if ($parentData['type'] === 'object[]') {
- if (!Arr::has($results, $dotPath)) {
- Arr::set($results, $dotPath, $value);
- }
- // If there's a second item in the array, set for that too.
- if ($value !== null && Arr::has($results, Str::replaceLast('[]', '.1', $baseName))) {
- // If value is optional, toss a coin on whether to set or not
- if ($isRequired || array_rand([true, false], 1)) {
- Arr::set($results, Str::replaceLast('.0', '.1', $dotPath), $value);
- }
- }
- }
- }
- }
- public function addAuthField(array $parsedRoute): array
- {
- $parsedRoute['auth'] = null;
- $isApiAuthed = $this->config->get('auth.enabled', false);
- if (!$isApiAuthed || !$parsedRoute['metadata']['authenticated']) {
- return $parsedRoute;
- }
- $strategy = $this->config->get('auth.in');
- $parameterName = $this->config->get('auth.name');
- $faker = Factory::create();
- if ($this->config->get('faker_seed')) {
- $faker->seed($this->config->get('faker_seed'));
- }
- $token = $faker->shuffle('abcdefghkvaZVDPE1864563');
- $valueToUse = $this->config->get('auth.use_value');
- $valueToDisplay = $this->config->get('auth.placeholder');
- switch ($strategy) {
- case 'query':
- case 'query_or_body':
- $parsedRoute['auth'] = "cleanQueryParameters.$parameterName." . ($valueToUse ?: $token);
- $parsedRoute['queryParameters'][$parameterName] = [
- 'name' => $parameterName,
- 'type' => 'string',
- 'value' => $valueToDisplay ?: $token,
- 'description' => 'Authentication key.',
- 'required' => true,
- ];
- break;
- case 'body':
- $parsedRoute['auth'] = "cleanBodyParameters.$parameterName." . ($valueToUse ?: $token);
- $parsedRoute['bodyParameters'][$parameterName] = [
- 'name' => $parameterName,
- 'type' => 'string',
- 'value' => $valueToDisplay ?: $token,
- 'description' => 'Authentication key.',
- 'required' => true,
- ];
- break;
- case 'bearer':
- $parsedRoute['auth'] = "headers.Authorization.Bearer " . ($valueToUse ?: $token);
- $parsedRoute['headers']['Authorization'] = "Bearer " . ($valueToDisplay ?: $token);
- break;
- case 'basic':
- $parsedRoute['auth'] = "headers.Authorization.Basic " . ($valueToUse ?: base64_encode($token));
- $parsedRoute['headers']['Authorization'] = "Basic " . ($valueToDisplay ?: base64_encode($token));
- break;
- case 'header':
- $parsedRoute['auth'] = "headers.$parameterName." . ($valueToUse ?: $token);
- $parsedRoute['headers'][$parameterName] = $valueToDisplay ?: $token;
- break;
- }
- return $parsedRoute;
- }
- protected static function convertStringValueToUploadedFileInstance(string $filePath): UploadedFile
- {
- $fileName = basename($filePath);
- return new UploadedFile(
- $filePath, $fileName, mime_content_type($filePath), 0, false
- );
- }
- /**
- * Transform body parameters such that object fields have a `fields` property containing a list of all subfields
- * Subfields will be removed from the main parameter map
- * For instance, if $parameters is ['dad' => [], 'dad.cars' => [], 'dad.age' => []],
- * normalise this into ['dad' => [..., 'fields' => ['dad.cars' => [], 'dad.age' => []]]
- */
- public static function nestArrayAndObjectFields(array $parameters)
- {
- $finalParameters = [];
- foreach ($parameters as $name => $parameter) {
- if (Str::contains($name, '.')) { // Likely an object field
- // Get the various pieces of the name
- $parts = array_reverse(explode('.', $name));
- $fieldName = array_shift($parts);
- $baseName = join('.fields.', array_reverse($parts));
- // The type should be indicated in the source object by now; we don't need it in the name
- $normalisedBaseName = str_replace('[]', '.fields', $baseName);
- $dotPath = preg_replace('/\.fields$/', '', $normalisedBaseName) . '.fields.' . $fieldName;
- Arr::set($finalParameters, $dotPath, $parameter);
- } else { // A regular field
- $parameter['fields'] = [];
- $finalParameters[$name] = $parameter;
- }
- }
- return $finalParameters;
- }
- }
|