update after stop build
All checks were successful
Build, Push and Deploy / build-and-push (push) Successful in 3m36s
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 16s
Build, Push and Deploy / deploy-staging (push) Successful in 42s
Build, Push and Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-04-18 16:10:57 +07:00
parent e449146306
commit 1228c52538
260 changed files with 43609 additions and 226 deletions

View File

@@ -0,0 +1,172 @@
<?php
namespace Knuckles\Camel;
use Illuminate\Contracts\Support\Arrayable;
class BaseDTO implements \ArrayAccess, Arrayable
{
/**
* @var array
* Added so end-users can dynamically add additional properties for their own use
*/
public array $custom = [];
public function __construct(array $parameters = [])
{
// Initialize all properties to their default values first
$this->initializeProperties();
foreach ($parameters as $key => $value) {
if (property_exists($this, $key)) {
$this->{$key} = $this->castProperty($key, $value);
}
}
}
public static function create(array|self $data, array|self $inheritFrom = []): static
{
if ($data instanceof static) {
return $data;
}
$mergedData = $inheritFrom instanceof static ? $inheritFrom->toArray() : $inheritFrom;
foreach ($data as $property => $value) {
$mergedData[$property] = $value;
}
return new static($mergedData);
}
public function toArray(): array
{
$array = [];
foreach (get_object_vars($this) as $property => $value) {
$array[$property] = $value;
}
return $this->parseArray($array);
}
public static function make(array|self $data): static
{
return $data instanceof static ? $data : new static($data);
}
public function offsetExists(mixed $offset): bool
{
return isset($this->{$offset});
}
public function offsetGet(mixed $offset): mixed
{
return $this->{$offset};
}
public function offsetSet(mixed $offset, mixed $value): void
{
$this->{$offset} = $value;
}
public function offsetUnset(mixed $offset): void
{
unset($this->{$offset});
}
public function except(string ...$keys): array
{
$array = [];
foreach (get_object_vars($this) as $property => $value) {
if (! in_array($property, $keys)) {
$array[$property] = $value;
}
}
return $this->parseArray($array);
}
public static function arrayOf(array $items): array
{
return array_map(function ($item) {
return $item instanceof static ? $item : new static($item);
}, $items);
}
protected function initializeProperties(): void
{
$reflection = new \ReflectionClass($this);
foreach ($reflection->getProperties(\ReflectionProperty::IS_PUBLIC) as $property) {
$name = $property->getName();
// Skip if already initialized (has a default value)
if ($property->hasDefaultValue()) {
continue;
}
$type = $property->getType();
if ($type && $type->allowsNull()) {
$this->{$name} = null;
}
}
}
protected function castProperty(string $key, mixed $value): mixed
{
// If the value is already the correct type, return it as-is
if (! is_array($value)) {
return $value;
}
// Get property type through reflection
$reflection = new \ReflectionClass($this);
if (! $reflection->hasProperty($key)) {
return $value;
}
$property = $reflection->getProperty($key);
$type = $property->getType();
if ($type && $type instanceof \ReflectionNamedType && ! $type->isBuiltin()) {
$className = $type->getName();
// If it's a DTO class in our namespace, instantiate it
if (class_exists($className) && is_subclass_of($className, self::class)) {
return new $className($value);
}
// If it's another class in our namespace that has a constructor accepting arrays
if (class_exists($className)) {
try {
return new $className($value);
} catch (\Throwable $e) {
// If instantiation fails, return the original value
return $value;
}
}
}
return $value;
}
protected function parseArray(array $array): array
{
// Reimplementing here so our DTOCollection items can be recursively toArray'ed
foreach ($array as $key => $value) {
if ($value instanceof Arrayable) {
$array[$key] = $value->toArray();
continue;
}
if (! is_array($value)) {
continue;
}
$array[$key] = $this->parseArray($value);
}
return $array;
}
}

View File

@@ -0,0 +1,50 @@
<?php
namespace Knuckles\Camel;
use Illuminate\Contracts\Support\Arrayable;
use Illuminate\Support\Collection;
/**
* @template T of \Knuckles\Camel\BaseDTO
*/
class BaseDTOCollection extends Collection
{
/**
* @var string the name of the base DTO class
*/
public static string $base = '';
public function __construct($items = [])
{
// Manually cast nested arrays
$items = array_map(
fn ($item) => is_array($item) ? new static::$base($item) : $item,
$items instanceof Collection ? $items->toArray() : $items
);
parent::__construct($items);
}
/**
* Append items to the collection, mutating it.
*
* @param array[]|T[] $items
*/
public function concat($items)
{
foreach ($items as $item) {
$this->push(is_array($item) ? new static::$base($item) : $item);
}
return $this;
}
public function toArray(): array
{
return array_map(
fn ($item) => $item instanceof Arrayable ? $item->toArray() : $item,
$this->items
);
}
}

