OpenAPISpecWriter.php 18 KB

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