OutputEndpointData.php 12 KB

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