View File

@@ -0,0 +1,231 @@
<?php
namespace Knuckles\Camel;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;
use Knuckles\Camel\Output\OutputEndpointData;
use Knuckles\Scribe\Tools\PathConfig;
use Knuckles\Scribe\Tools\Utils;
use Symfony\Component\Yaml\Yaml;
class Camel
{
public static function cacheDir(PathConfig $paths): string
{
return $paths->intermediateOutputPath('endpoints.cache');
}
public static function camelDir(PathConfig $paths): string
{
return $paths->intermediateOutputPath('endpoints');
}
/**
* Load endpoints from the Camel files into groups (arrays).
*
* @return array[] each array is a group with keys including `name` and `endpoints`
*/
public static function loadEndpointsIntoGroups(string $folder): array
{
$groups = [];
self::loadEndpointsFromCamelFiles($folder, function (array $group) use (&$groups) {
$groups[$group['name']] = $group;
});
return $groups;
}
/**
* Load endpoints from the Camel files into a flat list of endpoint arrays.
* Useful when we don't care about groups, but simply want to compare endpoints contents
* to see if anything changed.
*
* @return array[] list of endpoint arrays
*/
public static function loadEndpointsToFlatPrimitivesArray(string $folder): array
{
$endpoints = [];
self::loadEndpointsFromCamelFiles($folder, function (array $group) use (&$endpoints) {
foreach ($group['endpoints'] as $endpoint) {
$endpoints[] = $endpoint;
}
});
return $endpoints;
}
public static function loadEndpointsFromCamelFiles(string $folder, callable $callback): void
{
$contents = Utils::listDirectoryContents($folder);
foreach ($contents as $object) {
if (
$object->isFile()
&& Str::endsWith(basename($object->path()), '.yaml')
&& ! Str::startsWith(basename($object->path()), 'custom.')
) {
$group = Yaml::parseFile($object['path']);
$callback($group);
}
}
}
public static function loadUserDefinedEndpoints(string $folder): array
{
$contents = Utils::listDirectoryContents($folder);
$userDefinedEndpoints = [];
foreach ($contents as $object) {
if (
$object->isFile()
&& Str::endsWith(basename($object->path()), '.yaml')
&& Str::startsWith(basename($object->path()), 'custom.')
) {
$endpoints = Yaml::parseFile($object->path());
foreach (($endpoints ?: []) as $endpoint) {
$userDefinedEndpoints[] = $endpoint;
}
}
}
return $userDefinedEndpoints;
}
public static function doesGroupContainEndpoint(array $group, OutputEndpointData $endpoint): bool
{
return (bool) (Arr::first($group['endpoints'], function ($e) use ($endpoint) {
return $e->endpointId() === $endpoint->endpointId();
}));
}
/**
* @param array[] $groupedEndpoints
* @param array $configFileOrder the order for groups that users specified in their config file
* @return array[]
*/
public static function sortByConfigFileOrder(array $groupedEndpoints, array $configFileOrder): array
{
if (empty($configFileOrder)) {
ksort($groupedEndpoints, SORT_NATURAL);
return $groupedEndpoints;
}
// First, sort groups
$groupsOrder = Utils::getTopLevelItemsFromMixedConfigList($configFileOrder);
$groupsCollection = collect($groupedEndpoints);
$wildcardPosition = array_search('*', $groupsOrder);
if ($wildcardPosition !== false) {
$promotedGroups = array_splice($groupsOrder, 0, $wildcardPosition);
$demotedGroups = array_splice($groupsOrder, 1);
$promotedOrderedGroups = $groupsCollection->filter(fn ($group, $groupName) => in_array($groupName, $promotedGroups))
->sortKeysUsing(self::getOrderListComparator($promotedGroups));
$demotedOrderedGroups = $groupsCollection->filter(fn ($group, $groupName) => in_array($groupName, $demotedGroups))
->sortKeysUsing(self::getOrderListComparator($demotedGroups));
$nonWildcardGroups = array_merge($promotedGroups, $demotedGroups);
$wildCardOrderedGroups = $groupsCollection->filter(fn ($group, $groupName) => ! in_array($groupName, $nonWildcardGroups))
->sortKeysUsing(self::getOrderListComparator($demotedGroups));
$groupedEndpoints = $promotedOrderedGroups->merge($wildCardOrderedGroups)
->merge($demotedOrderedGroups);
} else {
$groupedEndpoints = $groupsCollection->sortKeysUsing(self::getOrderListComparator($groupsOrder));
}
return $groupedEndpoints->map(function (array $group, string $groupName) use ($configFileOrder) {
$sortedEndpoints = collect($group['endpoints']);
if (isset($configFileOrder[$groupName])) {
// Second-level order list. Can contain endpoint or subgroup names
$level2Order = Utils::getTopLevelItemsFromMixedConfigList($configFileOrder[$groupName]);
$sortedEndpoints = $sortedEndpoints->sortBy(
function (OutputEndpointData $e) use ($configFileOrder, $level2Order) {
$endpointIdentifier = $e->httpMethods[0].' /'.$e->uri;
// First, check if there's an ordering specified for the endpoint itself
$indexOfEndpointInL2Order = array_search($endpointIdentifier, $level2Order);
if ($indexOfEndpointInL2Order !== false) {
return $indexOfEndpointInL2Order;
}
// Check if there's an ordering for the endpoint's subgroup
$indexOfSubgroupInL2Order = array_search($e->metadata->subgroup, $level2Order);
if ($indexOfSubgroupInL2Order !== false) {
// There's a subgroup order; check if there's an endpoints order within that
$orderOfEndpointsInSubgroup = $configFileOrder[$e->metadata->groupName][$e->metadata->subgroup] ?? [];
$indexOfEndpointInSubGroup = array_search($endpointIdentifier, $orderOfEndpointsInSubgroup);
return ($indexOfEndpointInSubGroup === false)
? $indexOfSubgroupInL2Order
: ($indexOfSubgroupInL2Order + ($indexOfEndpointInSubGroup * 0.1));
}
return INF;
},
);
}
return [
'name' => $groupName,
'description' => $group['description'],
'endpoints' => $sortedEndpoints->all(),
];
})->values()->all();
}
/**
* Prepare endpoints to be turned into HTML.
* Map them into OutputEndpointData DTOs, and sort them by the specified order in the config file.
*
* @param array<string,array[]> $groupedEndpoints
*/
public static function prepareGroupedEndpointsForOutput(array $groupedEndpoints, array $configFileOrder = []): array
{
$groups = array_map(function (array $group) {
return [
'name' => $group['name'],
'description' => $group['description'],
'endpoints' => array_map(
fn (array $endpoint) => OutputEndpointData::fromExtractedEndpointArray($endpoint),
$group['endpoints']
),
];
}, $groupedEndpoints);
return self::sortByConfigFileOrder($groups, $configFileOrder);
}
/**
* Given an $order list like ['first', 'second', ...], return a compare function that can be used to sort
* a list of strings based on the order of items in $order.
* Any strings not in the list are sorted with natural sort.
*/
protected static function getOrderListComparator(array $order): \Closure
{
return function ($a, $b) use ($order) {
$indexOfA = array_search($a, $order);
$indexOfB = array_search($b, $order);
// If both are in the $order list, compare them normally based on their position in the list
if ($indexOfA !== false && $indexOfB !== false) {
return $indexOfA <=> $indexOfB;
}
// If only A is in the $order list, then it must come before B.
if ($indexOfA !== false) {
return -1;
}
// If only B is in the $order list, then it must come before A.
if ($indexOfB !== false) {
return 1;
}
// If neither is present, fall back to natural sort
return strnatcmp($a, $b);
};
}
}

