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; } }