OpenAPISpecWriter.php 16 KB

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