View File

@@ -0,0 +1,169 @@
<?php
namespace Knuckles\Camel\Extraction;
use Illuminate\Http\UploadedFile;
use Illuminate\Routing\Route;
use Knuckles\Camel\BaseDTO;
use Knuckles\Scribe\Extracting\Shared\UrlParamsNormalizer;
use Knuckles\Scribe\Tools\Globals;
use Knuckles\Scribe\Tools\Utils as u;
class ExtractedEndpointData 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,array|UploadedFile>
*/
public array $fileParameters = [];
public ResponseCollection $responses;
/**
* @var array<string,ResponseField>
*/
public array $responseFields = [];
/**
* Authentication info for this endpoint. In the form [{where}, {name}, {sample}]
* Example: ["queryParameters", "api_key", "njiuyiw97865rfyvgfvb1"].
*/
public array $auth = [];
public ?\ReflectionClass $controller;
public ?\ReflectionFunctionAbstract $method;
public ?Route $route;
public function __construct(array $parameters = [])
{
$parameters['metadata'] ??= new Metadata([]);
$parameters['responses'] ??= new ResponseCollection([]);
parent::__construct($parameters);
$defaultNormalizer = fn () => UrlParamsNormalizer::normalizeParameterNamesInRouteUri($this->route, $this->method);
$this->uri = match (is_callable(Globals::$__normalizeEndpointUrlUsing)) {
true => call_user_func_array(
Globals::$__normalizeEndpointUrlUsing,
[$this->route->uri, $this->route, $this->method, $this->controller, $defaultNormalizer]
),
default => $defaultNormalizer(),
};
}
/**
* @param array $extras Only used for quick overrides in tests
*
* @throws \ReflectionException
*/
public static function fromRoute(Route $route, array $extras = []): self
{
$httpMethods = self::getMethods($route);
$uri = $route->uri();
[$controllerName, $methodName] = u::getRouteClassAndMethodNames($route);
$controller = new \ReflectionClass($controllerName);
$method = u::getReflectedRouteMethod([$controllerName, $methodName]);
$data = compact('httpMethods', 'uri', 'controller', 'method', 'route');
$data = array_merge($data, $extras);
return new self($data);
}
/**
* @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 function name()
{
return sprintf("[%s] {$this->route->uri}.", implode(',', $this->route->methods));
}
public function endpointId()
{
return $this->httpMethods[0].str_replace(['/', '?', '{', '}', ':', '\\', '+', '|'], '-', $this->uri);
}
/**
* Prepare the endpoint data for serialising.
*/
public function forSerialisation()
{
$copyArray = $this->except(
// Get rid of all duplicate data
'cleanQueryParameters',
'cleanUrlParameters',
'fileParameters',
'cleanBodyParameters',
// and objects used only in extraction
'route',
'controller',
'method',
'auth',
);
// Remove these, since they're on the parent group object
if (isset($copyArray['metadata']) && $copyArray['metadata'] instanceof Metadata) {
$copyArray['metadata'] = $copyArray['metadata']->except('groupName', 'groupDescription');
}
return $copyArray;
}
}

