From bc9d0df7f1672a9b61addbb0b8a088e216bf21f6 Mon Sep 17 00:00:00 2001 From: olivernybroe Date: Fri, 1 Nov 2024 12:11:21 +0100 Subject: [PATCH 1/5] Add custom generator support to open api spec --- src/Writing/OpenAPISpecWriter.php | 635 +----------------- .../OpenApiSpecGenerators/BaseGenerator.php | 587 ++++++++++++++++ .../OpenApiGenerator.php | 46 ++ .../SecurityGenerator.php | 61 ++ tests/Fixtures/TestOpenApiGenerator.php | 21 + tests/Unit/OpenAPISpecWriterTest.php | 25 + 6 files changed, 776 insertions(+), 599 deletions(-) create mode 100644 src/Writing/OpenApiSpecGenerators/BaseGenerator.php create mode 100644 src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php create mode 100644 src/Writing/OpenApiSpecGenerators/SecurityGenerator.php create mode 100644 tests/Fixtures/TestOpenApiGenerator.php diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php index 0356f5e0..317fb602 100644 --- a/src/Writing/OpenAPISpecWriter.php +++ b/src/Writing/OpenAPISpecWriter.php @@ -12,6 +12,9 @@ use Knuckles\Scribe\Extracting\ParamHelpers; use Knuckles\Scribe\Tools\DocumentationConfig; use Knuckles\Scribe\Tools\Utils; +use Knuckles\Scribe\Writing\OpenApiSpecGenerators\BaseGenerator; +use Knuckles\Scribe\Writing\OpenApiSpecGenerators\OpenApiGenerator; +use Knuckles\Scribe\Writing\OpenApiSpecGenerators\SecurityGenerator; use function array_map; class OpenAPISpecWriter @@ -22,48 +25,47 @@ class OpenAPISpecWriter private DocumentationConfig $config; + /** + * @var Collection + */ + private Collection $generators; + public function __construct(DocumentationConfig $config = null) { $this->config = $config ?: new DocumentationConfig(config('scribe', [])); + $this->generators = collect([ + BaseGenerator::class, + SecurityGenerator::class, + ]) + ->merge($this->config->get('openapi.generators',[])) + ->map(fn($generatorClass) => app()->makeWith($generatorClass, ['config' => $this->config])); } /** * See https://swagger.io/specification/ * - * @param array[] $groupedEndpoints + * @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()); + $paths = ['paths' => $this->generatePathsSpec($groupedEndpoints)]; + + $content = []; + foreach ($this->generators as $generator) { + $content = array_merge($content, $generator->specContent($groupedEndpoints)); + } + + return array_merge($content, $paths); } /** - * @param array[] $groupedEndpoints + * @param array $groupedEndpoints * - * @return mixed + * @return array */ - protected function generatePathsSpec(array $groupedEndpoints) + protected function generatePathsSpec(array $groupedEndpoints): array { $allEndpoints = collect($groupedEndpoints)->map->endpoints->flatten(1); // OpenAPI groups endpoints by path, then method @@ -73,24 +75,10 @@ protected function generatePathsSpec(array $groupedEndpoints) }); 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']], - ]; + $spec = []; - if (count($endpoint->bodyParameters)) { - $spec['requestBody'] = $this->generateEndpointRequestBodySpec($endpoint); - } - - if (!$endpoint->metadata->authenticated) { - // Make sure to exclude non-auth endpoints from auth - $spec['security'] = []; + foreach ($this->generators as $generator) { + $spec = array_merge($spec, $generator->pathSpecOperation($groupedEndpoints, $endpoint)); } return [strtolower($endpoint->httpMethods[0]) => $spec]; @@ -100,570 +88,19 @@ protected function generatePathsSpec(array $groupedEndpoints) // 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' => '', - ], - ]; + /** @var OutputEndpointData $urlParameterEndpoint */ + $urlParameterEndpoint = $endpoints[0]; - if ($parameterData['example'] !== null) { - $parameterData['examples']['present'] = [ - 'summary' => 'When the value is present', - 'value' => $parameterData['example'], - ]; - } + $parameters = []; - // Can't have `example` and `examples` - unset($parameterData['example']); - } - $parameters[] = $parameterData; + foreach ($this->generators as $generator) { + $parameters = array_merge($parameters, $generator->pathSpecUrlParameters($endpoints->all(), $urlParameterEndpoint->urlParameters)); } - $pathItem['parameters'] = $parameters; // @phpstan-ignore-line + + $pathItem['parameters'] = $parameters; } return [$path => $pathItem]; })->toArray(); } - - /** - * Add query parameters and headers. - * - * @param OutputEndpointData $endpoint - * - * @return array> - */ - 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), - ]; - } elseif (isset($responses[$response->status])) { - // If we already have a response for this status code and content type, - // we change to a `oneOf` which includes all the responses - $content = $this->generateResponseContentSpec($response->content, $endpoint); - $contentType = array_keys($content)[0]; - if (isset($responses[$response->status]['content'][$contentType])) { - $newResponseExample = array_replace([ - 'description' => $this->getResponseDescription($response), - ], $content[$contentType]['schema']); - - // If we've already created the oneOf object, add this response - if (isset($responses[$response->status]['content'][$contentType]['schema']['oneOf'])) { - $responses[$response->status]['content'][$contentType]['schema']['oneOf'][] = $newResponseExample; - } else { - // Create the oneOf object - $existingResponseExample = array_replace([ - 'description' => $responses[$response->status]['description'], - ], $responses[$response->status]['content'][$contentType]['schema']); - - $responses[$response->status]['description'] = ''; - $responses[$response->status]['content'][$contentType]['schema'] = [ - 'oneOf' => [$existingResponseExample, $newResponseExample] - ]; - } - } - } else { - // Store as the response for this status - $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, "<>")) { - return trim(str_replace("<>", "", $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, '<>')) { - 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(); - $required = $this->filterRequiredFields($endpoint, array_keys($properties)); - - $data = [ - 'application/json' => [ - 'schema' => [ - 'type' => 'object', - 'example' => $decoded, - 'properties' => $this->objectIfEmpty($properties), - ], - ], - ]; - if ($required) { - $data['application/json']['schema']['required'] = $required; - } - - return $data; - } - } - - 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 ?: '', - 'nullable' => $field->nullable, - ]; - } else if (Utils::isArrayType($field->type)) { - $baseType = Utils::getBaseTypeFromArrayType($field->type); - $baseItem = ($baseType === 'file') ? [ - 'type' => 'string', - 'format' => 'binary', - ] : ['type' => $baseType]; - - if (!empty($field->enumValues)) { - $baseItem['enum'] = $field->enumValues; - } - - if ($field->nullable) { - $baseItem['nullable'] = true; - } - - $fieldData = [ - 'type' => 'array', - 'description' => $field->description ?: '', - 'example' => $field->example, - 'items' => Utils::isArrayType($baseType) - ? $this->generateFieldData([ - 'name' => '', - 'type' => $baseType, - 'example' => ($field->example ?: [null])[0], - 'nullable' => $field->nullable, - ]) - : $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, - 'nullable'=> $field->nullable, - '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, - 'nullable' => $field->nullable, - ]; - 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); - } - $required = $this->filterRequiredFields($endpoint, array_keys($properties), $path); - - $schema = [ - 'type' => 'object', - 'properties' => $this->objectIfEmpty($properties), - ]; - if ($required) { - $schema['required'] = $required; - } - $this->setDescription($schema, $endpoint, $path); - - return $schema; - } - - $schema = [ - 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value)), - 'example' => $value, - ]; - $this->setDescription($schema, $endpoint, $path); - - // Set enum values for the property if they exist - if (isset($endpoint->responseFields[$path]->enumValues)) { - $schema['enum'] = $endpoint->responseFields[$path]->enumValues; - } - - 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; - } - - /** - * Given an enpoint and a set of object keys at a path, return the properties that are specified as required. - */ - public function filterRequiredFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array - { - $required = []; - foreach ($properties as $property) { - $responseField = $endpoint->responseFields["$path.$property"] ?? $endpoint->responseFields[$property] ?? null; - if ($responseField && $responseField->required) { - $required[] = $property; - } - } - - return $required; - } - - /* - * Set the description for the schema. If the field has a description, it is set in the schema. - */ - private function setDescription(array &$schema, OutputEndpointData $endpoint, string $path): void - { - if (isset($endpoint->responseFields[$path]->description)) { - $schema['description'] = $endpoint->responseFields[$path]->description; - } - } } diff --git a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php new file mode 100644 index 00000000..63e24aa0 --- /dev/null +++ b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php @@ -0,0 +1,587 @@ + OpenAPISpecWriter::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'), '/'), + ], + ], + 'tags' => array_values(array_map(function (array $group) { + return [ + 'name' => $group['name'], + 'description' => $group['description'], + ]; + }, $groupedEndpoints)), + ]; + } + + public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $endpoint): array + { + $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); + } + + return $spec; + } + + + public function pathSpecUrlParameters(array $endpoints, array $urlParameters): array + { + $parameters = []; + + foreach ($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; + } + + return $parameters; + } + + + 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)); + } + + /** + * Add query parameters and headers. + * + * @param OutputEndpointData $endpoint + * + * @return array> + */ + 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), + ]; + } elseif (isset($responses[$response->status])) { + // If we already have a response for this status code and content type, + // we change to a `oneOf` which includes all the responses + $content = $this->generateResponseContentSpec($response->content, $endpoint); + $contentType = array_keys($content)[0]; + if (isset($responses[$response->status]['content'][$contentType])) { + $newResponseExample = array_replace([ + 'description' => $this->getResponseDescription($response), + ], $content[$contentType]['schema']); + + // If we've already created the oneOf object, add this response + if (isset($responses[$response->status]['content'][$contentType]['schema']['oneOf'])) { + $responses[$response->status]['content'][$contentType]['schema']['oneOf'][] = $newResponseExample; + } else { + // Create the oneOf object + $existingResponseExample = array_replace([ + 'description' => $responses[$response->status]['description'], + ], $responses[$response->status]['content'][$contentType]['schema']); + + $responses[$response->status]['description'] = ''; + $responses[$response->status]['content'][$contentType]['schema'] = [ + 'oneOf' => [$existingResponseExample, $newResponseExample] + ]; + } + } + } else { + // Store as the response for this status + $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, "<>")) { + return trim(str_replace("<>", "", $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, '<>')) { + 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(); + $required = $this->filterRequiredFields($endpoint, array_keys($properties)); + + $data = [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'example' => $decoded, + 'properties' => $this->objectIfEmpty($properties), + ], + ], + ]; + if ($required) { + $data['application/json']['schema']['required'] = $required; + } + + return $data; + default: + return []; + } + } + + /** + * 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(); + } + + + /** + * @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 ?: '', + 'nullable' => $field->nullable, + ]; + } else if (Utils::isArrayType($field->type)) { + $baseType = Utils::getBaseTypeFromArrayType($field->type); + $baseItem = ($baseType === 'file') ? [ + 'type' => 'string', + 'format' => 'binary', + ] : ['type' => $baseType]; + + if (!empty($field->enumValues)) { + $baseItem['enum'] = $field->enumValues; + } + + if ($field->nullable) { + $baseItem['nullable'] = true; + } + + $fieldData = [ + 'type' => 'array', + 'description' => $field->description ?: '', + 'example' => $field->example, + 'items' => Utils::isArrayType($baseType) + ? $this->generateFieldData([ + 'name' => '', + 'type' => $baseType, + 'example' => ($field->example ?: [null])[0], + 'nullable' => $field->nullable, + ]) + : $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, + 'nullable'=> $field->nullable, + '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, + 'nullable' => $field->nullable, + ]; + if (!empty($field->enumValues)) { + $schema['enum'] = $field->enumValues; + } + + return $schema; + } + } + + + /** + * 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); + } + $required = $this->filterRequiredFields($endpoint, array_keys($properties), $path); + + $schema = [ + 'type' => 'object', + 'properties' => $this->objectIfEmpty($properties), + ]; + if ($required) { + $schema['required'] = $required; + } + $this->setDescription($schema, $endpoint, $path); + + return $schema; + } + + $schema = [ + 'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value)), + 'example' => $value, + ]; + $this->setDescription($schema, $endpoint, $path); + + // Set enum values for the property if they exist + if (isset($endpoint->responseFields[$path]->enumValues)) { + $schema['enum'] = $endpoint->responseFields[$path]->enumValues; + } + + 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; + } + + + /** + * Given an enpoint and a set of object keys at a path, return the properties that are specified as required. + */ + public function filterRequiredFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array + { + $required = []; + foreach ($properties as $property) { + $responseField = $endpoint->responseFields["$path.$property"] ?? $endpoint->responseFields[$property] ?? null; + if ($responseField && $responseField->required) { + $required[] = $property; + } + } + + return $required; + } + + /* + * Set the description for the schema. If the field has a description, it is set in the schema. + */ + private function setDescription(array &$schema, OutputEndpointData $endpoint, string $path): void + { + if (isset($endpoint->responseFields[$path]->description)) { + $schema['description'] = $endpoint->responseFields[$path]->description; + } + } + + protected function convertScribeOrPHPTypeToOpenAPIType($type) + { + return match ($type) { + 'float', 'double' => 'number', + 'NULL' => 'string', + default => $type, + }; + } +} diff --git a/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php b/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php new file mode 100644 index 00000000..86348dd1 --- /dev/null +++ b/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php @@ -0,0 +1,46 @@ + $groupedEndpoints + * @return array + */ + public function specContent(array $groupedEndpoints): array + { + return []; + } + + /** + * @param array $groupedEndpoints + * @param OutputEndpointData $endpoint + * @return array + */ + public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $endpoint): array + { + return []; + } + + /** + * @param OutputEndpointData[] $endpoints + * @param Parameter[] $urlParameters + * @return array + */ + public function pathSpecUrlParameters(array $endpoints, array $urlParameters): array + { + return []; + } +} diff --git a/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php b/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php new file mode 100644 index 00000000..38e5e5c6 --- /dev/null +++ b/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php @@ -0,0 +1,61 @@ +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' => [], + ], + ], + ]; + } + + public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $endpoint): array + { + if (!$endpoint->metadata->authenticated) { + // Make sure to exclude non-auth endpoints from auth + return [ + 'security' => [], + ]; + } + return []; + } +} diff --git a/tests/Fixtures/TestOpenApiGenerator.php b/tests/Fixtures/TestOpenApiGenerator.php new file mode 100644 index 00000000..2a31e58a --- /dev/null +++ b/tests/Fixtures/TestOpenApiGenerator.php @@ -0,0 +1,21 @@ + $permissions */ + $permissions = $endpoint->custom['permissions']; + + return [ + 'security' => [ + ['default' => $permissions] + ], + ]; + } +} diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index ec338569..96324b90 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -7,6 +7,7 @@ use Knuckles\Camel\Camel; use Knuckles\Camel\Output\OutputEndpointData; use Knuckles\Scribe\Tests\BaseUnitTest; +use Knuckles\Scribe\Tests\Fixtures\TestOpenApiGenerator; use Knuckles\Scribe\Tools\DocumentationConfig; use Knuckles\Scribe\Writing\OpenAPISpecWriter; @@ -810,6 +811,30 @@ public function adds_enum_values_to_response_properties() ], $results['paths']['/path']['post']['responses']); } + /** @test */ + public function can_extend_openapi_generator() + { + $endpointData1 = $this->createMockEndpointData([ + 'uri' => '/path', + 'httpMethods' => ['POST'], + 'custom' => ['permissions' => ['post:view']] + ]); + $groups = [$this->createGroup([$endpointData1])]; + $extraGenerator = TestOpenApiGenerator::class; + $config = array_merge($this->config, [ + 'openapi' => [ + 'generators' => [ + $extraGenerator, + ], + ], + ]); + $writer = new OpenAPISpecWriter(new DocumentationConfig($config)); + + $results = $writer->generateSpecContent($groups); + + $this->assertEquals([['default' => ['post:view']]], $results['paths']['/path']['post']['security']); + } + protected function createMockEndpointData(array $custom = []): OutputEndpointData { $faker = Factory::create(); From 00b791fba6432dc60a59baa8bf2593febc10e745 Mon Sep 17 00:00:00 2001 From: olivernybroe Date: Mon, 11 Nov 2024 10:44:36 +0100 Subject: [PATCH 2/5] Add documentation and improve naming --- config/scribe.php | 4 ++ src/Config/Output.php | 1 + src/Writing/OpenAPISpecWriter.php | 25 +++++----- .../OpenApiSpecGenerators/BaseGenerator.php | 25 ++++++---- .../OpenApiGenerator.php | 33 ++++++++++--- .../OverridesGenerator.php | 14 ++++++ .../SecurityGenerator.php | 16 +++---- src/Writing/Writer.php | 9 +--- tests/Fixtures/ComponentsOpenApiGenerator.php | 36 +++++++++++++++ tests/Fixtures/TestOpenApiGenerator.php | 10 ++-- tests/Unit/OpenAPISpecWriterTest.php | 46 +++++++++++++++++++ 11 files changed, 169 insertions(+), 50 deletions(-) create mode 100644 src/Writing/OpenApiSpecGenerators/OverridesGenerator.php create mode 100644 tests/Fixtures/ComponentsOpenApiGenerator.php diff --git a/config/scribe.php b/config/scribe.php index 66dbb831..49821435 100644 --- a/config/scribe.php +++ b/config/scribe.php @@ -160,6 +160,10 @@ 'overrides' => [ // 'info.version' => '2.0.0', ], + + // Additional generators to use when generating the OpenAPI spec. + // Should extend `Knuckles\Scribe\Writing\OpenApiSpecGenerators\OpenApiGenerator`. + 'generators' => [], ], 'groups' => [ diff --git a/src/Config/Output.php b/src/Config/Output.php index e90b53b1..e5d11c80 100644 --- a/src/Config/Output.php +++ b/src/Config/Output.php @@ -86,6 +86,7 @@ public static function postman( public static function openApi( bool $enabled = true, array $overrides = [], + array $generators = [], ): array { return get_defined_vars(); diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php index 317fb602..9824d47d 100644 --- a/src/Writing/OpenAPISpecWriter.php +++ b/src/Writing/OpenAPISpecWriter.php @@ -14,6 +14,7 @@ use Knuckles\Scribe\Tools\Utils; use Knuckles\Scribe\Writing\OpenApiSpecGenerators\BaseGenerator; use Knuckles\Scribe\Writing\OpenApiSpecGenerators\OpenApiGenerator; +use Knuckles\Scribe\Writing\OpenApiSpecGenerators\OverridesGenerator; use Knuckles\Scribe\Writing\OpenApiSpecGenerators\SecurityGenerator; use function array_map; @@ -36,6 +37,7 @@ public function __construct(DocumentationConfig $config = null) $this->generators = collect([ BaseGenerator::class, SecurityGenerator::class, + OverridesGenerator::class, ]) ->merge($this->config->get('openapi.generators',[])) ->map(fn($generatorClass) => app()->makeWith($generatorClass, ['config' => $this->config])); @@ -54,10 +56,10 @@ public function generateSpecContent(array $groupedEndpoints): array $content = []; foreach ($this->generators as $generator) { - $content = array_merge($content, $generator->specContent($groupedEndpoints)); + $content = $generator->root($content, $groupedEndpoints); } - return array_merge($content, $paths); + return array_replace_recursive($content, $paths); } /** @@ -78,7 +80,7 @@ protected function generatePathsSpec(array $groupedEndpoints): array $spec = []; foreach ($this->generators as $generator) { - $spec = array_merge($spec, $generator->pathSpecOperation($groupedEndpoints, $endpoint)); + $spec = $generator->pathItem($spec, $groupedEndpoints, $endpoint); } return [strtolower($endpoint->httpMethods[0]) => $spec]; @@ -87,17 +89,16 @@ protected function generatePathsSpec(array $groupedEndpoints): array $pathItem = $operations; // Placing all URL parameters at the path level, since it's the same path anyway - if (count($endpoints[0]->urlParameters)) { - /** @var OutputEndpointData $urlParameterEndpoint */ - $urlParameterEndpoint = $endpoints[0]; + /** @var OutputEndpointData $urlParameterEndpoint */ + $urlParameterEndpoint = $endpoints[0]; - $parameters = []; + $parameters = []; - foreach ($this->generators as $generator) { - $parameters = array_merge($parameters, $generator->pathSpecUrlParameters($endpoints->all(), $urlParameterEndpoint->urlParameters)); - } - - $pathItem['parameters'] = $parameters; + foreach ($this->generators as $generator) { + $parameters = $generator->pathParameters($parameters, $endpoints->all(), $urlParameterEndpoint->urlParameters); + } + if (!empty($parameters)) { + $pathItem['parameters'] = array_values($parameters); } return [$path => $pathItem]; diff --git a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php index 63e24aa0..1db34afb 100644 --- a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php @@ -11,12 +11,14 @@ use Knuckles\Scribe\Tools\Utils; use Knuckles\Scribe\Writing\OpenAPISpecWriter; +/** + * The main generator for Open API Spec. It adds the minimum needed information to the spec. + */ class BaseGenerator extends OpenApiGenerator { - - public function specContent(array $groupedEndpoints): array + public function root(array $root, array $groupedEndpoints): array { - return [ + return array_merge($root, [ 'openapi' => OpenAPISpecWriter::SPEC_VERSION, 'info' => [ 'title' => $this->config->get('title') ?: config('app.name', ''), @@ -34,10 +36,10 @@ public function specContent(array $groupedEndpoints): array 'description' => $group['description'], ]; }, $groupedEndpoints)), - ]; + ]); } - public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $endpoint): array + public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array { $spec = [ 'summary' => $endpoint->metadata->title, @@ -54,14 +56,12 @@ public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $e $spec['requestBody'] = $this->generateEndpointRequestBodySpec($endpoint); } - return $spec; + return array_merge($pathItem, $spec); } - public function pathSpecUrlParameters(array $endpoints, array $urlParameters): array + public function pathParameters(array $parameters, array $endpoints, array $urlParameters): array { - $parameters = []; - foreach ($urlParameters as $name => $details) { $parameterData = [ 'in' => 'path', @@ -94,7 +94,7 @@ public function pathSpecUrlParameters(array $endpoints, array $urlParameters): a // Can't have `example` and `examples` unset($parameterData['example']); } - $parameters[] = $parameterData; + $parameters[$name] = $parameterData; } return $parameters; @@ -544,6 +544,11 @@ public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoin return [$k => $this->generateSchemaForValue($v, $endpoint, "$path.$k")]; })->toArray(); } + + $required = $this->filterRequiredFields($endpoint, array_keys($schema['items']['properties']), $path); + if ($required) { + $schema['required'] = $required; + } } return $schema; diff --git a/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php b/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php index 86348dd1..a4690a9d 100644 --- a/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php @@ -7,6 +7,14 @@ use Knuckles\Scribe\Extracting\ParamHelpers; use Knuckles\Scribe\Tools\DocumentationConfig; +/** + * Class used to generate OpenAPI spec. + * + * This class is responsible for generating the OpenAPI spec for your API. For additional customization, you can extend + * this class and override the methods. + * Each method corresponds to a different part of the OpenAPI spec. The return value of each method will set the value, + * so if you want to extend on what other generators have done, add to the array and return it. + */ abstract class OpenApiGenerator { use ParamHelpers; @@ -16,31 +24,44 @@ public function __construct(protected DocumentationConfig $config) } /** + * This section is the root of the OpenAPI document. It contains general info about the API. + * * @param array $groupedEndpoints * @return array + * @see https://spec.openapis.org/oas/v3.1.1.html#openapi-object */ - public function specContent(array $groupedEndpoints): array + public function root(array $root, array $groupedEndpoints): array { - return []; + return $root; } /** + * This section is the individual path item object in an OpenApi document. It contains the details of the specific + * endpoint. This will be called for each individual endpoint, e.g. post, get, put, delete, etc. + * * @param array $groupedEndpoints * @param OutputEndpointData $endpoint * @return array + * @see https://spec.openapis.org/oas/v3.1.1.html#path-item-object */ - public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $endpoint): array + public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array { - return []; + return $pathItem; } /** + * This section of the spec is the parameters object inside the path item object. It contains the details of all the + * parameters for the endpoints matching the path. This will be called for each individual path, e.g. /users, /posts + * it will not be called if a path has multiple endpoints, e.g. get and post. + * + * @param array $parameters * @param OutputEndpointData[] $endpoints * @param Parameter[] $urlParameters * @return array + * @see https://spec.openapis.org/oas/v3.1.1.html#parameter-object */ - public function pathSpecUrlParameters(array $endpoints, array $urlParameters): array + public function pathParameters(array $parameters, array $endpoints, array $urlParameters): array { - return []; + return $parameters; } } diff --git a/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php b/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php new file mode 100644 index 00000000..d6ce12bd --- /dev/null +++ b/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php @@ -0,0 +1,14 @@ +config->get('openapi.overrides', []); + return array_merge($root, $overrides); + } +} diff --git a/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php b/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php index 38e5e5c6..621fce90 100644 --- a/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/SecurityGenerator.php @@ -6,11 +6,11 @@ class SecurityGenerator extends OpenApiGenerator { - public function specContent(array $groupedEndpoints): array + public function root(array $root, array $groupedEndpoints): array { $isApiAuthed = $this->config->get('auth.enabled', false); if (!$isApiAuthed) { - return []; + return $root; } $location = $this->config->get('auth.in'); @@ -31,7 +31,7 @@ public function specContent(array $groupedEndpoints): array default => [], }; - return [ + return array_merge($root, [ // All security schemes must be registered in `components.securitySchemes`... 'components' => [ 'securitySchemes' => [ @@ -45,17 +45,15 @@ public function specContent(array $groupedEndpoints): array 'default' => [], ], ], - ]; + ]); } - public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $endpoint): array + public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array { if (!$endpoint->metadata->authenticated) { // Make sure to exclude non-auth endpoints from auth - return [ - 'security' => [], - ]; + $pathItem['security'] = []; } - return []; + return $pathItem; } } diff --git a/src/Writing/Writer.php b/src/Writing/Writer.php index 87f750af..664e403c 100644 --- a/src/Writing/Writer.php +++ b/src/Writing/Writer.php @@ -140,14 +140,7 @@ public function generateOpenAPISpec(array $groupedEndpoints): string { /** @var OpenAPISpecWriter $writer */ $writer = app()->makeWith(OpenAPISpecWriter::class, ['config' => $this->config]); - $spec = $writer->generateSpecContent($groupedEndpoints); - $overrides = $this->config->get('openapi.overrides', []); - if (count($overrides)) { - foreach ($overrides as $key => $value) { - data_set($spec, $key, $value); - } - } return Yaml::dump($spec, 20, 2, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP); } @@ -178,7 +171,7 @@ protected function performFinalTasksForLaravelType(): void $contents = str_replace('href="../docs/collection.json"', 'href="{{ route("' . $this->paths->outputPath('postman', '.') . '") }}"', $contents); $contents = str_replace('href="../docs/openapi.yaml"', 'href="{{ route("' . $this->paths->outputPath('openapi', '.') . '") }}"', $contents); $contents = str_replace('url="../docs/openapi.yaml"', 'url="{{ route("' . $this->paths->outputPath('openapi', '.') . '") }}"', $contents); - // With Elements theme, we'd have paths->outputPath('openapi', '.') . '") }}"', $contents); file_put_contents("$this->laravelTypeOutputPath/index.blade.php", $contents); diff --git a/tests/Fixtures/ComponentsOpenApiGenerator.php b/tests/Fixtures/ComponentsOpenApiGenerator.php new file mode 100644 index 00000000..1e91f07d --- /dev/null +++ b/tests/Fixtures/ComponentsOpenApiGenerator.php @@ -0,0 +1,36 @@ + [ + 'in' => 'path', + 'name' => 'slug', + 'description' => 'The slug of the organization.', + 'example' => 'acme-corp', + 'required' => true, + 'schema' => [ + 'type' => 'string', + ], + ], + ]); + $root['components']['parameters'] = $parameters; + + return $root; + } + + public function pathParameters(array $parameters, array $endpoints, array $urlParameters): array + { + $parameters['slug'] = ['$ref' => "#/components/parameters/slugParam"]; + + return $parameters; + } +} diff --git a/tests/Fixtures/TestOpenApiGenerator.php b/tests/Fixtures/TestOpenApiGenerator.php index 2a31e58a..769d6688 100644 --- a/tests/Fixtures/TestOpenApiGenerator.php +++ b/tests/Fixtures/TestOpenApiGenerator.php @@ -7,15 +7,15 @@ class TestOpenApiGenerator extends OpenApiGenerator { - public function pathSpecOperation(array $groupedEndpoints, OutputEndpointData $endpoint): array + public function pathItem(array $pathItem, array $groupedEndpoints, OutputEndpointData $endpoint): array { /** @var array $permissions */ $permissions = $endpoint->custom['permissions']; - return [ - 'security' => [ - ['default' => $permissions] - ], + $pathItem['security'] = [ + ['default' => $permissions] ]; + + return $pathItem; } } diff --git a/tests/Unit/OpenAPISpecWriterTest.php b/tests/Unit/OpenAPISpecWriterTest.php index d030b0ab..6b4747bd 100644 --- a/tests/Unit/OpenAPISpecWriterTest.php +++ b/tests/Unit/OpenAPISpecWriterTest.php @@ -7,6 +7,7 @@ use Knuckles\Camel\Camel; use Knuckles\Camel\Output\OutputEndpointData; use Knuckles\Scribe\Tests\BaseUnitTest; +use Knuckles\Scribe\Tests\Fixtures\ComponentsOpenApiGenerator; use Knuckles\Scribe\Tests\Fixtures\TestOpenApiGenerator; use Knuckles\Scribe\Tools\DocumentationConfig; use Knuckles\Scribe\Writing\OpenAPISpecWriter; @@ -921,6 +922,51 @@ public function can_extend_openapi_generator() $this->assertEquals([['default' => ['post:view']]], $results['paths']['/path']['post']['security']); } + /** @test */ + public function can_extend_openapi_generator_parameters() + { + $endpointData1 = $this->createMockEndpointData([ + 'uri' => '/{slug}/path', + 'httpMethods' => ['POST'], + 'custom' => ['permissions' => ['post:view']], + 'urlParameters.slug' => [ + 'description' => 'Something', + 'required' => true, + 'example' => 56, + 'type' => 'integer', + 'name' => 'slug', + ], + ]); + $groups = [$this->createGroup([$endpointData1])]; + $extraGenerator = ComponentsOpenApiGenerator::class; + $config = array_merge($this->config, [ + 'openapi' => [ + 'generators' => [ + $extraGenerator, + ], + ], + ]); + $writer = new OpenAPISpecWriter(new DocumentationConfig($config)); + + $results = $writer->generateSpecContent($groups); + + $actualParameters = $results['paths']['/{slug}/path']['parameters']; + $this->assertCount(1, $actualParameters); + $this->assertEquals(['$ref' => "#/components/parameters/slugParam"], $actualParameters[0]); + $this->assertEquals([ + 'slugParam' => [ + 'in' => 'path', + 'name' => 'slug', + 'description' => 'The slug of the organization.', + 'example' => 'acme-corp', + 'required' => true, + 'schema' => [ + 'type' => 'string', + ], + ] + ], $results['components']['parameters']); + } + protected function createMockEndpointData(array $custom = []): OutputEndpointData { $faker = Factory::create(); From f5d2523143156e112323621a71f8361f346976eb Mon Sep 17 00:00:00 2001 From: olivernybroe Date: Mon, 11 Nov 2024 10:44:55 +0100 Subject: [PATCH 3/5] fix phpdoc --- src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php b/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php index a4690a9d..c4b402e7 100644 --- a/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/OpenApiGenerator.php @@ -26,6 +26,7 @@ public function __construct(protected DocumentationConfig $config) /** * This section is the root of the OpenAPI document. It contains general info about the API. * + * @param array $root * @param array $groupedEndpoints * @return array * @see https://spec.openapis.org/oas/v3.1.1.html#openapi-object @@ -39,6 +40,7 @@ public function root(array $root, array $groupedEndpoints): array * This section is the individual path item object in an OpenApi document. It contains the details of the specific * endpoint. This will be called for each individual endpoint, e.g. post, get, put, delete, etc. * + * @param array $pathItem * @param array $groupedEndpoints * @param OutputEndpointData $endpoint * @return array From d26ac993dc657efcec039b941d10a7d82a53e85a Mon Sep 17 00:00:00 2001 From: olivernybroe Date: Mon, 11 Nov 2024 10:50:34 +0100 Subject: [PATCH 4/5] fix override generator --- src/Writing/OpenApiSpecGenerators/OverridesGenerator.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php b/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php index d6ce12bd..dd647010 100644 --- a/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/OverridesGenerator.php @@ -9,6 +9,6 @@ class OverridesGenerator extends BaseGenerator public function root(array $root, array $groupedEndpoints): array { $overrides = $this->config->get('openapi.overrides', []); - return array_merge($root, $overrides); + return array_replace_recursive($root, Arr::undot($overrides)); } } From adb08d439952a1ee86d8be8b7f29525e58a14550 Mon Sep 17 00:00:00 2001 From: Shalvah Date: Mon, 3 Feb 2025 22:14:49 +0100 Subject: [PATCH 5/5] Resolve conflicts --- .../OpenApiSpecGenerators/BaseGenerator.php | 26 ++++++++++++------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php index 1db34afb..7cf678c8 100644 --- a/src/Writing/OpenApiSpecGenerators/BaseGenerator.php +++ b/src/Writing/OpenApiSpecGenerators/BaseGenerator.php @@ -160,7 +160,7 @@ protected function generateEndpointParametersSpec(OutputEndpointData $endpoint): return $parameters; } - protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint) + protected function generateEndpointRequestBodySpec(OutputEndpointData $endpoint): array|\stdClass { $body = []; @@ -368,9 +368,9 @@ protected function generateResponseContentSpec(?string $responseContent, OutputE case 'object': $properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) { - return [$key => $this->generateSchemaForValue($value, $endpoint, $key)]; + return [$key => $this->generateSchemaForResponseValue($value, $endpoint, $key)]; })->toArray(); - $required = $this->filterRequiredFields($endpoint, array_keys($properties)); + $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties)); $data = [ 'application/json' => [ @@ -467,7 +467,7 @@ public function generateFieldData($field): array return $fieldData; } else if ($field->type === 'object') { - return [ + $data = [ 'type' => 'object', 'description' => $field->description ?: '', 'example' => $field->example, @@ -475,7 +475,13 @@ public function generateFieldData($field): array 'properties' => $this->objectIfEmpty(collect($field->__fields)->mapWithKeys(function ($subfield, $subfieldName) { return [$subfieldName => $this->generateFieldData($subfield)]; })->all()), + 'required' => collect($field->__fields)->filter(fn ($f) => $f['required'])->keys()->toArray(), ]; + // The spec doesn't allow for an empty `required` array. Must have something there. + if (empty($data['required'])) { + unset($data['required']); + } + return $data; } else { $schema = [ 'type' => static::normalizeTypeName($field->type), @@ -497,7 +503,7 @@ public function generateFieldData($field): array * 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 + public function generateSchemaForResponseValue(mixed $value, OutputEndpointData $endpoint, string $path): array { if ($value instanceof \stdClass) { $value = (array)$value; @@ -505,9 +511,9 @@ public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoin // Recurse into the object foreach ($value as $subField => $subValue) { $subFieldPath = sprintf('%s.%s', $path, $subField); - $properties[$subField] = $this->generateSchemaForValue($subValue, $endpoint, $subFieldPath); + $properties[$subField] = $this->generateSchemaForResponseValue($subValue, $endpoint, $subFieldPath); } - $required = $this->filterRequiredFields($endpoint, array_keys($properties), $path); + $required = $this->filterRequiredResponseFields($endpoint, array_keys($properties), $path); $schema = [ 'type' => 'object', @@ -541,11 +547,11 @@ public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoin if ($typeOfEachItem === 'object') { $schema['items']['properties'] = collect($sample)->mapWithKeys(function ($v, $k) use ($endpoint, $path) { - return [$k => $this->generateSchemaForValue($v, $endpoint, "$path.$k")]; + return [$k => $this->generateSchemaForResponseValue($v, $endpoint, "$path.$k")]; })->toArray(); } - $required = $this->filterRequiredFields($endpoint, array_keys($schema['items']['properties']), $path); + $required = $this->filterRequiredResponseFields($endpoint, array_keys($schema['items']['properties']), $path); if ($required) { $schema['required'] = $required; } @@ -558,7 +564,7 @@ public function generateSchemaForValue(mixed $value, OutputEndpointData $endpoin /** * Given an enpoint and a set of object keys at a path, return the properties that are specified as required. */ - public function filterRequiredFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array + public function filterRequiredResponseFields(OutputEndpointData $endpoint, array $properties, string $path = ''): array { $required = []; foreach ($properties as $property) {