diff --git a/camel/Extraction/Parameter.php b/camel/Extraction/Parameter.php index d1538953..beb03787 100644 --- a/camel/Extraction/Parameter.php +++ b/camel/Extraction/Parameter.php @@ -12,6 +12,7 @@ class Parameter extends BaseDTO public bool $required = false; public mixed $example = null; public string $type = 'string'; + public array $enumValues = []; public function __construct(array $parameters = []) { diff --git a/camel/Extraction/ResponseField.php b/camel/Extraction/ResponseField.php index 85d82e64..ffc92baf 100644 --- a/camel/Extraction/ResponseField.php +++ b/camel/Extraction/ResponseField.php @@ -7,6 +7,9 @@ class ResponseField extends BaseDTO { + // TODO make this extend Parameter, so we can have strong types and a unified API + // but first we need to normalize incoming data + /** @var string */ public $name; @@ -15,4 +18,6 @@ class ResponseField extends BaseDTO /** @var string */ public $type; + + public array $enumValues = []; } diff --git a/resources/views/components/field-details.blade.php b/resources/views/components/field-details.blade.php index 57200c1c..222a094a 100644 --- a/resources/views/components/field-details.blade.php +++ b/resources/views/components/field-details.blade.php @@ -69,3 +69,7 @@ } @endphp {!! Parsedown::instance()->text(trim($description)) !!} +@if(!empty($enumValues)) +Must be one of: + +@endif diff --git a/resources/views/components/nested-fields.blade.php b/resources/views/components/nested-fields.blade.php index 1deb7c0b..e4137835 100644 --- a/resources/views/components/nested-fields.blade.php +++ b/resources/views/components/nested-fields.blade.php @@ -25,6 +25,7 @@ 'required' => $subfield['required'] ?? false, 'description' => $subfield['description'] ?? '', 'example' => $subfield['example'] ?? '', + 'enumValues' => $subfield['enumValues'] ?? null, 'endpointId' => $endpointId, 'hasChildren' => false, 'component' => 'body', @@ -66,6 +67,7 @@ 'required' => $subfield['required'] ?? false, 'description' => $subfield['description'] ?? '', 'example' => $subfield['example'] ?? '', + 'enumValues' => $field['enumValues'] ?? null, 'endpointId' => $endpointId, 'hasChildren' => false, 'component' => 'body', @@ -86,6 +88,7 @@ 'required' => $field['required'] ?? false, 'description' => $field['description'] ?? '', 'example' => $field['example'] ?? '', + 'enumValues' => $field['enumValues'] ?? null, 'endpointId' => $endpointId, 'hasChildren' => false, 'component' => 'body', diff --git a/resources/views/themes/default/endpoint.blade.php b/resources/views/themes/default/endpoint.blade.php index 94e77598..8da30799 100644 --- a/resources/views/themes/default/endpoint.blade.php +++ b/resources/views/themes/default/endpoint.blade.php @@ -132,6 +132,7 @@ 'required' => $parameter->required, 'description' => $parameter->description, 'example' => $parameter->example ?? '', + 'enumValues' => $parameter->enumValues, 'endpointId' => $endpoint->endpointId(), 'component' => 'url', 'isInput' => true, @@ -156,6 +157,7 @@ 'required' => $parameter->required, 'description' => $parameter->description, 'example' => $parameter->example ?? '', + 'enumValues' => $parameter->enumValues, 'endpointId' => $endpoint->endpointId(), 'component' => 'query', 'isInput' => true, diff --git a/resources/views/themes/elements/components/field-details.blade.php b/resources/views/themes/elements/components/field-details.blade.php index ea41dedc..b2682fc0 100644 --- a/resources/views/themes/elements/components/field-details.blade.php +++ b/resources/views/themes/elements/components/field-details.blade.php @@ -36,6 +36,10 @@ class="svg-inline--fa fa-chevron-right fa-fw fa-sm sl-icon" role="img" {!! Parsedown::instance()->text($description) !!} @endif + @if(!empty($enumValues)) + Must be one of: + + @endif @if($isArrayBody)
array of:
diff --git a/resources/views/themes/elements/components/nested-fields.blade.php b/resources/views/themes/elements/components/nested-fields.blade.php index ec7583e7..1e2ab3de 100644 --- a/resources/views/themes/elements/components/nested-fields.blade.php +++ b/resources/views/themes/elements/components/nested-fields.blade.php @@ -15,6 +15,7 @@ 'required' => $field['required'] ?? false, 'description' => $field['description'] ?? '', 'example' => $field['example'] ?? '', + 'enumValues' => $field['enumValues'] ?? null, 'endpointId' => $endpointId, 'hasChildren' => !empty($field['__fields']), 'component' => 'body', diff --git a/resources/views/themes/elements/endpoint.blade.php b/resources/views/themes/elements/endpoint.blade.php index 61690c28..470c1617 100644 --- a/resources/views/themes/elements/endpoint.blade.php +++ b/resources/views/themes/elements/endpoint.blade.php @@ -81,6 +81,7 @@ class="sl-overflow-x-hidden sl-truncate sl-text-muted">{{ rtrim($baseUrl, '/') } 'required' => $parameter->required, 'description' => $parameter->description, 'example' => $parameter->example ?? '', + 'enumValues' => $parameter->enumValues, 'endpointId' => $endpoint->endpointId(), 'component' => 'url', 'isInput' => true, @@ -104,6 +105,7 @@ class="sl-overflow-x-hidden sl-truncate sl-text-muted">{{ rtrim($baseUrl, '/') } 'required' => $parameter->required, 'description' => $parameter->description, 'example' => $parameter->example ?? '', + 'enumValues' => $parameter->enumValues, 'endpointId' => $endpoint->endpointId(), 'component' => 'query', 'isInput' => true, diff --git a/src/Attributes/GenericParam.php b/src/Attributes/GenericParam.php index df977173..2e34de38 100644 --- a/src/Attributes/GenericParam.php +++ b/src/Attributes/GenericParam.php @@ -13,6 +13,7 @@ public function __construct( public ?string $description = '', public ?bool $required = true, public mixed $example = null, /* Pass 'No-example' to omit the example */ + public mixed $enum = null, // Can pass a list of values, or a native PHP enum ) { } @@ -24,6 +25,29 @@ public function toArray() "type" => $this->type, "required" => $this->required, "example" => $this->example, + "enumValues" => $this->getEnumValues(), ]; } + + protected function getEnumValues() + { + if (!$this->enum) { + return null; + } + + if (is_array($this->enum)) { + return $this->enum; + } + + if (function_exists('enum_exists') && enum_exists($this->enum)) { + return array_map( + fn ($case) => $case->value, + $this->enum::cases() + ); + } + + throw new \InvalidArgumentException( + 'The enum property of a parameter must be either a PHP enum or an array of values' + ); + } } diff --git a/src/Attributes/ResponseField.php b/src/Attributes/ResponseField.php index 1ebe576b..a11c790e 100644 --- a/src/Attributes/ResponseField.php +++ b/src/Attributes/ResponseField.php @@ -5,21 +5,17 @@ use Attribute; #[Attribute(Attribute::IS_REPEATABLE | Attribute::TARGET_FUNCTION | Attribute::TARGET_METHOD | Attribute::TARGET_CLASS)] -class ResponseField +class ResponseField extends GenericParam { + // Don't default to string; type inference is currently handled by the normalizer + // TODO change this in the future public function __construct( public string $name, public ?string $type = null, public ?string $description = '', + public ?bool $required = true, + public mixed $example = null, /* Pass 'No-example' to omit the example */ + public mixed $enum = null, // Can pass a list of values, or a native PHP enum ) { } - - public function toArray() - { - return [ - "name" => $this->name, - "description" => $this->description, - "type" => $this->type, - ]; - } } diff --git a/src/Extracting/ParamHelpers.php b/src/Extracting/ParamHelpers.php index 8afd230b..a8125bab 100644 --- a/src/Extracting/ParamHelpers.php +++ b/src/Extracting/ParamHelpers.php @@ -4,6 +4,7 @@ use Faker\Factory; use Illuminate\Http\UploadedFile; +use Illuminate\Support\Arr; use Illuminate\Support\Str; trait ParamHelpers @@ -46,6 +47,10 @@ protected function getFaker(): \Faker\Generator protected function generateDummyValue(string $type, array $hints = []) { + if(!empty($hints['enumValues'])) { + return Arr::random($hints['enumValues']); + } + $fakeFactory = $this->getDummyValueGenerator($type, $hints); return $fakeFactory(); @@ -232,6 +237,8 @@ protected function shouldExcludeExample(string $description): bool protected function parseExampleFromParamDescription(string $description, string $type): array { $example = null; + $enumValues = []; + if (preg_match('/(.*)\bExample:\s*([\s\S]+)\s*/s', $description, $content)) { $description = trim($content[1]); @@ -239,6 +246,15 @@ protected function parseExampleFromParamDescription(string $description, string $example = $this->castToType($content[2], $type); } - return [$description, $example]; + if (preg_match('/(.*)\bEnum:\s*([\s\S]+)\s*/s', $description, $content)) { + $description = trim($content[1]); + + $enumValues = array_map( + fn ($value) => $this->castToType(trim($value), $type), + explode(',', rtrim(trim($content[2]), '.')) + ); + } + + return [$description, $example, $enumValues]; } } diff --git a/src/Extracting/ParsesValidationRules.php b/src/Extracting/ParsesValidationRules.php index c39dce40..2e1a47a8 100644 --- a/src/Extracting/ParsesValidationRules.php +++ b/src/Extracting/ParsesValidationRules.php @@ -203,7 +203,7 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly if (enum_exists($type) && method_exists($type, 'tryFrom')) { $cases = array_map(fn ($case) => $case->value, $type::cases()); $parameterData['type'] = gettype($cases[0]); - $parameterData['description'] .= ' Must be one of ' . w::getListOfValuesAsFriendlyHtmlString($cases) . ' '; + $parameterData['enumValues'] = $cases; $parameterData['setter'] = fn () => Arr::random($cases); } @@ -464,8 +464,7 @@ protected function parseRule($rule, array &$parameterData, bool $independentOnly * Other rules. */ case 'in': - // Not using the rule description here because it only says "The attribute is invalid" - $parameterData['description'] .= ' Must be one of ' . w::getListOfValuesAsFriendlyHtmlString($arguments) . ' '; + $parameterData['enumValues'] = $arguments; $parameterData['setter'] = function () use ($arguments) { return Arr::random($arguments); }; diff --git a/src/Extracting/Strategies/BodyParameters/GetFromBodyParamTag.php b/src/Extracting/Strategies/BodyParameters/GetFromBodyParamTag.php index 33992997..1d52e579 100644 --- a/src/Extracting/Strategies/BodyParameters/GetFromBodyParamTag.php +++ b/src/Extracting/Strategies/BodyParameters/GetFromBodyParamTag.php @@ -33,8 +33,9 @@ public function parseTag(string $tagContent): array } $type = static::normalizeTypeName($type); - [$description, $example] = $this->getDescriptionAndExample($description, $type, $tagContent, $name); + [$description, $example, $enumValues] = + $this->getDescriptionAndExample($description, $type, $tagContent, $name); - return compact('name', 'type', 'description', 'required', 'example'); + return compact('name', 'type', 'description', 'required', 'example', 'enumValues'); } } diff --git a/src/Extracting/Strategies/GetFieldsFromTagStrategy.php b/src/Extracting/Strategies/GetFieldsFromTagStrategy.php index 60b4c0f8..1fdf59f3 100644 --- a/src/Extracting/Strategies/GetFieldsFromTagStrategy.php +++ b/src/Extracting/Strategies/GetFieldsFromTagStrategy.php @@ -33,17 +33,21 @@ public function getFromTags(array $tagsOnMethod, array $tagsOnClass = []): array abstract protected function parseTag(string $tagContent): array; - protected function getDescriptionAndExample(string $description, string $type, string $tagContent, string $fieldName): array + protected function getDescriptionAndExample( + string $description, string $type, string $tagContent, string $fieldName + ): array { - [$description, $example] = $this->parseExampleFromParamDescription($description, $type); - $example = $this->setExampleIfNeeded($example, $type, $tagContent, $fieldName); - return [$description, $example]; + [$description, $example, $enumValues] = $this->parseExampleFromParamDescription($description, $type); + $example = $this->setExampleIfNeeded($example, $type, $tagContent, $fieldName, $enumValues); + return [$description, $example, $enumValues]; } - protected function setExampleIfNeeded(mixed $currentExample, string $type, string $tagContent, string $fieldName): mixed + protected function setExampleIfNeeded( + mixed $currentExample, string $type, string $tagContent, string $fieldName, ?array $enumValues = [] + ): mixed { return (is_null($currentExample) && !$this->shouldExcludeExample($tagContent)) - ? $this->generateDummyValue($type, hints: ['name' => $fieldName]) + ? $this->generateDummyValue($type, hints: ['name' => $fieldName, 'enumValues' => $enumValues]) : $currentExample; } } diff --git a/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php b/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php index b0bd36ce..42ff07cc 100644 --- a/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php +++ b/src/Extracting/Strategies/GetParamsFromAttributeStrategy.php @@ -30,7 +30,10 @@ protected function normalizeParameterData(array $data): array { $data['type'] = static::normalizeTypeName($data['type']); if (is_null($data['example'])) { - $data['example'] = $this->generateDummyValue($data['type'], ['name' => $data['name']]); + $data['example'] = $this->generateDummyValue($data['type'], [ + 'name' => $data['name'], + 'enumValues' => $data['enumValues'], + ]); } else if ($data['example'] == 'No-example' || $data['example'] == 'No-example.') { $data['example'] = null; } diff --git a/src/Extracting/Strategies/QueryParameters/GetFromQueryParamTag.php b/src/Extracting/Strategies/QueryParameters/GetFromQueryParamTag.php index 3f1cbe83..de84944c 100644 --- a/src/Extracting/Strategies/QueryParameters/GetFromQueryParamTag.php +++ b/src/Extracting/Strategies/QueryParameters/GetFromQueryParamTag.php @@ -63,8 +63,9 @@ public function parseTag(string $tagContent): array } - [$description, $example] = $this->getDescriptionAndExample($description, $type, $tagContent, $name); + [$description, $example, $enumValues] = + $this->getDescriptionAndExample($description, $type, $tagContent, $name); - return compact('name', 'description', 'required', 'example', 'type'); + return compact('name', 'description', 'required', 'example', 'type', 'enumValues'); } } diff --git a/src/Extracting/Strategies/UrlParameters/GetFromUrlParamTag.php b/src/Extracting/Strategies/UrlParameters/GetFromUrlParamTag.php index b6194971..48cb5714 100644 --- a/src/Extracting/Strategies/UrlParameters/GetFromUrlParamTag.php +++ b/src/Extracting/Strategies/UrlParameters/GetFromUrlParamTag.php @@ -46,8 +46,9 @@ protected function parseTag(string $tagContent): array : static::normalizeTypeName($type); } - [$description, $example] = $this->getDescriptionAndExample($description, $type, $tagContent, $name); + [$description, $example, $enumValues] = + $this->getDescriptionAndExample($description, $type, $tagContent, $name); - return compact('name', 'description', 'required', 'example', 'type'); + return compact('name', 'description', 'required', 'example', 'type', 'enumValues'); } } diff --git a/src/Writing/OpenAPISpecWriter.php b/src/Writing/OpenAPISpecWriter.php index 5599ed9e..0857bfe6 100644 --- a/src/Writing/OpenAPISpecWriter.php +++ b/src/Writing/OpenAPISpecWriter.php @@ -510,11 +510,16 @@ public function generateFieldData($field): array })->all()), ]; } else { - return [ + $schema = [ 'type' => static::normalizeTypeName($field->type), 'description' => $field->description ?: '', 'example' => $field->example, ]; + if (!empty($field->enumValues)) { + $schema['enum'] = $field->enumValues; + } + + return $schema; } } @@ -523,30 +528,30 @@ 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)); + 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 + 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. + * 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; + $value = (array)$value; $properties = []; // Recurse into the object - foreach($value as $subField => $subValue){ + foreach ($value as $subField => $subValue) { $subFieldPath = sprintf('%s.%s', $path, $subField); $properties[$subField] = $this->generateSchemaForValue($subValue, $endpoint, $subFieldPath); } diff --git a/tests/GenerateDocumentation/OutputTest.php b/tests/GenerateDocumentation/OutputTest.php index 234defe1..28e41a34 100644 --- a/tests/GenerateDocumentation/OutputTest.php +++ b/tests/GenerateDocumentation/OutputTest.php @@ -417,6 +417,7 @@ public function will_not_overwrite_manually_modified_content_unless_force_flag_i 'required' => true, 'example' => 6, 'type' => 'integer', + 'enumValues' => [], 'custom' => [], ]; $group['endpoints'][0]['urlParameters']['a_param'] = $extraParam; diff --git a/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php b/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php index 6669063f..93c90be0 100644 --- a/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php +++ b/tests/Strategies/BodyParameters/GetFromBodyParamAttributeTest.php @@ -89,6 +89,12 @@ public function can_fetch_from_bodyparam_attribute() 'description' => '', 'required' => true, ], + 'state' => [ + 'type' => 'string', + 'description' => '', + 'required' => true, + 'enumValues' => ["active", "pending"] + ], 'users' => [ 'type' => 'object[]', 'description' => 'Users\' details', @@ -216,6 +222,7 @@ class BodyParamAttributeTestController #[BodyParam("book.author_id", type: "integer")] #[BodyParam("book.pages_count", type: "integer")] #[BodyParam("ids", "integer[]")] + #[BodyParam("state", enum: ["active", "pending"])] #[BodyParam("users", "object[]", "Users' details", required: false)] #[BodyParam("users[].first_name", "string", "The first name of the user.", example: "John", required: false)] #[BodyParam("users[].last_name", "string", "The last name of the user.", example: "Doe", required: false)] diff --git a/tests/Strategies/GetFromInlineValidatorTest.php b/tests/Strategies/GetFromInlineValidatorTest.php index 8d4d9d93..4235b598 100644 --- a/tests/Strategies/GetFromInlineValidatorTest.php +++ b/tests/Strategies/GetFromInlineValidatorTest.php @@ -26,7 +26,7 @@ class GetFromInlineValidatorTest extends BaseLaravelTest 'room_id' => [ 'type' => 'string', 'required' => false, - 'description' => 'The id of the room. Must be one of 3, 5, or 6.', + 'description' => 'The id of the room.', ], 'forever' => [ 'type' => 'boolean', @@ -193,12 +193,12 @@ public function can_fetch_inline_enum_rules() $expected = [ 'enum_class' => [ 'type' => 'string', - 'description' => 'Must be one of red, green, or blue.', + 'description' => '', 'required' => true, ], 'enum_string' => [ 'type' => 'string', - 'description' => 'Must be one of 1, 2, or 3.', + 'description' => '', 'required' => true, ], 'enum_inexistent' => [ diff --git a/tests/Unit/ValidationRuleParsingTest.php b/tests/Unit/ValidationRuleParsingTest.php index 2380162f..10ba7e4b 100644 --- a/tests/Unit/ValidationRuleParsingTest.php +++ b/tests/Unit/ValidationRuleParsingTest.php @@ -68,8 +68,8 @@ public function can_parse_rule_objects() 'in_param' => ['numeric', Rule::in([3,5,6])] ]); $this->assertEquals( - 'Must be one of 3, 5, or 6.', - $results['in_param']['description'] + [3, 5, 6], + $results['in_param']['enumValues'] ); } @@ -228,8 +228,9 @@ public static function supportedRules() ['in_param' => 'in:3,5,6'], ['in_param' => ['description' => $description]], [ - 'description' => "$description. Must be one of 3, 5, or 6.", + 'description' => $description.".", 'type' => 'string', + 'enumValues' => [3,5,6] ], ]; yield 'not_in' => [ @@ -533,8 +534,8 @@ public function can_parse_enum_rules() ]); $this->assertEquals('string', $results['enum']['type']); $this->assertEquals( - 'Must be one of red, green, or blue.', - $results['enum']['description'] + ['red', 'green', 'blue'], + $results['enum']['enumValues'] ); $this->assertTrue(in_array( $results['enum']['example'], @@ -547,8 +548,8 @@ public function can_parse_enum_rules() ]); $this->assertEquals('integer', $results['enum']['type']); $this->assertEquals( - 'Must be one of 1, 2, or 3.', - $results['enum']['description'] + [1, 2, 3], + $results['enum']['enumValues'] ); $this->assertTrue(in_array( $results['enum']['example'], @@ -562,7 +563,7 @@ public function can_parse_enum_rules() ]); $this->assertEquals('string', $results['enum']['type']); $this->assertEquals( - 'A description. Must be one of red, green, or blue.', + 'A description.', $results['enum']['description'] ); $this->assertTrue(in_array(