Add test to ensure attribute parameters are matching annotations (#1217)

This commit is contained in:
Martin Rademacher 2022-04-30 11:31:20 +12:00 committed by GitHub
parent fd0d164fb7
commit 6e13ebbeba
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 365 additions and 42 deletions

1
.gitignore vendored
View File

@ -7,4 +7,5 @@ coverage/
docs/.vitepress/dist/
docs/node_modules/
docs/package-lock.json
scratch/
vendor/

View File

@ -21,7 +21,7 @@ abstract class AbstractAnnotation implements \JsonSerializable
* For further details see https://github.com/OAI/OpenAPI-Specification/blob/main/versions/3.1.0.md#specificationExtensions
* The keys inside the array will be prefixed with `x-`.
*
* @var array
* @var array<string,string>
*/
public $x = Generator::UNDEFINED;

View File

@ -89,7 +89,7 @@ class Components extends AbstractAnnotation
/**
* Reusable Callbacks.
*
* @var array
* @var callable[]
*/
public $callbacks = Generator::UNDEFINED;

View File

@ -54,7 +54,7 @@ class Examples extends AbstractAnnotation
* To represent examples of media types that cannot naturally be represented
* in JSON or YAML, use a string value to contain the example, escaping where necessary.
*
* @var string
* @var int|string|array
*/
public $value = Generator::UNDEFINED;

View File

@ -57,6 +57,8 @@ class Flow extends AbstractAnnotation
* The available scopes for the OAuth2 security scheme.
*
* A map between the scope name and a short description for it.
*
* @var array
*/
public $scopes = Generator::UNDEFINED;

View File

@ -44,7 +44,7 @@ class Header extends AbstractAnnotation
/**
* Schema object.
*
* @var \OpenApi\Annotations\Schema
* @var Schema
*/
public $schema = Generator::UNDEFINED;

View File

@ -69,6 +69,8 @@ class Link extends AbstractAnnotation
* be evaluated and passed to the linked operation.
* The parameter name can be qualified using the parameter location [{in}.]{name} for operations
* that use the same parameter name in different locations (e.g. path.id).
*
* @var array<string,mixed>
*/
public $parameters = Generator::UNDEFINED;

View File

@ -52,7 +52,7 @@ class MediaType extends AbstractAnnotation
* Furthermore, if referencing a schema which contains an example,
* the examples value shall override the example provided by the schema.
*
* @var Examples[]
* @var array<string,Examples>
*/
public $examples = Generator::UNDEFINED;
@ -64,7 +64,7 @@ class MediaType extends AbstractAnnotation
* The encoding object shall only apply to requestBody objects when the media type is multipart or
* application/x-www-form-urlencoded.
*
* @var array
* @var array<string,mixed>
*/
public $encoding = Generator::UNDEFINED;

View File

@ -33,7 +33,7 @@ class OpenApi extends AbstractAnnotation
*
* This is not related to the API info::version string.
*
* @var string|null
* @var string
*/
public $openapi = self::DEFAULT_VERSION;

View File

@ -107,7 +107,7 @@ abstract class Operation extends AbstractAnnotation
/**
* The list of possible responses as they are returned from executing this operation.
*
* @var \OpenApi\Annotations\Response[]
* @var Response[]
*/
public $responses = Generator::UNDEFINED;

View File

@ -151,7 +151,7 @@ class Parameter extends AbstractAnnotation
* The examples object is mutually exclusive of the example object.
* Furthermore, if referencing a schema which contains an example, the examples value shall override the example provided by the schema.
*
* @var array
* @var array<string,Examples>
*/
public $examples = Generator::UNDEFINED;

View File

@ -28,7 +28,7 @@ class PathItem extends AbstractAnnotation
public $ref = Generator::UNDEFINED;
/**
* key for the Path Object (OpenApi->paths array).
* Key for the Path Object (OpenApi->paths array).
*
* @var string
*/

View File

@ -17,6 +17,11 @@ use OpenApi\Generator;
*/
class RequestBody extends AbstractAnnotation
{
/**
* @see [Using refs](https://swagger.io/docs/specification/using-ref/)
*
* @var string
*/
public $ref = Generator::UNDEFINED;
/**

View File

@ -64,7 +64,7 @@ class Response extends AbstractAnnotation
* For responses that match multiple keys, only the most specific key is applicable;
* e.g. <code>text/plain</code> overrides <code>text/*</code>.
*
* @var MediaType[]
* @var MediaType|JsonContent|XmlContent|array<MediaType|JsonContent|XmlContent>
*/
public $content = Generator::UNDEFINED;
@ -74,7 +74,7 @@ class Response extends AbstractAnnotation
* The key of the map is a short name for the link, following the naming constraints of the names for Component
* Objects.
*
* @var array
* @var Link[]
*/
public $links = Generator::UNDEFINED;

View File

@ -129,7 +129,7 @@ class Schema extends AbstractAnnotation
/**
* @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor17)
*
* @var number
* @var int|float
*/
public $maximum = Generator::UNDEFINED;
@ -143,7 +143,7 @@ class Schema extends AbstractAnnotation
/**
* @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor21)
*
* @var number
* @var int|float
*/
public $minimum = Generator::UNDEFINED;
@ -199,7 +199,7 @@ class Schema extends AbstractAnnotation
/**
* @see [JSON schema validation](http://json-schema.org/latest/json-schema-validation.html#anchor76)
*
* @var array
* @var string[]
*/
public $enum = Generator::UNDEFINED;

View File

@ -42,7 +42,7 @@ class Server extends AbstractAnnotation
*
* The value is used for substitution in the server's URL template.
*
* @var array
* @var ServerVariable[]
*/
public $variables = Generator::UNDEFINED;

View File

@ -18,7 +18,7 @@ use OpenApi\Generator;
class XmlContent extends Schema
{
/**
* @var array
* @var array<string,Examples>
*/
public $examples = Generator::UNDEFINED;

View File

@ -12,13 +12,21 @@ use OpenApi\Generator;
class AdditionalProperties extends \OpenApi\Annotations\AdditionalProperties
{
/**
* @param string[]|null $required
* @param string[] $required
* @param Property[] $properties
* @param int|float $maximum
* @param int|float $minimum
* @param string[] $enum
* @param Schema[] $allOf
* @param Schema[] $anyOf
* @param Schema[] $oneOf
* @param mixed $const
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
// schema
string|object|null $ref = null,
string|null $ref = null,
?string $schema = null,
?string $title = null,
?string $description = null,

View File

@ -19,9 +19,9 @@ class Examples extends \OpenApi\Annotations\Examples
?string $example = null,
?string $summary = null,
?string $description = null,
string|array|null $value = null,
int|string|array|null $value = null,
?string $externalValue = null,
string|object|null $ref = null,
string|null $ref = null,
// annotation
?array $x = null,
?array $attachables = null

View File

@ -15,7 +15,7 @@ class Header extends \OpenApi\Annotations\Header
* @param Attachable[]|null $attachables
*/
public function __construct(
string|object|null $ref = null,
string|null $ref = null,
?string $header = null,
?string $description = null,
?bool $required = null,

View File

@ -14,12 +14,19 @@ class Items extends \OpenApi\Annotations\Items
/**
* @param string[] $required
* @param Property[] $properties
* @param int|float $maximum
* @param int|float $minimum
* @param string[] $enum
* @param Schema[] $allOf
* @param Schema[] $anyOf
* @param Schema[] $oneOf
* @param mixed $const
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
// schema
string|object|null $ref = null,
string|null $ref = null,
?string $schema = null,
?string $title = null,
?string $description = null,

View File

@ -12,16 +12,22 @@ use OpenApi\Generator;
class JsonContent extends \OpenApi\Annotations\JsonContent
{
/**
* @param array<string,Examples> $examples
* @param string[] $required
* @param Property[] $properties
* @param int|float $maximum
* @param int|float $minimum
* @param string[] $enum
* @param Schema[] $allOf
* @param Schema[] $anyOf
* @param Schema[] $oneOf
* @param mixed $const
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
?array $examples = null,
// schema
string|object|null $ref = null,
string|null $ref = null,
?string $schema = null,
?string $title = null,
?string $description = null,

View File

@ -18,20 +18,27 @@ class Link extends \OpenApi\Annotations\Link
*/
public function __construct(
?string $link = null,
string|object|null $ref = null,
?string $operationRef = null,
string|null $ref = null,
?string $operationId = null,
?array $parameters = null,
$requestBody = null,
?string $description = null,
?Server $server = null,
// annotation
?array $x = null,
?array $attachables = null
) {
parent::__construct([
'link' => $link ?? Generator::UNDEFINED,
'operationRef' => $operationRef ?? Generator::UNDEFINED,
'ref' => $ref ?? Generator::UNDEFINED,
'operationId' => $operationId ?? Generator::UNDEFINED,
'parameters' => $parameters ?? Generator::UNDEFINED,
'requestBody' => $requestBody ?? Generator::UNDEFINED,
'description' => $description ?? Generator::UNDEFINED,
'x' => $x ?? Generator::UNDEFINED,
'value' => $this->combine($attachables),
'value' => $this->combine($server, $attachables),
]);
}
}

View File

@ -13,6 +13,7 @@ class MediaType extends \OpenApi\Annotations\MediaType
{
/**
* @param array<string,Examples> $examples
* @param array<string,mixed> $encoding
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
@ -21,7 +22,7 @@ class MediaType extends \OpenApi\Annotations\MediaType
?Schema $schema = null,
$example = Generator::UNDEFINED,
?array $examples = null,
?string $encoding = null,
?array $encoding = null,
// annotation
?array $x = null,
?array $attachables = null

View File

@ -14,6 +14,7 @@ class OpenApi extends \OpenApi\Annotations\OpenApi
/**
* @param Server[]|null $servers
* @param Tag[]|null $tags
* @param PathItem[]|null $paths
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
@ -24,6 +25,8 @@ class OpenApi extends \OpenApi\Annotations\OpenApi
?array $security = null,
?array $tags = null,
?ExternalDocumentation $externalDocs = null,
?array $paths = null,
?Components $components = null,
// annotation
?array $x = null,
?array $attachables = null
@ -32,7 +35,7 @@ class OpenApi extends \OpenApi\Annotations\OpenApi
'openapi' => $openapi,
'security' => $security ?? Generator::UNDEFINED,
'x' => $x ?? Generator::UNDEFINED,
'value' => $this->combine($info, $servers, $tags, $externalDocs, $attachables),
'value' => $this->combine($info, $servers, $tags, $externalDocs, $paths, $components, $attachables),
]);
}
}

View File

@ -16,6 +16,7 @@ trait OperationTrait
* @param string[] $tags
* @param Parameter[] $parameters
* @param Response[] $responses
* @param callable[] $callbacks
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/

View File

@ -12,6 +12,7 @@ trait ParameterTrait
{
/**
* @param array<string,Examples> $examples
* @param MediaType[] $content
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
@ -21,11 +22,17 @@ trait ParameterTrait
?string $description = null,
?string $in = null,
?bool $required = null,
string|object|null $ref = null,
?bool $deprecated = null,
?bool $allowEmptyValue = null,
string|null $ref = null,
?Schema $schema = null,
$example = Generator::UNDEFINED,
?array $examples = null,
?string $style = null,
?bool $explode = null,
?bool $allowReserved = null,
?array $spaceDelimited = null,
?array $pipeDelimited = null,
// annotation
?array $x = null,
?array $attachables = null
@ -36,9 +43,15 @@ trait ParameterTrait
'description' => $description ?? Generator::UNDEFINED,
'in' => Generator::isDefault($this->in) ? $in : $this->in,
'required' => !Generator::isDefault($this->required) ? $this->required : ($required ?? Generator::UNDEFINED),
'deprecated' => $deprecated ?? Generator::UNDEFINED,
'allowEmptyValue' => $allowEmptyValue ?? Generator::UNDEFINED,
'ref' => $ref ?? Generator::UNDEFINED,
'example' => $example,
'style' => $style ?? Generator::UNDEFINED,
'explode' => $explode ?? Generator::UNDEFINED,
'allowReserved' => $allowReserved ?? Generator::UNDEFINED,
'spaceDelimited' => $spaceDelimited ?? Generator::UNDEFINED,
'pipeDelimited' => $pipeDelimited ?? Generator::UNDEFINED,
'x' => $x ?? Generator::UNDEFINED,
'value' => $this->combine($schema, $examples, $attachables),
]);

View File

@ -12,17 +12,27 @@ use OpenApi\Generator;
class PathItem extends \OpenApi\Annotations\PathItem
{
/**
* @param Server[]|null $servers
* @param Parameter[]|null $parameters
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
?string $path = null,
?string $summary = null,
?array $servers = null,
?array $parameters = null,
// annotation
?array $x = null,
?array $attachables = null
) {
parent::__construct([
'path' => $path ?? Generator::UNDEFINED,
'summary' => $summary ?? Generator::UNDEFINED,
'x' => $x ?? Generator::UNDEFINED,
'value' => $this->combine($attachables),
'value' => $this->combine($servers, $parameters, $attachables),
]);
}
}
//Missing parameters: get, put, post, delete, options, head, patch, trace, parameters

View File

@ -14,13 +14,20 @@ class Property extends \OpenApi\Annotations\Property
/**
* @param string[] $required
* @param Property[] $properties
* @param int|float $maximum
* @param int|float $minimum
* @param string[] $enum
* @param Schema[] $allOf
* @param Schema[] $anyOf
* @param Schema[] $oneOf
* @param mixed $const
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
?string $property = null,
// schema
string|object|null $ref = null,
string|null $ref = null,
?string $schema = null,
?string $title = null,
?string $description = null,

View File

@ -17,7 +17,7 @@ class RequestBody extends \OpenApi\Annotations\RequestBody
* @param Attachable[]|null $attachables
*/
public function __construct(
string|object|null $ref = null,
string|null $ref = null,
?string $request = null,
?string $description = null,
?bool $required = null,

View File

@ -6,23 +6,27 @@
namespace OpenApi\Attributes;
use OpenApi\Annotations\JsonContent;
use OpenApi\Annotations\MediaType;
use OpenApi\Annotations\XmlContent;
use OpenApi\Generator;
#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)]
class Response extends \OpenApi\Annotations\Response
{
/**
* @param Header[] $headers
* @param Link[] $links
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
* @param Header[] $headers
* @param MediaType|JsonContent|XmlContent|array<MediaType|JsonContent|XmlContent> $content
* @param Link[] $links
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
string|object|null $ref = null,
string|null $ref = null,
int|string $response = null,
?string $description = null,
?array $headers = null,
$content = null,
MediaType|JsonContent|XmlContent|array|null $content = null,
?array $links = null,
// annotation
?array $x = null,

View File

@ -13,13 +13,20 @@ class Schema extends \OpenApi\Annotations\Schema
{
/**
* @param string[] $required
* @param Property[] $properties
* @param int|float $maximum
* @param int|float $minimum
* @param string[] $enum
* @param Schema[] $allOf
* @param Schema[] $anyOf
* @param Schema[] $oneOf
* @param mixed $const
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
// schema
string|object|null $ref = null,
string|null $ref = null,
?string $schema = null,
?string $title = null,
?string $description = null,

View File

@ -17,7 +17,7 @@ class SecurityScheme extends \OpenApi\Annotations\SecurityScheme
* @param Attachable[]|null $attachables
*/
public function __construct(
string|object|null $ref = null,
string|null $ref = null,
?string $securityScheme = null,
?string $type = null,
?string $description = null,

View File

@ -12,6 +12,7 @@ use OpenApi\Generator;
class ServerVariable extends \OpenApi\Annotations\ServerVariable
{
/**
* @param string[]|null $enum
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/

View File

@ -14,14 +14,21 @@ class XmlContent extends \OpenApi\Annotations\XmlContent
/**
* @param array<string,Examples> $examples
* @param string[] $required
* @param int|float $maximum
* @param int|float $minimum
* @param Property[] $properties
* @param string[] $enum
* @param Schema[] $allOf
* @param Schema[] $anyOf
* @param Schema[] $oneOf
* @param mixed $const
* @param array<string,string>|null $x
* @param Attachable[]|null $attachables
*/
public function __construct(
?array $examples = null,
// schema
string|object|null $ref = null,
string|null $ref = null,
?string $schema = null,
?string $title = null,
?string $description = null,

View File

@ -0,0 +1,208 @@
<?php declare(strict_types=1);
namespace OpenApi\Tests\Annotations;
use OpenApi\Annotations as OA;
use OpenApi\Tests\OpenApiTestCase;
/**
* @requires PHP 8.1
*/
class AttributesSyncTest extends OpenApiTestCase
{
public static $SCHEMA_EXCLUSIONS = ['const', 'maxProperties', 'minProperties', 'multipleOf', 'not', 'additionalItems', 'contains', 'patternProperties', 'dependencies', 'propertyNames'];
public static $PATHITEM_EXCLUSIONS = ['ref', 'get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace'];
public static $PARAMETER_EXCLUSIONS = ['content', 'matrix', 'label', 'form', 'simple', 'deepObject'];
public function testCounts()
{
$this->assertSameSize($this->allAnnotationClasses(), $this->allAttributeClasses());
}
/**
* @dataProvider allAnnotationClasses
*/
public function testParameterCompleteness($annotation): void
{
$annotationRC = new \ReflectionClass($annotation);
$attributeRC = new \ReflectionClass('OpenApi\\Attributes\\' . $annotationRC->getShortName());
$attributeCtor = $attributeRC->getMethod('__construct');
$attributeParameters = $attributeCtor->getParameters();
$missing = [];
$typeMismatch = [];
foreach ($annotationRC->getProperties() as $property) {
$propertyName = $property->getName();
if (in_array($propertyName, $annotation::$_blacklist) || $propertyName[0] == '_') {
continue;
}
$found = false;
foreach ($attributeParameters as $attributeParameter) {
if ($attributeParameter->getName() == $propertyName) {
$annotationType = $this->propertyType($property);
$attributeType = $this->parameterType($propertyName, $attributeParameter);
if ($annotationType != $attributeType) {
$typeMismatch[$propertyName] = [$annotationType, $attributeType];
}
$found = true;
break;
}
}
// oh, well...
if ($attributeRC->isSubclassOf(OA\PathParameter::class)) {
// not relevant
unset($typeMismatch['in']);
// uses inheritdoc
unset($typeMismatch['required']);
}
if (!$found) {
// exclusions...
if ($attributeRC->isSubclassOf(OA\Operation::class) && 'method' == $propertyName) {
continue;
}
if ($attributeRC->isSubclassOf(OA\Attachable::class) && 'x' == $propertyName) {
continue;
}
if ($attributeRC->isSubclassOf(OA\AdditionalProperties::class) && 'additionalProperties' == $propertyName) {
continue;
}
if (in_array($propertyName, static::$SCHEMA_EXCLUSIONS)) {
continue;
}
if ($attributeRC->isSubclassOf(OA\PathItem::class) && in_array($propertyName, static::$PATHITEM_EXCLUSIONS)) {
continue;
}
if ($attributeRC->isSubclassOf(OA\Parameter::class) && in_array($propertyName, static::$PARAMETER_EXCLUSIONS)) {
continue;
}
$missing[] = $propertyName;
}
}
if ($missing) {
$this->fail('Missing parameters: ' . implode(', ', $missing));
}
if ($typeMismatch) {
var_dump($typeMismatch);
$this->fail('Type mismatch: ' . count($typeMismatch));
}
}
protected function prepDocComment($docComment): array
{
if (!$docComment) {
return [];
}
$lines = preg_split('/(\n|\r\n)/', $docComment);
$lines[0] = preg_replace('/[ \t]*\\/\*\*/', '', $lines[0]); // strip '/**'
$i = count($lines) - 1;
$lines[$i] = preg_replace('/\*\/[ \t]*$/', '', $lines[$i]); // strip '*/'
foreach ($lines as $ii => $line) {
$lines[$ii] = ltrim($line, "\t *");
}
return $lines;
}
protected function propertyType(\ReflectionProperty $property): ?string
{
$var = null;
foreach ($this->prepDocComment($property->getDocComment()) as $line) {
if (substr($line, 0, 1) === '@') {
if (substr($line, 0, 5) === '@var ') {
$var = trim(substr($line, 5));
}
}
}
if ($var) {
$var = str_replace(['OpenApi\\Annotations\\', 'OpenApi\\Attributes\\'], '', $var);
if (false === strpos($var, '<')) {
$var = explode('|', $var);
sort($var);
$var = implode('|', $var);
}
}
return $var;
}
protected function parameterType(string $parameterName, \ReflectionParameter $parameter): ?string
{
$var = null;
if ($type = $parameter->getType()) {
if ($type instanceof \ReflectionUnionType) {
$var = [];
foreach ($type->getTypes() as $unionType) {
if ('null' != $unionType->getName()) {
// null means default for most parameters
$var[] = $unionType->getName();
}
}
sort($var);
$var = implode('|', $var);
} else {
$var = $type->getName();
}
}
foreach ($this->prepDocComment($parameter->getDeclaringFunction()->getDocComment()) as $line) {
if (substr($line, 0, 1) === '@') {
if (substr($line, 0, 7) === '@param ') {
$line = preg_replace('/ +/', ' ', $line);
$token = explode(' ', trim(substr($line, 7)));
if (2 == count($token)) {
[$type, $name] = $token;
if (str_replace('$', '', $name) == $parameterName) {
$var = str_replace(['|null', 'null|'], '', $type);
}
}
}
}
}
if ($var) {
$var = str_replace(['OpenApi\\Annotations\\', 'OpenApi\\Attributes\\'], '', $var);
if (false === strpos($var, '<')) {
$var = explode('|', $var);
sort($var);
$var = implode('|', $var);
}
}
return $var;
}
/**
* @dataProvider allAttributeClasses
*/
public function testPropertyCompleteness($attribute)
{
$attributeRC = new \ReflectionClass($attribute);
$annotationRC = new \ReflectionClass('OpenApi\\Annotations\\' . $attributeRC->getShortName());
$attributeCtor = $attributeRC->getMethod('__construct');
$stale = [];
foreach ($attributeCtor->getParameters() as $parameter) {
$parameterName = $parameter->getName();
if (!$annotationRC->hasProperty($parameterName)) {
// exclusions...
if ($attributeRC->isSubclassOf(OA\Attachable::class) && 'properties' == $parameterName) {
continue;
}
$stale[] = $parameterName;
}
}
if ($stale) {
$this->fail('Stale parameters: ' . implode(', ', $stale));
}
}
}

View File

@ -268,4 +268,27 @@ class OpenApiTestCase extends TestCase
return $classes;
}
/**
* Collect list of all non-abstract attribute classes.
*
* @return array
*/
public function allAttributeClasses(): array
{
$classes = [];
$dir = new DirectoryIterator(__DIR__ . '/../src/Attributes');
foreach ($dir as $entry) {
if (!$entry->isFile() || $entry->getExtension() != 'php') {
continue;
}
$class = $entry->getBasename('.php');
if (in_array($class, ['OperationTrait', 'ParameterTrait'])) {
continue;
}
$classes[$class] = ['OpenApi\\Attributes\\' . $class];
}
return $classes;
}
}