Skip to content

Commit

Permalink
Support limitng parameter values (enums) (#713)
Browse files Browse the repository at this point in the history
* First pass: support enum in tags, attributes, and rules

* Support enum in output

* Support enum parameters

* Support enums in response fields
  • Loading branch information
shalvah authored Aug 24, 2023
1 parent ce566d4 commit 9b72f1a
Show file tree
Hide file tree
Showing 22 changed files with 127 additions and 46 deletions.
1 change: 1 addition & 0 deletions camel/Extraction/Parameter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [])
{
Expand Down
5 changes: 5 additions & 0 deletions camel/Extraction/ResponseField.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -15,4 +18,6 @@ class ResponseField extends BaseDTO

/** @var string */
public $type;

public array $enumValues = [];
}
4 changes: 4 additions & 0 deletions resources/views/components/field-details.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,7 @@
}
@endphp
{!! Parsedown::instance()->text(trim($description)) !!}
@if(!empty($enumValues))
Must be one of:
<ul style="list-style-type: square;">{!! implode(" ", array_map(fn($val) => "<li><code>$val</code></li>", $enumValues)) !!}</ul>
@endif
3 changes: 3 additions & 0 deletions resources/views/components/nested-fields.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
'required' => $subfield['required'] ?? false,
'description' => $subfield['description'] ?? '',
'example' => $subfield['example'] ?? '',
'enumValues' => $subfield['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => false,
'component' => 'body',
Expand Down Expand Up @@ -66,6 +67,7 @@
'required' => $subfield['required'] ?? false,
'description' => $subfield['description'] ?? '',
'example' => $subfield['example'] ?? '',
'enumValues' => $field['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => false,
'component' => 'body',
Expand All @@ -86,6 +88,7 @@
'required' => $field['required'] ?? false,
'description' => $field['description'] ?? '',
'example' => $field['example'] ?? '',
'enumValues' => $field['enumValues'] ?? null,
'endpointId' => $endpointId,
'hasChildren' => false,
'component' => 'body',
Expand Down
2 changes: 2 additions & 0 deletions resources/views/themes/default/endpoint.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
'required' => $parameter->required,
'description' => $parameter->description,
'example' => $parameter->example ?? '',
'enumValues' => $parameter->enumValues,
'endpointId' => $endpoint->endpointId(),
'component' => 'url',
'isInput' => true,
Expand All @@ -156,6 +157,7 @@
'required' => $parameter->required,
'description' => $parameter->description,
'example' => $parameter->example ?? '',
'enumValues' => $parameter->enumValues,
'endpointId' => $endpoint->endpointId(),
'component' => 'query',
'isInput' => true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ class="svg-inline--fa fa-chevron-right fa-fw fa-sm sl-icon" role="img"
{!! Parsedown::instance()->text($description) !!}
</div>
@endif
@if(!empty($enumValues))
Must be one of:
<ul style="list-style-position: inside; list-style-type: square;">{!! implode(" ", array_map(fn($val) => "<li><code>$val</code></li>", $enumValues)) !!}</ul>
@endif
@if($isArrayBody)
<div class="sl-flex sl-items-baseline sl-text-base">
<div class="sl-font-mono sl-font-semibold sl-mr-2">array of:</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions resources/views/themes/elements/endpoint.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
24 changes: 24 additions & 0 deletions src/Attributes/GenericParam.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {
}

Expand All @@ -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'
);
}
}
16 changes: 6 additions & 10 deletions src/Attributes/ResponseField.php
Original file line number Diff line number Diff line change
Expand Up @@ -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,
];
}
}
18 changes: 17 additions & 1 deletion src/Extracting/ParamHelpers.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

use Faker\Factory;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

trait ParamHelpers
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -232,13 +237,24 @@ 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]);

// Examples are parsed as strings by default, we need to cast them properly
$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];
}
}
5 changes: 2 additions & 3 deletions src/Extracting/ParsesValidationRules.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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);
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
16 changes: 10 additions & 6 deletions src/Extracting/Strategies/GetFieldsFromTagStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
5 changes: 4 additions & 1 deletion src/Extracting/Strategies/GetParamsFromAttributeStrategy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
21 changes: 13 additions & 8 deletions src/Writing/OpenAPISpecWriter.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Expand All @@ -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);
}
Expand Down
1 change: 1 addition & 0 deletions tests/GenerateDocumentation/OutputTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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)]
Expand Down
Loading

0 comments on commit 9b72f1a

Please sign in to comment.