OpenAPISpecWriter.php 22 KB

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