OutputEndpointData.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359
  1. <?php
  2. namespace Knuckles\Camel\Output;
  3. use Illuminate\Http\UploadedFile;
  4. use Illuminate\Routing\Route;
  5. use Illuminate\Support\Arr;
  6. use Illuminate\Support\Str;
  7. use Knuckles\Camel\BaseDTO;
  8. use Knuckles\Camel\Extraction\Metadata;
  9. use Knuckles\Camel\Extraction\Parameter;
  10. use Knuckles\Camel\Extraction\ResponseCollection;
  11. use Knuckles\Camel\Extraction\ResponseField;
  12. use Knuckles\Scribe\Extracting\Extractor;
  13. use Knuckles\Scribe\Tools\Utils as u;
  14. use Knuckles\Scribe\Tools\WritingUtils;
  15. /**
  16. * Endpoint DTO, optimized for generating HTML output.
  17. * Unneeded properties removed, extra properties and helper methods added.
  18. */
  19. class OutputEndpointData extends BaseDTO
  20. {
  21. /**
  22. * @var array<string>
  23. */
  24. public array $httpMethods;
  25. public string $uri;
  26. public Metadata $metadata;
  27. /**
  28. * @var array<string,string>
  29. */
  30. public array $headers = [];
  31. /**
  32. * @var array<string,\Knuckles\Camel\Output\Parameter>
  33. */
  34. public array $urlParameters = [];
  35. /**
  36. * @var array<string,mixed>
  37. */
  38. public array $cleanUrlParameters = [];
  39. /**
  40. * @var array<string,\Knuckles\Camel\Output\Parameter>
  41. */
  42. public array $queryParameters = [];
  43. /**
  44. * @var array<string,mixed>
  45. */
  46. public array $cleanQueryParameters = [];
  47. /**
  48. * @var array<string, \Knuckles\Camel\Output\Parameter>
  49. */
  50. public array $bodyParameters = [];
  51. /**
  52. * @var array<string,mixed>
  53. */
  54. public array $cleanBodyParameters = [];
  55. /**
  56. * @var array<string,\Illuminate\Http\UploadedFile>
  57. */
  58. public array $fileParameters = [];
  59. public ResponseCollection $responses;
  60. /**
  61. * @var array<string,\Knuckles\Camel\Extraction\ResponseField>
  62. */
  63. public array $responseFields = [];
  64. /**
  65. * The same as bodyParameters, but organized in a hierarchy.
  66. * So, top-level items first, with a __fields property containing their children, and so on.
  67. * Useful so we can easily render and nest on the frontend.
  68. * @var array<string, array>
  69. */
  70. public array $nestedBodyParameters = [];
  71. /**
  72. * @var array<string, array>
  73. */
  74. public array $nestedResponseFields = [];
  75. public ?string $boundUri;
  76. public function __construct(array $parameters = [])
  77. {
  78. // spatie/dto currently doesn't auto-cast nested DTOs like that
  79. $parameters['responses'] = new ResponseCollection($parameters['responses'] ?? []);
  80. $parameters['bodyParameters'] = array_map(fn($param) => new Parameter($param), $parameters['bodyParameters'] ?? []);
  81. $parameters['queryParameters'] = array_map(fn($param) => new Parameter($param), $parameters['queryParameters'] ?? []);
  82. $parameters['urlParameters'] = array_map(fn($param) => new Parameter($param), $parameters['urlParameters'] ?? []);
  83. $parameters['responseFields'] = array_map(fn($param) => new ResponseField($param), $parameters['responseFields'] ?? []);
  84. parent::__construct($parameters);
  85. $this->cleanBodyParameters = Extractor::cleanParams($this->bodyParameters);
  86. $this->cleanQueryParameters = Extractor::cleanParams($this->queryParameters);
  87. $this->cleanUrlParameters = Extractor::cleanParams($this->urlParameters);
  88. $this->nestedBodyParameters = self::nestArrayAndObjectFields($this->bodyParameters, $this->cleanBodyParameters);
  89. $this->nestedResponseFields = self::nestArrayAndObjectFields($this->responseFields);
  90. $this->boundUri = u::getUrlWithBoundParameters($this->uri, $this->cleanUrlParameters);
  91. [$files, $regularParameters] = static::splitIntoFileAndRegularParameters($this->cleanBodyParameters);
  92. if (count($files)) {
  93. $this->headers['Content-Type'] = 'multipart/form-data';
  94. }
  95. $this->fileParameters = $files;
  96. $this->cleanBodyParameters = $regularParameters;
  97. }
  98. /**
  99. * @param Route $route
  100. *
  101. * @return array<string>
  102. */
  103. public static function getMethods(Route $route): array
  104. {
  105. $methods = $route->methods();
  106. // Laravel adds an automatic "HEAD" endpoint for each GET request, so we'll strip that out,
  107. // but not if there's only one method (means it was intentional)
  108. if (count($methods) === 1) {
  109. return $methods;
  110. }
  111. return array_diff($methods, ['HEAD']);
  112. }
  113. public static function fromExtractedEndpointArray(array $endpoint): OutputEndpointData
  114. {
  115. return new self($endpoint);
  116. }
  117. /**
  118. * Transform body parameters such that object fields have a `fields` property containing a list of all subfields
  119. * Subfields will be removed from the main parameter map
  120. * For instance, if $parameters is [
  121. * 'dad' => new Parameter(...),
  122. * 'dad.age' => new Parameter(...),
  123. * 'dad.cars[]' => new Parameter(...),
  124. * 'dad.cars[].model' => new Parameter(...),
  125. * 'dad.cars[].price' => new Parameter(...),
  126. * ],
  127. * normalise this into [
  128. * 'dad' => [
  129. * ...,
  130. * '__fields' => [
  131. * 'dad.age' => [...],
  132. * 'dad.cars' => [
  133. * ...,
  134. * '__fields' => [
  135. * 'model' => [...],
  136. * 'price' => [...],
  137. * ],
  138. * ],
  139. * ],
  140. * ]]
  141. *
  142. * @param array $parameters
  143. *
  144. * @return array
  145. */
  146. public static function nestArrayAndObjectFields(array $parameters, array $cleanParameters = []): array
  147. {
  148. // First, we'll make sure all object fields have parent fields properly set
  149. $normalisedParameters = [];
  150. foreach ($parameters as $name => $parameter) {
  151. if (Str::contains($name, '.')) {
  152. // If the user didn't add a parent field, we'll helpfully add it for them
  153. $ancestors = [];
  154. $parts = explode('.', $name);
  155. $fieldName = array_pop($parts);
  156. $parentName = rtrim(join('.', $parts), '[]');
  157. // When the body is an array, param names will be "[].paramname",
  158. // so $parentName is empty. Let's fix that.
  159. if (empty($parentName)) {
  160. $parentName = '[]';
  161. }
  162. while ($parentName) {
  163. if (!empty($normalisedParameters[$parentName])) {
  164. break;
  165. }
  166. $details = [
  167. "name" => $parentName,
  168. "type" => $parentName === '[]' ? "object[]" : "object",
  169. "description" => "",
  170. "required" => false,
  171. ];
  172. if ($parameter instanceof ResponseField) {
  173. $ancestors[] = [$parentName, new ResponseField($details)];
  174. } else {
  175. $lastParentExample = $details["example"] =
  176. [$fieldName => $lastParentExample ?? $parameter->example];
  177. $ancestors[] = [$parentName, new Parameter($details)];
  178. }
  179. $fieldName = array_pop($parts);
  180. $parentName = rtrim(join('.', $parts), '[]');
  181. }
  182. // We add ancestors in reverse so we can iterate over parents first in the next section
  183. foreach (array_reverse($ancestors) as [$ancestorName, $ancestor]) {
  184. $normalisedParameters[$ancestorName] = $ancestor;
  185. }
  186. }
  187. $normalisedParameters[$name] = $parameter;
  188. unset($lastParentExample);
  189. }
  190. $finalParameters = [];
  191. foreach ($normalisedParameters as $name => $parameter) {
  192. $parameter = $parameter->toArray();
  193. if (Str::contains($name, '.')) { // An object field
  194. // Get the various pieces of the name
  195. $parts = explode('.', $name);
  196. $fieldName = array_pop($parts);
  197. $baseName = join('.__fields.', $parts);
  198. // For subfields, the type is indicated in the source object
  199. // eg test.items[].more and test.items.more would both have parent field with name `items` and containing __fields => more
  200. // The difference would be in the parent field's `type` property (object[] vs object)
  201. // So we can get rid of all [] to get the parent name
  202. $dotPathToParent = str_replace('[]', '', $baseName);
  203. // When the body is an array, param names will be "[].paramname",
  204. // so $parts is ['[]']
  205. if ($parts[0] == '[]') {
  206. $dotPathToParent = '[]' . $dotPathToParent;
  207. }
  208. $dotPath = $dotPathToParent . '.__fields.' . $fieldName;
  209. Arr::set($finalParameters, $dotPath, $parameter);
  210. } else { // A regular field, not a subfield of anything
  211. // Note: we're assuming any subfields of this field are listed *after* it,
  212. // and will set __fields correctly when we iterate over them
  213. // Hence why we create a new "normalisedParameters" array above and push the parent to that first
  214. $parameter['__fields'] = [];
  215. $finalParameters[$name] = $parameter;
  216. }
  217. }
  218. // Finally, if the body is an array, remove any other items.
  219. if (isset($finalParameters['[]'])) {
  220. $finalParameters = ["[]" => $finalParameters['[]']];
  221. // At this point, the examples are likely [[], []],
  222. // but have been correctly set in clean parameters, so let's update them
  223. if ($finalParameters["[]"]["example"][0] == [] && !empty($cleanParameters)) {
  224. $finalParameters["[]"]["example"] = $cleanParameters;
  225. }
  226. }
  227. return $finalParameters;
  228. }
  229. public function endpointId(): string
  230. {
  231. return $this->httpMethods[0] . str_replace(['/', '?', '{', '}', ':', '\\', '+', '|', '.'], '-', $this->uri);
  232. }
  233. public function name(): string
  234. {
  235. return $this->metadata->title ?: ($this->httpMethods[0] . " " . $this->uri);
  236. }
  237. public function fullSlug(): string
  238. {
  239. $groupSlug = Str::slug($this->metadata->groupName);
  240. $endpointId = $this->endpointId();
  241. return "$groupSlug-$endpointId";
  242. }
  243. public function hasResponses(): bool
  244. {
  245. return count($this->responses) > 0;
  246. }
  247. public function hasFiles(): bool
  248. {
  249. return count($this->fileParameters) > 0;
  250. }
  251. public function isArrayBody(): bool
  252. {
  253. return count($this->nestedBodyParameters) === 1
  254. && array_keys($this->nestedBodyParameters)[0] === "[]";
  255. }
  256. public function isGet(): bool
  257. {
  258. return in_array('GET', $this->httpMethods);
  259. }
  260. public function isAuthed(): bool
  261. {
  262. return $this->metadata->authenticated;
  263. }
  264. public function hasJsonBody(): bool
  265. {
  266. if ($this->hasFiles() || empty($this->nestedBodyParameters))
  267. return false;
  268. $contentType = data_get($this->headers, "Content-Type", data_get($this->headers, "content-type", ""));
  269. return str_contains($contentType, "json");
  270. }
  271. public function getSampleBody()
  272. {
  273. return WritingUtils::getSampleBody($this->nestedBodyParameters);
  274. }
  275. public function hasHeadersOrQueryOrBodyParams(): bool
  276. {
  277. return !empty($this->headers)
  278. || !empty($this->cleanQueryParameters)
  279. || !empty($this->cleanBodyParameters);
  280. }
  281. public static function splitIntoFileAndRegularParameters(array $parameters): array
  282. {
  283. $files = [];
  284. $regularParameters = [];
  285. foreach ($parameters as $name => $example) {
  286. if ($example instanceof UploadedFile) {
  287. $files[$name] = $example;
  288. } else if (is_array($example) && !empty($example)) {
  289. [$subFiles, $subRegulars] = static::splitIntoFileAndRegularParameters($example);
  290. foreach ($subFiles as $subName => $subExample) {
  291. $files[$name][$subName] = $subExample;
  292. }
  293. foreach ($subRegulars as $subName => $subExample) {
  294. $regularParameters[$name][$subName] = $subExample;
  295. }
  296. } else {
  297. $regularParameters[$name] = $example;
  298. }
  299. }
  300. return [$files, $regularParameters];
  301. }
  302. }