File "Extractor.php"
Full Path: /var/www/html/back/vendor/knuckleswtf/scribe/src/Extracting/Extractor.php
File size: 21.79 KB
MIME-type: text/x-php
Charset: utf-8
<?php
namespace Knuckles\Scribe\Extracting;
use Knuckles\Camel\Extraction\ExtractedEndpointData;
use Knuckles\Camel\Extraction\Metadata;
use Knuckles\Camel\Extraction\Parameter;
use Faker\Factory;
use Illuminate\Http\Testing\File;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\Extraction\ResponseCollection;
use Knuckles\Camel\Extraction\ResponseField;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Scribe\Extracting\Strategies\StaticData;
use Knuckles\Scribe\Tools\ConsoleOutputUtils as c;
use Knuckles\Scribe\Tools\DocumentationConfig;
use Knuckles\Scribe\Tools\RoutePatternMatcher;
class Extractor
{
private DocumentationConfig $config;
use ParamHelpers;
private static ?Route $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
* @param array $routeRules Rules to apply when generating documentation for this route. Deprecated. Use strategy config instead.
*
* @return ExtractedEndpointData
*
*/
public function processRoute(Route $route, array $routeRules = []): ExtractedEndpointData
{
self::$routeBeingProcessed = $route;
$endpointData = ExtractedEndpointData::fromRoute($route);
$inheritedDocsOverrides = [];
if ($endpointData->controller->hasMethod('inheritedDocsOverrides')) {
$inheritedDocsOverrides = call_user_func([$endpointData->controller->getName(), 'inheritedDocsOverrides']);
$inheritedDocsOverrides = $inheritedDocsOverrides[$endpointData->method->getName()] ?? [];
}
$this->fetchMetadata($endpointData, $routeRules);
$this->mergeInheritedMethodsData('metadata', $endpointData, $inheritedDocsOverrides);
$this->fetchUrlParameters($endpointData, $routeRules);
$this->mergeInheritedMethodsData('urlParameters', $endpointData, $inheritedDocsOverrides);
$endpointData->cleanUrlParameters = self::cleanParams($endpointData->urlParameters);
$this->addAuthField($endpointData);
$this->fetchQueryParameters($endpointData, $routeRules);
$this->mergeInheritedMethodsData('queryParameters', $endpointData, $inheritedDocsOverrides);
$endpointData->cleanQueryParameters = self::cleanParams($endpointData->queryParameters);
$this->fetchRequestHeaders($endpointData, $routeRules);
$this->mergeInheritedMethodsData('headers', $endpointData, $inheritedDocsOverrides);
$this->fetchBodyParameters($endpointData, $routeRules);
$endpointData->cleanBodyParameters = self::cleanParams($endpointData->bodyParameters);
$this->mergeInheritedMethodsData('bodyParameters', $endpointData, $inheritedDocsOverrides);
if (count($endpointData->cleanBodyParameters) && !isset($endpointData->headers['Content-Type'])) {
// Set content type if the user forgot to set it
$endpointData->headers['Content-Type'] = 'application/json';
}
// We need to do all this so response calls can work correctly,
// even though they're only needed for output
// Note that this
[$files, $regularParameters] = OutputEndpointData::splitIntoFileAndRegularParameters($endpointData->cleanBodyParameters);
if (count($files)) {
$endpointData->headers['Content-Type'] = 'multipart/form-data';
}
$endpointData->fileParameters = $files;
$endpointData->cleanBodyParameters = $regularParameters;
$this->fetchResponses($endpointData, $routeRules);
$this->mergeInheritedMethodsData('responses', $endpointData, $inheritedDocsOverrides);
$this->fetchResponseFields($endpointData, $routeRules);
$this->mergeInheritedMethodsData('responseFields', $endpointData, $inheritedDocsOverrides);
self::$routeBeingProcessed = null;
return $endpointData;
}
protected function fetchMetadata(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$endpointData->metadata = new Metadata([
'groupName' => $this->config->get('groups.default', ''),
"authenticated" => $this->config->get("auth.default", false),
]);
$this->iterateThroughStrategies('metadata', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
$hadPreviousValue = !is_null($endpointData->metadata->$key);
$noNewValueSet = is_null($item) || $item === "";
if ($hadPreviousValue && $noNewValueSet) {
continue;
}
$endpointData->metadata->$key = $item;
}
});
}
protected function fetchUrlParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('urlParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if (empty($item['name'])) {
$item['name'] = $key;
}
$endpointData->urlParameters[$key] = Parameter::create($item, $endpointData->urlParameters[$key] ?? []);
}
});
}
protected function fetchQueryParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('queryParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if (empty($item['name'])) {
$item['name'] = $key;
}
$endpointData->queryParameters[$key] = Parameter::create($item, $endpointData->queryParameters[$key] ?? []);
}
});
}
protected function fetchBodyParameters(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('bodyParameters', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if (empty($item['name'])) {
$item['name'] = $key;
}
$endpointData->bodyParameters[$key] = Parameter::create($item, $endpointData->bodyParameters[$key] ?? []);
}
});
}
protected function fetchResponses(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('responses', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
// Responses from different strategies are all added, not overwritten
$endpointData->responses->concat($results);
});
// Ensure 200 responses come first
$endpointData->responses = new ResponseCollection($endpointData->responses->sortBy('status')->values());
}
protected function fetchResponseFields(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('responseFields', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
$endpointData->responseFields[$key] = Parameter::create($item, $endpointData->responseFields[$key] ?? []);
}
});
}
protected function fetchRequestHeaders(ExtractedEndpointData $endpointData, array $rulesToApply): void
{
$this->iterateThroughStrategies('headers', $endpointData, $rulesToApply, function ($results) use ($endpointData) {
foreach ($results as $key => $item) {
if ($item) {
$endpointData->headers[$key] = $item;
}
}
});
}
/**
* Iterate through all defined strategies for this stage.
* A strategy may return an array of attributes
* to be added to that stage data, or it may modify the stage data directly.
*
* @param string $stage
* @param ExtractedEndpointData $endpointData
* @param array $rulesToApply Deprecated. Use strategy config instead.
* @param callable $handler Function to run after each strategy returns its results (an array).
*
*/
protected function iterateThroughStrategies(
string $stage, ExtractedEndpointData $endpointData, array $rulesToApply, callable $handler
): void
{
$strategies = $this->config->get("strategies.$stage", []);
foreach ($strategies as $strategyClassOrTuple) {
if (is_array($strategyClassOrTuple)) {
[$strategyClass, &$settings] = $strategyClassOrTuple;
if ($strategyClass == 'override') {
c::warn("The 'override' strategy was renamed to 'static_data', and will stop working in the future. Please replace 'override' in your config file with 'static_data'.");
$strategyClass = 'static_data';
}
if ($strategyClass == 'static_data') {
$strategyClass = StaticData::class;
// Static data can be short: ['static_data', ['key' => 'value']],
// or extended ['static_data', ['data' => ['key' => 'value'], 'only' => ['GET *'], 'except' => []]],
$settingsFormat = array_key_exists('data', $settings) ? 'extended' : 'short';
$settings = match($settingsFormat) {
'extended' => $settings,
'short' => ['data' => $settings],
};
}
} else {
$strategyClass = $strategyClassOrTuple;
$settings = [];
}
$routesToExclude = Arr::wrap($settings["except"] ?? []);
$routesToInclude = Arr::wrap($settings["only"] ?? []);
if ($this->shouldSkipRoute($endpointData->route, $routesToExclude, $routesToInclude)) {
continue;
}
$strategy = new $strategyClass($this->config);
$results = $strategy($endpointData, $settings);
if (is_array($results)) {
$handler($results);
}
}
}
public function shouldSkipRoute($route, array $routesToExclude, array $routesToInclude): bool
{
if (!empty($routesToExclude) && RoutePatternMatcher::matches($route, $routesToExclude)) {
return true;
} elseif (!empty($routesToInclude) && !RoutePatternMatcher::matches($route, $routesToInclude)) {
return true;
}
return false;
}
/**
* 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' => new Parameter([
* 'description' => 'The age',
* 'example' => 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<string,Parameter> $parameters
*
* @return array
*/
public static function cleanParams(array $parameters): array
{
$cleanParameters = [];
/**
* @var string $paramName
* @var Parameter $details
*/
foreach ($parameters as $paramName => $details) {
// Remove params which have no intentional examples and are optional.
if (!$details->exampleWasSpecified) {
if (is_null($details->example) && $details->required === false) {
continue;
}
}
if ($details->type === 'file') {
if (is_string($details->example)) {
$details->example = self::convertStringValueToUploadedFileInstance($details->example);
} else if (is_null($details->example)) {
$details->example = (new self)->generateDummyValue($details->type);
}
}
if (Str::startsWith($paramName, '[].')) { // Entire body is an array
if (empty($parameters["[]"])) { // Make sure there's a parent
$cleanParameters["[]"] = [[], []];
$parameters["[]"] = new Parameter([
"name" => "[]",
"type" => "object[]",
"description" => "",
"required" => true,
"example" => [$paramName => $details->example],
]);
}
}
if (Str::contains($paramName, '.')) { // Object field (or array of objects)
self::setObject($cleanParameters, $paramName, $details->example, $parameters, $details->required);
} else {
$cleanParameters[$paramName] = $details->example;
}
}
// Finally, if the body is an array, flatten it.
if (isset($cleanParameters['[]'])) {
$cleanParameters = $cleanParameters['[]'];
}
return $cleanParameters;
}
public static function setObject(array &$results, string $path, $value, array $source, bool $isRequired)
{
$parts = explode('.', $path);
array_pop($parts); // Get rid of the field name
$baseName = join('.', $parts);
// For array fields, the type should be indicated in the source object by now;
// eg test.items[] would actually be described as name: test.items, type: object[]
// So we get rid of that ending []
// For other fields (eg test.items[].name), it remains as-is
$baseNameInOriginalParams = $baseName;
while (Str::endsWith($baseNameInOriginalParams, '[]')) {
$baseNameInOriginalParams = substr($baseNameInOriginalParams, 0, -2);
}
// When the body is an array, param names will be "[].paramname",
// so $baseNameInOriginalParams here will be empty
if (Str::startsWith($path, '[].')) {
$baseNameInOriginalParams = '[]';
}
if (Arr::has($source, $baseNameInOriginalParams)) {
/** @var Parameter $parentData */
$parentData = Arr::get($source, $baseNameInOriginalParams);
// Path we use for data_set
$dotPath = str_replace('[]', '.0', $path);
// Don't overwrite parent if there's already data there
if ($parentData->type === 'object') {
$parentPath = explode('.', $dotPath);
$property = array_pop($parentPath);
$parentPath = implode('.', $parentPath);
$exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$property] ?? null;
if (empty($exampleFromParent)) {
Arr::set($results, $dotPath, $value);
}
} else if ($parentData->type === 'object[]') {
// When the body is an array, param names will be "[].paramname", so dot paths won't work correctly with "[]"
if (Str::startsWith($path, '[].')) {
$valueDotPath = substr($dotPath, 3); // Remove initial '.0.'
if (isset($results['[]'][0]) && !Arr::has($results['[]'][0], $valueDotPath)) {
Arr::set($results['[]'][0], $valueDotPath, $value);
}
} else {
$parentPath = explode('.', $dotPath);
$index = (int)array_pop($parentPath);
$parentPath = implode('.', $parentPath);
$exampleFromParent = Arr::get($results, $dotPath) ?? $parentData->example[$index] ?? null;
if (empty($exampleFromParent)) {
Arr::set($results, $dotPath, $value);
}
}
}
}
}
public function addAuthField(ExtractedEndpointData $endpointData): void
{
$isApiAuthed = $this->config->get('auth.enabled', false);
if (!$isApiAuthed || !$endpointData->metadata->authenticated) {
return;
}
$strategy = $this->config->get('auth.in');
$parameterName = $this->config->get('auth.name');
$faker = Factory::create();
if ($seed = $this->config->get('examples.faker_seed')) {
$faker->seed($seed);
}
$token = $faker->shuffleString('abcdefghkvaZVDPE1864563');
$valueToUse = $this->config->get('auth.use_value');
$valueToDisplay = $this->config->get('auth.placeholder');
switch ($strategy) {
case 'query':
case 'query_or_body':
$endpointData->auth = ["queryParameters", $parameterName, $valueToUse ?: $token];
$endpointData->queryParameters[$parameterName] = new Parameter([
'name' => $parameterName,
'type' => 'string',
'example' => $valueToDisplay ?: $token,
'description' => 'Authentication key.',
'required' => true,
]);
return;
case 'body':
$endpointData->auth = ["bodyParameters", $parameterName, $valueToUse ?: $token];
$endpointData->bodyParameters[$parameterName] = new Parameter([
'name' => $parameterName,
'type' => 'string',
'example' => $valueToDisplay ?: $token,
'description' => 'Authentication key.',
'required' => true,
]);
return;
case 'bearer':
$endpointData->auth = ["headers", "Authorization", "Bearer " . ($valueToUse ?: $token)];
$endpointData->headers['Authorization'] = "Bearer " . ($valueToDisplay ?: $token);
return;
case 'basic':
$endpointData->auth = ["headers", "Authorization", "Basic " . ($valueToUse ?: base64_encode($token))];
$endpointData->headers['Authorization'] = "Basic " . ($valueToDisplay ?: base64_encode($token));
return;
case 'header':
$endpointData->auth = ["headers", $parameterName, $valueToUse ?: $token];
$endpointData->headers[$parameterName] = $valueToDisplay ?: $token;
return;
}
}
protected static function convertStringValueToUploadedFileInstance(string $filePath): UploadedFile
{
$fileName = basename($filePath);
return new File($fileName, fopen($filePath, 'r'));
}
protected function mergeInheritedMethodsData(string $stage, ExtractedEndpointData $endpointData, array $inheritedDocsOverrides = []): void
{
$overrides = $inheritedDocsOverrides[$stage] ?? [];
$normalizeParamData = fn($data, $key) => array_merge($data, ["name" => $key]);
if (is_array($overrides)) {
foreach ($overrides as $key => $item) {
switch ($stage) {
case "responses":
$endpointData->responses->concat($overrides);
$endpointData->responses->sortBy('status');
break;
case "urlParameters":
case "bodyParameters":
case "queryParameters":
$endpointData->$stage[$key] = Parameter::make($normalizeParamData($item, $key));
break;
case "responseFields":
$endpointData->$stage[$key] = ResponseField::make($normalizeParamData($item, $key));
break;
default:
$endpointData->$stage[$key] = $item;
}
}
} else if (is_callable($overrides)) {
$results = $overrides($endpointData);
$endpointData->$stage = match ($stage) {
"responses" => ResponseCollection::make($results),
"urlParameters", "bodyParameters", "queryParameters" => collect($results)->map(fn($param, $name) => Parameter::make($normalizeParamData($param, $name)))->all(),
"responseFields" => collect($results)->map(fn($field, $name) => ResponseField::make($normalizeParamData($field, $name)))->all(),
default => $results,
};
}
}
public static function transformOldRouteRulesIntoNewSettings($stage, $rulesToApply, $strategyName, $strategySettings = [])
{
if ($stage == 'responses' && Str::is('*ResponseCalls*', $strategyName) &&!empty($rulesToApply['response_calls'])) {
// Transform `methods` to `only`
$strategySettings = [];
if (isset($rulesToApply['response_calls']['methods'])) {
if(empty($rulesToApply['response_calls']['methods'])) {
// This means all routes are forbidden
$strategySettings['except'] = ["*"];
} else {
$strategySettings['only'] = array_map(
fn($method) => "$method *",
$rulesToApply['response_calls']['methods']
);
}
}
// Copy all other keys over as is
foreach (array_keys($rulesToApply['response_calls']) as $key) {
if ($key == 'methods') continue;
$strategySettings[$key] = $rulesToApply['response_calls'][$key];
}
}
return $strategySettings;
}
}