OpenAPISpecWriter.php 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. <?php
  2. namespace Knuckles\Scribe\Writing;
  3. use Illuminate\Support\Arr;
  4. use Illuminate\Support\Collection;
  5. use Illuminate\Support\Str;
  6. use Knuckles\Camel\Camel;
  7. use Knuckles\Camel\Extraction\Response;
  8. use Knuckles\Camel\Output\OutputEndpointData;
  9. use Knuckles\Camel\Output\Parameter;
  10. use Knuckles\Scribe\Extracting\ParamHelpers;
  11. use Knuckles\Scribe\Tools\DocumentationConfig;
  12. use Knuckles\Scribe\Tools\Utils;
  13. use function array_map;
  14. class OpenAPISpecWriter
  15. {
  16. use ParamHelpers;
  17. const SPEC_VERSION = '3.0.3';
  18. private DocumentationConfig $config;
  19. /**
  20. * Object to represent empty values, since empty arrays get serialised as objects.
  21. * Can't use a constant because of initialisation expression.
  22. *
  23. */
  24. public \stdClass $EMPTY;
  25. public function __construct(DocumentationConfig $config = null)
  26. {
  27. $this->config = $config ?: new DocumentationConfig(config('scribe', []));
  28. $this->EMPTY = new \stdClass();
  29. }
  30. /**
  31. * See https://swagger.io/specification/
  32. *
  33. * @param array[] $groupedEndpoints
  34. *
  35. * @return array
  36. */
  37. public function generateSpecContent(array $groupedEndpoints): array
  38. {
  39. return array_merge([
  40. 'openapi' => self::SPEC_VERSION,
  41. 'info' => [
  42. 'title' => $this->config->get('title') ?: config('app.name', ''),
  43. 'description' => $this->config->get('description', ''),
  44. 'version' => '1.0.0',
  45. ],
  46. 'servers' => [
  47. [
  48. 'url' => rtrim($this->config->get('base_url') ?? config('app.url'), '/'),
  49. ],
  50. ],
  51. 'paths' => $this->generatePathsSpec($groupedEndpoints),
  52. 'tags' => array_values(array_map(function (array $group) {
  53. return [
  54. 'name' => $group['name'],
  55. 'description' => $group['description'],
  56. ];
  57. }, $groupedEndpoints)),
  58. ], $this->generateSecurityPartialSpec());
  59. }
  60. /**
  61. * @param array[] $groupedEndpoints
  62. *
  63. * @return mixed
  64. */
  65. protected function generatePathsSpec(array $groupedEndpoints)
  66. {
  67. $allEndpoints = collect($groupedEndpoints)->map->endpoints->flatten(1);
  68. // OpenAPI groups endpoints by path, then method
  69. $groupedByPath = $allEndpoints->groupBy(function ($endpoint) {
  70. $path = str_replace("?}", "}", $endpoint->uri); // Remove optional parameters indicator in path
  71. return '/' . ltrim($path, '/');
  72. });
  73. return $groupedByPath->mapWithKeys(function (Collection $endpoints, $path) use ($groupedEndpoints) {
  74. $operations = $endpoints->mapWithKeys(function (OutputEndpointData $endpoint) use ($groupedEndpoints) {
  75. $spec = [
  76. 'summary' => $endpoint->metadata->title,
  77. 'operationId' => $this->operationId($endpoint),
  78. 'description' => $endpoint->metadata->description,
  79. 'parameters' => $this->generateEndpointParametersSpec($endpoint),
  80. 'responses' => $this->generateEndpointResponsesSpec($endpoint),
  81. 'tags' => [Arr::first($groupedEndpoints, function ($group) use ($endpoint) {
  82. return Camel::doesGroupContainEndpoint($group, $endpoint);
  83. })['name']],
  84. ];
  85. if (count($endpoint->bodyParameters)) {
  86. $spec['requestBody'] = $this->generateEndpointRequestBodySpec($endpoint);
  87. }
  88. if (!$endpoint->metadata->authenticated) {
  89. // Make sure to exclude non-auth endpoints from auth
  90. $spec['security'] = [];
  91. }
  92. return [strtolower($endpoint->httpMethods[0]) => $spec];
  93. });
  94. $pathItem = $operations;
  95. // Placing all URL parameters at the path level, since it's the same path anyway
  96. if (count($endpoints[0]->urlParameters)) {
  97. $parameters = [];
  98. /**
  99. * @var string $name
  100. * @var Parameter $details
  101. */
  102. foreach ($endpoints[0]->urlParameters as $name => $details) {
  103. $parameterData = [
  104. 'in' => 'path',
  105. 'name' => $name,
  106. 'description' => $details->description,
  107. 'example' => $details->example,
  108. // Currently, OAS requires path parameters to be required
  109. 'required' => true,
  110. 'schema' => [
  111. 'type' => $details->type,
  112. ],
  113. ];
  114. // Workaround for optional parameters
  115. if (empty($details->required)) {
  116. $parameterData['description'] = rtrim('Optional parameter. ' . $parameterData['description']);
  117. $parameterData['examples'] = [
  118. 'omitted' => [
  119. 'summary' => 'When the value is omitted',
  120. 'value' => '',
  121. ],
  122. ];
  123. if ($parameterData['example'] !== null) {
  124. $parameterData['examples']['present'] = [
  125. 'summary' => 'When the value is present',
  126. 'value' => $parameterData['example'],
  127. ];
  128. }
  129. // Can't have `example` and `examples`
  130. unset($parameterData['example']);
  131. }
  132. $parameters[] = $parameterData;
  133. }
  134. $pathItem['parameters'] = $parameters; // @phpstan-ignore-line
  135. }
  136. return [$path => $pathItem];
  137. })->toArray();
  138. }
  139. /**
  140. * Add query parameters and headers.
  141. *
  142. * @param OutputEndpointData $endpoint
  143. *
  144. * @return array<int, array<string,mixed>>
  145. */
  146. protected function generateEndpointParametersSpec(OutputEndpointData $endpoint): array
  147. {
  148. $parameters = [];
  149. if (count($endpoint->queryParameters)) {
  150. /**
  151. * @var string $name
  152. * @var Parameter $details
  153. */
  154. foreach ($endpoint->queryParameters as $name => $details) {
  155. $parameterData = [
  156. 'in' => 'query',
  157. 'name' => $name,
  158. 'description' => $details->description,
  159. 'example' => $details->example,
  160. 'required' => $details->required,
  161. 'schema' => $this->generateFieldData($details),
  162. ];
  163. $parameters[] = $parameterData;
  164. }
  165. }
  166. if (count($endpoint->headers)) {
  167. foreach ($endpoint->headers as $name => $value) {
  168. $parameters[] = [
  169. 'in' => 'header',
  170. 'name' => $name,
  171. 'description' => '',
  172. 'example' => $value,
  173. 'schema' => [
  174. 'type' => 'string',
  175. ],
  176. ];
  177. }
  178. }
  179. return $parameters;
  180. }
  181. protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint)
  182. {
  183. $body = [];
  184. if (count($endpoint->bodyParameters)) {
  185. $schema = [
  186. 'type' => 'object',
  187. 'properties' => [],
  188. ];
  189. $hasRequiredParameter = false;
  190. $hasFileParameter = false;
  191. foreach ($endpoint->nestedBodyParameters as $name => $details) {
  192. if ($name === "[]") { // Request body is an array
  193. $hasRequiredParameter = true;
  194. $schema = $this->generateFieldData($details);
  195. break;
  196. }
  197. if ($details['required']) {
  198. $hasRequiredParameter = true;
  199. // Don't declare this earlier.
  200. // The spec doesn't allow for an empty `required` array. Must have something there.
  201. $schema['required'][] = $name;
  202. }
  203. if ($details['type'] === 'file') {
  204. $hasFileParameter = true;
  205. }
  206. $fieldData = $this->generateFieldData($details);
  207. $schema['properties'][$name] = $fieldData;
  208. }
  209. $body['required'] = $hasRequiredParameter;
  210. if ($hasFileParameter) {
  211. // If there are file parameters, content type changes to multipart
  212. $contentType = 'multipart/form-data';
  213. } elseif (isset($endpoint->headers['Content-Type'])) {
  214. $contentType = $endpoint->headers['Content-Type'];
  215. } else {
  216. $contentType = 'application/json';
  217. }
  218. $body['content'][$contentType]['schema'] = $schema;
  219. }
  220. // return object rather than empty array, so can get properly serialised as object
  221. return count($body) > 0 ? $body : $this->EMPTY;
  222. }
  223. protected function generateEndpointResponsesSpec(OutputEndpointData $endpoint)
  224. {
  225. // See https://swagger.io/docs/specification/describing-responses/
  226. $responses = [];
  227. foreach ($endpoint->responses as $response) {
  228. // OpenAPI groups responses by status code
  229. // Only one response type per status code, so only the last one will be used
  230. if (intval($response->status) === 204) {
  231. // Must not add content for 204
  232. $responses[204] = [
  233. 'description' => $this->getResponseDescription($response),
  234. ];
  235. } else {
  236. $responses[$response->status] = [
  237. 'description' => $this->getResponseDescription($response),
  238. 'content' => $this->generateResponseContentSpec($response->content, $endpoint),
  239. ];
  240. }
  241. }
  242. // return object rather than empty array, so can get properly serialised as object
  243. return count($responses) > 0 ? $responses : $this->EMPTY;
  244. }
  245. protected function getResponseDescription(Response $response): string
  246. {
  247. if (Str::startsWith($response->content, "<<binary>>")) {
  248. return trim(str_replace("<<binary>>", "", $response->content));
  249. }
  250. $description = strval($response->description);
  251. // Don't include the status code in description; see https://github.com/knuckleswtf/scribe/issues/271
  252. if (preg_match("/\d{3},\s+(.+)/", $description, $matches)) {
  253. $description = $matches[1];
  254. } else if ($description === strval($response->status)) {
  255. $description = '';
  256. }
  257. return $description;
  258. }
  259. protected function generateResponseContentSpec(?string $responseContent, OutputEndpointData $endpoint)
  260. {
  261. if (Str::startsWith($responseContent, '<<binary>>')) {
  262. return [
  263. 'application/octet-stream' => [
  264. 'schema' => [
  265. 'type' => 'string',
  266. 'format' => 'binary',
  267. ],
  268. ],
  269. ];
  270. }
  271. if ($responseContent === null) {
  272. return [
  273. 'application/json' => [
  274. 'schema' => [
  275. 'type' => 'object',
  276. // See https://swagger.io/docs/specification/data-models/data-types/#null
  277. 'nullable' => true,
  278. ],
  279. ],
  280. ];
  281. }
  282. $decoded = json_decode($responseContent);
  283. if ($decoded === null) { // Decoding failed, so we return the content string as is
  284. return [
  285. 'text/plain' => [
  286. 'schema' => [
  287. 'type' => 'string',
  288. 'example' => $responseContent,
  289. ],
  290. ],
  291. ];
  292. }
  293. switch ($type = gettype($decoded)) {
  294. case 'string':
  295. case 'boolean':
  296. case 'integer':
  297. case 'double':
  298. return [
  299. 'application/json' => [
  300. 'schema' => [
  301. 'type' => $type === 'double' ? 'number' : $type,
  302. 'example' => $decoded,
  303. ],
  304. ],
  305. ];
  306. case 'array':
  307. if (!count($decoded)) {
  308. // empty array
  309. return [
  310. 'application/json' => [
  311. 'schema' => [
  312. 'type' => 'array',
  313. 'items' => [
  314. 'type' => 'object', // No better idea what to put here
  315. ],
  316. 'example' => $decoded,
  317. ],
  318. ],
  319. ];
  320. }
  321. // Non-empty array
  322. return [
  323. 'application/json' => [
  324. 'schema' => [
  325. 'type' => 'array',
  326. 'items' => [
  327. 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])),
  328. ],
  329. 'example' => $decoded,
  330. ],
  331. ],
  332. ];
  333. case 'object':
  334. $properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) {
  335. $spec = [
  336. // Note that we aren't recursing for nested objects. We stop at one level.
  337. 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value)),
  338. 'example' => $value,
  339. ];
  340. if (isset($endpoint->responseFields[$key]->description)) {
  341. $spec['description'] = $endpoint->responseFields[$key]->description;
  342. }
  343. if ($spec['type'] === 'array' && !empty($value)) {
  344. $spec['items']['type'] = $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value[0]));
  345. }
  346. return [
  347. $key => $spec,
  348. ];
  349. })->toArray();
  350. if (!count($properties)) {
  351. $properties = $this->EMPTY;
  352. }
  353. return [
  354. 'application/json' => [
  355. 'schema' => [
  356. 'type' => 'object',
  357. 'example' => $decoded,
  358. 'properties' => $properties,
  359. ],
  360. ],
  361. ];
  362. }
  363. }
  364. protected function generateSecurityPartialSpec(): array
  365. {
  366. $isApiAuthed = $this->config->get('auth.enabled', false);
  367. if (!$isApiAuthed) {
  368. return [];
  369. }
  370. $location = $this->config->get('auth.in');
  371. $parameterName = $this->config->get('auth.name');
  372. $scheme = match ($location) {
  373. 'query', 'header' => [
  374. 'type' => 'apiKey',
  375. 'name' => $parameterName,
  376. 'in' => $location,
  377. 'description' => '',
  378. ],
  379. 'bearer', 'basic' => [
  380. 'type' => 'http',
  381. 'scheme' => $location,
  382. 'description' => '',
  383. ],
  384. default => [],
  385. };
  386. return [
  387. // All security schemes must be registered in `components.securitySchemes`...
  388. 'components' => [
  389. 'securitySchemes' => [
  390. // 'default' is an arbitrary name for the auth scheme. Can be anything, really.
  391. 'default' => $scheme,
  392. ],
  393. ],
  394. // ...and then can be applied in `security`
  395. 'security' => [
  396. [
  397. 'default' => [],
  398. ],
  399. ],
  400. ];
  401. }
  402. protected function convertScribeOrPHPTypeToOpenAPIType($type)
  403. {
  404. return match ($type) {
  405. 'float', 'double' => 'number',
  406. 'NULL' => 'string',
  407. default => $type,
  408. };
  409. }
  410. /**
  411. * @param Parameter|array $field
  412. *
  413. * @return array
  414. */
  415. public function generateFieldData($field): array
  416. {
  417. if (is_array($field)) {
  418. $field = new Parameter($field);
  419. }
  420. if ($field->type === 'file') {
  421. // See https://swagger.io/docs/specification/describing-request-body/file-upload/
  422. return [
  423. 'type' => 'string',
  424. 'format' => 'binary',
  425. 'description' => $field->description ?: '',
  426. ];
  427. } else if (Utils::isArrayType($field->type)) {
  428. $baseType = Utils::getBaseTypeFromArrayType($field->type);
  429. $baseItem = ($baseType === 'file') ? [
  430. 'type' => 'string',
  431. 'format' => 'binary',
  432. ] : ['type' => $baseType];
  433. $fieldData = [
  434. 'type' => 'array',
  435. 'description' => $field->description ?: '',
  436. 'example' => $field->example,
  437. 'items' => Utils::isArrayType($baseType)
  438. ? $this->generateFieldData([
  439. 'name' => '',
  440. 'type' => $baseType,
  441. 'example' => ($field->example ?: [null])[0],
  442. ])
  443. : $baseItem,
  444. ];
  445. if (str_replace('[]', "", $field->type) === 'file') {
  446. // Don't include example for file params in OAS; it's hard to translate it correctly
  447. unset($fieldData['example']);
  448. }
  449. if ($baseType === 'object' && !empty($field->__fields)) {
  450. if ($fieldData['items']['type'] === 'object') {
  451. $fieldData['items']['properties'] = [];
  452. }
  453. foreach ($field->__fields as $fieldSimpleName => $subfield) {
  454. $fieldData['items']['properties'][$fieldSimpleName] = $this->generateFieldData($subfield);
  455. if ($subfield['required']) {
  456. $fieldData['items']['required'][] = $fieldSimpleName;
  457. }
  458. }
  459. }
  460. return $fieldData;
  461. } else if ($field->type === 'object') {
  462. return [
  463. 'type' => 'object',
  464. 'description' => $field->description ?: '',
  465. 'example' => $field->example,
  466. 'properties' => collect($field->__fields)->mapWithKeys(function ($subfield, $subfieldName) {
  467. return [$subfieldName => $this->generateFieldData($subfield)];
  468. })->all(),
  469. ];
  470. } else {
  471. return [
  472. 'type' => static::normalizeTypeName($field->type),
  473. 'description' => $field->description ?: '',
  474. 'example' => $field->example,
  475. ];
  476. }
  477. }
  478. function operationId(OutputEndpointData $endpoint): string
  479. {
  480. if ($endpoint->metadata->title) return preg_replace('/[^\w+]/', '', Str::camel($endpoint->metadata->title));
  481. $parts = preg_split('/[^\w+]/', $endpoint->uri, -1, PREG_SPLIT_NO_EMPTY);
  482. return Str::lower($endpoint->httpMethods[0]) . join('', array_map(fn ($part) => ucfirst($part), $parts));
  483. }
  484. }