123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589 |
- <?php
- namespace Knuckles\Scribe\Writing;
- use Illuminate\Support\Arr;
- use Illuminate\Support\Collection;
- use Illuminate\Support\Str;
- use Knuckles\Camel\Camel;
- use Knuckles\Camel\Extraction\Response;
- use Knuckles\Camel\Output\OutputEndpointData;
- use Knuckles\Camel\Output\Parameter;
- use Knuckles\Scribe\Extracting\ParamHelpers;
- use Knuckles\Scribe\Tools\DocumentationConfig;
- use Knuckles\Scribe\Tools\Utils;
- use function array_map;
- class OpenAPISpecWriter
- {
- use ParamHelpers;
- const SPEC_VERSION = '3.0.3';
- private DocumentationConfig $config;
- public function __construct(DocumentationConfig $config = null)
- {
- $this->config = $config ?: new DocumentationConfig(config('scribe', []));
- }
- /**
- * See https://swagger.io/specification/
- *
- * @param array[] $groupedEndpoints
- *
- * @return array
- */
- public function generateSpecContent(array $groupedEndpoints): array
- {
- return array_merge([
- 'openapi' => self::SPEC_VERSION,
- 'info' => [
- 'title' => $this->config->get('title') ?: config('app.name', ''),
- 'description' => $this->config->get('description', ''),
- 'version' => '1.0.0',
- ],
- 'servers' => [
- [
- 'url' => rtrim($this->config->get('base_url') ?? config('app.url'), '/'),
- ],
- ],
- 'paths' => $this->generatePathsSpec($groupedEndpoints),
- 'tags' => array_values(array_map(function (array $group) {
- return [
- 'name' => $group['name'],
- 'description' => $group['description'],
- ];
- }, $groupedEndpoints)),
- ], $this->generateSecurityPartialSpec());
- }
- /**
- * @param array[] $groupedEndpoints
- *
- * @return mixed
- */
- protected function generatePathsSpec(array $groupedEndpoints)
- {
- $allEndpoints = collect($groupedEndpoints)->map->endpoints->flatten(1);
- // OpenAPI groups endpoints by path, then method
- $groupedByPath = $allEndpoints->groupBy(function ($endpoint) {
- $path = str_replace("?}", "}", $endpoint->uri); // Remove optional parameters indicator in path
- return '/' . ltrim($path, '/');
- });
- return $groupedByPath->mapWithKeys(function (Collection $endpoints, $path) use ($groupedEndpoints) {
- $operations = $endpoints->mapWithKeys(function (OutputEndpointData $endpoint) use ($groupedEndpoints) {
- $spec = [
- 'summary' => $endpoint->metadata->title,
- 'operationId' => $this->operationId($endpoint),
- 'description' => $endpoint->metadata->description,
- 'parameters' => $this->generateEndpointParametersSpec($endpoint),
- 'responses' => $this->generateEndpointResponsesSpec($endpoint),
- 'tags' => [Arr::first($groupedEndpoints, function ($group) use ($endpoint) {
- return Camel::doesGroupContainEndpoint($group, $endpoint);
- })['name']],
- ];
- if (count($endpoint->bodyParameters)) {
- $spec['requestBody'] = $this->generateEndpointRequestBodySpec($endpoint);
- }
- if (!$endpoint->metadata->authenticated) {
- // Make sure to exclude non-auth endpoints from auth
- $spec['security'] = [];
- }
- return [strtolower($endpoint->httpMethods[0]) => $spec];
- });
- $pathItem = $operations;
- // Placing all URL parameters at the path level, since it's the same path anyway
- if (count($endpoints[0]->urlParameters)) {
- $parameters = [];
- /**
- * @var string $name
- * @var Parameter $details
- */
- foreach ($endpoints[0]->urlParameters as $name => $details) {
- $parameterData = [
- 'in' => 'path',
- 'name' => $name,
- 'description' => $details->description,
- 'example' => $details->example,
- // Currently, OAS requires path parameters to be required
- 'required' => true,
- 'schema' => [
- 'type' => $details->type,
- ],
- ];
- // Workaround for optional parameters
- if (empty($details->required)) {
- $parameterData['description'] = rtrim('Optional parameter. ' . $parameterData['description']);
- $parameterData['examples'] = [
- 'omitted' => [
- 'summary' => 'When the value is omitted',
- 'value' => '',
- ],
- ];
- if ($parameterData['example'] !== null) {
- $parameterData['examples']['present'] = [
- 'summary' => 'When the value is present',
- 'value' => $parameterData['example'],
- ];
- }
- // Can't have `example` and `examples`
- unset($parameterData['example']);
- }
- $parameters[] = $parameterData;
- }
- $pathItem['parameters'] = $parameters; // @phpstan-ignore-line
- }
- return [$path => $pathItem];
- })->toArray();
- }
- /**
- * Add query parameters and headers.
- *
- * @param OutputEndpointData $endpoint
- *
- * @return array<int, array<string,mixed>>
- */
- protected function generateEndpointParametersSpec(OutputEndpointData $endpoint): array
- {
- $parameters = [];
- if (count($endpoint->queryParameters)) {
- /**
- * @var string $name
- * @var Parameter $details
- */
- foreach ($endpoint->queryParameters as $name => $details) {
- $parameterData = [
- 'in' => 'query',
- 'name' => $name,
- 'description' => $details->description,
- 'example' => $details->example,
- 'required' => $details->required,
- 'schema' => $this->generateFieldData($details),
- ];
- $parameters[] = $parameterData;
- }
- }
- if (count($endpoint->headers)) {
- foreach ($endpoint->headers as $name => $value) {
- if (in_array(strtolower($name), ['content-type', 'accept', 'authorization']))
- // These headers are not allowed in the spec.
- // https://swagger.io/docs/specification/describing-parameters/#header-parameters
- continue;
- $parameters[] = [
- 'in' => 'header',
- 'name' => $name,
- 'description' => '',
- 'example' => $value,
- 'schema' => [
- 'type' => 'string',
- ],
- ];
- }
- }
- return $parameters;
- }
- protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint)
- {
- $body = [];
- if (count($endpoint->bodyParameters)) {
- $schema = [
- 'type' => 'object',
- 'properties' => [],
- ];
- $hasRequiredParameter = false;
- $hasFileParameter = false;
- foreach ($endpoint->nestedBodyParameters as $name => $details) {
- if ($name === "[]") { // Request body is an array
- $hasRequiredParameter = true;
- $schema = $this->generateFieldData($details);
- break;
- }
- if ($details['required']) {
- $hasRequiredParameter = true;
- // Don't declare this earlier.
- // The spec doesn't allow for an empty `required` array. Must have something there.
- $schema['required'][] = $name;
- }
- if ($details['type'] === 'file') {
- $hasFileParameter = true;
- }
- $fieldData = $this->generateFieldData($details);
- $schema['properties'][$name] = $fieldData;
- }
- // We remove 'properties' if the request body is an array, so we need to check if it's still there
- if (array_key_exists('properties', $schema)) {
- $schema['properties'] = $this->objectIfEmpty($schema['properties']);
- }
- $body['required'] = $hasRequiredParameter;
- if ($hasFileParameter) {
- // If there are file parameters, content type changes to multipart
- $contentType = 'multipart/form-data';
- } elseif (isset($endpoint->headers['Content-Type'])) {
- $contentType = $endpoint->headers['Content-Type'];
- } else {
- $contentType = 'application/json';
- }
- $body['content'][$contentType]['schema'] = $schema;
- }
- // return object rather than empty array, so can get properly serialised as object
- return $this->objectIfEmpty($body);
- }
- protected function generateEndpointResponsesSpec(OutputEndpointData $endpoint)
- {
- // See https://swagger.io/docs/specification/describing-responses/
- $responses = [];
- foreach ($endpoint->responses as $response) {
- // OpenAPI groups responses by status code
- // Only one response type per status code, so only the last one will be used
- if (intval($response->status) === 204) {
- // Must not add content for 204
- $responses[204] = [
- 'description' => $this->getResponseDescription($response),
- ];
- } else {
- $responses[$response->status] = [
- 'description' => $this->getResponseDescription($response),
- 'content' => $this->generateResponseContentSpec($response->content, $endpoint),
- ];
- }
- }
- // return object rather than empty array, so can get properly serialised as object
- return $this->objectIfEmpty($responses);
- }
- protected function getResponseDescription(Response $response): string
- {
- if (Str::startsWith($response->content, "<<binary>>")) {
- return trim(str_replace("<<binary>>", "", $response->content));
- }
- $description = strval($response->description);
- // Don't include the status code in description; see https://github.com/knuckleswtf/scribe/issues/271
- if (preg_match("/\d{3},\s+(.+)/", $description, $matches)) {
- $description = $matches[1];
- } else if ($description === strval($response->status)) {
- $description = '';
- }
- return $description;
- }
- protected function generateResponseContentSpec(?string $responseContent, OutputEndpointData $endpoint)
- {
- if (Str::startsWith($responseContent, '<<binary>>')) {
- return [
- 'application/octet-stream' => [
- 'schema' => [
- 'type' => 'string',
- 'format' => 'binary',
- ],
- ],
- ];
- }
- if ($responseContent === null) {
- return [
- 'application/json' => [
- 'schema' => [
- 'type' => 'object',
- // See https://swagger.io/docs/specification/data-models/data-types/#null
- 'nullable' => true,
- ],
- ],
- ];
- }
- $decoded = json_decode($responseContent);
- if ($decoded === null) { // Decoding failed, so we return the content string as is
- return [
- 'text/plain' => [
- 'schema' => [
- 'type' => 'string',
- 'example' => $responseContent,
- ],
- ],
- ];
- }
- switch ($type = gettype($decoded)) {
- case 'string':
- case 'boolean':
- case 'integer':
- case 'double':
- return [
- 'application/json' => [
- 'schema' => [
- 'type' => $type === 'double' ? 'number' : $type,
- 'example' => $decoded,
- ],
- ],
- ];
- case 'array':
- if (!count($decoded)) {
- // empty array
- return [
- 'application/json' => [
- 'schema' => [
- 'type' => 'array',
- 'items' => [
- 'type' => 'object', // No better idea what to put here
- ],
- 'example' => $decoded,
- ],
- ],
- ];
- }
- // Non-empty array
- return [
- 'application/json' => [
- 'schema' => [
- 'type' => 'array',
- 'items' => [
- 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])),
- ],
- 'example' => $decoded,
- ],
- ],
- ];
- case 'object':
- $properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) {
- return [$key => $this->generateSchemaForValue($value, $endpoint, $key)];
- })->toArray();
- return [
- 'application/json' => [
- 'schema' => [
- 'type' => 'object',
- 'example' => $decoded,
- 'properties' => $this->objectIfEmpty($properties),
- ],
- ],
- ];
- }
- }
- protected function generateSecurityPartialSpec(): array
- {
- $isApiAuthed = $this->config->get('auth.enabled', false);
- if (!$isApiAuthed) {
- return [];
- }
- $location = $this->config->get('auth.in');
- $parameterName = $this->config->get('auth.name');
- $description = $this->config->get('auth.extra_info');
- $scheme = match ($location) {
- 'query', 'header' => [
- 'type' => 'apiKey',
- 'name' => $parameterName,
- 'in' => $location,
- 'description' => $description,
- ],
- 'bearer', 'basic' => [
- 'type' => 'http',
- 'scheme' => $location,
- 'description' => $description,
- ],
- default => [],
- };
- return [
- // All security schemes must be registered in `components.securitySchemes`...
- 'components' => [
- 'securitySchemes' => [
- // 'default' is an arbitrary name for the auth scheme. Can be anything, really.
- 'default' => $scheme,
- ],
- ],
- // ...and then can be applied in `security`
- 'security' => [
- [
- 'default' => [],
- ],
- ],
- ];
- }
- protected function convertScribeOrPHPTypeToOpenAPIType($type)
- {
- return match ($type) {
- 'float', 'double' => 'number',
- 'NULL' => 'string',
- default => $type,
- };
- }
- /**
- * @param Parameter|array $field
- *
- * @return array
- */
- public function generateFieldData($field): array
- {
- if (is_array($field)) {
- $field = new Parameter($field);
- }
- if ($field->type === 'file') {
- // See https://swagger.io/docs/specification/describing-request-body/file-upload/
- return [
- 'type' => 'string',
- 'format' => 'binary',
- 'description' => $field->description ?: '',
- ];
- } else if (Utils::isArrayType($field->type)) {
- $baseType = Utils::getBaseTypeFromArrayType($field->type);
- $baseItem = ($baseType === 'file') ? [
- 'type' => 'string',
- 'format' => 'binary',
- ] : ['type' => $baseType];
- $fieldData = [
- 'type' => 'array',
- 'description' => $field->description ?: '',
- 'example' => $field->example,
- 'items' => Utils::isArrayType($baseType)
- ? $this->generateFieldData([
- 'name' => '',
- 'type' => $baseType,
- 'example' => ($field->example ?: [null])[0],
- ])
- : $baseItem,
- ];
- if (str_replace('[]', "", $field->type) === 'file') {
- // Don't include example for file params in OAS; it's hard to translate it correctly
- unset($fieldData['example']);
- }
- if ($baseType === 'object' && !empty($field->__fields)) {
- if ($fieldData['items']['type'] === 'object') {
- $fieldData['items']['properties'] = [];
- }
- foreach ($field->__fields as $fieldSimpleName => $subfield) {
- $fieldData['items']['properties'][$fieldSimpleName] = $this->generateFieldData($subfield);
- if ($subfield['required']) {
- $fieldData['items']['required'][] = $fieldSimpleName;
- }
- }
- }
- return $fieldData;
- } else if ($field->type === 'object') {
- return [
- 'type' => 'object',
- 'description' => $field->description ?: '',
- 'example' => $field->example,
- 'properties' => $this->objectIfEmpty(collect($field->__fields)->mapWithKeys(function ($subfield, $subfieldName) {
- return [$subfieldName => $this->generateFieldData($subfield)];
- })->all()),
- ];
- } else {
- $schema = [
- 'type' => static::normalizeTypeName($field->type),
- 'description' => $field->description ?: '',
- 'example' => $field->example,
- ];
- if (!empty($field->enumValues)) {
- $schema['enum'] = $field->enumValues;
- }
- return $schema;
- }
- }
- protected function operationId(OutputEndpointData $endpoint): string
- {
- if ($endpoint->metadata->title) return preg_replace('/[^\w+]/', '', Str::camel($endpoint->metadata->title));
- $parts = preg_split('/[^\w+]/', $endpoint->uri, -1, PREG_SPLIT_NO_EMPTY);
- return Str::lower($endpoint->httpMethods[0]) . join('', array_map(fn($part) => ucfirst($part), $parts));
- }
- /**
- * Given an array, return an object if the array is empty. To be used with fields that are
- * required by OpenAPI spec to be objects, since empty arrays get serialised as [].
- */
- protected function objectIfEmpty(array $field): array|\stdClass
- {
- return count($field) > 0 ? $field : new \stdClass();
- }
- /**
- * Given a value, generate the schema for it. The schema consists of: {type:, example:, properties: (if value is an
- * object)}, and possibly a description for each property. The $endpoint and $path are used for looking up response
- * field descriptions.
- */
- public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoint, string $path): array
- {
- if ($value instanceof \stdClass) {
- $value = (array)$value;
- $properties = [];
- // Recurse into the object
- foreach ($value as $subField => $subValue) {
- $subFieldPath = sprintf('%s.%s', $path, $subField);
- $properties[$subField] = $this->generateSchemaForValue($subValue, $endpoint, $subFieldPath);
- }
- return [
- 'type' => 'object',
- 'properties' => $this->objectIfEmpty($properties),
- ];
- }
- $schema = [
- 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value)),
- 'example' => $value,
- ];
- if (isset($endpoint->responseFields[$path]->description)) {
- $schema['description'] = $endpoint->responseFields[$path]->description;
- }
- if ($schema['type'] === 'array' && !empty($value)) {
- $schema['example'] = json_decode(json_encode($schema['example']), true); // Convert stdClass to array
- $sample = $value[0];
- $typeOfEachItem = $this->convertScribeOrPHPTypeToOpenAPIType(gettype($sample));
- $schema['items']['type'] = $typeOfEachItem;
- if ($typeOfEachItem === 'object') {
- $schema['items']['properties'] = collect($sample)->mapWithKeys(function ($v, $k) use ($endpoint, $path) {
- return [$k => $this->generateSchemaForValue($v, $endpoint, "$path.$k")];
- })->toArray();
- }
- }
- return $schema;
- }
- }
|