Files
kulakpos_web/vendor/knuckleswtf/scribe/camel/Output/OutputEndpointData.php
2026-04-18 19:47:13 +07:00

356 lines
12 KiB
PHP

<?php
namespace Knuckles\Camel\Output;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\BaseDTO;
use Knuckles\Camel\Extraction\Metadata;
use Knuckles\Camel\Extraction\ResponseCollection;
use Knuckles\Camel\Extraction\ResponseField;
use Knuckles\Scribe\Extracting\Extractor;
use Knuckles\Scribe\Tools\Utils as u;
use Knuckles\Scribe\Tools\WritingUtils;
/**
* Endpoint DTO, optimized for generating HTML output.
* Unneeded properties removed, extra properties and helper methods added.
*/
class OutputEndpointData extends BaseDTO
{
/**
* @var array<string>
*/
public array $httpMethods;
public string $uri;
public Metadata $metadata;
/**
* @var array<string,string>
*/
public array $headers = [];
/**
* @var array<string,Parameter>
*/
public array $urlParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanUrlParameters = [];
/**
* @var array<string,Parameter>
*/
public array $queryParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanQueryParameters = [];
/**
* @var array<string, Parameter>
*/
public array $bodyParameters = [];
/**
* @var array<string,mixed>
*/
public array $cleanBodyParameters = [];
/**
* @var array<string,UploadedFile>
*/
public array $fileParameters = [];
public ResponseCollection $responses;
/**
* @var array<string,ResponseField>
*/
public array $responseFields = [];
/**
* The same as bodyParameters, but organized in a hierarchy.
* So, top-level items first, with a __fields property containing their children, and so on.
* Useful so we can easily render and nest on the frontend.
*
* @var array<string, array>
*/
public array $nestedBodyParameters = [];
/**
* @var array<string, array>
*/
public array $nestedResponseFields = [];
public ?string $boundUri;
public function __construct(array $parameters = [])
{
// spatie/dto currently doesn't auto-cast nested DTOs like that
$parameters['responses'] = new ResponseCollection($parameters['responses'] ?? []);
$parameters['bodyParameters'] = array_map(fn ($param) => new Parameter($param), $parameters['bodyParameters'] ?? []);
$parameters['queryParameters'] = array_map(fn ($param) => new Parameter($param), $parameters['queryParameters'] ?? []);
$parameters['urlParameters'] = array_map(fn ($param) => new Parameter($param), $parameters['urlParameters'] ?? []);
$parameters['responseFields'] = array_map(fn ($param) => new ResponseField($param), $parameters['responseFields'] ?? []);
parent::__construct($parameters);
$this->cleanBodyParameters = Extractor::cleanParams($this->bodyParameters);
$this->cleanQueryParameters = Extractor::cleanParams($this->queryParameters);
$this->cleanUrlParameters = Extractor::cleanParams($this->urlParameters);
$this->nestedBodyParameters = self::nestArrayAndObjectFields($this->bodyParameters, $this->cleanBodyParameters);
$this->nestedResponseFields = self::nestArrayAndObjectFields($this->responseFields);
$this->boundUri = u::getUrlWithBoundParameters($this->uri, $this->cleanUrlParameters);
[$files, $regularParameters] = static::splitIntoFileAndRegularParameters($this->cleanBodyParameters);
if (count($files)) {
$this->headers['Content-Type'] = 'multipart/form-data';
}
$this->fileParameters = $files;
$this->cleanBodyParameters = $regularParameters;
}
/**
* @return array<string>
*/
public static function getMethods(Route $route): array
{
$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']);
}
public static function fromExtractedEndpointArray(array $endpoint): self
{
return new self($endpoint);
}
/**
* 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' => new Parameter(...),
* 'dad.age' => new Parameter(...),
* 'dad.cars[]' => new Parameter(...),
* 'dad.cars[].model' => new Parameter(...),
* 'dad.cars[].price' => new Parameter(...),
* ],
* normalise this into [
* 'dad' => [
* ...,
* '__fields' => [
* 'dad.age' => [...],
* 'dad.cars' => [
* ...,
* '__fields' => [
* 'model' => [...],
* 'price' => [...],
* ],
* ],
* ],
* ]].
*/
public static function nestArrayAndObjectFields(array $parameters, array $cleanParameters = []): array
{
// First, we'll make sure all object fields have parent fields properly set
$normalisedParameters = [];
foreach ($parameters as $name => $parameter) {
if (Str::contains($name, '.')) {
// If the user didn't add a parent field, we'll helpfully add it for them
$ancestors = [];
$parts = explode('.', $name);
$fieldName = array_pop($parts);
$parentName = mb_rtrim(implode('.', $parts), '[]');
// When the body is an array, param names will be "[].paramname",
// so $parentName is empty. Let's fix that.
if (empty($parentName)) {
$parentName = '[]';
}
while ($parentName) {
if (! empty($normalisedParameters[$parentName])) {
break;
}
$details = [
'name' => $parentName,
'type' => $parentName === '[]' ? 'object[]' : 'object',
'description' => '',
'required' => false,
];
if ($parameter instanceof ResponseField) {
$ancestors[] = [$parentName, new ResponseField($details)];
} else {
$lastParentExample = $details['example']
= [$fieldName => $lastParentExample ?? $parameter->example];
$ancestors[] = [$parentName, new Parameter($details)];
}
$fieldName = array_pop($parts);
$parentName = mb_rtrim(implode('.', $parts), '[]');
}
// We add ancestors in reverse so we can iterate over parents first in the next section
foreach (array_reverse($ancestors) as [$ancestorName, $ancestor]) {
$normalisedParameters[$ancestorName] = $ancestor;
}
}
$normalisedParameters[$name] = $parameter;
unset($lastParentExample);
}
$finalParameters = [];
foreach ($normalisedParameters as $name => $parameter) {
$parameter = $parameter->toArray();
if (Str::contains($name, '.')) { // An object field
// Get the various pieces of the name
$parts = explode('.', $name);
$fieldName = array_pop($parts);
$baseName = implode('.__fields.', $parts);
// For subfields, the type is indicated in the source object
// eg test.items[].more and test.items.more would both have parent field with name `items` and containing __fields => more
// The difference would be in the parent field's `type` property (object[] vs object)
// So we can get rid of all [] to get the parent name
$dotPathToParent = str_replace('[]', '', $baseName);
// When the body is an array, param names will be "[].paramname",
// so $parts is ['[]']
if ($parts[0] === '[]') {
$dotPathToParent = '[]'.$dotPathToParent;
}
$dotPath = $dotPathToParent.'.__fields.'.$fieldName;
Arr::set($finalParameters, $dotPath, $parameter);
} else { // A regular field, not a subfield of anything
// Note: we're assuming any subfields of this field are listed *after* it,
// and will set __fields correctly when we iterate over them
// Hence why we create a new "normalisedParameters" array above and push the parent to that first
$parameter['__fields'] = [];
$finalParameters[$name] = $parameter;
}
}
// Finally, if the body is an array, remove any other items.
if (isset($finalParameters['[]'])) {
$finalParameters = ['[]' => $finalParameters['[]']];
// At this point, the examples are likely [[], []],
// but have been correctly set in clean parameters, so let's update them
if ($finalParameters['[]']['example'][0] === [] && ! empty($cleanParameters)) {
$finalParameters['[]']['example'] = $cleanParameters;
}
}
return $finalParameters;
}
public function endpointId(): string
{
return $this->httpMethods[0].str_replace(['/', '?', '{', '}', ':', '\\', '+', '|', '.'], '-', $this->uri);
}
public function name(): string
{
return $this->metadata->title ?: ($this->httpMethods[0].' '.$this->uri);
}
public function fullSlug(): string
{
$groupSlug = Str::slug($this->metadata->groupName);
$endpointId = $this->endpointId();
return "{$groupSlug}-{$endpointId}";
}
public function hasResponses(): bool
{
return count($this->responses) > 0;
}
public function hasFiles(): bool
{
return count($this->fileParameters) > 0;
}
public function isArrayBody(): bool
{
return count($this->nestedBodyParameters) === 1
&& array_keys($this->nestedBodyParameters)[0] === '[]';
}
public function isGet(): bool
{
return in_array('GET', $this->httpMethods);
}
public function isAuthed(): bool
{
return $this->metadata->authenticated;
}
public function hasJsonBody(): bool
{
if ($this->hasFiles() || empty($this->nestedBodyParameters)) {
return false;
}
$contentType = data_get($this->headers, 'Content-Type', data_get($this->headers, 'content-type', ''));
return str_contains($contentType, 'json');
}
public function getSampleBody()
{
return WritingUtils::getSampleBody($this->nestedBodyParameters);
}
public function hasHeadersOrQueryOrBodyParams(): bool
{
return ! empty($this->headers)
|| ! empty($this->cleanQueryParameters)
|| ! empty($this->cleanBodyParameters);
}
public static function splitIntoFileAndRegularParameters(array $parameters): array
{
$files = [];
$regularParameters = [];
foreach ($parameters as $name => $example) {
if ($example instanceof UploadedFile) {
$files[$name] = $example;
} elseif (is_array($example) && ! empty($example)) {
[$subFiles, $subRegulars] = static::splitIntoFileAndRegularParameters($example);
foreach ($subFiles as $subName => $subExample) {
$files[$name][$subName] = $subExample;
}
foreach ($subRegulars as $subName => $subExample) {
$regularParameters[$name][$subName] = $subExample;
}
} else {
$regularParameters[$name] = $example;
}
}
return [$files, $regularParameters];
}
}