View File

@@ -0,0 +1,24 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTO;
class Metadata extends BaseDTO
{
public ?string $groupName;
public ?string $groupDescription;
public ?string $subgroup;
public ?string $subgroupDescription;
public ?string $title;
public ?string $description;
public bool $authenticated = false;
public bool|string $deprecated = false;
}

View File

@@ -0,0 +1,32 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTO;
class Parameter extends BaseDTO
{
public string $name;
public ?string $description = null;
public bool $required = false;
public mixed $example = null;
public string $type = 'string';
public array $enumValues = [];
public bool $exampleWasSpecified = false;
public bool $nullable = false;
public bool $deprecated = false;
public function __construct(array $parameters = [])
{
unset($parameters['setter']);
parent::__construct($parameters);
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace Knuckles\Camel\Extraction;
use Illuminate\Support\Str;
use Knuckles\Camel\BaseDTO;
class Response extends BaseDTO
{
public int $status;
public ?string $content;
public array $headers = [];
public ?string $description;
public function __construct(array $parameters = [])
{
if (is_array($parameters['content'] ?? null)) {
$parameters['content'] = json_encode($parameters['content'], JSON_UNESCAPED_SLASHES);
}
if (isset($parameters['status'])) {
$parameters['status'] = (int) $parameters['status'];
}
$hiddenHeaders = [
'date',
'Date',
'etag',
'ETag',
'last-modified',
'Last-Modified',
'date',
'Date',
'content-length',
'Content-Length',
'connection',
'Connection',
'x-powered-by',
'X-Powered-By',
];
if (! empty($parameters['headers'])) {
foreach ($hiddenHeaders as $headerName) {
unset($parameters['headers'][$headerName]);
}
}
parent::__construct($parameters);
}
public function fullDescription()
{
$description = $this->status;
if ($this->description) {
$description .= ", {$this->description}";
}
return $description;
}
public function isBinary(): bool
{
return is_string($this->content) && Str::startsWith($this->content, '<<binary>>');
}
}

View File

@@ -0,0 +1,20 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTOCollection;
/**
* @extends BaseDTOCollection<Response>
*/
class ResponseCollection extends BaseDTOCollection
{
public static string $base = Response::class;
public function hasSuccessResponse(): bool
{
return $this->first(
fn ($response) => '2' === ((string) ($response->status))[0]
) !== null;
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace Knuckles\Camel\Extraction;
use Knuckles\Camel\BaseDTO;
class ResponseField extends BaseDTO
{
// TODO make this extend Parameter, so we can have strong types and a unified API
// but first we need to normalize incoming data
/** @var string */
public $name;
/** @var string */
public $description;
/** @var string */
public $type;
/** @var bool */
public $required;
/** @var mixed */
public $example;
public array $enumValues = [];
/** @var bool */
public $nullable;
}

View File

@@ -0,0 +1,355 @@
<?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];
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Knuckles\Camel\Output;
class Parameter extends \Knuckles\Camel\Extraction\Parameter
{
public array $__fields = [];
public function toArray(): array
{
return $this->except('__fields');
}
}