diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e558d9367..029c1816a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,11 @@ name: build on: push: + branches: + - master pull_request: + branches: + - master jobs: test: @@ -11,8 +15,11 @@ jobs: fail-fast: true matrix: operating-system: [ ubuntu-latest ] - php: [ '7.2', '7.3', '7.4', '8.0' ] + php: [ '7.2', '7.3', '7.4', '8.0', '8.1' ] dependencies: [ 'lowest', 'highest' ] + exclude: + - php: '8.1' + dependencies: 'lowest' name: PHP ${{ matrix.php }} on ${{ matrix.operating-system }} with ${{ matrix.dependencies }} dependencies @@ -33,3 +40,8 @@ jobs: - name: PHPUnit Tests run: bin/phpunit --configuration phpunit.xml.dist --coverage-text + + - name: PHPUnit Legacy Tests + run: bin/phpunit --configuration phpunit.xml.dist --coverage-text + env: + PHPUNIT_ANALYSER: 'legacy' diff --git a/.github/workflows/code-style.yml b/.github/workflows/code-style.yml index 907056470..fe46c5faf 100644 --- a/.github/workflows/code-style.yml +++ b/.github/workflows/code-style.yml @@ -2,7 +2,11 @@ name: code-style on: push: + branches: + - master pull_request: + branches: + - master jobs: php-cs: diff --git a/.github/workflows/security-checks.yml b/.github/workflows/security-checks.yml index 1a77e0b56..c4338fca9 100644 --- a/.github/workflows/security-checks.yml +++ b/.github/workflows/security-checks.yml @@ -2,7 +2,11 @@ name: security-checks on: push: + branches: + - master pull_request: + branches: + - master jobs: security-checker: diff --git a/Examples/Readme.md b/Examples/Readme.md index 63a48e361..c0e7bbc4c 100644 --- a/Examples/Readme.md +++ b/Examples/Readme.md @@ -8,6 +8,7 @@ Collection of code/annotation examples and their corresponding OpenAPI specs gen using swagger-php annotations. * openapi-spec: [source](openapi-spec) / [spec](openapi-spec/openapi-spec.yaml) + * openapi-spec-attributes: [source](openapi-spec-attributes) / [spec](openapi-spec-attributes/openapi-spec-attributes.yaml) - **requires PHP 8.1** * petstore-3.0 (includes oauth2 auth flow): [source](petstore-3.0) / [spec](openapi-spec/petstore-3.0.yaml) * **petstore.swagger.io** diff --git a/Examples/example-object/example-object.php b/Examples/example-object/example-object.php index c4a7714a8..714f0d939 100644 --- a/Examples/example-object/example-object.php +++ b/Examples/example-object/example-object.php @@ -1,5 +1,7 @@ '$response.body#/username'])] + public function getRepositoriesByOwner($username) + { + } + + /** + ** @OA\Get(path="/2.0/repositories/{username}/{slug}", + * operationId="getRepository", + * @OA\Parameter(name="username", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter(name="slug", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response(response=200, + * description="The repository", + * @OA\JsonContent(ref="#/components/schemas/repository"), + * @OA\Link(link="repositoryPullRequests", ref="#/components/links/RepositoryPullRequests") + * ) + * ) + * ) + * @OA\Link(link="UserRepository", + * operationId="getRepository", + * parameters={ + * "username"="$response.body#/owner/username", + * "slug"="$response.body#/slug" + * } + * ) + */ + #[OA\Get(path: '/2.0/repositories/{username}/{slug}', operationId: 'getRepository', parameters: [new OA\Parameter(name: 'username', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'slug', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], responses: [new OA\Response(response: 200, description: 'The repository', content: new OA\JsonContent(ref: '#/components/schemas/repository'), links: [new OA\Link(link: 'repositoryPullRequests', ref: '#/components/links/RepositoryPullRequests')])])] + #[OA\Link(link: 'UserRepository', operationId: 'getRepository', parameters: ['username' => '$response.body#/owner/username', 'slug' => '$response.body#/slug'])] + public function getRepository() + { + } + + /** + * @OA\Get(path="/2.0/repositories/{username}/{slug}/pullrequests", + * operationId="getPullRequestsByRepository", + * @OA\Parameter(name="username", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter(name="slug", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter(name="state", + * in="query", + * @OA\Schema(type="string", + * enum={"open", "merged", "declined"} + * ) + * ), + * @OA\Response(response=200, + * description="An array of pull request objects", + * @OA\JsonContent(type="array", + * @OA\Items(ref="#/components/schemas/pullrequest") + * ) + * ) + * ) + * @OA\Link(link="RepositoryPullRequests", + * operationId="getPullRequestsByRepository", + * parameters={ + * "username"="$response.body#/owner/username", + * "slug"="$response.body#/slug" + * } + * ) + */ + #[OA\Get(path: '/2.0/repositories/{username}/{slug}/pullrequests', operationId: 'getPullRequestsByRepository', parameters: [new OA\Parameter(name: 'username', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'state', in: 'query', schema: new OA\Schema(type: 'string', enum: ['open', 'merged', 'declined']))], responses: [new OA\Response(response: 200, description: 'An array of pull request objects', content: new OA\JsonContent(type: 'array', items: new OA\Items(ref: '#/components/schemas/pullrequest')))])] + #[OA\Link(link: 'RepositoryPullRequests', operationId: 'getPullRequestsByRepository', parameters: ['username' => '$response.body#/owner/username', 'slug' => '$response.body#/slug'])] + public function getPullRequestsByRepository() + { + } + + /** + * @OA\Get(path="/2.0/repositories/{username}/{slug}/pullrequests/{pid}", + * operationId="getPullRequestsById", + * @OA\Parameter(name="username", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter(name="slug", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter(name="pid", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response(response=200, + * description="A pull request object", + * @OA\JsonContent(ref="#/components/schemas/pullrequest"), + * @OA\Link(link="pullRequestMerge", ref="#/components/links/PullRequestMerge") + * ) + * ) + */ + #[OA\Get(path: '/2.0/repositories/{username}/{slug}/pullrequests/{pid}', operationId: 'getPullRequestsById', parameters: [new OA\Parameter(name: 'username', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'pid', in: 'path', required: true, schema: new OA\Schema(type: 'string'))], responses: [new OA\Response(response: 200, description: 'A pull request object', content: new OA\JsonContent(ref: '#/components/schemas/pullrequest'), links: [new OA\Link(link: 'pullRequestMerge', ref: '#/components/links/PullRequestMerge')])])] + public function getPullRequestsById() + { + } + + /** + * @OA\Post(path="/2.0/repositories/{username}/{slug}/pullrequests/{pid}/merge", + * operationId="mergePullRequest", + * @OA\Parameter(name="username", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter(name="slug", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Parameter(name="pid", + * in="path", + * required=true, + * @OA\Schema(type="string") + * ), + * @OA\Response(response=204, + * description="The PR was successfully merged" + * ) + * ) + * @OA\Link(link="PullRequestMerge", + * operationId="mergePullRequest", + * parameters={ + * "username"="$response.body#/author/username", + * "slug"="$response.body#/repository/slug", + * "pid"="$response.body#/id" + * } + * ) + */ + #[OA\Post(path: '/2.0/repositories/{username}/{slug}/pullrequests/{pid}/merge', operationId: 'mergePullRequest', parameters: [new OA\Parameter(name: 'username', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'slug', in: 'path', required: true, schema: new OA\Schema(type: 'string')), new OA\Parameter(name: 'pid', in: 'path', required: true, schema: new OA\Schema(type: 'string')), ], responses: [new OA\Response(response: 204, description: 'The PR was successfully merged')])] + #[OA\Link(link: 'PullRequestMerge', operationId: 'mergePullRequest', parameters: ['username' => '$response.body#/author/username', 'slug' => '$response.body#/repository/slug', 'pid' => '$response.body#/id'])] + public function mergePullRequest() + { + } +} diff --git a/Examples/openapi-spec-attributes/Repository.php b/Examples/openapi-spec-attributes/Repository.php new file mode 100644 index 000000000..db9c80a04 --- /dev/null +++ b/Examples/openapi-spec-attributes/Repository.php @@ -0,0 +1,27 @@ + - - - - diff --git a/Examples/openapi-spec/Repository.php b/Examples/openapi-spec/Repository.php index 68e8c6988..61bbb91c5 100644 --- a/Examples/openapi-spec/Repository.php +++ b/Examples/openapi-spec/Repository.php @@ -1,5 +1,8 @@ getAnnotationsOfType(Operation::class); foreach ($operations as $operation) { - if ($operation->x !== UNDEFINED && array_key_exists(self::X_QUERY_AGS_REF, $operation->x)) { + if ($operation->x !== Generator::UNDEFINED && array_key_exists(self::X_QUERY_AGS_REF, $operation->x)) { if ($schema = $this->schemaForRef($schemas, $operation->x[self::X_QUERY_AGS_REF])) { $this->expandQueryArgs($operation, $schema); $this->cleanUp($operation); @@ -52,11 +52,11 @@ protected function schemaForRef(array $schemas, string $ref) */ protected function expandQueryArgs(Operation $operation, Schema $schema) { - if ($schema->properties === UNDEFINED || !$schema->properties) { + if ($schema->properties === Generator::UNDEFINED || !$schema->properties) { return; } - $operation->parameters = $operation->parameters === UNDEFINED ? [] : $operation->parameters; + $operation->parameters = $operation->parameters === Generator::UNDEFINED ? [] : $operation->parameters; foreach ($schema->properties as $property) { $parameter = new Parameter([ 'name' => $property->property, @@ -74,7 +74,7 @@ protected function cleanUp($operation) { unset($operation->x[self::X_QUERY_AGS_REF]); if (!$operation->x) { - $operation->x = UNDEFINED; + $operation->x = Generator::UNDEFINED; } } } diff --git a/Examples/processors/schema-query-parameter/app/api-spec.php b/Examples/processors/schema-query-parameter/app/api-spec.php index 4e393170b..2f8665d42 100644 --- a/Examples/processors/schema-query-parameter/app/api-spec.php +++ b/Examples/processors/schema-query-parameter/app/api-spec.php @@ -8,6 +8,9 @@ * title="Example of using a custom processor in swagger-php", * ) */ +class OpenApi +{ +} ?> Uses a custom processor `QueryArgsFromSchema` processor to convert a vendor extension into query parameters. The parameters are extracted from the schema referenced by the custom extension. diff --git a/Examples/processors/schema-query-parameter/scan.php b/Examples/processors/schema-query-parameter/scan.php index 51dde145c..4db6fdd55 100644 --- a/Examples/processors/schema-query-parameter/scan.php +++ b/Examples/processors/schema-query-parameter/scan.php @@ -1,15 +1,21 @@ getProcessors() as $processor) { $processors[] = $processor; - if ($processor instanceof \OpenApi\Processors\BuildPaths) { - $processors[] = new \SchemaQueryParameterProcessor\SchemaQueryParameter(); + if ($processor instanceof BuildPaths) { + $processors[] = new SchemaQueryParameter(); } } @@ -17,9 +23,8 @@ 'processors' => $processors, ]; -$openapi = (new OpenApi\Generator()) - ->setProcessors($processors) - ->generate([__DIR__ . '/app']); -$spec = json_encode($openapi, JSON_PRETTY_PRINT); -file_put_contents(__DIR__ . '/schema-query-parameter.json', $spec); -//echo $spec; \ No newline at end of file +$openapi = $generator + ->setProcessors($processors) + ->generate([__DIR__ . '/app']); +//file_put_contents(__DIR__ . '/schema-query-parameter.yaml', $openapi->toYaml()); +echo $openapi->toYaml(); \ No newline at end of file diff --git a/Examples/processors/schema-query-parameter/schema-query-parameter.json b/Examples/processors/schema-query-parameter/schema-query-parameter.json deleted file mode 100644 index 9dd7dfbfa..000000000 --- a/Examples/processors/schema-query-parameter/schema-query-parameter.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "openapi": "3.0.0", - "info": { - "title": "Example of using a custom processor in swagger-php", - "version": "1.0.0" - }, - "paths": { - "\/products\/{id}": { - "get": { - "tags": [ - "Products" - ], - "operationId": "399b71a7672f0a46be1b5f4c120c355d", - "responses": { - "200": { - "description": "A single product", - "content": { - "application\/json": { - "schema": { - "$ref": "#\/components\/schemas\/Product" - } - } - } - } - } - } - }, - "\/products\/search": { - "get": { - "tags": [ - "Products" - ], - "summary": "Controller that takes all `Product` properties as query parameter.", - "operationId": "178f74de3417eec20dee95709821e6ca", - "parameters": [ - { - "name": "id", - "in": "query", - "required": false - }, - { - "name": "name", - "in": "query", - "required": false - } - ], - "responses": { - "200": { - "description": "A list of matching products", - "content": { - "application\/json": { - "schema": { - "type": "array", - "items": { - "$ref": "#\/components\/schemas\/Product" - } - } - } - } - } - } - } - } - }, - "components": { - "schemas": { - "Product": { - "title": "Product", - "description": "A simple product model", - "properties": { - "id": { - "description": "The unique identifier of a product in our catalog.", - "type": "integer", - "format": "int64", - "example": 1 - }, - "name": { - "type": "string", - "format": "int64", - "example": 1 - } - }, - "type": "object" - } - } - } -} \ No newline at end of file diff --git a/Examples/processors/schema-query-parameter/schema-query-parameter.yaml b/Examples/processors/schema-query-parameter/schema-query-parameter.yaml new file mode 100644 index 000000000..27b69f98d --- /dev/null +++ b/Examples/processors/schema-query-parameter/schema-query-parameter.yaml @@ -0,0 +1,57 @@ +openapi: 3.0.0 +info: + title: 'Example of using a custom processor in swagger-php' + version: 1.0.0 +paths: + '/products/{id}': + get: + tags: + - Products + operationId: 399b71a7672f0a46be1b5f4c120c355d + responses: + '200': + description: 'A single product' + content: + application/json: + schema: + $ref: '#/components/schemas/Product' + /products/search: + get: + tags: + - Products + summary: 'Controller that takes all `Product` properties as query parameter.' + operationId: 178f74de3417eec20dee95709821e6ca + parameters: + - + name: id + in: query + required: false + - + name: name + in: query + required: false + responses: + '200': + description: 'A list of matching products' + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Product' +components: + schemas: + Product: + title: Product + description: 'A simple product model' + properties: + id: + description: 'The unique identifier of a product in our catalog.' + type: integer + format: int64 + example: 1 + name: + type: string + format: int64 + example: 1 + type: object diff --git a/Examples/swagger-spec/petstore-simple/SimplePet.php b/Examples/swagger-spec/petstore-simple/SimplePet.php index 7e32b8fdc..9b900214e 100644 --- a/Examples/swagger-spec/petstore-simple/SimplePet.php +++ b/Examples/swagger-spec/petstore-simple/SimplePet.php @@ -1,6 +1,6 @@ -A common scenario is to let swagger-php generate a definition based on your model class. -These definitions can then be referenced with `ref="#/components/schemas/$classname" - -You can define top-level parameters which can be references with $ref="#/components/parameters/$parameter" - -You can define top-level responses which can be references with $ref="#/components/responses/$response" + You can define top-level responses which can be references with $ref="#/components/responses/$response" -I find it usefull to add @OA\Response(ref="#/components/responses/todo") to the operations when i'm starting out with writting the swagger documentation. -As it bypasses the "@OA\Get() requires at least one @OA\Response()" error and you'll get a nice list of the available api calls in swagger-ui. + I find it usefull to add @OA\Response(ref="#/components/responses/todo") to the operations when i'm starting out with writting the swagger documentation. + As it bypasses the "@OA\Get() requires at least one @OA\Response()" error and you'll get a nice list of the available api calls in swagger-ui. + + Then later, a search for '#/components/responses/todo' will reveal the operations I haven't documented yet. + -And although definitions are generally used for model-level schema's' they can be used for smaller things as well. -Like a @OA\Schema, @OA\Property or @OA\Items that is uses multiple times. + And although definitions are generally used for model-level schema's' they can be used for smaller things as well. + Like a @OA\Schema, @OA\Property or @OA\Items that is uses multiple times. + + false, 'output' => false, 'format' => 'auto', 'exclude' => [], @@ -27,6 +32,7 @@ $options = [ 'processor' => [], ]; $aliases = [ + 'l' => 'legacy', 'o' => 'output', 'e' => 'exclude', 'n' => 'pattern', @@ -109,6 +115,7 @@ if ($options['help']) { Usage: openapi [--option value] [/path/to/project ...] Options: + --legacy (-l) Use legacy TokenAnalyser; default is the new ReflectionAnalyser --output (-o) Path to store the generated documentation. ex: --output openapi.yaml --exclude (-e) Exclude path(s). @@ -192,7 +199,13 @@ foreach ($options["processor"] as $processor) { $generator->addProcessor($processor); } -$openapi = $generator->generate(Util::finder($paths, $exclude, $pattern)); +$analyser = $options['legacy'] + ? new TokenAnalyser() + : new ReflectionAnalyser([new DocBlockAnnotationFactory(), new AttributeAnnotationFactory()]); + +$openapi = $generator + ->setAnalyser($analyser) + ->generate(Util::finder($paths, $exclude, $pattern)); if ($logger->called()) { $logger->notice('', ['prefix' => '']); diff --git a/composer.json b/composer.json index 9b7d46596..e03c1e8cb 100644 --- a/composer.json +++ b/composer.json @@ -35,6 +35,11 @@ "sort-packages": true }, "minimum-stability": "stable", + "extra": { + "branch-alias": { + "dev-master": "4.x-dev" + } + }, "require": { "php": ">=7.2", "ext-json": "*", @@ -46,10 +51,7 @@ "autoload": { "psr-4": { "OpenApi\\": "src" - }, - "files": [ - "src/functions.php" - ] + } }, "require-dev": { "composer/package-versions-deprecated": "1.11.99.2", @@ -69,11 +71,9 @@ "phpunit", "@lint" ], - "phpstan": "phpstan analyze --level=3 src | grep -v 'does not accept default value of type string'", - "psalm": "psalm", "analyse": [ - "@phpstan", - "@psalm" + "phpstan analyse --memory-limit=2G", + "psalm --show-info=true" ], "validate-examples": { "spectral": "for ff in `find Examples -name *.yaml`; do spectral lint $ff; done", diff --git a/docs/Generator-migration.md b/docs/Generator-migration.md index dc006e34c..54fd14a6f 100644 --- a/docs/Generator-migration.md +++ b/docs/Generator-migration.md @@ -1,21 +1,7 @@ # Using the `Generator` ## Motivation - -Code to perform a fully customized scan using `swagger-php` so far required to use 3 separate static elements: -1. `\OpenApi\scan()` - - The function to scan for OpenApi annotations. -1. `Analyser::$whitelist` - - List of namespaces that should be detected by the doctrine annotation parser. -1. `Analyser::$defaultImports` - - Imports to be set on the used doctrine `DocParser`. - Allows to pre-define annotation namespaces. The `@OA` namespace, for example, is configured - as `['oa' => 'OpenApi\\Annotations']`. - -The new `Generator` class provides an object-oriented way to use `swagger-php` and all its aspects in a single place. +The `Generator` class provides an object-oriented way to use `swagger-php` and all its aspects in a single place. ## The `\OpenApi\scan()` function @@ -45,7 +31,7 @@ require("vendor/autoload.php"); $openapi = \OpenApi\scan(__DIR__, ['exclude' => ['tests'], 'pattern' => '*.php']); ``` -The two configuration options for the underlying Doctrine doc-block parser `Analyser::$whitelist` and `Analyser::$defaultImports` +The two configuration options for the underlying Doctrine doc-block parser `aliases` and `namespaces` are not part of this function and need to be set separately. Being static this means setting them back is the callers responsibility and there is also the fact that @@ -55,7 +41,7 @@ Therefore, having a single side-effect free way of using swwagger-php seemed lik ## The `\OpenApi\Generator` class -The new `Generator` class can be used in object-oriented (and fluent) style which allows for easy customization +The `Generator` class can be used in object-oriented (and fluent) style which allows for easy customization if needed. In that case to actually process the given input files the **non-static** method `generate()` is to be used. @@ -73,13 +59,23 @@ $finder = \Symfony\Component\Finder\Finder::create()->files()->name('*.php')->in $openapi = (new \OpenApi\Generator($logger)) ->setProcessors($processors) ->setAliases(['MY' => 'My\Annotations']) - ->setAnalyser(new \OpenApi\StaticAnalyser()) ->setNamespaces(['My\\Annotations\\']) + ->setAnalyser(new \OpenApi\Analysers\TokenAnalyser()) ->generate(['/path1/to/project', $finder], new \OpenApi\Analysis(), $validate); ``` -The `aliases` property corresponds to the now also deprecated static `Analyser::$defaultImports`, -`namespaces` to `Analysis::$whitelist`. +`Aliases` and `namespaces` are additional options that allow to customize the parsing of docblocks. + +Defaults: +* **aliases**: `['oa' => 'OpenApi\\Annotations']` + + Aliases help the underlying `doctrine annotations library` to parse annotations. Effectively they avoid having + to write `use OpenApi\Annotations as OA;` in your code and make `@OA\property(..)` annotations still work. + +* **namespaces**: `['OpenApi\\Annotations\\']` + + Namespaces control which annotation namespaces can be autoloaded automatically. Under the hood this + is handled by registering a custom loader with the `doctrine annotation library`. Advantages: * The `Generator` code will handle configuring things as before in a single place @@ -137,13 +133,13 @@ echo $openapi->toYaml(); * * \SplFileInfo * * \Symfony\Component\Finder\Finder * @param array $options - * aliases: null|array Defaults to `Analyser::$defaultImports`. - * namespaces: null|array Defaults to `Analyser::$whitelist`. - * analyser: null|StaticAnalyser Defaults to a new `StaticAnalyser`. + * aliases: null|array Defaults to `['oa' => 'OpenApi\\Annotations']`. + * namespaces: null|array Defaults to `['OpenApi\\Annotations\\']`. + * analyser: null|AnalyserInterface Defaults to a new `ReflectionAnalyser` supporting both docblocks and attributes. * analysis: null|Analysis Defaults to a new `Analysis`. * processors: null|array Defaults to `Analysis::processors()`. - * validate: bool Defaults to `true`. * logger: null|\Psr\Log\LoggerInterface If not set logging will use \OpenApi\Logger as before. + * validate: bool Defaults to `true`. */ public static function scan(iterable $sources, array $options = []): OpenApi { /* ... */ } ``` diff --git a/docs/Getting-started.md b/docs/Getting-started.md index 32015c9c5..6e39dead6 100644 --- a/docs/Getting-started.md +++ b/docs/Getting-started.md @@ -41,9 +41,9 @@ For cli usage from anywhere install swagger-php globally and add the `~/.compose composer global require zircote/swagger-php ``` -## Write annotations +## Document your code using annotations or PHP attributes -The goal of swagger-php is to generate a openapi.json using phpdoc annotations. +The goal of swagger-php is to generate a openapi.json using phpdoc annotations or PHP attributes. #### When you write: @@ -75,6 +75,24 @@ paths: description: "An example resource" ``` +#### PHP Attributes +This documentation uses annotations in its examples. However, as per PHP 8.1 you may also use all documented +annotations as attributes. Then the above example would look like this: + +```php +#[OA\Info(title="My First API", version="0.1")] +class OpenApi {} + +class Controller{ + #[OA\Get(path: '/api/resource.json')] + #[OA\Response(response: '200', description: 'An example resource')] + public function getResource() + { + // ... + } +} +``` + ### Using variables You can use constants inside doctrine annotations. @@ -102,6 +120,8 @@ swagger-php will scan your project and merge all annotations into one @OA\OpenAp The big benefit swagger-php provides is that the documentation lives close to the code implementing the API. +**As of swagger-php v4 all annotations or attributes must be associated with code (class, method, parameter)** + ### Arrays and Objects Doctrine annotation supports arrays, but uses `{` and `}` instead of `[` and `]`. diff --git a/docs/Migrating-to-v4.md b/docs/Migrating-to-v4.md new file mode 100644 index 000000000..1584935c5 --- /dev/null +++ b/docs/Migrating-to-v4.md @@ -0,0 +1,134 @@ +# Migrating to Swagger-PHP v4.x + +## Overview of changes +* As of PHP 8.1 annotations may be used as + [PHP attributes](https://www.php.net/manual/en/language.attributes.overview.php) instead. + That means all references to annotations in this document also apply to attributes. +* Annotations now **must be** associated with either a class/trait/interface, + method or property. +* A new annotation `PathParameter` was added for improved framework support. +* A new annotation `Attachable` was added to simplify custom processing. + `Attachable` can be used to attach arbitrary data to any given annotation. +* Deprecated elements have been removed + * `\Openapi\Analysis::processors()` + * `\Openapi\Analyser::$whitelist` + * `\Openapi\Analyser::$defaultImports` + * `\Openapi\Logger` +* Legacy support is available via the previous `TokenAnalyser` +* Improvements to the `Generator` class + +## Annotations as PHP attributes +While PHP attributes have been around since PHP 8.0 they were lacking the ability to be nested. +This changes with PHP 8.1 which allows to use `new` in initializers. + +Swagger-php attributes also make use of named arguments, so attribute parameters can be (mostly) typed. +There are some limitations to type hints which can only be resolved once support for PHP 7.x is dropped. + +### Example +**Using annotations** +```php +/** + * @OA\Info( + * version="1.0.0", + * title="My API", + * @OA\License(name="MIT"), + * @OA\Attachable() + * ) + */ +class OpenApiSpec +{ +} +``` +**Using attributes** +```php +#[OA\Info( + version: '1.0.0', + title: 'My API', + attachables: [new OA\Attachable()] +)] +#[OA\License(name: 'MIT')] +class OpenApiSpec +{ +} +``` + +#### Optional nesting +One of the few differences between annotations and attributes visible in the above example is that the `OA\License` attribute +is not nested within `OA\Info`. Nesting of attributes is possible and required in certain cases however, **in cases where there +is no ambiguity attributes may be all written on the top level** and swagger-php will do the rest. + +## Annotations must be associated with code +The (now legacy) way of parsing PHP files meant that docblocks could live in a file without a single line +of actual PHP code. + +PHP Attributes cannot exist in isolation; they need code to be associated with and then are available +via reflection on the associated code. +In order to allow to keep supporting annotations and the code simple it made sense to treat annotations and attributes +the same in this respect. + +## The `PathParameter` annotation +As annotation this is just a short form for +```php + @OA\Parameter(in='body') +``` + +Things get more interesting when it comes to using it as attribute, though. In the context of +a controller you can now do something like +```php +class MyController +{ + #[OA\Get(path: '/products/{product_id}')] + public function getProduct( + #[OA\PathParameter] string $product_id) + { + } +``` +Here it avoid having to duplicate details about the `$product_id` parameter and the simple use of the attribute +will pick up typehints automatically. + +## The `Attachable` annotation +Technically these were added in version 3.3.0, however they become really useful only with version 4. + +The attachable annotation is similar to the OpenApi vendor extension `x=`. The main difference are that +1. Attachables allow complex structures and strong typing +2. **Attachables are not added to the generated spec.** + +Their main purpose is to make customizing swagger-php easier by allowing to add arbitrary data to any annotation. + +One possible use case could be custom annotations. Classes extnding `Attachable` are allowed to limit +the allowed parent annotations. This means it would be easy to create a new attribute to flag certain endpoints +as private and exclude them under certain conditions from the spec (via a custom processor). + +## Removed deprecated elements +### `\Openapi\Analysis::processors()` +Processors have been moved into the `Generator` class incl. some new convenicen methods. +### `\Openapi\Analyser::$whitelist` +This has been replaced with the `Generator` `namespaces` property. +### `\Openapi\Analyser::$defaultImports` +This has been replaced with the `Generator` `aliases` property. +### `\Openapi\Logger` +This class has been removed completely. Instead, you may configure a [PSR-3 logger](https://www.php-fig.org/psr/psr-3/). + +## Improvements to the `Generator` class +The removal of deprecated static config options means that the `Generator` class now is +the main entry point into swagger-php when used programmatically. + +To make the migration as simple as possible a new `Generator::withContext(callable)` has been added. +This allows to use parts of the library (an `Analyser` instance, for example) within the context of a `Generator` instance. + +Example: +```php +$analyser = createMyAnalyser(); + +$analysis = (new Generator()) + ->addAlias('fo', 'My\\Attribute\\Namespace') + ->addNamespace('Other\\Annotations\\') + ->withContext(function (Generator $generator, Analysis $analysis, Context $context) use ($analyser) { + $analyser->setGenerator($generator); + $analysis = $analyser->fromFile('my_code.php', $context); + $analysis->process($generator->getProcessors()); + + return $analysis; + }); + +``` \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index cdaacb763..f156699b6 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,6 +43,23 @@ Add annotations to your php files. */ ``` +Or, as of PHP 8.1 use attributes + + +```php +#[OA\Info(title="My First API", version="0.1")] +class OpenApi {} + +class Controller{ + #[OA\Get(path: '/api/resource.json')] + #[OA\Response(response: '200', description: 'An example resource')] + public function getResource() + { + // ... + } +} +``` + And view and interact with your API using [Swagger UI ](https://swagger.io/tools/swagger-ui/) ## Links @@ -51,6 +68,7 @@ And view and interact with your API using [Swagger UI ](https://swagger.io/tools - [OpenApi Documentation](https://swagger.io/docs/) - [OpenApi Specification](http://swagger.io/specification/) - [Migration from 2.x to 3.x](Migrating-to-v3.md) +- [Migration from 3.x to 4.x](Migrating-to-v4.md) - [Learn by example](https://github.com/zircote/swagger-php/tree/master/Examples) lots of example of how to generate - [Related projects](Related-projects.md) - [Swagger-php 2.x documentation](https://github.com/zircote/swagger-php/tree/2.x/docs) The docs for swagger-php v2 diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 000000000..97c6fcb13 --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,12 @@ +parameters: + level: 5 + paths: + - src + - tests + parallel: + processTimeout: 300.0 + ignoreErrors: + - '#does not accept default value of type string#' + - '#Access to an undefined property#' + excludePaths: + - 'tests/Fixtures/*' diff --git a/psalm.xml b/psalm.xml index 7c0333df3..1b7084948 100644 --- a/psalm.xml +++ b/psalm.xml @@ -12,4 +12,8 @@ + + + + diff --git a/src/Analyser.php b/src/Analyser.php deleted file mode 100644 index e4e97049f..000000000 --- a/src/Analyser.php +++ /dev/null @@ -1,121 +0,0 @@ - 'OpenApi\\Annotations']; - - /** - * Allows Annotation classes to know the context of the annotation that is being processed. - * - * @var null|Context - */ - public static $context; - - /** - * @var DocParser - */ - public $docParser; - - public function __construct(?DocParser $docParser = null) - { - if ($docParser === null) { - $docParser = new DocParser(); - $docParser->setIgnoreNotImportedAnnotations(true); - $docParser->setImports(static::$defaultImports); - } - $this->docParser = $docParser; - } - - /** - * Use doctrine to parse the comment block and return the detected annotations. - * - * @param string $comment a T_DOC_COMMENT - * @param Context $context - * - * @return array Annotations - */ - public function fromComment(string $comment, Context $context): array - { - $context->comment = $comment; - - try { - self::$context = $context; - if ($context->is('annotations') === false) { - $context->annotations = []; - } - $annotations = $this->docParser->parse($comment); - self::$context = null; - - return $annotations; - } catch (\Exception $e) { - self::$context = null; - if (preg_match('/^(.+) at position ([0-9]+) in ' . preg_quote((string) $context, '/') . '\.$/', $e->getMessage(), $matches)) { - $errorMessage = $matches[1]; - $errorPos = (int) $matches[2]; - $atPos = strpos($comment, '@'); - $context->line += substr_count($comment, "\n", 0, $atPos + $errorPos); - $lines = explode("\n", substr($comment, $atPos, $errorPos)); - $context->character = strlen(array_pop($lines)) + 1; // position starts at 0 character starts at 1 - $context->logger->error($errorMessage . ' in ' . $context, ['exception' => $e]); - } else { - $context->logger->error($e); - } - - return []; - } - } -} diff --git a/src/Analysers/AnalyserInterface.php b/src/Analysers/AnalyserInterface.php new file mode 100644 index 000000000..864d226a9 --- /dev/null +++ b/src/Analysers/AnalyserInterface.php @@ -0,0 +1,18 @@ +generator = $generator; + } + + public function build(\Reflector $reflector, Context $context): array + { + if (\PHP_VERSION_ID < 80100) { + return []; + } + + // no proper way to inject + Generator::$context = $context; + + /** @var AbstractAnnotation[] $annotations */ + $annotations = []; + try { + foreach ($reflector->getAttributes() as $attribute) { + $instance = $attribute->newInstance(); + $annotations[] = $instance; + } + if ($reflector instanceof \ReflectionMethod) { + // also look at parameter attributes + foreach ($reflector->getParameters() as $rp) { + foreach ($rp->getAttributes(PathParameter::class) as $attribute) { + $instance = $attribute->newInstance(); + $instance->name = $rp->getName(); + if ($rnt = $rp->getType()) { + $instance->schema = new Schema(['type' => $rnt->getName(), '_context' => new Context(['nested' => $this], $context)]); + } + $annotations[] = $instance; + } + } + } + } finally { + Generator::$context = null; + } + + $annotations = array_values(array_filter($annotations, function ($a) { + return $a !== null && $a instanceof AbstractAnnotation; + })); + + // merge backwards into parents... + $isParent = function (AbstractAnnotation $annotation, AbstractAnnotation $possibleParent): bool { + // regular anootation hierachy + $explicitParent = array_key_exists(get_class($annotation), $possibleParent::$_nested); + + $isParentAllowed = false; + // support Attachable subclasses + if ($isAttachable = $annotation instanceof Attachable && array_key_exists(Attachable::class, $possibleParent::$_nested)) { + if (!$isParentAllowed = (null === $annotation->allowedParents())) { + // check for allowed parents + foreach ($annotation->allowedParents() as $allowedParent) { + if ($possibleParent instanceof $allowedParent) { + $isParentAllowed = true; + break; + } + } + } + } + + return $explicitParent || ($isAttachable && $isParentAllowed); + }; + foreach ($annotations as $index => $annotation) { + for ($ii = 0; $ii < count($annotations); ++$ii) { + if ($ii === $index) { + continue; + } + $possibleParent = $annotations[$ii]; + if ($isParent($annotation, $possibleParent)) { + $possibleParent->merge([$annotation]); + } + } + } + + $annotations = array_filter($annotations, function ($a) { + return !$a instanceof Attachable; + }); + + return $annotations; + } +} diff --git a/src/Analysers/ComposerAutoloaderScanner.php b/src/Analysers/ComposerAutoloaderScanner.php new file mode 100644 index 000000000..d604f56a9 --- /dev/null +++ b/src/Analysers/ComposerAutoloaderScanner.php @@ -0,0 +1,49 @@ +getComposerAutoloader()) { + foreach (array_keys($autoloader->getClassMap()) as $unit) { + foreach ($namespaces as $namespace) { + if (0 === strpos($unit, $namespace)) { + $units[] = $unit; + break; + } + } + } + } + + return $units; + } + + public static function getComposerAutoloader(): ?ClassLoader + { + foreach (spl_autoload_functions() as $fkt) { + if (is_array($fkt) && $fkt[0] instanceof ClassLoader) { + return $fkt[0]; + } + } + + return null; + } +} diff --git a/src/Analysers/DocBlockAnnotationFactory.php b/src/Analysers/DocBlockAnnotationFactory.php new file mode 100644 index 000000000..6aaa911a5 --- /dev/null +++ b/src/Analysers/DocBlockAnnotationFactory.php @@ -0,0 +1,53 @@ +docBlockParser = $docBlockParser ?: new DocBlockParser(); + } + + public function setGenerator(Generator $generator): void + { + $this->generator = $generator; + + $this->docBlockParser->setAliases($generator->getAliases()); + } + + public function build(\Reflector $reflector, Context $context): array + { + $aliases = $this->generator ? $this->generator->getAliases() : []; + if (method_exists($reflector, 'getShortName')) { + $aliases[strtolower($reflector->getShortName())] = $reflector->getName(); + } + + if ($context->with('scanned')) { + $details = $context->scanned; + foreach ($details as $alias => $name) { + $aliases[strtolower($alias)] = $name; + } + } + $this->docBlockParser->setAliases($aliases); + + if ($comment = $reflector->getDocComment()) { + return $this->docBlockParser->fromComment($comment, $context); + } + + return []; + } +} diff --git a/src/Analysers/DocBlockParser.php b/src/Analysers/DocBlockParser.php new file mode 100644 index 000000000..ac7583bb4 --- /dev/null +++ b/src/Analysers/DocBlockParser.php @@ -0,0 +1,73 @@ +setIgnoreNotImportedAnnotations(true); + $docParser->setImports($aliases); + $this->docParser = $docParser; + } + + public function setAliases(array $aliases): void + { + $this->docParser->setImports($aliases); + } + + /** + * Use doctrine to parse the comment block and return the detected annotations. + * + * @param string $comment a T_DOC_COMMENT + * @param Context $context + * + * @return array Annotations + */ + public function fromComment(string $comment, Context $context): array + { + $context->comment = $comment; + + try { + Generator::$context = $context; + if ($context->is('annotations') === false) { + $context->annotations = []; + } + + return $this->docParser->parse($comment); + } catch (\Exception $e) { + if (preg_match('/^(.+) at position ([0-9]+) in ' . preg_quote((string) $context, '/') . '\.$/', $e->getMessage(), $matches)) { + $errorMessage = $matches[1]; + $errorPos = (int) $matches[2]; + $atPos = strpos($comment, '@'); + $context->line += substr_count($comment, "\n", 0, $atPos + $errorPos); + $lines = explode("\n", substr($comment, $atPos, $errorPos)); + $context->character = strlen(array_pop($lines)) + 1; // position starts at 0 character starts at 1 + $context->logger->error($errorMessage . ' in ' . $context, ['exception' => $e]); + } else { + $context->logger->error($e->getMessage(), ['exception' => $e]); + } + + return []; + } finally { + Generator::$context = null; + } + } +} diff --git a/src/Analysers/ReflectionAnalyser.php b/src/Analysers/ReflectionAnalyser.php new file mode 100644 index 000000000..a176e60e4 --- /dev/null +++ b/src/Analysers/ReflectionAnalyser.php @@ -0,0 +1,164 @@ +annotationFactories = $annotationFactories; + if (!$this->annotationFactories) { + throw new \InvalidArgumentException('Need at least one annotation factory'); + } + } + + public function setGenerator(Generator $generator): void + { + $this->generator = $generator; + + foreach ($this->annotationFactories as $annotationFactory) { + $annotationFactory->setGenerator($generator); + } + } + + public function fromFile(string $filename, Context $context): Analysis + { + $scanner = new TokenScanner(); + $fileDetails = $scanner->scanFile($filename); + + require_once $filename; + + $analysis = new Analysis([], $context); + foreach ($fileDetails as $fqdn => $details) { + $this->analyzeFqdn($fqdn, $analysis, $details); + } + + return $analysis; + } + + public function fromFqdn(string $fqdn, Analysis $analysis): Analysis + { + $fqdn = ltrim($fqdn, '\\'); + + $rc = new \ReflectionClass($fqdn); + if (!$filename = $rc->getFileName()) { + return $analysis; + } + + $scanner = new TokenScanner(); + $fileDetails = $scanner->scanFile($filename); + + $this->analyzeFqdn($fqdn, $analysis, $fileDetails[$fqdn]); + + return $analysis; + } + + protected function analyzeFqdn(string $fqdn, Analysis $analysis, array $details): Analysis + { + if (!class_exists($fqdn) && !interface_exists($fqdn) && !trait_exists($fqdn)) { + return $analysis; + } + + $rc = new \ReflectionClass($fqdn); + $contextType = $rc->isInterface() ? 'interface' : ($rc->isTrait() ? 'trait' : 'class'); + $context = new Context([ + $contextType => $rc->getShortName(), + 'namespace' => $rc->getNamespaceName() ?: Generator::UNDEFINED, + 'comment' => $rc->getDocComment() ?: Generator::UNDEFINED, + 'filename' => $rc->getFileName() ?: Generator::UNDEFINED, + 'line' => $rc->getStartLine(), + 'annotations' => [], + 'scanned' => $details, + ], $analysis->context); + + $definition = [ + $contextType => $rc->getShortName(), + 'extends' => null, + 'implements' => [], + 'traits' => [], + 'properties' => [], + 'methods' => [], + 'context' => $context, + ]; + $normaliseClass = function (string $name): string { + return '\\' . $name; + }; + if ($parentClass = $rc->getParentClass()) { + $definition['extends'] = $normaliseClass($parentClass->getName()); + } + $definition[$contextType == 'class' ? 'implements' : 'extends'] = array_map($normaliseClass, $details['interfaces']); + $definition['traits'] = array_map($normaliseClass, $details['traits']); + + foreach ($this->annotationFactories as $annotationFactory) { + $analysis->addAnnotations($annotationFactory->build($rc, $context), $context); + } + + foreach ($rc->getMethods() as $method) { + if (in_array($method->name, $details['methods'])) { + $definition['methods'][$method->getName()] = $ctx = new Context([ + 'method' => $method->getName(), + 'comment' => $method->getDocComment() ?: Generator::UNDEFINED, + 'filename' => $method->getFileName() ?: Generator::UNDEFINED, + 'line' => $method->getStartLine(), + 'annotations' => [], + ], $context); + foreach ($this->annotationFactories as $annotationFactory) { + $analysis->addAnnotations($annotationFactory->build($method, $ctx), $ctx); + } + } + } + + foreach ($rc->getProperties() as $property) { + if (in_array($property->name, $details['properties'])) { + $definition['properties'][$property->getName()] = $ctx = new Context([ + 'property' => $property->getName(), + 'comment' => $property->getDocComment() ?: Generator::UNDEFINED, + 'annotations' => [], + ], $context); + if ($property->isStatic()) { + $ctx->static = true; + } + if (\PHP_VERSION_ID >= 70400 && ($type = $property->getType())) { + $ctx->nullable = $type->allowsNull(); + if ($type instanceof \ReflectionNamedType) { + $ctx->type = $type->getName(); + // Context::fullyQualifiedName(...) exppects this + if (class_exists($absFqn = '\\' . $ctx->type)) { + $ctx->type = $absFqn; + } + } + } + foreach ($this->annotationFactories as $annotationFactory) { + $analysis->addAnnotations($annotationFactory->build($property, $ctx), $ctx); + } + } + } + + $addDefinition = 'add' . ucfirst($contextType) . 'Definition'; + $analysis->{$addDefinition}($definition); + + return $analysis; + } +} diff --git a/src/StaticAnalyser.php b/src/Analysers/TokenAnalyser.php similarity index 89% rename from src/StaticAnalyser.php rename to src/Analysers/TokenAnalyser.php index bcf03937c..87af0d8a4 100644 --- a/src/StaticAnalyser.php +++ b/src/Analysers/TokenAnalyser.php @@ -4,13 +4,25 @@ * @license Apache 2.0 */ -namespace OpenApi; +namespace OpenApi\Analysers; + +use OpenApi\Analysis; +use OpenApi\Context; +use OpenApi\Generator; /** - * OpenApi\StaticAnalyser extracts swagger-php annotations from php code using static analysis. + * Extracts swagger-php annotations from php code using static analysis. */ -class StaticAnalyser +class TokenAnalyser implements AnalyserInterface { + /** @var Generator */ + protected $generator; + + public function setGenerator(Generator $generator): void + { + $this->generator = $generator; + } + /** * Extract and process all doc-comments from a file. * @@ -53,13 +65,14 @@ public function fromCode(string $code, Context $context): Analysis */ protected function fromTokens(array $tokens, Context $parseContext): Analysis { - $analyser = new Analyser(); + $generator = $this->generator ?: new Generator(); $analysis = new Analysis([], $parseContext); + $docBlockParser = new DocBlockParser($generator->getAliases()); reset($tokens); $token = ''; - $imports = Analyser::$defaultImports; + $aliases = $generator->getAliases(); $parseContext->uses = []; // default to parse context to start with @@ -91,7 +104,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis if ($token[0] === T_DOC_COMMENT) { if ($comment) { // 2 Doc-comments in succession? - $this->analyseComment($analysis, $analyser, $comment, new Context(['line' => $line], $schemaContext)); + $this->analyseComment($analysis, $docBlockParser, $comment, new Context(['line' => $line], $schemaContext)); } $comment = $token[1]; $line = $token[2] + $lineOffset; @@ -151,7 +164,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis if ($comment) { $schemaContext->line = $line; - $this->analyseComment($analysis, $analyser, $comment, $schemaContext); + $this->analyseComment($analysis, $docBlockParser, $comment, $schemaContext); $comment = false; continue; } @@ -185,7 +198,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis if ($comment) { $schemaContext->line = $line; - $this->analyseComment($analysis, $analyser, $comment, $schemaContext); + $this->analyseComment($analysis, $docBlockParser, $comment, $schemaContext); $comment = false; continue; } @@ -211,7 +224,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis if ($comment) { $schemaContext->line = $line; - $this->analyseComment($analysis, $analyser, $comment, $schemaContext); + $this->analyseComment($analysis, $docBlockParser, $comment, $schemaContext); $comment = false; continue; } @@ -239,7 +252,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis $traitDefinition['properties'][$propertyContext->property] = $propertyContext; } if ($comment) { - $this->analyseComment($analysis, $analyser, $comment, $propertyContext); + $this->analyseComment($analysis, $docBlockParser, $comment, $propertyContext); $comment = false; } continue; @@ -270,7 +283,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis $traitDefinition['properties'][$propertyContext->property] = $propertyContext; } if ($comment) { - $this->analyseComment($analysis, $analyser, $comment, $propertyContext); + $this->analyseComment($analysis, $docBlockParser, $comment, $propertyContext); $comment = false; } } elseif ($token[0] === T_FUNCTION) { @@ -294,7 +307,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis $traitDefinition['methods'][$token[1]] = $methodContext; } if ($comment) { - $this->analyseComment($analysis, $analyser, $comment, $methodContext); + $this->analyseComment($analysis, $docBlockParser, $comment, $methodContext); $comment = false; } } @@ -321,7 +334,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis $traitDefinition['methods'][$token[1]] = $methodContext; } if ($comment) { - $this->analyseComment($analysis, $analyser, $comment, $methodContext); + $this->analyseComment($analysis, $docBlockParser, $comment, $methodContext); $comment = false; } } @@ -331,15 +344,15 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis // Skip "use" & "namespace" to prevent "never imported" warnings) if ($comment) { // Not a doc-comment for a class, property or method? - $this->analyseComment($analysis, $analyser, $comment, new Context(['line' => $line], $schemaContext)); + $this->analyseComment($analysis, $docBlockParser, $comment, new Context(['line' => $line], $schemaContext)); $comment = false; } } if ($token[0] === T_NAMESPACE) { $parseContext->namespace = $this->parseNamespace($tokens, $token, $parseContext); - $imports['__NAMESPACE__'] = $parseContext->namespace; - $analyser->docParser->setImports($imports); + $aliases['__NAMESPACE__'] = $parseContext->namespace; + $docBlockParser->setAliases($aliases); continue; } @@ -356,17 +369,18 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis // not a trait use $parseContext->uses[$alias] = $target; - if (Analyser::$whitelist === false) { - $imports[strtolower($alias)] = $target; + $namespaces = $generator->getNamespaces(); + if (null === $namespaces) { + $aliases[strtolower($alias)] = $target; } else { - foreach (Analyser::$whitelist as $namespace) { + foreach ($namespaces as $namespace) { if (strcasecmp(substr($target . '\\', 0, strlen($namespace)), $namespace) === 0) { - $imports[strtolower($alias)] = $target; + $aliases[strtolower($alias)] = $target; break; } } } - $analyser->docParser->setImports($imports); + $docBlockParser->setAliases($aliases); } } } @@ -374,7 +388,7 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis // cleanup final comment and definition if ($comment) { - $this->analyseComment($analysis, $analyser, $comment, new Context(['line' => $line], $schemaContext)); + $this->analyseComment($analysis, $docBlockParser, $comment, new Context(['line' => $line], $schemaContext)); } if ($classDefinition) { $analysis->addClassDefinition($classDefinition); @@ -392,9 +406,9 @@ protected function fromTokens(array $tokens, Context $parseContext): Analysis /** * Parse comment and add annotations to analysis. */ - private function analyseComment(Analysis $analysis, Analyser $analyser, string $comment, Context $context): void + private function analyseComment(Analysis $analysis, DocBlockParser $docBlockParser, string $comment, Context $context): void { - $analysis->addAnnotations($analyser->fromComment($comment, $context), $context); + $analysis->addAnnotations($docBlockParser->fromComment($comment, $context), $context); } /** diff --git a/src/Analysers/TokenScanner.php b/src/Analysers/TokenScanner.php new file mode 100644 index 000000000..239a03018 --- /dev/null +++ b/src/Analysers/TokenScanner.php @@ -0,0 +1,273 @@ +scanTokens(token_get_all(file_get_contents($filename))); + } + + /** + * Scan file for all classes, interfaces and traits. + * + * @return string[][] File details + */ + protected function scanTokens(array $tokens): array + { + $units = []; + $uses = []; + $isInterface = false; + $namespace = ''; + $currentName = null; + $lastToken = null; + $curlyNested = 0; + $stack = []; + + while (false !== ($token = $this->nextToken($tokens))) { + if (!is_array($token)) { + switch ($token) { + case '{': + ++$curlyNested; + break; + case '}': + --$curlyNested; + break; + } + if ($stack) { + $last = array_pop($stack); + if ($last[1] < $curlyNested) { + $stack[] = $last; + } + } + continue; + } + switch ($token[0]) { + case T_NAMESPACE: + $namespace = $this->nextWord($tokens); + break; + case T_USE: + if (!$stack) { + $uses = array_merge($uses, $this->parseFQNStatement($tokens, $token)); + } elseif ($currentName) { + $traits = $this->resolveFQN($this->parseFQNStatement($tokens, $token), $namespace, $uses); + $units[$currentName]['traits'] = array_merge($units[$currentName]['traits'], $traits); + } + break; + case T_CLASS: + if ($lastToken && is_array($lastToken) && $lastToken[0] === T_DOUBLE_COLON) { + // ::class + break; + } + + // class name + $token = $this->nextToken($tokens); + + // unless ... + if (is_string($token) && ($token === '(' || $token === '{')) { + // new class[()] { ... } + break; + } elseif (is_array($token) && in_array($token[1], ['extends', 'implements'])) { + // new class[()] extends { ... } + break; + } + + $isInterface = false; + $currentName = $namespace . '\\' . $token[1]; + $stack[] = [$currentName, $curlyNested]; + $units[$currentName] = ['uses' => $uses, 'interfaces' => [], 'traits' => [], 'methods' => [], 'properties' => []]; + break; + case T_INTERFACE: + $isInterface = true; + $token = $this->nextToken($tokens); + $currentName = $namespace . '\\' . $token[1]; + $stack[] = [$currentName, $curlyNested]; + $units[$currentName] = ['uses' => $uses, 'interfaces' => [], 'traits' => [], 'methods' => [], 'properties' => []]; + break; + case T_TRAIT: + $isInterface = false; + $token = $this->nextToken($tokens); + $currentName = $namespace . '\\' . $token[1]; + $this->skipTo($tokens, '{'); + $stack[] = [$currentName, $curlyNested++]; + $units[$currentName] = ['uses' => $uses, 'interfaces' => [], 'traits' => [], 'methods' => [], 'properties' => []]; + break; + case T_EXTENDS: + $fqns = $this->parseFQNStatement($tokens, $token); + if ($isInterface && $currentName) { + $units[$currentName]['interfaces'] = $this->resolveFQN($fqns, $namespace, $uses); + } + if (!is_array($token) || T_IMPLEMENTS !== $token[0]) { + break; + } + // no break + case T_IMPLEMENTS: + $fqns = $this->parseFQNStatement($tokens, $token); + if ($currentName) { + $units[$currentName]['interfaces'] = $this->resolveFQN($fqns, $namespace, $uses); + } + break; + case T_FUNCTION: + $token = $this->nextToken($tokens); + + if (1 == count($stack)) { + $name = $stack[0][0]; + if (!$isInterface) { + // more nesting + $this->skipTo($tokens, '{'); + $stack[] = [$token[1], ++$curlyNested]; + } else { + // no function body + $this->skipTo($tokens, ';'); + } + + $units[$name]['methods'][] = $token[1]; + } + break; + case T_VARIABLE: + if (1 == count($stack)) { + $name = $stack[0][0]; + $units[$name]['properties'][] = substr($token[1], 1); + } + break; + } + $lastToken = $token; + } + + return $units; + } + + /** + * Get the next token that is not whitespace or comment. + */ + protected function nextToken(array &$tokens) + { + $token = true; + while ($token) { + $token = next($tokens); + if (is_array($token)) { + if (in_array($token[0], [T_WHITESPACE, T_COMMENT])) { + continue; + } + } + + return $token; + } + + return $token; + } + + protected function resolveFQN(array $names, string $namespace, array $uses): array + { + $resolve = function ($name) use ($namespace, $uses) { + if ('\\' == $name[0]) { + return substr($name, 1); + } + + if (array_key_exists($name, $uses)) { + return $uses[$name]; + } + + return $namespace . '\\' . $name; + }; + + return array_values(array_map($resolve, $names)); + } + + protected function skipTo(array &$tokens, $char): void + { + while (false !== ($token = next($tokens))) { + if (is_string($token) && $token == $char) { + break; + } + } + } + + /** + * Read next word. + * + * Skips leading whitespace. + */ + protected function nextWord(array &$tokens): string + { + $word = ''; + while (false !== ($token = next($tokens))) { + if (is_array($token)) { + if ($token[0] === T_WHITESPACE) { + if ($word) { + break; + } + continue; + } + $word .= $token[1]; + } + } + + return $word; + } + + /** + * Parse a use statement. + */ + protected function parseFQNStatement(array &$tokens, &$token): array + { + $normalizeAlias = function ($alias) { + $alias = ltrim($alias, '\\'); + $elements = explode('\\', $alias); + + return array_pop($elements); + }; + + $class = ''; + $alias = ''; + $statements = []; + $explicitAlias = false; + $php8NSToken = defined('T_NAME_QUALIFIED') ? [T_NAME_QUALIFIED, T_NAME_FULLY_QUALIFIED] : []; + $nsToken = array_merge([T_STRING, T_NS_SEPARATOR], $php8NSToken); + while ($token !== false) { + $token = $this->nextToken($tokens); + $isNameToken = in_array($token[0], $nsToken); + if (!$explicitAlias && $isNameToken) { + $class .= $token[1]; + $alias = $token[1]; + } elseif ($explicitAlias && $isNameToken) { + $alias .= $token[1]; + } elseif ($token[0] === T_AS) { + $explicitAlias = true; + $alias = ''; + } elseif ($token[0] === T_IMPLEMENTS) { + $statements[$normalizeAlias($alias)] = $class; + break; + } elseif ($token === ',') { + $statements[$normalizeAlias($alias)] = $class; + $class = ''; + $alias = ''; + $explicitAlias = false; + } elseif ($token === ';') { + $statements[$normalizeAlias($alias)] = $class; + break; + } elseif ($token === '{') { + $statements[$normalizeAlias($alias)] = $class; + prev($tokens); + break; + } else { + break; + } + } + + return $statements; + } +} diff --git a/src/Analysis.php b/src/Analysis.php index d07598076..87998f952 100644 --- a/src/Analysis.php +++ b/src/Analysis.php @@ -9,20 +9,6 @@ use OpenApi\Annotations\AbstractAnnotation; use OpenApi\Annotations\OpenApi; use OpenApi\Annotations\Schema; -use OpenApi\Processors\AugmentParameters; -use OpenApi\Processors\AugmentProperties; -use OpenApi\Processors\AugmentSchemas; -use OpenApi\Processors\BuildPaths; -use OpenApi\Processors\CleanUnmerged; -use OpenApi\Processors\DocBlockDescriptions; -use OpenApi\Processors\ExpandInterfaces; -use OpenApi\Processors\ExpandClasses; -use OpenApi\Processors\ExpandTraits; -use OpenApi\Processors\MergeIntoComponents; -use OpenApi\Processors\MergeIntoOpenApi; -use OpenApi\Processors\MergeJsonContent; -use OpenApi\Processors\MergeXmlContent; -use OpenApi\Processors\OperationId; /** * Result of the analyser. @@ -70,13 +56,6 @@ class Analysis */ public $context; - /** - * Registry for the post-processing operations. - * - * @var callable[] - */ - private static $processors; - public function __construct(array $annotations = [], Context $context = null) { $this->annotations = new \SplObjectStorage(); @@ -85,20 +64,19 @@ public function __construct(array $annotations = [], Context $context = null) $this->addAnnotations($annotations, $context); } - public function addAnnotation($annotation, ?Context $context): void + public function addAnnotation($annotation, Context $context): void { if ($this->annotations->contains($annotation)) { return; } - if ($annotation instanceof AbstractAnnotation) { - $context = $annotation->_context ?: $this->context; - if ($this->openapi === null && $annotation instanceof OpenApi) { - $this->openapi = $annotation; - } + + if ($annotation instanceof OpenApi) { + $this->openapi = $this->openapi ?: $annotation; } else { if ($context->is('annotations') === false) { $context->annotations = []; } + if (in_array($annotation, $context->annotations, true) === false) { $context->annotations[] = $annotation; } @@ -125,7 +103,7 @@ public function addAnnotation($annotation, ?Context $context): void } } - public function addAnnotations(array $annotations, ?Context $context): void + public function addAnnotations(array $annotations, Context $context): void { foreach ($annotations as $annotation) { $this->addAnnotation($annotation, $context); @@ -426,14 +404,10 @@ public function split() /** * Apply the processor(s). * - * @param \Closure|\Closure[] $processors One or more processors + * @param callable|callable[] $processors One or more processors */ public function process($processors = null): void { - if ($processors === null) { - // Use the default and registered processors. - $processors = self::processors(); - } if (is_array($processors) === false && is_callable($processors)) { $processors = [$processors]; } @@ -442,67 +416,6 @@ public function process($processors = null): void } } - /** - * Get direct access to the processors array. - * - * @return array reference - * - * @deprecated Superseded by `Generator` methods - */ - public static function &processors() - { - if (!self::$processors) { - // Add default processors. - self::$processors = [ - new DocBlockDescriptions(), - new MergeIntoOpenApi(), - new MergeIntoComponents(), - new ExpandClasses(), - new ExpandInterfaces(), - new ExpandTraits(), - new AugmentSchemas(), - new AugmentProperties(), - new BuildPaths(), - new AugmentParameters(), - new MergeJsonContent(), - new MergeXmlContent(), - new OperationId(), - new CleanUnmerged(), - ]; - } - - return self::$processors; - } - - /** - * Register a processor. - * - * @param \Closure $processor - * - * @deprecated Superseded by `Generator` methods - */ - public static function registerProcessor($processor): void - { - array_push(self::processors(), $processor); - } - - /** - * Unregister a processor. - * - * @param \Closure $processor - * - * @deprecated Superseded by `Generator` methods - */ - public static function unregisterProcessor($processor): void - { - $processors = &self::processors(); - $key = array_search($processor, $processors, true); - if ($key === false) { - throw new \Exception('Given processor was not registered'); - } - unset($processors[$key]); - } - public function validate(): bool { if ($this->openapi !== null) { diff --git a/src/Annotations/AbstractAnnotation.php b/src/Annotations/AbstractAnnotation.php index 3012ad834..42737ad31 100644 --- a/src/Annotations/AbstractAnnotation.php +++ b/src/Annotations/AbstractAnnotation.php @@ -6,7 +6,6 @@ namespace OpenApi\Annotations; -use OpenApi\Analyser; use OpenApi\Context; use OpenApi\Generator; use OpenApi\Util; @@ -94,8 +93,8 @@ public function __construct(array $properties) if (isset($properties['_context'])) { $this->_context = $properties['_context']; unset($properties['_context']); - } elseif (Analyser::$context) { - $this->_context = Analyser::$context; + } elseif (Generator::$context) { + $this->_context = Generator::$context; } else { $this->_context = Context::detect(1); } @@ -119,7 +118,7 @@ public function __construct(array $properties) } elseif (is_array($value)) { $annotations = []; foreach ($value as $annotation) { - if (is_object($annotation) && $annotation instanceof AbstractAnnotation) { + if ($annotation instanceof AbstractAnnotation) { $annotations[] = $annotation; } else { $this->_context->logger->warning('Unexpected field in ' . $this->identity() . ' in ' . $this->_context); @@ -129,7 +128,9 @@ public function __construct(array $properties) } elseif (is_object($value)) { $this->merge([$value]); } else { - $this->_context->logger->warning('Unexpected parameter in ' . $this->identity()); + if ($value !== Generator::UNDEFINED) { + $this->_context->logger->warning('Unexpected parameter "' . $property . '" in ' . $this->identity()); + } } } } @@ -658,12 +659,32 @@ private function validateArrayType($value): bool * * @return AbstractAnnotation */ - private function nested($annotation, Context $nestedContext) + protected function nested($annotation, Context $nestedContext) { + if (!$annotation) { + return $annotation; + } + if (property_exists($annotation, '_context') && $annotation->_context === $this->_context) { $annotation->_context = $nestedContext; } return $annotation; } + + protected function combine(...$args): array + { + $combined = []; + foreach ($args as $arg) { + if (is_array($arg)) { + $combined = array_merge($combined, $arg); + } else { + $combined[] = $arg; + } + } + + return array_filter($combined, function ($value) { + return $value !== Generator::UNDEFINED && $value !== null; + }); + } } diff --git a/src/Annotations/AdditionalProperties.php b/src/Annotations/AdditionalProperties.php index 0cdf2113a..9c8b72f81 100644 --- a/src/Annotations/AdditionalProperties.php +++ b/src/Annotations/AdditionalProperties.php @@ -6,10 +6,12 @@ namespace OpenApi\Annotations; +use OpenApi\Generator; + /** * @Annotation */ -class AdditionalProperties extends Schema +abstract class AbstractAdditionalProperties extends Schema { /** * @inheritdoc @@ -36,3 +38,34 @@ class AdditionalProperties extends Schema Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class AdditionalProperties extends AbstractAdditionalProperties + { + public function __construct( + array $properties = [], + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class AdditionalProperties extends AbstractAdditionalProperties + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Attachable.php b/src/Annotations/Attachable.php index 447f9e517..ed89f0506 100644 --- a/src/Annotations/Attachable.php +++ b/src/Annotations/Attachable.php @@ -12,7 +12,7 @@ * A container for custom data to be attached to an annotation. * These will be ignored by swagger-php but can be used for custom processing. */ -class Attachable extends AbstractAnnotation +abstract class AbstractAttachable extends AbstractAnnotation { /** * @inheritdoc @@ -41,6 +41,7 @@ class Attachable extends AbstractAnnotation Parameter::class, Patch::class, PathItem::class, + PathParameter::class, Post::class, Property::class, Put::class, @@ -70,3 +71,30 @@ public function allowedParents(): ?array return null; } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_ALL | \Attribute::IS_REPEATABLE)] + class Attachable extends AbstractAttachable + { + public function __construct( + array $properties = [] + ) { + parent::__construct($properties + [ + ]); + } + } +} else { + /** + * @Annotation + */ + class Attachable extends AbstractAttachable + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Components.php b/src/Annotations/Components.php index ee342160c..ed1ca4d72 100644 --- a/src/Annotations/Components.php +++ b/src/Annotations/Components.php @@ -9,13 +9,14 @@ use OpenApi\Generator; /** - * @Annotation - * A Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#components-object + * A Components Object: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#components-object. * * Holds a set of reusable objects for different aspects of the OA. * All objects defined within the components object will have no effect on the API unless they are explicitly referenced from properties outside the components object. + * + * @Annotation */ -class Components extends AbstractAnnotation +abstract class AbstractComponents extends AbstractAnnotation { /** * Schema reference. @@ -101,6 +102,7 @@ class Components extends AbstractAnnotation Schema::class => ['schemas', 'schema'], Response::class => ['responses', 'response'], Parameter::class => ['parameters', 'parameter'], + PathParameter::class => ['parameters', 'parameter'], RequestBody::class => ['requestBodies', 'request'], Examples::class => ['examples', 'example'], Header::class => ['headers', 'header'], @@ -109,3 +111,30 @@ class Components extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Components extends AbstractComponents + { + public function __construct( + array $properties = [] + ) { + parent::__construct($properties + [ + ]); + } + } +} else { + /** + * @Annotation + */ + class Components extends AbstractComponents + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Contact.php b/src/Annotations/Contact.php index 03b445d9f..d971662ee 100644 --- a/src/Annotations/Contact.php +++ b/src/Annotations/Contact.php @@ -9,12 +9,13 @@ use OpenApi\Generator; /** - * @Annotation - * A "Contact Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#contact-object + * A "Contact Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#contact-object. * * Contact information for the exposed API. + * + * @Annotation */ -class Contact extends AbstractAnnotation +abstract class AbstractContact extends AbstractAnnotation { /** * The identifying name of the contact person/organization. @@ -60,3 +61,40 @@ class Contact extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Contact extends AbstractContact + { + public function __construct( + array $properties = [], + string $name = Generator::UNDEFINED, + string $url = Generator::UNDEFINED, + string $email = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'name' => $name, + 'url' => $url, + 'email' => $email, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Contact extends AbstractContact + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Delete.php b/src/Annotations/Delete.php index 8c3a54fd2..745bfb6cb 100644 --- a/src/Annotations/Delete.php +++ b/src/Annotations/Delete.php @@ -9,6 +9,7 @@ /** * @Annotation */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Delete extends Operation { /** diff --git a/src/Annotations/Discriminator.php b/src/Annotations/Discriminator.php index 479c7dc32..654a894fc 100644 --- a/src/Annotations/Discriminator.php +++ b/src/Annotations/Discriminator.php @@ -9,16 +9,18 @@ use OpenApi\Generator; /** - * @Annotation * The discriminator is a specific object in a schema which is used to inform the consumer of * the specification of an alternative schema based on the value associated with it. + * * This object is based on the [JSON Schema Specification](http://json-schema.org) and uses a predefined subset of it. * On top of this subset, there are extensions provided by this specification to allow for more complete documentation. * * A "Discriminator Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#discriminatorObject * JSON Schema: http://json-schema.org/ + * + * @Annotation */ -class Discriminator extends AbstractAnnotation +abstract class AbstractDiscriminator extends AbstractAnnotation { /** * The name of the property in the payload that will hold the discriminator value. @@ -65,3 +67,38 @@ class Discriminator extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Discriminator extends AbstractDiscriminator + { + public function __construct( + array $properties = [], + string $propertyName = Generator::UNDEFINED, + string $mapping = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'propertyName' => $propertyName, + 'mapping' => $mapping, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Discriminator extends AbstractDiscriminator + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Examples.php b/src/Annotations/Examples.php index 57676008c..d5ae57b8a 100644 --- a/src/Annotations/Examples.php +++ b/src/Annotations/Examples.php @@ -11,7 +11,7 @@ /** * @Annotation */ -class Examples extends AbstractAnnotation +abstract class AbstractExamples extends AbstractAnnotation { /** * $ref See https://swagger.io/docs/specification/using-ref/. @@ -76,6 +76,7 @@ class Examples extends AbstractAnnotation public static $_parents = [ Components::class, Parameter::class, + PathParameter::class, MediaType::class, JsonContent::class, XmlContent::class, @@ -88,3 +89,43 @@ class Examples extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Examples extends AbstractExamples + { + public function __construct( + array $properties = [], + string $summary = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + string $value = Generator::UNDEFINED, + string $externalValue = Generator::UNDEFINED, + string $ref = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'summary' => $summary, + 'description' => $description, + 'value' => $value, + 'externalValue' => $externalValue, + 'ref' => $ref, + 'x' => $x ?? Generator::UNDEFINED, + ]); + } + } +} else { + /** + * @Annotation + */ + class Examples extends AbstractExamples + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/ExternalDocumentation.php b/src/Annotations/ExternalDocumentation.php index 6cf04d631..6d886d46c 100644 --- a/src/Annotations/ExternalDocumentation.php +++ b/src/Annotations/ExternalDocumentation.php @@ -9,12 +9,13 @@ use OpenApi\Generator; /** - * @Annotation * Allows referencing an external resource for extended documentation. * * A "External Documentation Object": https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#external-documentation-object + * + * @Annotation */ -class ExternalDocumentation extends AbstractAnnotation +abstract class AbstractExternalDocumentation extends AbstractAnnotation { /** * A short description of the target documentation. GFM syntax can be used for rich text representation. @@ -73,3 +74,38 @@ class ExternalDocumentation extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class ExternalDocumentation extends AbstractExternalDocumentation + { + public function __construct( + array $properties = [], + string $description = Generator::UNDEFINED, + string $url = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'description' => $description, + 'url' => $url, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class ExternalDocumentation extends AbstractExternalDocumentation + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Flow.php b/src/Annotations/Flow.php index aa9711bd8..f05b6432b 100644 --- a/src/Annotations/Flow.php +++ b/src/Annotations/Flow.php @@ -14,7 +14,7 @@ * * @Annotation */ -class Flow extends AbstractAnnotation +abstract class AbstractFlow extends AbstractAnnotation { /** * The authorization url to be used for this flow. @@ -97,3 +97,44 @@ public function jsonSerialize() return parent::jsonSerialize(); } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Flow extends AbstractFlow + { + public function __construct( + array $properties = [], + string $authorizationUrl = Generator::UNDEFINED, + string $tokenUrl = Generator::UNDEFINED, + string $refreshUrl = Generator::UNDEFINED, + string $flow = Generator::UNDEFINED, + ?array $scopes = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'authorizationUrl' => $authorizationUrl, + 'tokenUrl' => $tokenUrl, + 'refreshUrl' => $refreshUrl, + 'flow' => $flow, + 'scopes' => $scopes ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Flow extends AbstractFlow + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Get.php b/src/Annotations/Get.php index c0ff44237..a10d9a6cd 100644 --- a/src/Annotations/Get.php +++ b/src/Annotations/Get.php @@ -9,6 +9,7 @@ /** * @Annotation */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Get extends Operation { /** diff --git a/src/Annotations/Head.php b/src/Annotations/Head.php index 4b6b2adb0..6356acd8f 100644 --- a/src/Annotations/Head.php +++ b/src/Annotations/Head.php @@ -9,6 +9,7 @@ /** * @Annotation */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Head extends Operation { /** diff --git a/src/Annotations/Header.php b/src/Annotations/Header.php index c0992afff..bb85ddf8e 100644 --- a/src/Annotations/Header.php +++ b/src/Annotations/Header.php @@ -8,11 +8,11 @@ use OpenApi\Generator; /** - * @Annotation + * A "Header Object" https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#headerObject. * - * A "Header Object" https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#headerObject + * @Annotation */ -class Header extends AbstractAnnotation +abstract class AbstractHeader extends AbstractAnnotation { /** * $ref See https://swagger.io/docs/specification/using-ref/. @@ -91,3 +91,34 @@ class Header extends AbstractAnnotation Response::class, ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Header extends AbstractHeader + { + public function __construct( + array $properties = [], + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Header extends AbstractHeader + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Info.php b/src/Annotations/Info.php index 364227ac1..9d589d654 100644 --- a/src/Annotations/Info.php +++ b/src/Annotations/Info.php @@ -9,13 +9,14 @@ use OpenApi\Generator; /** - * @Annotation - * An "Info Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#info-object + * An "Info Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#info-object. * * The object provides metadata about the API. * The metadata may be used by the clients if needed, and may be presented in editing or documentation generation tools for convenience. + * + * @Annotation */ -class Info extends AbstractAnnotation +abstract class AbstractInfo extends AbstractAnnotation { /** * The title of the application. @@ -90,3 +91,44 @@ class Info extends AbstractAnnotation OpenApi::class, ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Info extends AbstractInfo + { + public function __construct( + array $properties = [], + string $version = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + string $title = Generator::UNDEFINED, + string $termsOfService = Generator::UNDEFINED, + ?Contact $contact = null, + ?License $license = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'version' => $version, + 'description' => $description, + 'title' => $title, + 'termsOfService' => $termsOfService, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($contact, $license, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Info extends AbstractInfo + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Items.php b/src/Annotations/Items.php index 6e3d02a66..1a1bc50f3 100644 --- a/src/Annotations/Items.php +++ b/src/Annotations/Items.php @@ -6,11 +6,14 @@ namespace OpenApi\Annotations; +use OpenApi\Generator; + /** + * The description of an item in a Schema with type "array". + * * @Annotation - * The description of an item in a Schema with type "array" */ -class Items extends Schema +abstract class AbstractItems extends Schema { /** * @inheritdoc @@ -58,3 +61,48 @@ public function validate(array $parents = [], array $skip = [], string $ref = '' // @todo Additional validation when used inside a Header or Parameter context. } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] + class Items extends AbstractItems + { + public function __construct( + array $properties = [], + string $type = Generator::UNDEFINED, + string $ref = Generator::UNDEFINED, + ?bool $deprecated = null, + ?array $allOf = null, + ?array $anyOf = null, + ?array $oneOf = null, + ?bool $nullable = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'type' => $type, + 'ref' => $ref, + 'nullable' => $nullable ?? Generator::UNDEFINED, + 'deprecated' => $deprecated ?? Generator::UNDEFINED, + 'allOf' => $allOf ?? Generator::UNDEFINED, + 'anyOf' => $anyOf ?? Generator::UNDEFINED, + 'oneOf' => $oneOf ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Items extends AbstractItems + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/JsonContent.php b/src/Annotations/JsonContent.php index db2c00163..51930bc21 100644 --- a/src/Annotations/JsonContent.php +++ b/src/Annotations/JsonContent.php @@ -9,12 +9,13 @@ use OpenApi\Generator; /** - * @Annotation * Shorthand for a json response. * * Use as an Schema inside a Response and the MediaType "application/json" will be generated. + * + * @Annotation */ -class JsonContent extends Schema +abstract class AbstractJsonContent extends Schema { /** @@ -45,3 +46,45 @@ class JsonContent extends Schema Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class JsonContent extends AbstractJsonContent + { + public function __construct( + array $properties = [], + string $ref = Generator::UNDEFINED, + ?array $allOf = null, + ?array $anyOf = null, + ?array $oneOf = null, + string $type = Generator::UNDEFINED, + $items = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'ref' => $ref, + 'allOf' => $allOf ?? Generator::UNDEFINED, + 'anyOf' => $anyOf ?? Generator::UNDEFINED, + 'oneOf' => $oneOf ?? Generator::UNDEFINED, + 'type' => $type, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($items, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class JsonContent extends AbstractJsonContent + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/License.php b/src/Annotations/License.php index 6e0bfd3cb..5246ad86e 100644 --- a/src/Annotations/License.php +++ b/src/Annotations/License.php @@ -9,12 +9,13 @@ use OpenApi\Generator; /** - * @Annotation * License information for the exposed API. * * A "License Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#license-object + * + * @Annotation */ -class License extends AbstractAnnotation +abstract class AbstractLicense extends AbstractAnnotation { /** * The license name used for the API. @@ -57,3 +58,38 @@ class License extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class License extends AbstractLicense + { + public function __construct( + array $properties = [], + string $name = Generator::UNDEFINED, + string $url = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'name' => $name, + 'url' => $url, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class License extends AbstractLicense + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Link.php b/src/Annotations/Link.php index 11256fcc9..bfa29f309 100644 --- a/src/Annotations/Link.php +++ b/src/Annotations/Link.php @@ -9,15 +9,16 @@ use OpenApi\Generator; /** - * @Annotation - * A "Link Object" https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#link-object + * A "Link Object" https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#link-object. * * The Link object represents a possible design-time link for a response. * The presence of a link does not guarantee the caller's ability to successfully invoke it, rather it provides a known relationship and traversal mechanism between responses and other operations. * Unlike dynamic links (i.e. links provided in the response payload), the OA linking mechanism does not require link information in the runtime response. * For computing links, and providing instructions to execute them, a runtime expression is used for accessing values in an operation and using them as parameters while invoking the linked operation. + * + * @Annotation */ -class Link extends AbstractAnnotation +abstract class AbstractLink extends AbstractAnnotation { /** @@ -94,3 +95,42 @@ class Link extends AbstractAnnotation Response::class, ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD)] + class Link extends AbstractLink + { + public function __construct( + array $properties = [], + string $link = Generator::UNDEFINED, + string $ref = Generator::UNDEFINED, + string $operationId = Generator::UNDEFINED, + ?array $parameters = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'link' => $link, + 'ref' => $ref, + 'operationId' => $operationId, + 'parameters' => $parameters ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Link extends AbstractLink + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/MediaType.php b/src/Annotations/MediaType.php index 028a5cf4b..b04b3cf61 100644 --- a/src/Annotations/MediaType.php +++ b/src/Annotations/MediaType.php @@ -9,12 +9,13 @@ use OpenApi\Generator; /** - * @Annotation - * A "Media Type Object" https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#media-type-object + * A "Media Type Object" https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#media-type-object. * * Each Media Type Object provides schema and examples for the media type identified by its key. + * + * @Annotation */ -class MediaType extends AbstractAnnotation +abstract class AbstractMediaType extends AbstractAnnotation { /** @@ -73,3 +74,42 @@ class MediaType extends AbstractAnnotation RequestBody::class, ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class MediaType extends AbstractMediaType + { + public function __construct( + array $properties = [], + string $mediaType = Generator::UNDEFINED, + ?Schema $schema = null, + $example = Generator::UNDEFINED, + ?array $examples = null, + string $encoding = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'mediaType' => $mediaType, + 'example' => $example, + 'encoding' => $encoding, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($schema, $examples, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class MediaType extends AbstractMediaType + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/OpenApi.php b/src/Annotations/OpenApi.php index 91ebab81d..2fd7eb681 100644 --- a/src/Annotations/OpenApi.php +++ b/src/Annotations/OpenApi.php @@ -11,13 +11,16 @@ use OpenApi\Util; /** - * @Annotation * This is the root document object for the API specification. * * A "OpenApi Object": https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#openapi-object + * + * @Annotation */ -class OpenApi extends AbstractAnnotation +#[\Attribute(\Attribute::TARGET_CLASS)] +class AbstractOpenApi extends AbstractAnnotation { + public const DEFAULT_VERSION = '3.0.0'; /** * The semantic version number of the OpenAPI Specification version that the OpenAPI document uses. * The openapi field should be used by tooling specifications and clients to interpret the OpenAPI document. @@ -25,7 +28,7 @@ class OpenApi extends AbstractAnnotation * * @var string */ - public $openapi = '3.0.0'; + public $openapi = self::DEFAULT_VERSION; /** * Provides metadata about the API. The metadata may be used by tooling as required. @@ -220,3 +223,40 @@ private static function resolveRef(string $ref, string $resolved, $container, ar throw new \Exception('$ref "' . $unresolved . '" not found'); } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class OpenApi extends AbstractOpenApi + { + public function __construct( + array $properties = [], + string $openapi = self::DEFAULT_VERSION, + ?Info $info = null, + ?array $servers = null, + ?array $tags = null, + ?ExternalDocumentation $externalDocs = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'openapi' => $openapi, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($info, $servers, $tags, $externalDocs, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class OpenApi extends AbstractOpenApi + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Operation.php b/src/Annotations/Operation.php index 8bc6440b5..7dbc58f3c 100644 --- a/src/Annotations/Operation.php +++ b/src/Annotations/Operation.php @@ -9,13 +9,14 @@ use OpenApi\Generator; /** - * @Annotation - * Base class for the @OA\Get(), @OA\Post(), @OA\Put(), @OA\Delete(), @OA\Patch(), etc + * Base class for the @OA\Get(), @OA\Post(), @OA\Put(), @OA\Delete(), @OA\Patch(), etc. * * An "Operation Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#operation-object * Describes a single API operation on a path. + * + * @Annotation */ -abstract class Operation extends AbstractAnnotation +abstract class AbstractOperation extends AbstractAnnotation { /** * key in the OpenApi "Paths Object" for this operation. @@ -158,6 +159,7 @@ abstract class Operation extends AbstractAnnotation */ public static $_nested = [ Parameter::class => ['parameters'], + PathParameter::class => ['parameters'], Response::class => ['responses', 'response'], ExternalDocumentation::class => 'externalDocs', Server::class => ['servers'], @@ -203,3 +205,51 @@ public function validate(array $parents = [], array $skip = [], string $ref = '' return $valid; } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + abstract class Operation extends AbstractOperation + { + public function __construct( + array $properties = [], + string $path = Generator::UNDEFINED, + string $operationId = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + string $summary = Generator::UNDEFINED, + ?array $security = null, + ?array $servers = null, + ?RequestBody $requestBody = null, + ?array $tags = null, + ?array $parameters = null, + ?array $responses = null, + ?ExternalDocumentation $externalDocs = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'path' => $path, + 'operationId' => $operationId, + 'description' => $description, + 'summary' => $summary, + 'security' => $security ?? Generator::UNDEFINED, + 'servers' => $servers ?? Generator::UNDEFINED, + 'tags' => $tags ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($requestBody, $responses, $parameters, $externalDocs, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + abstract class Operation extends AbstractOperation + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Options.php b/src/Annotations/Options.php index 312505e79..d8eebe06d 100644 --- a/src/Annotations/Options.php +++ b/src/Annotations/Options.php @@ -6,10 +6,12 @@ namespace OpenApi\Annotations; +use OpenApi\Generator; + /** * @Annotation */ -class Options extends Operation +abstract class AbstractOptions extends Operation { /** * @inheritdoc @@ -23,3 +25,34 @@ class Options extends Operation PathItem::class, ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Options extends AbstractOptions + { + public function __construct( + array $properties = [], + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Options extends AbstractOptions + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Parameter.php b/src/Annotations/Parameter.php index e3aabcef6..62453bafc 100644 --- a/src/Annotations/Parameter.php +++ b/src/Annotations/Parameter.php @@ -9,12 +9,15 @@ use OpenApi\Generator; /** - * @Annotation - * [A "Parameter Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#parameter-object + * [A "Parameter Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#parameter-object. + * * Describes a single operation parameter. + * * A unique parameter is defined by a combination of a name and location. + * + * @Annotation */ -class Parameter extends AbstractAnnotation +abstract class AbstractParameter extends AbstractAnnotation { /** * $ref See https://swagger.io/docs/specification/using-ref/. @@ -261,3 +264,44 @@ public function identity(): string return parent::_identity(['name', 'in']); } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY | \Attribute::IS_REPEATABLE)] + class Parameter extends AbstractParameter + { + public function __construct( + array $properties = [], + string $name = Generator::UNDEFINED, + string $in = Generator::UNDEFINED, + ?bool $required = null, + string $ref = Generator::UNDEFINED, + ?Schema $schema = null, + ?array $examples = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'name' => $name, + 'in' => $this->in !== Generator::UNDEFINED ? $this->in : $in, + 'required' => $this->required !== Generator::UNDEFINED ? $this->required : ($required ?? Generator::UNDEFINED), + 'ref' => $ref, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($schema, $examples, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Parameter extends AbstractParameter + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Patch.php b/src/Annotations/Patch.php index 7972f3bd3..54bb46024 100644 --- a/src/Annotations/Patch.php +++ b/src/Annotations/Patch.php @@ -9,6 +9,7 @@ /** * @Annotation */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Patch extends Operation { /** diff --git a/src/Annotations/PathItem.php b/src/Annotations/PathItem.php index b1cd5b9b0..f3beab52b 100644 --- a/src/Annotations/PathItem.php +++ b/src/Annotations/PathItem.php @@ -9,13 +9,16 @@ use OpenApi\Generator; /** - * @Annotation - * A "Path Item Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#path-item-object + * A "Path Item Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#path-item-object. + * * Describes the operations available on a single path. + * * A Path Item may be empty, due to ACL constraints. * The path itself is still exposed to the documentation viewer but they will not know which operations and parameters are available. + * + * @Annotation */ -class PathItem extends AbstractAnnotation +abstract class AbstractPathItem extends AbstractAnnotation { /** * $ref See https://swagger.io/docs/specification/using-ref/. @@ -117,6 +120,7 @@ class PathItem extends AbstractAnnotation */ public static $_types = [ 'path' => 'string', + 'summary' => 'string', ]; /** @@ -132,6 +136,7 @@ class PathItem extends AbstractAnnotation Head::class => 'head', Options::class => 'options', Parameter::class => ['parameters'], + PathParameter::class => ['parameters'], Server::class => ['servers'], Attachable::class => ['attachables'], ]; @@ -143,3 +148,34 @@ class PathItem extends AbstractAnnotation OpenApi::class, ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class PathItem extends AbstractPathItem + { + public function __construct( + array $properties = [], + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class PathItem extends AbstractPathItem + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/PathParameter.php b/src/Annotations/PathParameter.php new file mode 100644 index 000000000..122072e33 --- /dev/null +++ b/src/Annotations/PathParameter.php @@ -0,0 +1,43 @@ +properties array. @@ -52,3 +52,60 @@ class Property extends Schema Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] + class Property extends AbstractProperty + { + public function __construct( + array $properties = [], + string $property = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + string $title = Generator::UNDEFINED, + string $type = Generator::UNDEFINED, + string $format = Generator::UNDEFINED, + string $ref = Generator::UNDEFINED, + ?array $allOf = null, + ?array $anyOf = null, + ?array $oneOf = null, + ?bool $nullable = null, + ?Items $items = null, + ?bool $deprecated = null, + $example = Generator::UNDEFINED, + $examples = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'property' => $property, + 'description' => $description, + 'title' => $title, + 'type' => $type, + 'format' => $format, + 'nullable' => $nullable ?? Generator::UNDEFINED, + 'deprecated' => $deprecated ?? Generator::UNDEFINED, + 'example' => $example, + 'ref' => $ref, + 'allOf' => $allOf ?? Generator::UNDEFINED, + 'anyOf' => $anyOf ?? Generator::UNDEFINED, + 'oneOf' => $oneOf ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($items, $examples, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Property extends AbstractProperty + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Put.php b/src/Annotations/Put.php index a0a03035f..656e82ed8 100644 --- a/src/Annotations/Put.php +++ b/src/Annotations/Put.php @@ -9,6 +9,7 @@ /** * @Annotation */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Put extends Operation { /** diff --git a/src/Annotations/RequestBody.php b/src/Annotations/RequestBody.php index 3b094ff7c..2f03092d1 100644 --- a/src/Annotations/RequestBody.php +++ b/src/Annotations/RequestBody.php @@ -9,13 +9,14 @@ use OpenApi\Generator; /** - * @Annotation - * A "Response Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#requestBodyObject + * A "Response Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#requestBodyObject. * * Describes a single response from an API Operation, including design-time, static links to operations based on the * response. + * + * @Annotation */ -class RequestBody extends AbstractAnnotation +abstract class AbstractRequestBody extends AbstractAnnotation { public $ref = Generator::UNDEFINED; @@ -83,3 +84,39 @@ class RequestBody extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PARAMETER)] + class RequestBody extends AbstractRequestBody + { + public function __construct( + array $properties = [], + string $description = Generator::UNDEFINED, + ?bool $required = null, + $content = Generator::UNDEFINED, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'description' => $description, + 'required' => $required ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($content, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class RequestBody extends AbstractRequestBody + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Response.php b/src/Annotations/Response.php index 7562b2d41..0efcc8325 100644 --- a/src/Annotations/Response.php +++ b/src/Annotations/Response.php @@ -9,12 +9,13 @@ use OpenApi\Generator; /** - * @Annotation - * A "Response Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#response-object + * A "Response Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#response-object. * * Describes a single response from an API Operation, including design-time, static links to operations based on the response. + * + * @Annotation */ -class Response extends AbstractAnnotation +abstract class AbstractResponse extends AbstractAnnotation { /** * $ref See https://swagger.io/docs/specification/using-ref/. @@ -102,3 +103,40 @@ class Response extends AbstractAnnotation Trace::class, ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] + class Response extends AbstractResponse + { + public function __construct( + array $properties = [], + $response = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + $content = Generator::UNDEFINED, + ?array $links = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'response' => $response, + 'description' => $description, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($content, $links, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Response extends AbstractResponse + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Schema.php b/src/Annotations/Schema.php index 8b8861522..335bdcdb1 100644 --- a/src/Annotations/Schema.php +++ b/src/Annotations/Schema.php @@ -9,7 +9,6 @@ use OpenApi\Generator; /** - * @Annotation * The definition of input and output data types. * These types can be objects, but also primitives and arrays. * This object is based on the [JSON Schema Specification](http://json-schema.org) and uses a predefined subset of it. @@ -17,8 +16,10 @@ * * A "Schema Object": https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#schemaObject * JSON Schema: http://json-schema.org/ + * + * @Annotation */ -class Schema extends AbstractAnnotation +abstract class AbstractSchema extends AbstractAnnotation { /** * $ref See https://swagger.io/docs/specification/using-ref/. @@ -327,6 +328,7 @@ class Schema extends AbstractAnnotation * @inheritdoc */ public static $_types = [ + 'title' => 'string', 'description' => 'string', 'required' => '[string]', 'format' => 'string', @@ -366,6 +368,7 @@ class Schema extends AbstractAnnotation public static $_parents = [ Components::class, Parameter::class, + PathParameter::class, MediaType::class, Header::class, ]; @@ -381,3 +384,52 @@ public function validate(array $parents = [], array $skip = [], string $ref = '' return parent::validate($parents, $skip, $ref); } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_PROPERTY)] + class Schema extends AbstractSchema + { + public function __construct( + array $properties = [], + string $schema = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + string $title = Generator::UNDEFINED, + string $type = Generator::UNDEFINED, + string $format = Generator::UNDEFINED, + string $ref = Generator::UNDEFINED, + ?Items $items = null, + ?array $enum = null, + ?bool $deprecated = null, + ?ExternalDocumentation $externalDocs = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'schema' => $schema, + 'description' => $description, + 'title' => $title, + 'type' => $type, + 'format' => $format, + 'ref' => $ref, + 'enum' => $enum ?? Generator::UNDEFINED, + 'deprecated' => $deprecated ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($externalDocs, $items, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Schema extends AbstractSchema + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/SecurityScheme.php b/src/Annotations/SecurityScheme.php index f972777d6..990a226da 100644 --- a/src/Annotations/SecurityScheme.php +++ b/src/Annotations/SecurityScheme.php @@ -9,11 +9,13 @@ use OpenApi\Generator; /** - * @Annotation - * A "Security Scheme Object": + * A "Security Scheme Object". + * * https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#securitySchemeObject + * + * @Annotation */ -class SecurityScheme extends AbstractAnnotation +abstract class AbstractSecurityScheme extends AbstractAnnotation { /** * $ref See http://json-schema.org/latest/json-schema-core.html#rfc.section.7. @@ -133,3 +135,53 @@ public function merge(array $annotations, bool $ignore = false): array return $unmerged; } } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class SecurityScheme extends AbstractSecurityScheme + { + public function __construct( + array $properties = [], + string $ref = Generator::UNDEFINED, + string $securityScheme = Generator::UNDEFINED, + string $type = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + string $name = Generator::UNDEFINED, + string $in = Generator::UNDEFINED, + string $bearerFormat = Generator::UNDEFINED, + string $scheme = Generator::UNDEFINED, + string $openIdConnectUrl = Generator::UNDEFINED, + ?array $flows = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'ref' => $ref, + 'securityScheme' => $securityScheme, + 'type' => $type, + 'description' => $description, + 'name' => $name, + 'in' => $in, + 'bearerFormat' => $bearerFormat, + 'scheme' => $scheme, + 'openIdConnectUrl' => $openIdConnectUrl, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($flows, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class SecurityScheme extends AbstractSecurityScheme + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Server.php b/src/Annotations/Server.php index 44a32b5cc..d9c03a738 100644 --- a/src/Annotations/Server.php +++ b/src/Annotations/Server.php @@ -9,11 +9,13 @@ use OpenApi\Generator; /** - * @Annotation - * A Server Object https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#server-object + * A Server Object https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#server-object. + * * An object representing a Server. + * + * @Annotation */ -class Server extends AbstractAnnotation +abstract class AbstractServer extends AbstractAnnotation { /** * A URL to the target host. This URL supports Server Variables and may be relative, @@ -79,3 +81,39 @@ class Server extends AbstractAnnotation 'description' => 'string', ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Server extends AbstractServer + { + public function __construct( + array $properties = [], + string $url = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + ?array $variables = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'url' => $url, + 'description' => $description, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($variables, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Server extends AbstractServer + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/ServerVariable.php b/src/Annotations/ServerVariable.php index aeb3139f9..dff45ea47 100644 --- a/src/Annotations/ServerVariable.php +++ b/src/Annotations/ServerVariable.php @@ -9,11 +9,13 @@ use OpenApi\Generator; /** - * @Annotation - * A Server Variable Object https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#server-variable-object + * A Server Variable Object https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.0.md#server-variable-object. + * * An object representing a Server Variable for server URL template substitution. + * + * @Annotation */ -class ServerVariable extends AbstractAnnotation +abstract class AbstractServerVariable extends AbstractAnnotation { /** * The key into Server->variables array. @@ -80,3 +82,44 @@ class ServerVariable extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class ServerVariable extends AbstractServerVariable + { + public function __construct( + array $properties = [], + string $serverVariable = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + string $default = Generator::UNDEFINED, + ?array $enum = null, + ?array $variables = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'serverVariable' => $serverVariable, + 'description' => $description, + 'default' => $default, + 'enum' => $enum ?? Generator::UNDEFINED, + 'variables' => $variables ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class ServerVariable extends AbstractServerVariable + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Tag.php b/src/Annotations/Tag.php index ac87bf21b..170f6c6ae 100644 --- a/src/Annotations/Tag.php +++ b/src/Annotations/Tag.php @@ -9,11 +9,11 @@ use OpenApi\Generator; /** - * @Annotation + * A "Tag Object": https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#tagObject. * - * A "Tag Object": https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#tagObject + * @Annotation */ -class Tag extends AbstractAnnotation +abstract class AbstractTag extends AbstractAnnotation { /** * The name of the tag. @@ -64,3 +64,39 @@ class Tag extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Tag extends AbstractTag + { + public function __construct( + array $properties = [], + string $name = Generator::UNDEFINED, + string $description = Generator::UNDEFINED, + ?ExternalDocumentation $externalDocs = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'name' => $name, + 'description' => $description, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($externalDocs, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Tag extends AbstractTag + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/Trace.php b/src/Annotations/Trace.php index aac2820b1..22882679f 100644 --- a/src/Annotations/Trace.php +++ b/src/Annotations/Trace.php @@ -9,6 +9,7 @@ /** * @Annotation */ +#[\Attribute(\Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::IS_REPEATABLE)] class Trace extends Operation { /** diff --git a/src/Annotations/Xml.php b/src/Annotations/Xml.php index edbb00b1c..12d35ce04 100644 --- a/src/Annotations/Xml.php +++ b/src/Annotations/Xml.php @@ -9,11 +9,11 @@ use OpenApi\Generator; /** - * @Annotation + * A "XML Object": https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#xmlObject. * - * A "XML Object": https://github.com/OAI/OpenAPI-Specification/blob/OpenAPI.next/versions/3.0.md#xmlObject + * @Annotation */ -class Xml extends AbstractAnnotation +abstract class AbstractXml extends AbstractAnnotation { /** * Replaces the name of the element/attribute used for the described schema property. When defined within the Items Object (items), it will affect the name of the individual XML elements within the list. When defined alongside type being array (outside the items), it will affect the wrapping element and only if wrapped is true. If wrapped is false, it will be ignored. @@ -80,3 +80,44 @@ class Xml extends AbstractAnnotation Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class Xml extends AbstractXml + { + public function __construct( + array $properties = [], + string $name = Generator::UNDEFINED, + string $namespace = Generator::UNDEFINED, + string $prefix = Generator::UNDEFINED, + ?bool $attribute = null, + ?bool $wrapped = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'name' => $name, + 'namespace' => $namespace, + 'prefix' => $prefix, + 'attribute' => $attribute ?? Generator::UNDEFINED, + 'wrapped' => $wrapped ?? Generator::UNDEFINED, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class Xml extends AbstractXml + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Annotations/XmlContent.php b/src/Annotations/XmlContent.php index 67b57d7f3..12aceb994 100644 --- a/src/Annotations/XmlContent.php +++ b/src/Annotations/XmlContent.php @@ -9,15 +9,16 @@ use OpenApi\Generator; /** - * @Annotation * Shorthand for a xml response. * * Use as an Schema inside a Response and the MediaType "application/xml" will be generated. + * + * @Annotation */ -class XmlContent extends Schema +abstract class AbstractXmlContent extends Schema { /** - * @var object + * @var Examples */ public $examples = Generator::UNDEFINED; @@ -40,3 +41,45 @@ class XmlContent extends Schema Attachable::class => ['attachables'], ]; } + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_CLASS)] + class XmlContent extends AbstractXmlContent + { + public function __construct( + array $properties = [], + string $ref = Generator::UNDEFINED, + ?array $allOf = null, + ?array $anyOf = null, + ?array $oneOf = null, + string $type = Generator::UNDEFINED, + ?Items $items = null, + ?array $x = null, + ?array $attachables = null + ) { + parent::__construct($properties + [ + 'ref' => $ref, + 'allOf' => $allOf ?? Generator::UNDEFINED, + 'anyOf' => $anyOf ?? Generator::UNDEFINED, + 'oneOf' => $oneOf ?? Generator::UNDEFINED, + 'type' => $type, + 'x' => $x ?? Generator::UNDEFINED, + 'value' => $this->combine($items, $attachables), + ]); + } + } +} else { + /** + * @Annotation + */ + class XmlContent extends AbstractXmlContent + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } +} diff --git a/src/Context.php b/src/Context.php index bbc817333..f66ee3460 100644 --- a/src/Context.php +++ b/src/Context.php @@ -6,7 +6,7 @@ namespace OpenApi; -use OpenApi\Logger\DefaultLogger; +use OpenApi\Loggers\DefaultLogger; /** * Context. @@ -42,6 +42,7 @@ * @property Annotations\AbstractAnnotation $nested * @property Annotations\AbstractAnnotation[] $annotations * @property \Psr\Log\LoggerInterface $logger Guaranteed to be set when using the `Generator` + * @property array $scanned Details of file scanner when using ReflectionAnalyser */ class Context { @@ -210,6 +211,7 @@ public function phpdocDescription() if (!$summary) { return Generator::UNDEFINED; } + if (false !== ($substr = substr($this->phpdocContent(), strlen($summary)))) { $description = trim($substr); } else { @@ -229,6 +231,10 @@ public function phpdocDescription() */ public function phpdocContent() { + if ($this->comment === Generator::UNDEFINED) { + return Generator::UNDEFINED; + } + $comment = preg_split('/(\n|\r\n)/', (string) $this->comment); $comment[0] = preg_replace('/[ \t]*\\/\*\*/', '', $comment[0]); // strip '/**' $i = count($comment) - 1; diff --git a/src/Generator.php b/src/Generator.php index bf28162f4..48592dbe1 100644 --- a/src/Generator.php +++ b/src/Generator.php @@ -6,8 +6,27 @@ namespace OpenApi; +use Doctrine\Common\Annotations\AnnotationRegistry; +use OpenApi\Analysers\AnalyserInterface; +use OpenApi\Analysers\AttributeAnnotationFactory; +use OpenApi\Analysers\DocBlockAnnotationFactory; +use OpenApi\Analysers\ReflectionAnalyser; use OpenApi\Annotations\OpenApi; -use OpenApi\Logger\DefaultLogger; +use OpenApi\Loggers\DefaultLogger; +use OpenApi\Processors\AugmentParameters; +use OpenApi\Processors\AugmentProperties; +use OpenApi\Processors\AugmentSchemas; +use OpenApi\Processors\BuildPaths; +use OpenApi\Processors\CleanUnmerged; +use OpenApi\Processors\DocBlockDescriptions; +use OpenApi\Processors\ExpandClasses; +use OpenApi\Processors\ExpandInterfaces; +use OpenApi\Processors\ExpandTraits; +use OpenApi\Processors\MergeIntoComponents; +use OpenApi\Processors\MergeIntoOpenApi; +use OpenApi\Processors\MergeJsonContent; +use OpenApi\Processors\MergeXmlContent; +use OpenApi\Processors\OperationId; use Psr\Log\LoggerInterface; /** @@ -17,21 +36,31 @@ * * This is an object oriented alternative to using the now deprecated `\OpenApi\scan()` function and * static class properties of the `Analyzer` and `Analysis` classes. - * - * The `aliases` property supersedes the `Analyser::$defaultImports`; `namespaces` maps to `Analysis::$whitelist`. */ class Generator { + /** + * Allows Annotation classes to know the context of the annotation that is being processed. + * + * @var null|Context + */ + public static $context; + /** @var string Magic value to differentiate between null and undefined. */ public const UNDEFINED = '@OA\Generator::UNDEFINED🙈'; + /** @var string[] */ + public const DEFAULT_ALIASES = ['oa' => 'OpenApi\\Annotations']; + /** @var string[] */ + public const DEFAULT_NAMESPACES = ['OpenApi\\Annotations\\']; + /** @var array Map of namespace aliases to be supported by doctrine. */ - protected $aliases = null; + protected $aliases; - /** @var array List of annotation namespaces to be autoloaded by doctrine. */ - protected $namespaces = null; + /** @var array|null List of annotation namespaces to be autoloaded by doctrine. */ + protected $namespaces; - /** @var StaticAnalyser The configured analyzer. */ + /** @var AnalyserInterface The configured analyzer. */ protected $analyser; /** @var null|callable[] List of configured processors. */ @@ -46,52 +75,81 @@ public function __construct(?LoggerInterface $logger = null) { $this->logger = $logger; + $this->setAliases(self::DEFAULT_ALIASES); + $this->setNamespaces(self::DEFAULT_NAMESPACES); + // kinda config stack to stay BC... $this->configStack = new class() { - private $defaultImports; - private $whitelist; + protected $generator; public function push(Generator $generator): void { - // save current state - $this->defaultImports = Analyser::$defaultImports; - $this->whitelist = Analyser::$whitelist; - - // update state with generator config - Analyser::$defaultImports = $generator->getAliases(); - Analyser::$whitelist = $generator->getNamespaces(); + $this->generator = $generator; + if (class_exists(AnnotationRegistry::class, true)) { + // keeping track of &this->generator allows to 'disable' the loader after we are done; + // no unload, unfortunately :/ + $gref = &$this->generator; + AnnotationRegistry::registerLoader( + function (string $class) use (&$gref): bool { + if ($gref) { + foreach ($gref->getNamespaces() as $namespace) { + if (strtolower(substr($class, 0, strlen($namespace))) === strtolower($namespace)) { + $loaded = class_exists($class); + if (!$loaded && $namespace === 'OpenApi\\Annotations\\') { + if (in_array(strtolower(substr($class, 20)), ['definition', 'path'])) { + // Detected an 2.x annotation? + throw new \Exception('The annotation @SWG\\' . substr($class, 20) . '() is deprecated. Found in ' . Generator::$context . "\nFor more information read the migration guide: https://github.com/zircote/swagger-php/blob/master/docs/Migrating-to-v3.md"); + } + } + + return $loaded; + } + } + } + + return false; + } + ); + } } public function pop(): void { - Analyser::$defaultImports = $this->defaultImports; - Analyser::$whitelist = $this->whitelist; + $this->generator = null; } }; } public function getAliases(): array { - $aliases = null !== $this->aliases ? $this->aliases : Analyser::$defaultImports; - $aliases['oa'] = 'OpenApi\\Annotations'; + return $this->aliases; + } + + public function addAlias(string $alias, string $namespace): Generator + { + $this->aliases[$alias] = $namespace; - return $aliases; + return $this; } - public function setAliases(?array $aliases): Generator + public function setAliases(array $aliases): Generator { $this->aliases = $aliases; return $this; } - public function getNamespaces(): array + public function getNamespaces(): ?array + { + return $this->namespaces; + } + + public function addNamespace(string $namespace): Generator { - $namespaces = null !== $this->namespaces ? $this->namespaces : Analyser::$whitelist; - $namespaces = false !== $namespaces ? $namespaces : []; - $namespaces[] = 'OpenApi\\Annotations\\'; + $namespaces = (array) $this->getNamespaces(); + $namespaces[] = $namespace; - return $namespaces; + return $this->setNamespaces(array_unique($namespaces)); } public function setNamespaces(?array $namespaces): Generator @@ -101,12 +159,12 @@ public function setNamespaces(?array $namespaces): Generator return $this; } - public function getAnalyser(): StaticAnalyser + public function getAnalyser(): AnalyserInterface { - return $this->analyser ?: new StaticAnalyser(); + return $this->analyser ?: new ReflectionAnalyser([new DocBlockAnnotationFactory(), new AttributeAnnotationFactory()]); } - public function setAnalyser(?StaticAnalyser $analyser): Generator + public function setAnalyser(?AnalyserInterface $analyser): Generator { $this->analyser = $analyser; @@ -118,7 +176,26 @@ public function setAnalyser(?StaticAnalyser $analyser): Generator */ public function getProcessors(): array { - return null !== $this->processors ? $this->processors : Analysis::processors(); + if (null === $this->processors) { + $this->processors = [ + new DocBlockDescriptions(), + new MergeIntoOpenApi(), + new MergeIntoComponents(), + new ExpandClasses(), + new ExpandInterfaces(), + new ExpandTraits(), + new AugmentSchemas(), + new AugmentProperties(), + new BuildPaths(), + new AugmentParameters(), + new MergeJsonContent(), + new MergeXmlContent(), + new OperationId(), + new CleanUnmerged(), + ]; + } + + return $this->processors; } /** @@ -158,19 +235,17 @@ public function removeProcessor(callable $processor, bool $silent = false): Gene /** * Update/replace an existing processor with a new one. * - * @param callable $processor the new processor + * @param callable $processor The new processor * @param null|callable $matcher Optional matcher callable to identify the processor to replace. * If none given, matching is based on the processors class. */ public function updateProcessor(callable $processor, ?callable $matcher = null): Generator { - if (!$matcher) { - $matcher = $matcher ?: function ($other) use ($processor) { - $otherClass = get_class($other); + $matcher = $matcher ?: function ($other) use ($processor) { + $otherClass = get_class($other); - return $processor instanceof $otherClass; - }; - } + return $processor instanceof $otherClass; + }; $processors = array_map(function ($other) use ($processor, $matcher) { return $matcher($other) ? $processor : $other; @@ -185,29 +260,12 @@ public function getLogger(): ?LoggerInterface return $this->logger ?: new DefaultLogger(); } - /** - * Static wrapper around `Generator::generate()`. - * - * @param iterable $sources PHP source files to scan. - * Supported sources: - * * string - * * \SplFileInfo - * * \Symfony\Component\Finder\Finder - * @param array $options - * aliases: null|array Defaults to `Analyser::$defaultImports`. - * namespaces: null|array Defaults to `Analyser::$whitelist`. - * analyser: null|StaticAnalyser Defaults to a new `StaticAnalyser`. - * analysis: null|Analysis Defaults to a new `Analysis`. - * processors: null|array Defaults to `Analysis::processors()`. - * logger: null|\Psr\Log\LoggerInterface If not set logging will use \OpenApi\Logger as before. - * validate: bool Defaults to `true`. - */ - public static function scan(iterable $sources, array $options = []): OpenApi + public static function scan(iterable $sources, array $options = []): ?OpenApi { // merge with defaults $config = $options + [ - 'aliases' => null, - 'namespaces' => null, + 'aliases' => self::DEFAULT_ALIASES, + 'namespaces' => self::DEFAULT_NAMESPACES, 'analyser' => null, 'analysis' => null, 'processors' => null, @@ -223,6 +281,27 @@ public static function scan(iterable $sources, array $options = []): OpenApi ->generate($sources, $config['analysis'], $config['validate']); } + /** + * Run code in the context of this generator. + * + * @param callable $callable Callable in the form of + * `function(Generator $generator, Analysis $analysis, Context $context): mixed` + * + * @return mixed the result of the `callable` + */ + public function withContext(callable $callable) + { + $rootContext = new Context(['logger' => $this->getLogger()]); + $analysis = new Analysis([], $rootContext); + + $this->configStack->push($this); + try { + return $callable($this, $analysis, $rootContext); + } finally { + $this->configStack->pop(); + } + } + /** * Generate OpenAPI spec by scanning the given source files. * @@ -234,7 +313,7 @@ public static function scan(iterable $sources, array $options = []): OpenApi * @param null|Analysis $analysis custom analysis instance * @param bool $validate flag to enable/disable validation of the returned spec */ - public function generate(iterable $sources, ?Analysis $analysis = null, bool $validate = true): OpenApi + public function generate(iterable $sources, ?Analysis $analysis = null, bool $validate = true): ?OpenApi { $rootContext = new Context(['logger' => $this->getLogger()]); $analysis = $analysis ?: new Analysis([], $rootContext); @@ -260,6 +339,8 @@ public function generate(iterable $sources, ?Analysis $analysis = null, bool $va protected function scanSources(iterable $sources, Analysis $analysis, Context $rootContext): void { $analyser = $this->getAnalyser(); + $analyser->setGenerator($this); + foreach ($sources as $source) { if (is_iterable($source)) { $this->scanSources($source, $analysis, $rootContext); diff --git a/src/Logger.php b/src/Logger.php deleted file mode 100644 index eaf882988..000000000 --- a/src/Logger.php +++ /dev/null @@ -1,84 +0,0 @@ -log = function ($entry, $type) { - if ($entry instanceof Exception) { - $entry = $entry->getMessage(); - } - trigger_error($entry, $type); - }; - } - - public static function getInstance(): Logger - { - if (self::$instance === null) { - self::$instance = new Logger(); - } - - return self::$instance; - } - - /** - * Log a OpenApi warning. - * - * @param Exception|string $entry - */ - public static function warning($entry): void - { - call_user_func(self::getInstance()->log, $entry, E_USER_WARNING); - } - - /** - * Log a OpenApi notice. - * - * @param Exception|string $entry - */ - public static function notice($entry): void - { - call_user_func(self::getInstance()->log, $entry, E_USER_NOTICE); - } - - /** - * Shorten class name(s). - * - * @param array|object|string $classes Class(es) to shorten - * - * @return string|string[] One or more shortened class names - */ - public static function shorten($classes) - { - return Util::shorten($classes); - } -} diff --git a/src/Logger/ConsoleLogger.php b/src/Loggers/ConsoleLogger.php similarity index 97% rename from src/Logger/ConsoleLogger.php rename to src/Loggers/ConsoleLogger.php index cc43929b4..4aab27595 100644 --- a/src/Logger/ConsoleLogger.php +++ b/src/Loggers/ConsoleLogger.php @@ -1,6 +1,10 @@ getMessage(); + } + if (in_array($level, [LogLevel::NOTICE, LogLevel::INFO, LogLevel::DEBUG])) { - Logger::notice($message); + $error_level = E_USER_NOTICE; } else { - Logger::warning($message); + $error_level = E_USER_WARNING; } + + trigger_error($message, $error_level); } } diff --git a/src/Processors/AugmentProperties.php b/src/Processors/AugmentProperties.php index 72d74525a..fe2137a95 100644 --- a/src/Processors/AugmentProperties.php +++ b/src/Processors/AugmentProperties.php @@ -48,8 +48,8 @@ public function __invoke(Analysis $analysis) if ($analysis->openapi->components !== Generator::UNDEFINED && $analysis->openapi->components->schemas !== Generator::UNDEFINED) { foreach ($analysis->openapi->components->schemas as $schema) { if ($schema->schema !== Generator::UNDEFINED) { - $refs[strtolower($schema->_context->fullyQualifiedName($schema->_context->class))] - = Components::SCHEMA_REF . Util::refEncode($schema->schema); + $refKey = $this->toRefKey($schema->_context, $schema->_context->class); + $refs[$refKey] = Components::SCHEMA_REF . Util::refEncode($schema->schema); } } } @@ -59,92 +59,109 @@ public function __invoke(Analysis $analysis) foreach ($properties as $property) { $context = $property->_context; - // Use the property names for @OA\Property() + if ($property->property === Generator::UNDEFINED) { $property->property = $context->property; } + if ($property->ref !== Generator::UNDEFINED) { continue; } + $comment = str_replace("\r\n", "\n", (string) $context->comment); - if ($property->type === Generator::UNDEFINED && $context->type && $context->type !== Generator::UNDEFINED) { - if ($context->nullable === true) { - $property->nullable = true; - } - $type = strtolower($context->type); - if (isset(self::$types[$type])) { - $this->applyType($property, static::$types[$type]); - } else { - $key = strtolower($context->fullyQualifiedName($type)); - if ($property->ref === Generator::UNDEFINED && array_key_exists($key, $refs)) { - $this->applyRef($property, $refs[$key]); - continue; - } - } - } elseif (preg_match('/@var\s+(?[^\s]+)([ \t])?(?.+)?$/im', $comment, $varMatches)) { - if ($property->type === Generator::UNDEFINED) { - $allTypes = trim($varMatches['type']); - $isNullable = $this->isNullable($allTypes); - $allTypes = $this->stripNull($allTypes); - preg_match('/^([^\[]+)(.*$)/', trim($allTypes), $typeMatches); - $type = $typeMatches[1]; - - if (array_key_exists(strtolower($type), static::$types) === false) { - $key = strtolower($context->fullyQualifiedName($type)); - if ($property->ref === Generator::UNDEFINED && $typeMatches[2] === '' && array_key_exists($key, $refs)) { - if ($isNullable) { - $property->oneOf = [ - new Schema([ - '_context' => $property->_context, - 'ref' => $refs[$key], - ]), - ]; - $property->nullable = true; - } else { - $property->ref = $refs[$key]; - } - continue; - } - } else { - $type = static::$types[strtolower($type)]; - if (is_array($type)) { - if ($property->format === Generator::UNDEFINED) { - $property->format = $type[1]; - } - $type = $type[0]; - } - $property->type = $type; - } - if ($typeMatches[2] === '[]') { - if ($property->items === Generator::UNDEFINED) { - $property->items = new Items( - [ - 'type' => $property->type, - '_context' => new Context(['generated' => true], $context), - ] - ); - if ($property->items->type === Generator::UNDEFINED) { - $key = strtolower($context->fullyQualifiedName($type)); - $property->items->ref = array_key_exists($key, $refs) ? $refs[$key] : null; - } - } - $property->type = 'array'; - } - if ($isNullable && $property->nullable === Generator::UNDEFINED) { - $property->nullable = true; - } - } - if ($property->description === Generator::UNDEFINED && isset($varMatches['description'])) { - $property->description = trim($varMatches['description']); - } + preg_match('/@var\s+(?[^\s]+)([ \t])?(?.+)?$/im', $comment, $varMatches); + + if ($property->type === Generator::UNDEFINED) { + $this->augmentType($property, $context, $refs, $varMatches); + } + + if ($property->description === Generator::UNDEFINED && isset($varMatches['description'])) { + $property->description = trim($varMatches['description']); + } + if ($property->description === Generator::UNDEFINED && $property->isRoot()) { + $property->description = $context->phpdocContent(); } if ($property->example === Generator::UNDEFINED && preg_match('/@example\s+([ \t])?(?.+)?$/im', $comment, $varMatches)) { $property->example = $varMatches['example']; } + } + } - if ($property->description === Generator::UNDEFINED && $property->isRoot()) { - $property->description = $context->phpdocContent(); + protected function toRefKey(Context $context, $name) + { + $fqn = strtolower($context->fullyQualifiedName($name)); + + return ltrim($fqn, '\\'); + } + + protected function augmentType(Property $property, Context $context, array $refs, array $varMatches) + { + // docblock typehints + if (isset($varMatches['type'])) { + $allTypes = strtolower(trim($varMatches['type'])); + + if ($this->isNullable($allTypes) && $property->nullable === Generator::UNDEFINED) { + $property->nullable = true; + } + + $allTypes = $this->stripNull($allTypes); + preg_match('/^([^\[]+)(.*$)/', $allTypes, $typeMatches); + $type = $typeMatches[1]; + + // finalise property type/ref + if (array_key_exists($type, static::$types)) { + $this->applyType($property, static::$types[$type]); + } else { + $refKey = $this->toRefKey($context, $type); + if ($property->ref === Generator::UNDEFINED && array_key_exists($refKey, $refs)) { + $property->ref = $refs[$refKey]; + } + } + + // ok, so we possibly have a type or ref + if ($property->ref !== Generator::UNDEFINED && $typeMatches[2] === '' && $property->nullable) { + $refKey = $this->toRefKey($context, $type); + $property->oneOf = [ + new Schema([ + '_context' => $property->_context, + 'ref' => $refs[$refKey], + ]), + ]; + $property->nullable = true; + } elseif ($typeMatches[2] === '[]') { + if ($property->items === Generator::UNDEFINED) { + $property->items = new Items( + [ + 'type' => $property->type, + '_context' => new Context(['generated' => true], $context), + ] + ); + if ($property->ref !== Generator::UNDEFINED) { + $property->items->ref = $property->ref; + $property->ref = Generator::UNDEFINED; + } + $property->type = 'array'; + } + } + } + + // native typehints + if ($context->type && $context->type !== Generator::UNDEFINED) { + if ($context->nullable === true) { + $property->nullable = true; + } + $type = strtolower($context->type); + if (isset(self::$types[$type])) { + $this->applyType($property, static::$types[$type]); + } else { + $refKey = $this->toRefKey($context, $type); + if ($property->ref === Generator::UNDEFINED && array_key_exists($refKey, $refs)) { + $this->applyRef($property, $refs[$refKey]); + + // cannot get more specific + return; + } } } } diff --git a/src/Processors/BuildPaths.php b/src/Processors/BuildPaths.php index ea003e09c..c11591853 100644 --- a/src/Processors/BuildPaths.php +++ b/src/Processors/BuildPaths.php @@ -54,7 +54,7 @@ public function __invoke(Analysis $analysis) } } } - if (count($paths)) { + if ($paths) { $analysis->openapi->paths = array_values($paths); } } diff --git a/src/Processors/DocBlockDescriptions.php b/src/Processors/DocBlockDescriptions.php index 1021ac7c6..a2d6b0847 100644 --- a/src/Processors/DocBlockDescriptions.php +++ b/src/Processors/DocBlockDescriptions.php @@ -32,7 +32,6 @@ public function __invoke(Analysis $analysis) // only annotations with context continue; } - $count = count($annotation->_context->annotations); if (!$annotation->isRoot()) { // only top-level annotations continue; diff --git a/src/Processors/ExpandInterfaces.php b/src/Processors/ExpandInterfaces.php index 19c1c5e2b..0074cac9e 100644 --- a/src/Processors/ExpandInterfaces.php +++ b/src/Processors/ExpandInterfaces.php @@ -26,10 +26,12 @@ public function __invoke(Analysis $analysis) foreach ($schemas as $schema) { if ($schema->_context->is('class')) { - $interfaces = $analysis->getInterfacesOfClass($schema->_context->fullyQualifiedName($schema->_context->class), true); + $className = $schema->_context->fullyQualifiedName($schema->_context->class); + $interfaces = $analysis->getInterfacesOfClass($className, true); $existing = []; foreach ($interfaces as $interface) { - $interfaceSchema = $analysis->getSchemaForSource($interface['context']->fullyQualifiedName($interface['interface'])); + $interfaceName = $interface['context']->fullyQualifiedName($interface['interface']); + $interfaceSchema = $analysis->getSchemaForSource($interfaceName); if ($interfaceSchema) { $refPath = $interfaceSchema->schema !== Generator::UNDEFINED ? $interfaceSchema->schema : $interface['interface']; $this->inheritFrom($schema, $interfaceSchema, $refPath, $interface['context']); diff --git a/src/Processors/InheritProperties.php b/src/Processors/InheritProperties.php deleted file mode 100644 index fe06aefe4..000000000 --- a/src/Processors/InheritProperties.php +++ /dev/null @@ -1,115 +0,0 @@ -getAnnotationsOfType(Schema::class, true); - $processed = []; - - foreach ($schemas as $schema) { - if ($schema->_context->is('class')) { - if (in_array($schema->_context, $processed, true)) { - // we should process only first schema in the same context - continue; - } - - $processed[] = $schema->_context; - - $existing = []; - if (is_array($schema->properties) || $schema->properties instanceof Traversable) { - foreach ($schema->properties as $property) { - if ($property->property) { - $existing[] = $property->property; - } - } - } - $classes = $analysis->getSuperClasses($schema->_context->fullyQualifiedName($schema->_context->class)); - foreach ($classes as $class) { - if ($class['context']->annotations) { - foreach ($class['context']->annotations as $annotation) { - if ($annotation instanceof Schema && $annotation->schema !== Generator::UNDEFINED) { - $this->inherit($schema, $annotation); - - continue 2; - } - } - } - - foreach ($class['properties'] as $property) { - if (is_array($property->annotations) === false && !($property->annotations instanceof Traversable)) { - continue; - } - foreach ($property->annotations as $annotation) { - if ($annotation instanceof Property && in_array($annotation->property, $existing) === false) { - $existing[] = $annotation->property; - $schema->merge([$annotation], true); - } - } - } - } - } - } - } - - /** - * Add schema to child schema allOf property. - */ - private function inherit(Schema $to, Schema $from): void - { - if ($to->allOf === Generator::UNDEFINED) { - // Move all properties into an `allOf` entry except the `schema` property. - $clone = new Schema(['_context' => new Context(['generated' => true], $to->_context)]); - $clone->mergeProperties($to); - $hasProperties = false; - $defaultValues = get_class_vars(Schema::class); - foreach (array_keys(get_object_vars($clone)) as $property) { - if (in_array($property, ['schema', 'title', 'description'])) { - $clone->$property = Generator::UNDEFINED; - continue; - } - if ($to->$property !== $defaultValues[$property]) { - $hasProperties = true; - } - $to->$property = $defaultValues[$property]; - } - $to->allOf = []; - if ($hasProperties) { - $to->allOf[] = $clone; - } - } - $append = true; - foreach ($to->allOf as $entry) { - if ($entry->ref !== Generator::UNDEFINED && $entry->ref === Components::SCHEMA_REF . Util::refEncode($from->schema)) { - $append = false; // ref was already specified manually - } - } - if ($append) { - array_unshift($to->allOf, new Schema([ - 'ref' => Components::SCHEMA_REF . Util::refEncode($from->schema), - '_context' => new Context(['generated' => true], $from->_context), - ])); - } - } -} diff --git a/src/Processors/InheritTraits.php b/src/Processors/InheritTraits.php deleted file mode 100644 index a3a49d6cc..000000000 --- a/src/Processors/InheritTraits.php +++ /dev/null @@ -1,45 +0,0 @@ -getAnnotationsOfType(Schema::class); - - foreach ($schemas as $schema) { - if ($schema->_context->is('class') || $schema->_context->is('trait')) { - $source = $schema->_context->class ?: $schema->_context->trait; - $traits = $analysis->getTraitsOfClass($schema->_context->fullyQualifiedName($source), true); - foreach ($traits as $trait) { - $traitSchema = $analysis->getSchemaForSource($trait['context']->fullyQualifiedName($trait['trait'])); - if ($traitSchema) { - $refPath = $traitSchema->schema !== Generator::UNDEFINED ? $traitSchema->schema : $trait['trait']; - if ($schema->allOf === Generator::UNDEFINED) { - $schema->allOf = []; - } - $schema->allOf[] = new Schema([ - '_context' => $trait['context']->_context, - 'ref' => Components::SCHEMA_REF . Util::refEncode($refPath), - ]); - } - } - } - } - } -} diff --git a/src/Processors/MergeTrait.php b/src/Processors/MergeTrait.php index 726e7b149..8706d34f5 100644 --- a/src/Processors/MergeTrait.php +++ b/src/Processors/MergeTrait.php @@ -24,7 +24,7 @@ */ trait MergeTrait { - protected function inheritFrom(Schema $schema, Schema $from, string $refPath, ?Context $context): void + protected function inheritFrom(Schema $schema, Schema $from, string $refPath, Context $context): void { if ($schema->allOf === Generator::UNDEFINED) { $schema->allOf = []; diff --git a/src/Processors/MergeTraits.php b/src/Processors/MergeTraits.php deleted file mode 100644 index 58484e170..000000000 --- a/src/Processors/MergeTraits.php +++ /dev/null @@ -1,63 +0,0 @@ -getAnnotationsOfType(Schema::class); - - foreach ($schemas as $schema) { - if ($schema->_context->is('class')) { - $existing = []; - $traits = $analysis->getTraitsOfClass($schema->_context->fullyQualifiedName($schema->_context->class)); - foreach ($traits as $trait) { - if (is_iterable($trait['context']->annotations)) { - foreach ($trait['context']->annotations as $annotation) { - if ($annotation instanceof Property && !in_array($annotation->_context->property, $existing)) { - $existing[] = $annotation->_context->property; - $schema->merge([$annotation], true); - } - } - } - - foreach ($trait['properties'] as $method) { - if (is_array($method->annotations) || $method->annotations instanceof Traversable) { - foreach ($method->annotations as $annotation) { - if ($annotation instanceof Property && !in_array($annotation->_context->property, $existing)) { - $existing[] = $annotation->_context->property; - $schema->merge([$annotation], true); - } - } - } - } - - foreach ($trait['methods'] as $method) { - if (is_array($method->annotations) || $method->annotations instanceof Traversable) { - foreach ($method->annotations as $annotation) { - if ($annotation instanceof Property && !in_array($annotation->_context->property, $existing)) { - $existing[] = $annotation->_context->property; - $schema->merge([$annotation], true); - } - } - } - } - } - } - } - } -} diff --git a/src/Serializer.php b/src/Serializer.php index 7a09ad275..0b3418e7a 100644 --- a/src/Serializer.php +++ b/src/Serializer.php @@ -38,6 +38,7 @@ class Serializer OA\Operation::class, OA\Options::class, OA\Parameter::class, + OA\PathParameter::class, OA\Patch::class, OA\PathItem::class, OA\Post::class, diff --git a/src/functions.php b/src/functions.php deleted file mode 100644 index 93afa61c8..000000000 --- a/src/functions.php +++ /dev/null @@ -1,58 +0,0 @@ -parseComment('@OA\Parameter(description="This is my parameter")'); - $this->assertIsArray($annotations); - $parameter = $annotations[0]; - $this->assertInstanceOf('OpenApi\Annotations\Parameter', $parameter); - $this->assertSame('This is my parameter', $parameter->description); - } - - public function testDeprecatedAnnotationWarning() - { - $this->assertOpenApiLogEntryContains('The annotation @SWG\Definition() is deprecated.'); - $this->parseComment('@SWG\Definition()'); - } -} diff --git a/tests/Analysers/ComposerAutoloaderScannerTest.php b/tests/Analysers/ComposerAutoloaderScannerTest.php new file mode 100644 index 000000000..745953b46 --- /dev/null +++ b/tests/Analysers/ComposerAutoloaderScannerTest.php @@ -0,0 +1,38 @@ + __FILE__, + 'OpenApi\Tests\\Scanners\\Bar' => __FILE__, + 'Other\\Duh' => __FILE__, + ]; + $mockClassloader = new ClassLoader(); + $mockClassloader->addClassMap($classMap); + spl_autoload_register([$mockClassloader, 'findFile'], true, true); + } + + public function testComposerClassloader() + { + $expected = [ + 'OpenApi\Tests\\Scanners\\Foo', + 'OpenApi\Tests\\Scanners\\Bar', + ]; + $result = (new ComposerAutoloaderScanner())->scan(['OpenApi\Tests']); + $this->assertEquals($expected, $result); + } +} diff --git a/tests/Analysers/DocBlockParserTest.php b/tests/Analysers/DocBlockParserTest.php new file mode 100644 index 000000000..28b4e4e46 --- /dev/null +++ b/tests/Analysers/DocBlockParserTest.php @@ -0,0 +1,39 @@ + 'OpenApi\Annotations']; + + public function testParseContents() + { + $annotations = $this->annotationsFromDocBlockParser('@OA\Parameter(description="This is my parameter")', self::SWG_ALIAS); + $this->assertIsArray($annotations); + $parameter = $annotations[0]; + $this->assertInstanceOf('OpenApi\Annotations\Parameter', $parameter); + $this->assertSame('This is my parameter', $parameter->description); + } + + public function testDeprecatedAnnotationWarning() + { + $this->assertOpenApiLogEntryContains('The annotation @SWG\Definition() is deprecated.'); + $this->annotationsFromDocBlockParser('@SWG\Definition()', self::SWG_ALIAS); + } + + public function testExtraAliases() + { + $extraAliases = [ + 'contact' => 'OpenApi\Annotations\Contact', // use OpenApi\Annotations\Contact; + 'ctest' => 'OpenApi\Tests\ConstantsTesT', // use OpenApi\Tests\ConstantsTesT as CTest; + ]; + $annotations = $this->annotationsFromDocBlockParser('@Contact(url=CTest::URL)', $extraAliases); + $this->assertSame('http://example.com', $annotations[0]->url); + } +} diff --git a/tests/Analysers/ReflectionAnalyserTest.php b/tests/Analysers/ReflectionAnalyserTest.php new file mode 100644 index 000000000..e2c1435bf --- /dev/null +++ b/tests/Analysers/ReflectionAnalyserTest.php @@ -0,0 +1,165 @@ +reflectors[$reflector->name] = $reflector; + + return []; + } + + public function setGenerator(Generator $generator): void + { + // noop + } + }; + } + + public function testClassInheritance() + { + $analyser = new ReflectionAnalyser([$annotationFactory = $this->collectingAnnotationFactory()]); + $analyser->fromFqdn(ExtendsClass::class, new Analysis([], $this->getContext())); + + $expected = [ + 'OpenApi\Tests\Fixtures\PHP\Inheritance\ExtendsClass', + 'extendsClassFunc', + 'extendsClassProp', + ]; + $this->assertEquals($expected, array_keys($annotationFactory->reflectors)); + } + + public function testTraitInheritance() + { + $analyser = new ReflectionAnalyser([$annotationFactory = $this->collectingAnnotationFactory()]); + $analyser->fromFqdn(ExtendsTrait::class, new Analysis([], $this->getContext())); + + $expected = [ + 'OpenApi\Tests\Fixtures\PHP\Inheritance\ExtendsTrait', + 'extendsTraitFunc', + 'extendsTraitProp', + ]; + $this->assertEquals($expected, array_keys($annotationFactory->reflectors)); + } + + public function analysers() + { + return [ + 'docblocks-attributes' => [new ReflectionAnalyser([new DocBlockAnnotationFactory(), new AttributeAnnotationFactory()])], + 'attributes-docblocks' => [new ReflectionAnalyser([new AttributeAnnotationFactory(),new DocBlockAnnotationFactory()])], + ]; + } + + /** + * @dataProvider analysers + */ + public function testApiDocBlockBasic(AnalyserInterface $analyser) + { + $analysis = (new Generator()) + ->withContext(function (Generator $generator) use ($analyser) { + $analyser->setGenerator($generator); + $analysis = $analyser->fromFile($this->fixture('Apis/DocBlocks/basic.php'), $this->getContext()); + $analysis->process($generator->getProcessors()); + + return $analysis; + }); + + $operations = $analysis->getAnnotationsOfType(Operation::class); + $this->assertIsArray($operations); + + $spec = $this->fixture('Apis/basic.yaml'); + //file_put_contents($spec, $analysis->openapi->toYaml()); + $this->assertTrue($analysis->validate()); + $this->assertSpecEquals($analysis->openapi, file_get_contents($spec)); + } + + /** + * @dataProvider analysers + * @requires PHP 8.1 + */ + public function testApiAttributesBasic(AnalyserInterface $analyser) + { + /** @var Analysis $analysis */ + $analysis = (new Generator()) + ->addAlias('oaf', 'OpenApi\\Tests\\Annotations') + ->addNamespace('OpenApi\\Tests\\Annotations\\') + ->withContext(function (Generator $generator) use ($analyser) { + $analyser->setGenerator($generator); + $analysis = $analyser->fromFile($this->fixture('Apis/Attributes/basic.php'), $this->getContext()); + $analysis->process((new Generator())->getProcessors()); + + return $analysis; + }); + + $operations = $analysis->getAnnotationsOfType(Operation::class); + $this->assertIsArray($operations); + + $spec = $this->fixture('Apis/basic.yaml'); + file_put_contents($spec, $analysis->openapi->toYaml()); + $this->assertTrue($analysis->validate()); + $this->assertSpecEquals($analysis->openapi, file_get_contents($spec)); + + // check CustomAttachable is only attached to @OA\Get + /** @var Get[] $gets */ + $gets = $analysis->getAnnotationsOfType(Get::class, true); + $this->assertCount(1, $gets); + $this->assertTrue(is_array($gets[0]->attachables), 'Attachables not set'); + $this->assertCount(1, $gets[0]->attachables); + + /** @var Response[] $responses */ + $responses = $analysis->getAnnotationsOfType(Response::class, true); + foreach ($responses as $response) { + $this->assertEquals(Generator::UNDEFINED, $response->attachables); + } + } + + /** + * @dataProvider analysers + * @requires PHP 8.1 + */ + public function testApiMixedBasic(AnalyserInterface $analyser) + { + $analysis = (new Generator()) + ->withContext(function (Generator $generator) use ($analyser) { + $analyser->setGenerator($generator); + $analysis = $analyser->fromFile($this->fixture('Apis/Mixed/basic.php'), $this->getContext()); + $analysis->process((new Generator())->getProcessors()); + + return $analysis; + }); + + $operations = $analysis->getAnnotationsOfType(Operation::class); + $this->assertIsArray($operations); + + $spec = $this->fixture('Apis/basic.yaml'); + //file_put_contents($spec, $analysis->openapi->toYaml()); + $this->assertTrue($analysis->validate()); + $this->assertSpecEquals($analysis->openapi, file_get_contents($spec)); + } +} diff --git a/tests/StaticAnalyserTest.php b/tests/Analysers/TokenAnalyserTest.php similarity index 53% rename from tests/StaticAnalyserTest.php rename to tests/Analysers/TokenAnalyserTest.php index 683ee3e70..e8d0c718b 100644 --- a/tests/StaticAnalyserTest.php +++ b/tests/Analysers/TokenAnalyserTest.php @@ -4,17 +4,26 @@ * @license Apache 2.0 */ -namespace OpenApi\Tests; +namespace OpenApi\Tests\Analysers; -use OpenApi\Analyser; +use OpenApi\Analysis; use OpenApi\Annotations\Property; use OpenApi\Annotations\Schema; use OpenApi\Generator; -use OpenApi\StaticAnalyser; +use OpenApi\Analysers\TokenAnalyser; use OpenApi\Tests\Fixtures\Parser\User; +use OpenApi\Tests\OpenApiTestCase; -class StaticAnalyserTest extends OpenApiTestCase +class TokenAnalyserTest extends OpenApiTestCase { + protected function analysisFromCode(string $code): Analysis + { + $analyser = new TokenAnalyser(); + $analyser->setGenerator(new Generator()); + + return $analyser->fromCode('getContext()); + } + public function singleDefinitionCases() { return [ @@ -22,9 +31,9 @@ public function singleDefinitionCases() 'global-interface' => ['interface AInterface {}', '\AInterface', 'AInterface', 'interfaces', 'interface'], 'global-trait' => ['trait ATrait {}', '\ATrait', 'ATrait', 'traits', 'trait'], - 'namespaced-class' => ['namespace Foo; class AClass {}', '\Foo\AClass', 'AClass', 'classes', 'class'], - 'namespaced-interface' => ['namespace Foo; interface AInterface {}', '\Foo\AInterface', 'AInterface', 'interfaces', 'interface'], - 'namespaced-trait' => ['namespace Foo; trait ATrait {}', '\Foo\ATrait', 'ATrait', 'traits', 'trait'], + 'namespaced-class' => ['namespace SNS\Foo; class AClass {}', '\SNS\Foo\AClass', 'AClass', 'classes', 'class'], + 'namespaced-interface' => ['namespace SNS\Foo; interface AInterface {}', '\SNS\Foo\AInterface', 'AInterface', 'interfaces', 'interface'], + 'namespaced-trait' => ['namespace SNS\Foo; trait ATrait {}', '\SNS\Foo\ATrait', 'ATrait', 'traits', 'trait'], ]; } @@ -46,23 +55,23 @@ public function testSingleDefinition($code, $fqdn, $name, $type, $typeKey) public function extendsDefinitionCases() { return [ - 'global-class' => ['class AClass extends Other {}', '\AClass', 'AClass', '\Other', 'classes', 'class'], - 'namespaced-class' => ['namespace Foo; class AClass extends \Other {}', '\Foo\AClass', 'AClass', '\Other', 'classes', 'class'], - 'global-class-explicit' => ['class AClass extends \Bar\Other {}', '\AClass', 'AClass', '\Bar\Other', 'classes', 'class'], - 'namespaced-class-explicit' => ['namespace Foo; class AClass extends \Bar\Other {}', '\Foo\AClass', 'AClass', '\Bar\Other', 'classes', 'class'], - 'global-class-use' => ['use Bar\Other; class AClass extends Other {}', '\AClass', 'AClass', '\Bar\Other', 'classes', 'class'], - 'namespaced-class-use' => ['namespace Foo; use Bar\Other; class AClass extends Other {}', '\Foo\AClass', 'AClass', '\Bar\Other', 'classes', 'class'], - 'namespaced-class-as' => ['namespace Foo; use Bar\Some as Other; class AClass extends Other {}', '\Foo\AClass', 'AClass', '\Bar\Some', 'classes', 'class'], - 'namespaced-class-same' => ['namespace Foo; class AClass extends Other {}', '\Foo\AClass', 'AClass', '\Foo\Other', 'classes', 'class'], - - 'global-interface' => ['interface AInterface extends Other {}', '\AInterface', 'AInterface', ['\Other'], 'interfaces', 'interface'], - 'namespaced-interface' => ['namespace Foo; interface AInterface extends \Other {}', '\Foo\AInterface', 'AInterface', ['\Other'], 'interfaces', 'interface'], - 'global-interface-explicit' => ['interface AInterface extends \Bar\Other {}', '\AInterface', 'AInterface', ['\Bar\Other'], 'interfaces', 'interface'], - 'namespaced-interface-explicit' => ['namespace Foo; interface AInterface extends \Bar\Other {}', '\Foo\AInterface', 'AInterface', ['\Bar\Other'], 'interfaces', 'interface'], - 'global-interface-use' => ['use Bar\Other; interface AInterface extends Other {}', '\AInterface', 'AInterface', ['\Bar\Other'], 'interfaces', 'interface'], - 'namespaced-interface-use' => ['namespace Foo; use Bar\Other; interface AInterface extends Other {}', '\Foo\AInterface', 'AInterface', ['\Bar\Other'], 'interfaces', 'interface'], - 'namespaced-interface-use-multi' => ['namespace Foo; use Bar\Other; interface AInterface extends Other, \More {}', '\Foo\AInterface', 'AInterface', ['\Bar\Other', '\More'], 'interfaces', 'interface'], - 'namespaced-interface-as' => ['namespace Foo; use Bar\Some as Other; interface AInterface extends Other {}', '\Foo\AInterface', 'AInterface', ['\Bar\Some'], 'interfaces', 'interface'], + 'global-class' => ['class BClass extends Other {}', '\BClass', 'BClass', '\Other', 'classes', 'class'], + 'namespaced-class' => ['namespace NC\Foo; class BClass extends \Other {}', '\NC\Foo\BClass', 'BClass', '\Other', 'classes', 'class'], + 'global-class-explicit' => ['class EClass extends \Bar\Other {}', '\EClass', 'EClass', '\Bar\Other', 'classes', 'class'], + 'namespaced-class-explicit' => ['namespace NCE\Foo; class AClass extends \Bar\Other {}', '\NCE\Foo\AClass', 'AClass', '\Bar\Other', 'classes', 'class'], + 'global-class-use' => ['use XBar\Other; class XClass extends Other {}', '\XClass', 'XClass', '\XBar\Other', 'classes', 'class'], + 'namespaced-class-use' => ['namespace NCU\Foo; use YBar\Other; class AClass extends Other {}', '\NCU\Foo\AClass', 'AClass', '\YBar\Other', 'classes', 'class'], + 'namespaced-class-as' => ['namespace NCA\Foo; use Bar\Some as Other; class AClass extends Other {}', '\NCA\Foo\AClass', 'AClass', '\Bar\Some', 'classes', 'class'], + 'namespaced-class-same' => ['namespace NCS\Foo; class AClass extends Other {}', '\NCS\Foo\AClass', 'AClass', '\NCS\Foo\Other', 'classes', 'class'], + + 'global-interface' => ['interface BInterface extends Other {}', '\BInterface', 'BInterface', ['\Other'], 'interfaces', 'interface'], + 'namespaced-interface' => ['namespace NI\Foo; interface AInterface extends \Other {}', '\NI\Foo\AInterface', 'AInterface', ['\Other'], 'interfaces', 'interface'], + 'global-interface-explicit' => ['interface XInterface extends \ZBar\Other {}', '\XInterface', 'XInterface', ['\ZBar\Other'], 'interfaces', 'interface'], + 'namespaced-interface-explicit' => ['namespace NIE\Foo; interface AInterface extends \ABar\Other {}', '\NIE\Foo\AInterface', 'AInterface', ['\ABar\Other'], 'interfaces', 'interface'], + 'global-interface-use' => ['use BBar\Other; interface YInterface extends Other {}', '\YInterface', 'YInterface', ['\BBar\Other'], 'interfaces', 'interface'], + 'namespaced-interface-use' => ['namespace NIU\Foo; use EBar\Other; interface AInterface extends Other {}', '\NIU\Foo\AInterface', 'AInterface', ['\EBar\Other'], 'interfaces', 'interface'], + 'namespaced-interface-use-multi' => ['namespace NIUM\Foo; use FBar\Other; interface AInterface extends Other, \More {}', '\NIUM\Foo\AInterface', 'AInterface', ['\FBar\Other', '\More'], 'interfaces', 'interface'], + 'namespaced-interface-as' => ['namespace NIA\Foo; use Bar\Some as Other; interface AInterface extends Other {}', '\NIA\Foo\AInterface', 'AInterface', ['\Bar\Some'], 'interfaces', 'interface'], ]; } @@ -82,17 +91,17 @@ public function testExtendsDefinition($code, $fqdn, $name, $extends, $type, $typ public function usesDefinitionCases() { return [ - 'global-class-use' => ['class AClass { use Other; }', '\AClass', 'AClass', ['\Other'], 'classes', 'class'], - 'namespaced-class-use' => ['namespace Foo; class AClass { use \Other; }', '\Foo\AClass', 'AClass', ['\Other'], 'classes', 'class'], - 'namespaced-class-use-namespaced' => ['namespace Foo; use Bar\Other; class AClass { use Other; }', '\Foo\AClass', 'AClass', ['\Bar\Other'], 'classes', 'class'], - 'namespaced-class-use-namespaced-as' => ['namespace Foo; use Bar\Other as Some; class AClass { use Some; }', '\Foo\AClass', 'AClass', ['\Bar\Other'], 'classes', 'class'], + 'global-class-use' => ['class YClass { use Other; }', '\YClass', 'YClass', ['\Other'], 'classes', 'class'], + 'namespaced-class-use' => ['namespace UNCU\Foo; class AClass { use \Other; }', '\UNCU\Foo\AClass', 'AClass', ['\Other'], 'classes', 'class'], + 'namespaced-class-use-namespaced' => ['namespace UNCUN\Foo; use GBar\Other; class AClass { use Other; }', '\UNCUN\Foo\AClass', 'AClass', ['\GBar\Other'], 'classes', 'class'], + 'namespaced-class-use-namespaced-as' => ['namespace UNCUNA\Foo; use HBar\Other as Some; class AClass { use Some; }', '\UNCUNA\Foo\AClass', 'AClass', ['\HBar\Other'], 'classes', 'class'], 'global-trait-use' => ['trait ATrait { use Other; }', '\ATrait', 'ATrait', ['\Other'], 'traits', 'trait'], - 'namespaced-trait-use' => ['namespace Foo; trait ATrait { use \Other; }', '\Foo\ATrait', 'ATrait', ['\Other'], 'traits', 'trait'], - 'namespaced-trait-use-explicit' => ['namespace Foo; trait ATrait { use \Bar\Other; }', '\Foo\ATrait', 'ATrait', ['\Bar\Other'], 'traits', 'trait'], - 'namespaced-trait-use-multi' => ['namespace Foo; trait ATrait { use \Other; use \More; }', '\Foo\ATrait', 'ATrait', ['\Other', '\More'], 'traits', 'trait'], - 'namespaced-trait-use-mixed' => ['namespace Foo; use Bar\Other; trait ATrait { use Other, \More; }', '\Foo\ATrait', 'ATrait', ['\Bar\Other', '\More'], 'traits', 'trait'], - 'namespaced-trait-use-as' => ['namespace Foo; use Bar\Other as Some; trait ATrait { use Some; }', '\Foo\ATrait', 'ATrait', ['\Bar\Other'], 'traits', 'trait'], + 'namespaced-trait-use' => ['namespace UNTU\Foo; trait ATrait { use \Other; }', '\UNTU\Foo\ATrait', 'ATrait', ['\Other'], 'traits', 'trait'], + 'namespaced-trait-use-explicit' => ['namespace UNTUE\Foo; trait ATrait { use \DBar\Other; }', '\UNTUE\Foo\ATrait', 'ATrait', ['\DBar\Other'], 'traits', 'trait'], + 'namespaced-trait-use-multi' => ['namespace UNTUEM\Foo; trait ATrait { use \Other; use \More; }', '\UNTUEM\Foo\ATrait', 'ATrait', ['\Other', '\More'], 'traits', 'trait'], + 'namespaced-trait-use-mixed' => ['namespace UNTUEX\Foo; use TBar\Other; trait ATrait { use Other, \More; }', '\UNTUEX\Foo\ATrait', 'ATrait', ['\TBar\Other', '\More'], 'traits', 'trait'], + 'namespaced-trait-use-as' => ['namespace UNTUEA\Foo; use MBar\Other as Some; trait ATrait { use Some; }', '\UNTUEA\Foo\ATrait', 'ATrait', ['\MBar\Other'], 'traits', 'trait'], ]; } @@ -111,32 +120,28 @@ public function testUsesDefinition($code, $fqdn, $name, $traits, $type, $typeKey public function testWrongCommentType() { - $analyser = new StaticAnalyser(); + $analyser = new TokenAnalyser(); $this->assertOpenApiLogEntryContains('Annotations are only parsed inside `/**` DocBlocks'); $analyser->fromCode("getContext()); } - public function testIndentationCorrection() - { - $analysis = $this->analysisFromFixtures('StaticAnalyser/routes.php'); - $this->assertCount(20, $analysis->annotations); - } - public function testThirdPartyAnnotations() { - $backup = Analyser::$whitelist; - Analyser::$whitelist = ['OpenApi\\Annotations\\']; - $analyser = new StaticAnalyser(); - $defaultAnalysis = $analyser->fromFile(__DIR__ . '/Fixtures/ThirdPartyAnnotations.php', $this->getContext()); + $generator = new Generator(); + $analyser = new TokenAnalyser(); + $analyser->setGenerator($generator); + $defaultAnalysis = $analyser->fromFile(__DIR__ . '/../Fixtures/ThirdPartyAnnotations.php', $this->getContext()); $this->assertCount(3, $defaultAnalysis->annotations, 'Only read the @OA annotations, skip the others.'); // Allow the analyser to parse 3rd party annotations, which might // contain useful info that could be extracted with a custom processor - Analyser::$whitelist[] = 'AnotherNamespace\\Annotations\\'; - $openapi = Generator::scan([__DIR__ . '/Fixtures/ThirdPartyAnnotations.php']); + $generator->addNamespace('AnotherNamespace\\Annotations\\'); + $openapi = $generator + ->setAnalyser(new TokenAnalyser()) + ->generate([__DIR__ . '/../Fixtures/ThirdPartyAnnotations.php']); $this->assertSame('api/3rd-party', $openapi->paths[0]->path); $this->assertCount(4, $openapi->_unmerged); - Analyser::$whitelist = $backup; + $analysis = $openapi->_analysis; $annotations = $analysis->getAnnotationsOfType('AnotherNamespace\Annotations\Unrelated'); $this->assertCount(4, $annotations); @@ -150,8 +155,9 @@ public function testThirdPartyAnnotations() public function testAnonymousClassProducesNoError() { try { - $analyser = new StaticAnalyser($this->fixtures('StaticAnalyser/php7.php')[0]); - $this->assertNotNull($analyser); + $analyser = new TokenAnalyser(); + $analysis = $analyser->fromFile($this->fixture('PHP/php7.php'), $this->getContext()); + $this->assertNotNull($analysis); } catch (\Throwable $t) { $this->fail("Analyser produced an error: {$t->getMessage()}"); } @@ -201,7 +207,7 @@ public function descriptions() */ public function testDescription($type, $name, $fixture, $fqdn, $extends, $methods, $interfaces, $traits) { - $analysis = $this->analysisFromFixtures($fixture); + $analysis = $this->analysisFromFixtures([$fixture]); list($pType, $sType) = $type; $description = $analysis->$pType[$fqdn]; @@ -223,7 +229,7 @@ public function testDescription($type, $name, $fixture, $fqdn, $extends, $method public function testNamespacedConstAccess() { - $analysis = $this->analysisFromFixtures('Parser/User.php'); + $analysis = $this->analysisFromFixtures(['Parser/User.php']); $schemas = $analysis->getAnnotationsOfType(Schema::class, true); $this->assertCount(1, $schemas); @@ -235,11 +241,12 @@ public function testNamespacedConstAccess() */ public function testPhp8AttributeMix() { - $analysis = $this->analysisFromFixtures('StaticAnalyser/Php8AttrMix.php'); + $analysis = $this->analysisFromFixtures(['PHP/Label.php', 'PHP/Php8AttrMix.php']); $schemas = $analysis->getAnnotationsOfType(Schema::class, true); $this->assertCount(1, $schemas); - $analysis->process(); + $analysis->process((new Generator())->getProcessors()); + $properties = $analysis->getAnnotationsOfType(Property::class, true); $this->assertCount(2, $properties); $this->assertEquals('id', $properties[0]->property); @@ -251,11 +258,12 @@ public function testPhp8AttributeMix() */ public function testPhp8NamedProperty() { - $analysis = $this->analysisFromFixtures('StaticAnalyser/Php8NamedProperty.php'); + $analysis = $this->analysisFromFixtures(['PHP/Php8NamedProperty.php'], [], new TokenAnalyser()); $schemas = $analysis->getAnnotationsOfType(Schema::class, true); $this->assertCount(1, $schemas); - $analysis->process(); + $analysis->process((new Generator())->getProcessors()); + $properties = $analysis->getAnnotationsOfType(Property::class, true); $this->assertCount(1, $properties); $this->assertEquals('labels', $properties[0]->property); diff --git a/tests/Analysers/TokenScannerTest.php b/tests/Analysers/TokenScannerTest.php new file mode 100644 index 000000000..2cd7df702 --- /dev/null +++ b/tests/Analysers/TokenScannerTest.php @@ -0,0 +1,173 @@ + [ + 'Apis/DocBlocks/basic.php', + [ + 'OpenApi\\Tests\\Fixtures\\Apis\\DocBlocks\\OpenApiSpec' => [ + 'uses' => ['OA' => 'OpenApi\Annotations'], + 'interfaces' => [], + 'traits' => [], + 'methods' => [], + 'properties' => [], + ], + 'OpenApi\\Tests\\Fixtures\\Apis\\DocBlocks\\Product' => [ + 'uses' => ['OA' => 'OpenApi\Annotations'], + 'interfaces' => ['OpenApi\\Tests\\Fixtures\\Apis\\DocBlocks\\ProductInterface'], + 'traits' => ['OpenApi\\Tests\\Fixtures\\Apis\\DocBlocks\\NameTrait'], + 'methods' => [], + 'properties' => ['id'], + ], + 'OpenApi\\Tests\\Fixtures\\Apis\\DocBlocks\\ProductController' => [ + 'uses' => ['OA' => 'OpenApi\Annotations'], + 'interfaces' => [], + 'traits' => [], + 'methods' => ['getProduct', 'addProduct'], + 'properties' => [], + ], + 'OpenApi\\Tests\\Fixtures\\Apis\\DocBlocks\\ProductInterface' => [ + 'uses' => ['OA' => 'OpenApi\Annotations'], + 'interfaces' => [], + 'traits' => [], + 'methods' => [], + 'properties' => [], + ], + 'OpenApi\\Tests\\Fixtures\\Apis\\DocBlocks\\NameTrait' => [ + 'uses' => ['OA' => 'OpenApi\Annotations'], + 'interfaces' => [], + 'traits' => [], + 'methods' => [], + 'properties' => ['name'], + ], + ], + ], + 'php7' => [ + 'PHP/php7.php', + [], + ], + 'php8' => [ + 'PHP/php8.php', + [ + 'OpenApi\\Tests\Fixtures\\PHP\\MethodAttr' => [ + 'uses' => [], + 'interfaces' => [], + 'traits' => [], + 'methods' => [], + 'properties' => [], + ], + 'OpenApi\Tests\\Fixtures\\PHP\\GenericAttr' => [ + 'uses' => [], + 'interfaces' => [], + 'traits' => [], + 'methods' => ['__construct'], + 'properties' => [], + ], + 'OpenApi\\Tests\\Fixtures\\PHP\\Decorated' => [ + 'uses' => [], + 'interfaces' => [], + 'traits' => [], + 'methods' => ['foo', 'bar'], + 'properties' => [], + ], + ], + ], + 'ExtendsClass' => [ + 'PHP/Inheritance/ExtendsClass.php', + [ + 'OpenApi\\Tests\\Fixtures\\PHP\\Inheritance\\ExtendsClass' => [ + 'uses' => [], + 'interfaces' => [], + 'traits' => [], + 'methods' => ['extendsClassFunc'], + 'properties' => ['extendsClassProp'], + ], + ], + ], + 'ExtendsInterface' => [ + 'PHP/Inheritance/ExtenedsBaseInterface.php', + [ + 'OpenApi\\Tests\\Fixtures\\PHP\\Inheritance\\ExtenedsBaseInterface' => [ + 'uses' => [], + 'interfaces' => ['OpenApi\\Tests\\Fixtures\\PHP\\Inheritance\\BaseInterface'], + 'traits' => [], + 'methods' => [], + 'properties' => [], + ], + ], + ], + 'CustomerInterface' => [ + 'CustomerInterface.php', + [ + 'OpenApi\\Tests\\Fixtures\\CustomerInterface' => [ + 'uses' => ['OA' => 'OpenApi\Annotations'], + 'interfaces' => [], + 'traits' => [], + 'methods' => ['firstname', 'secondname', 'thirdname', 'fourthname', 'lastname', 'tags', 'submittedBy', 'friends', 'bestFriend'], + 'properties' => [], + ], + ], + ], + 'AllTraits' => [ + 'Parser/AllTraits.php', + [ + 'OpenApi\\Tests\\Fixtures\\Parser\\AllTraits' => [ + 'uses' => [], + 'interfaces' => [], + 'traits' => ['OpenApi\\Tests\\Fixtures\\Parser\\AsTrait', 'OpenApi\\Tests\\Fixtures\\Parser\\HelloTrait'], + 'methods' => [], + 'properties' => [], + ], + ], + ], + 'User' => [ + 'Parser/User.php', + [ + 'OpenApi\\Tests\\Fixtures\\Parser\\User' => [ + 'uses' => [ + 'Hello' => 'OpenApi\\Tests\\Fixtures\\Parser\\HelloTrait', + 'ParentClass' => 'OpenApi\\Tests\\Fixtures\\Parser\\Sub\\SubClass', + ], + 'interfaces' => ['OpenApi\\Tests\\Fixtures\\Parser\\UserInterface'], + 'traits' => ['OpenApi\\Tests\\Fixtures\\Parser\\HelloTrait'], + 'methods' => ['getFirstName'], + 'properties' => [], + ], + ], + ], + 'Php8NamedProperty' => [ + 'PHP/Php8NamedProperty.php', + [ + 'OpenApi\\Tests\\Fixtures\\PHP\\Php8NamedProperty' => [ + 'uses' => ['Label' => 'OpenApi\Tests\Fixtures\PHP\Label'], + 'interfaces' => [], + 'traits' => [], + 'methods' => ['__construct'], + 'properties' => [], + ], + ], + ], + ]; + } + + /** + * @dataProvider scanCases + */ + public function testScanFile($fixture, $expected) + { + $result = (new TokenScanner())->scanFile($this->fixture($fixture)); + $this->assertEquals($expected, $result); + } +} diff --git a/tests/AnalysisTest.php b/tests/AnalysisTest.php index 0f4151d12..3121cd9c0 100644 --- a/tests/AnalysisTest.php +++ b/tests/AnalysisTest.php @@ -6,46 +6,27 @@ namespace OpenApi\Tests; -use OpenApi\Analysis; - class AnalysisTest extends OpenApiTestCase { - public function testRegisterProcessor() - { - $counter = 0; - $analysis = new Analysis([], $this->getContext()); - $analysis->process(); - $this->assertSame(0, $counter); - $countProcessor = function (Analysis $a) use (&$counter) { - $counter++; - }; - Analysis::registerProcessor($countProcessor); - $analysis->process(); - $this->assertSame(1, $counter); - Analysis::unregisterProcessor($countProcessor); - $analysis->process(); - $this->assertSame(1, $counter); - } - public function testGetSubclasses() { $analysis = $this->analysisFromFixtures([ 'AnotherNamespace/Child.php', - 'InheritProperties/GrandAncestor.php', - 'InheritProperties/Ancestor.php', + 'ExpandClasses/GrandAncestor.php', + 'ExpandClasses/Ancestor.php', ]); $this->assertCount(3, $analysis->classes, '3 classes should\'ve been detected'); - $subclasses = $analysis->getSubClasses('\OpenApi\Tests\Fixtures\InheritProperties\GrandAncestor'); + $subclasses = $analysis->getSubClasses('\OpenApi\Tests\Fixtures\ExpandClasses\GrandAncestor'); $this->assertCount(2, $subclasses, 'GrandAncestor has 2 subclasses'); $this->assertSame( - ['\OpenApi\Tests\Fixtures\InheritProperties\Ancestor', '\AnotherNamespace\Child'], + ['\OpenApi\Tests\Fixtures\ExpandClasses\Ancestor', '\AnotherNamespace\Child'], array_keys($subclasses) ); $this->assertSame( ['\AnotherNamespace\Child'], - array_keys($analysis->getSubClasses('\OpenApi\Tests\Fixtures\InheritProperties\Ancestor')) + array_keys($analysis->getSubClasses('\OpenApi\Tests\Fixtures\ExpandClasses\Ancestor')) ); } @@ -53,8 +34,8 @@ public function testGetAllAncestorClasses() { $analysis = $this->analysisFromFixtures([ 'AnotherNamespace/Child.php', - 'InheritProperties/GrandAncestor.php', - 'InheritProperties/Ancestor.php', + 'ExpandClasses/GrandAncestor.php', + 'ExpandClasses/Ancestor.php', ]); $this->assertCount(3, $analysis->classes, '3 classes should\'ve been detected'); @@ -62,12 +43,12 @@ public function testGetAllAncestorClasses() $superclasses = $analysis->getSuperClasses('\AnotherNamespace\Child'); $this->assertCount(2, $superclasses, 'Child has a chain of 2 super classes'); $this->assertSame( - ['\OpenApi\Tests\Fixtures\InheritProperties\Ancestor', '\OpenApi\Tests\Fixtures\InheritProperties\GrandAncestor'], + ['\OpenApi\Tests\Fixtures\ExpandClasses\Ancestor', '\OpenApi\Tests\Fixtures\ExpandClasses\GrandAncestor'], array_keys($superclasses) ); $this->assertSame( - ['\OpenApi\Tests\Fixtures\InheritProperties\GrandAncestor'], - array_keys($analysis->getSuperClasses('\OpenApi\Tests\Fixtures\InheritProperties\Ancestor')) + ['\OpenApi\Tests\Fixtures\ExpandClasses\GrandAncestor'], + array_keys($analysis->getSuperClasses('\OpenApi\Tests\Fixtures\ExpandClasses\Ancestor')) ); } @@ -75,8 +56,8 @@ public function testGetDirectAncestorClass() { $analysis = $this->analysisFromFixtures([ 'AnotherNamespace/Child.php', - 'InheritProperties/GrandAncestor.php', - 'InheritProperties/Ancestor.php', + 'ExpandClasses/GrandAncestor.php', + 'ExpandClasses/Ancestor.php', ]); $this->assertCount(3, $analysis->classes, '3 classes should\'ve been detected'); @@ -84,12 +65,12 @@ public function testGetDirectAncestorClass() $superclasses = $analysis->getSuperClasses('\AnotherNamespace\Child', true); $this->assertCount(1, $superclasses, 'Child has 1 parent class'); $this->assertSame( - ['\OpenApi\Tests\Fixtures\InheritProperties\Ancestor'], + ['\OpenApi\Tests\Fixtures\ExpandClasses\Ancestor'], array_keys($superclasses) ); $this->assertSame( - ['\OpenApi\Tests\Fixtures\InheritProperties\GrandAncestor'], - array_keys($analysis->getSuperClasses('\OpenApi\Tests\Fixtures\InheritProperties\Ancestor', true)) + ['\OpenApi\Tests\Fixtures\ExpandClasses\GrandAncestor'], + array_keys($analysis->getSuperClasses('\OpenApi\Tests\Fixtures\ExpandClasses\Ancestor', true)) ); } diff --git a/tests/Annotations/AbstractAnnotationTest.php b/tests/Annotations/AbstractAnnotationTest.php index f3258d5dc..7242bb040 100644 --- a/tests/Annotations/AbstractAnnotationTest.php +++ b/tests/Annotations/AbstractAnnotationTest.php @@ -15,7 +15,7 @@ class AbstractAnnotationTest extends OpenApiTestCase { public function testVendorFields() { - $annotations = $this->parseComment('@OA\Get(x={"internal-id": 123})'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Get(x={"internal-id": 123})'); $output = $annotations[0]->jsonSerialize(); $prefixedProperty = 'x-internal-id'; $this->assertSame(123, $output->$prefixedProperty); @@ -24,13 +24,13 @@ public function testVendorFields() public function testInvalidField() { $this->assertOpenApiLogEntryContains('Unexpected field "doesnot" for @OA\Get(), expecting'); - $this->parseComment('@OA\Get(doesnot="exist")'); + $this->annotationsFromDocBlockParser('@OA\Get(doesnot="exist")'); } public function testUmergedAnnotation() { $openapi = $this->createOpenApiWithInfo(); - $openapi->merge($this->parseComment('@OA\Items()')); + $openapi->merge($this->annotationsFromDocBlockParser('@OA\Items()')); $this->assertOpenApiLogEntryContains('Unexpected @OA\Items(), expected to be inside @OA\\'); $openapi->validate(); } @@ -45,7 +45,7 @@ public function testConflictedNesting() @OA\Contact(name="second") ) END; - $annotations = $this->parseComment($comment); + $annotations = $this->annotationsFromDocBlockParser($comment); $this->assertOpenApiLogEntryContains('Only one @OA\Contact() allowed for @OA\Info() multiple found in:'); $annotations[0]->validate(); } @@ -57,7 +57,7 @@ public function testKey() @OA\Header(header="X-CSRF-Token",description="Token to prevent Cross Site Request Forgery") ) END; - $annotations = $this->parseComment($comment); + $annotations = $this->annotationsFromDocBlockParser($comment); $this->assertEquals('{"headers":{"X-CSRF-Token":{"description":"Token to prevent Cross Site Request Forgery"}}}', json_encode($annotations[0])); } @@ -70,14 +70,14 @@ public function testConflictingKey() @OA\Header(header="X-CSRF-Token", @OA\Schema(type="string"), description="second") ) END; - $annotations = $this->parseComment($comment); + $annotations = $this->annotationsFromDocBlockParser($comment); $this->assertOpenApiLogEntryContains('Multiple @OA\Header() with the same header="X-CSRF-Token":'); $annotations[0]->validate(); } public function testRequiredFields() { - $annotations = $this->parseComment('@OA\Info()'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Info()'); $info = $annotations[0]; $this->assertOpenApiLogEntryContains('Missing required field "title" for @OA\Info() in '); $this->assertOpenApiLogEntryContains('Missing required field "version" for @OA\Info() in '); @@ -96,7 +96,7 @@ public function testTypeValidation() ) ) END; - $annotations = $this->parseComment($comment); + $annotations = $this->annotationsFromDocBlockParser($comment); $parameter = $annotations[0]; $this->assertOpenApiLogEntryContains('@OA\Parameter(name=123,in="dunno")->name is a "integer", expecting a "string" in '); $this->assertOpenApiLogEntryContains('@OA\Parameter(name=123,in="dunno")->in "dunno" is invalid, expecting "query", "header", "path", "cookie" in '); diff --git a/tests/Annotations/AttachableTest.php b/tests/Annotations/AttachableTest.php index 96dd4f7fc..64c26141c 100644 --- a/tests/Annotations/AttachableTest.php +++ b/tests/Annotations/AttachableTest.php @@ -16,7 +16,7 @@ class AttachableTest extends OpenApiTestCase { public function testAttachablesAreAttached() { - $analysis = $this->analysisFromFixtures('UsingVar.php'); + $analysis = $this->analysisFromFixtures(['UsingVar.php']); $schemas = $analysis->getAnnotationsOfType(Schema::class, true); @@ -28,9 +28,9 @@ public function testCustomAttachableImplementationsAreAttached() { $analysis = new Analysis([], $this->getContext()); (new Generator()) - //->setAliases(['oaf' => 'OpenApi\\Tests\\Annotations']) - ->setNamespaces(['OpenApi\\Tests\\Annotations\\']) - ->generate($this->fixtures('UsingCustomAttachables.php'), $analysis); + ->addAlias('oaf', 'OpenApi\\Tests\\Annotations') + ->addNamespace('OpenApi\\Tests\\Annotations\\') + ->generate($this->fixtures(['UsingCustomAttachables.php']), $analysis); $schemas = $analysis->getAnnotationsOfType(Schema::class, true); diff --git a/tests/Annotations/CustomAttachable.php b/tests/Annotations/CustomAttachable.php index a435ff056..88f4efe0b 100644 --- a/tests/Annotations/CustomAttachable.php +++ b/tests/Annotations/CustomAttachable.php @@ -3,12 +3,13 @@ namespace OpenApi\Tests\Annotations; use OpenApi\Annotations\Attachable; +use OpenApi\Annotations\Operation; use OpenApi\Generator; /** * @Annotation */ -class CustomAttachable extends Attachable +abstract class AbstractCustomAttachable extends Attachable { /** * The attribute value. @@ -21,4 +22,38 @@ class CustomAttachable extends Attachable * @inheritdoc */ public static $_required = ['value']; + + public function allowedParents(): ?array + { + return [Operation::class]; + } +} + +if (\PHP_VERSION_ID >= 80100) { + /** + * @Annotation + */ + #[\Attribute(\Attribute::TARGET_ALL | \Attribute::IS_REPEATABLE)] + class CustomAttachable extends AbstractCustomAttachable + { + public function __construct( + array $properties = [], + $value = Generator::UNDEFINED + ) { + parent::__construct($properties + [ + 'value' => $value, + ]); + } + } +} else { + /** + * @Annotation + */ + class CustomAttachable extends AbstractCustomAttachable + { + public function __construct(array $properties) + { + parent::__construct($properties); + } + } } diff --git a/tests/Annotations/ItemsTest.php b/tests/Annotations/ItemsTest.php index b901002c2..d12ec559d 100644 --- a/tests/Annotations/ItemsTest.php +++ b/tests/Annotations/ItemsTest.php @@ -6,36 +6,34 @@ namespace OpenApi\Tests\Annotations; -use OpenApi\StaticAnalyser; +use OpenApi\Generator; use OpenApi\Tests\OpenApiTestCase; class ItemsTest extends OpenApiTestCase { public function testItemTypeArray() { - $annotations = $this->parseComment('@OA\Items(type="array")'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Items(type="array")'); $this->assertOpenApiLogEntryContains('@OA\Items() is required when @OA\Items() has type "array" in '); $annotations[0]->validate(); } public function testSchemaTypeArray() { - $annotations = $this->parseComment('@OA\Schema(type="array")'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Schema(type="array")'); $this->assertOpenApiLogEntryContains('@OA\Items() is required when @OA\Schema() has type "array" in '); $annotations[0]->validate(); } public function testParentTypeArray() { - $annotations = $this->parseComment('@OA\Items() parent type must be "array"'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Items() parent type must be "array"'); $annotations[0]->validate(); } public function testRefDefinitionInProperty() { - $analyser = new StaticAnalyser(); - $analysis = $analyser->fromFile($this->fixtures('UsingVar.php')[0], $this->getContext()); - $analysis->process(); + $analysis = $this->analysisFromFixtures(['UsingVar.php'], (new Generator())->getProcessors()); $this->assertCount(2, $analysis->openapi->components->schemas); $this->assertEquals('UsingVar', $analysis->openapi->components->schemas[0]->schema); diff --git a/tests/Annotations/NestedPropertyTest.php b/tests/Annotations/NestedPropertyTest.php index c6ae8b9ff..005facacc 100644 --- a/tests/Annotations/NestedPropertyTest.php +++ b/tests/Annotations/NestedPropertyTest.php @@ -17,11 +17,13 @@ class NestedPropertyTest extends OpenApiTestCase { public function testNestedProperties() { - $analysis = $this->analysisFromFixtures('NestedProperty.php'); - $analysis->process(new MergeIntoOpenApi()); - $analysis->process(new MergeIntoComponents()); - $analysis->process(new AugmentSchemas()); - $analysis->process(new AugmentProperties()); + $analysis = $this->analysisFromFixtures(['NestedProperty.php']); + $analysis->process([ + new MergeIntoOpenApi(), + new MergeIntoComponents(), + new AugmentSchemas(), + new AugmentProperties(), + ]); $this->assertCount(1, $analysis->openapi->components->schemas); $schema = $analysis->openapi->components->schemas[0]; diff --git a/tests/Annotations/OperationTest.php b/tests/Annotations/OperationTest.php index 9244ea0a7..15c1272f7 100644 --- a/tests/Annotations/OperationTest.php +++ b/tests/Annotations/OperationTest.php @@ -51,9 +51,9 @@ public function testSecuritySerialization($security, $dockBlock, $expected) $json = $operation->toJson($flags); $this->assertEquals($expected, $json); - $analysis = $this->analysisFromDockBlock($dockBlock); - $this->assertCount(1, $analysis); - $json = $analysis[0]->toJson($flags); + $annotations = $this->annotationsFromDocBlockParser($dockBlock); + $this->assertCount(1, $annotations); + $json = $annotations[0]->toJson($flags); $this->assertEquals($expected, $json); } } diff --git a/tests/Annotations/ResponseTest.php b/tests/Annotations/ResponseTest.php index 0afcc09cb..669092e6a 100644 --- a/tests/Annotations/ResponseTest.php +++ b/tests/Annotations/ResponseTest.php @@ -27,7 +27,7 @@ public function testWrongRangeDefinition() protected function validateMisspelledAnnotation(string $response = '') { - $annotations = $this->parseComment( + $annotations = $this->annotationsFromDocBlockParser( '@OA\Get(@OA\Response(response="' . $response . '", description="description"))' ); /* diff --git a/tests/Annotations/SecuritySchemesTest.php b/tests/Annotations/SecuritySchemesTest.php index 1f86f0925..1ce2c1211 100644 --- a/tests/Annotations/SecuritySchemesTest.php +++ b/tests/Annotations/SecuritySchemesTest.php @@ -40,18 +40,18 @@ public function testParseServers() */ INFO; - $analysis = $this->analysisFromDockBlock($comment); + $annotations = $this->annotationsFromDocBlockParser($comment); - $this->assertCount(3, $analysis); - $this->assertInstanceOf(Info::class, $analysis[0]); - $this->assertInstanceOf(Server::class, $analysis[1]); - $this->assertInstanceOf(Server::class, $analysis[2]); + $this->assertCount(3, $annotations); + $this->assertInstanceOf(Info::class, $annotations[0]); + $this->assertInstanceOf(Server::class, $annotations[1]); + $this->assertInstanceOf(Server::class, $annotations[2]); - $this->assertEquals('http://example.com', $analysis[1]->url); - $this->assertEquals('First host', $analysis[1]->description); + $this->assertEquals('http://example.com', $annotations[1]->url); + $this->assertEquals('First host', $annotations[1]->description); - $this->assertEquals('http://example-second.com', $analysis[2]->url); - $this->assertEquals('Second host', $analysis[2]->description); + $this->assertEquals('http://example-second.com', $annotations[2]->url); + $this->assertEquals('Second host', $annotations[2]->description); } /** @@ -79,10 +79,10 @@ public function testImplicitFlowAnnotation() */ SCHEME; - $analysis = $this->analysisFromDockBlock($comment); - $this->assertCount(1, $analysis); + $annotations = $this->annotationsFromDocBlockParser($comment); + $this->assertCount(1, $annotations); /** @var \OpenApi\Annotations\SecurityScheme $security */ - $security = $analysis[0]; + $security = $annotations[0]; $this->assertInstanceOf(SecurityScheme::class, $security); $this->assertCount(1, $security->flows); @@ -119,10 +119,10 @@ public function testMultipleAnnotations() */ SCHEME; - $analysis = $this->analysisFromDockBlock($comment); - $this->assertCount(1, $analysis); + $annotations = $this->annotationsFromDocBlockParser($comment); + $this->assertCount(1, $annotations); /** @var \OpenApi\Annotations\SecurityScheme $security */ - $security = $analysis[0]; + $security = $annotations[0]; $this->assertCount(2, $security->flows); $this->assertEquals('implicit', $security->flows[0]->flow); diff --git a/tests/ConstantsTest.php b/tests/ConstantsTest.php index da7fa8ba8..96890d42f 100644 --- a/tests/ConstantsTest.php +++ b/tests/ConstantsTest.php @@ -6,8 +6,8 @@ namespace OpenApi\Tests; -use OpenApi\Analyser; -use OpenApi\StaticAnalyser; +use OpenApi\Analysers\TokenAnalyser; +use OpenApi\Generator; class ConstantsTest extends OpenApiTestCase { @@ -21,26 +21,26 @@ public function testConstant() $const = 'OPENAPI_TEST_' . self::$counter; $this->assertFalse(defined($const)); $this->assertOpenApiLogEntryContains("[Semantical Error] Couldn't find constant " . $const); - $this->parseComment('@OA\Contact(email=' . $const . ')'); + $this->annotationsFromDocBlockParser('@OA\Contact(email=' . $const . ')'); define($const, 'me@domain.org'); - $annotations = $this->parseComment('@OA\Contact(email=' . $const . ')'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Contact(email=' . $const . ')'); $this->assertSame('me@domain.org', $annotations[0]->email); } public function testFQCNConstant() { - $annotations = $this->parseComment('@OA\Contact(url=OpenApi\Tests\ConstantsTest::URL)'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Contact(url=OpenApi\Tests\ConstantsTest::URL)'); $this->assertSame('http://example.com', $annotations[0]->url); - $annotations = $this->parseComment('@OA\Contact(url=\OpenApi\Tests\ConstantsTest::URL)'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Contact(url=\OpenApi\Tests\ConstantsTest::URL)'); $this->assertSame('http://example.com', $annotations[0]->url); } public function testInvalidClass() { $this->assertOpenApiLogEntryContains("[Semantical Error] Couldn't find constant ConstantsTest::URL"); - $this->parseComment('@OA\Contact(url=ConstantsTest::URL)'); + $this->annotationsFromDocBlockParser('@OA\Contact(url=ConstantsTest::URL)'); } public function testAutoloadConstant() @@ -48,33 +48,15 @@ public function testAutoloadConstant() if (class_exists('AnotherNamespace\Annotations\Constants', false)) { $this->markTestSkipped(); } - $annotations = $this->parseComment('@OA\Contact(name=AnotherNamespace\Annotations\Constants::INVALID_TIMEZONE_LOCATION)'); + $annotations = $this->annotationsFromDocBlockParser('@OA\Contact(name=AnotherNamespace\Annotations\Constants::INVALID_TIMEZONE_LOCATION)'); $this->assertSame('invalidTimezoneLocation', $annotations[0]->name); } public function testDynamicImports() { - $backup = Analyser::$whitelist; - Analyser::$whitelist = false; - $analyser = new StaticAnalyser(); - $analysis = $analyser->fromFile(__DIR__ . '/Fixtures/Customer.php', $this->getContext()); - // @todo Only tests that $whitelist=false doesn't trigger errors, - // No constants are used, because by default only class constants in the whitelisted namespace are allowed and no class in OpenApi\Annotation namespace has a constant. - - // Scanning without whitelisting causes issues, to check uncomment next. - // $analyser->fromFile(__DIR__ . '/Fixtures/ThirdPartyAnnotations.php', $this->getContext()); - Analyser::$whitelist = $backup; - } - - public function testDefaultImports() - { - $backup = Analyser::$defaultImports; - Analyser::$defaultImports = [ - 'contact' => 'OpenApi\Annotations\Contact', // use OpenApi\Annotations\Contact; - 'ctest' => 'OpenApi\Tests\ConstantsTesT', // use OpenApi\Tests\ConstantsTesT as CTest; - ]; - $annotations = $this->parseComment('@Contact(url=CTest::URL)'); - $this->assertSame('http://example.com', $annotations[0]->url); - Analyser::$defaultImports = $backup; + $analyser = new TokenAnalyser(); + $analyser->setGenerator((new Generator())->setNamespaces(null)); + $analyser->fromFile($this->fixture('Customer.php'), $this->getContext()); + $analyser->fromFile($this->fixture('ThirdPartyAnnotations.php'), $this->getContext()); } } diff --git a/tests/ContextTest.php b/tests/ContextTest.php index 1aa84737f..d366ecb3a 100644 --- a/tests/ContextTest.php +++ b/tests/ContextTest.php @@ -6,6 +6,7 @@ namespace OpenApi\Tests; +use OpenApi\Analysers\TokenAnalyser; use OpenApi\Context; use OpenApi\Generator; @@ -26,7 +27,9 @@ public function testDetect() public function testFullyQualifiedName() { $this->assertOpenApiLogEntryContains('Required @OA\PathItem() not found'); - $openapi = Generator::scan([__DIR__ . '/Fixtures/Customer.php'], ['logger' => $this->getTrackingLogger()]); + $openapi = (new Generator($this->getTrackingLogger())) + ->setAnalyser(new TokenAnalyser()) + ->generate([__DIR__ . '/Fixtures/Customer.php']); $context = $openapi->components->schemas[0]->_context; // resolve with namespace $this->assertSame('\FullyQualified', $context->fullyQualifiedName('\FullyQualified')); diff --git a/tests/ExamplesTest.php b/tests/ExamplesTest.php index 3194cbb8a..84d621897 100644 --- a/tests/ExamplesTest.php +++ b/tests/ExamplesTest.php @@ -6,26 +6,46 @@ namespace OpenApi\Tests; +use Composer\Autoload\ClassLoader; +use OpenApi\Analysers\AttributeAnnotationFactory; +use OpenApi\Analysers\DocBlockAnnotationFactory; +use OpenApi\Analysers\ReflectionAnalyser; +use OpenApi\Analysers\TokenAnalyser; use OpenApi\Generator; class ExamplesTest extends OpenApiTestCase { public function exampleMappings() { - return [ + $analysers = [ + 'token' => new TokenAnalyser(), + 'reflection/docblock' => new ReflectionAnalyser([new DocBlockAnnotationFactory()]), + ]; + + $examples = [ + 'example-object' => ['example-object', 'example-object.yaml'], 'misc' => ['misc', 'misc.yaml'], + 'nesting' => ['nesting', 'nesting.yaml'], 'openapi-spec' => ['openapi-spec', 'openapi-spec.yaml'], - 'petstore.swagger.io' => ['petstore.swagger.io', 'petstore.swagger.io.yaml'], 'petstore-3.0' => ['petstore-3.0', 'petstore-3.0.yaml'], + 'petstore.swagger.io' => ['petstore.swagger.io', 'petstore.swagger.io.yaml'], 'swagger-spec/petstore' => ['swagger-spec/petstore', 'petstore.yaml'], 'swagger-spec/petstore-simple' => ['swagger-spec/petstore-simple', 'petstore-simple.yaml'], 'swagger-spec/petstore-with-external-docs' => ['swagger-spec/petstore-with-external-docs', 'petstore-with-external-docs.yaml'], - 'using-refs' => ['using-refs', 'using-refs.yaml'], - 'example-object' => ['example-object', 'example-object.yaml'], 'using-interfaces' => ['using-interfaces', 'using-interfaces.yaml'], + 'using-refs' => ['using-refs', 'using-refs.yaml'], 'using-traits' => ['using-traits', 'using-traits.yaml'], - 'nesting' => ['nesting', 'nesting.yaml'], ]; + + foreach ($examples as $ekey => $example) { + foreach ($analysers as $akey => $analyser) { + yield $akey . ':' . $ekey => array_merge($example, [$analyser]); + } + } + + if (\PHP_VERSION_ID >= 80100) { + yield 'reflection/attribute:openapi-spec-attributes' => ['openapi-spec-attributes', 'openapi-spec-attributes.yaml', new ReflectionAnalyser([new AttributeAnnotationFactory()])]; + } } /** @@ -33,11 +53,24 @@ public function exampleMappings() * * @dataProvider exampleMappings */ - public function testExamples($example, $spec) + public function testExamples($example, $spec, $analyser) { + // register autoloader for examples that require autoloading due to inheritance, etc. + $path = __DIR__ . '/../Examples/' . $example; + $exampleNS = str_replace(' ', '', ucwords(str_replace(['-', '.'], ' ', $example))); + $classloader = new ClassLoader(); + $classloader->addPsr4('OpenApi\\Examples\\' . $exampleNS . '\\', $path); + $classloader->register(); + $path = __DIR__ . '/../Examples/' . $example; - $openapi = Generator::scan([$path], ['validate' => true]); + $openapi = (new Generator()) + ->setAnalyser($analyser) + ->generate([$path], null, true); //file_put_contents($path . '/' . $spec, $openapi->toYaml()); - $this->assertSpecEquals(file_get_contents($path . '/' . $spec), $openapi, 'Examples/' . $example . '/' . $spec); + $this->assertSpecEquals( + $openapi, + file_get_contents($path . '/' . $spec), + get_class($analyser) . ': Examples/' . $example . '/' . $spec + ); } } diff --git a/tests/Fixtures/AnotherNamespace/Annotations/Constants.php b/tests/Fixtures/AnotherNamespace/Annotations/Constants.php index 729d0f4ed..b3ba44a9e 100644 --- a/tests/Fixtures/AnotherNamespace/Annotations/Constants.php +++ b/tests/Fixtures/AnotherNamespace/Annotations/Constants.php @@ -1,5 +1,9 @@ [$sourceDir, [$sourceDir]], - 'file-list' => [$sourceDir, $sources], - 'finder' => [$sourceDir, Util::finder($sourceDir)], - 'finder-list' => [$sourceDir, [Util::finder($sourceDir)]], - ]; + yield 'dir-list' => [$sourceDir, [$sourceDir]]; + yield 'file-list' => [$sourceDir, ["$sourceDir/SimplePet.php", "$sourceDir/SimplePetsController.php", "$sourceDir/api.php"]]; + yield 'finder' => [$sourceDir, Util::finder($sourceDir)]; + yield 'finder-list' => [$sourceDir, [Util::finder($sourceDir)]]; } /** @@ -40,36 +32,12 @@ public function sourcesProvider() public function testScan(string $sourceDir, iterable $sources) { $openapi = (new Generator()) - ->scan($sources); + ->setAnalyser(new TokenAnalyser()) + ->generate($sources); $this->assertSpecEquals(file_get_contents(sprintf('%s/%s.yaml', $sourceDir, basename($sourceDir))), $openapi); } - public function testUsingPsrLogger() - { - Logger::getInstance()->log = function ($entry, $type) { - $this->fail('Wrong logger'); - }; - - (new Generator(new NullLogger())) - ->setAliases(['swg' => 'OpenApi\Annotations']) - ->generate($this->fixtures('Deprecated.php')); - } - - public function testUsingLegacyLogger() - { - $legacyLoggerCalled = false; - Logger::getInstance()->log = function ($entry, $type) use (&$legacyLoggerCalled) { - $legacyLoggerCalled = true; - }; - - (new Generator()) - ->setAliases(['swg' => 'OpenApi\Annotations']) - ->generate($this->fixtures('Deprecated.php')); - - $this->assertTrue($legacyLoggerCalled, 'Expected legacy logger to be called'); - } - public function processorCases() { return [ @@ -89,7 +57,7 @@ public function testUpdateProcessor($p, $expected) ->updateProcessor($p); foreach ($generator->getProcessors() as $processor) { if ($processor instanceof OperationId) { - $this->assertSpecEquals($expected, $processor->isHash()); + $this->assertEquals($expected, $processor->isHash()); } } } @@ -104,6 +72,22 @@ public function testAddProcessor() $this->assertLessThan(count($generator->getProcessors()), count($processors)); } + public function testAddAlias() + { + $generator = new Generator(); + $generator->addAlias('foo', 'Foo\\Bar'); + + $this->assertEquals(['oa' => 'OpenApi\\Annotations', 'foo' => 'Foo\\Bar'], $generator->getAliases()); + } + + public function testAddNamespace() + { + $generator = new Generator(); + $generator->addNamespace('Foo\\Bar\\'); + + $this->assertEquals(['OpenApi\\Annotations\\', 'Foo\\Bar\\'], $generator->getNamespaces()); + } + public function testRemoveProcessor() { $generator = new Generator(); diff --git a/tests/OpenApiTestCase.php b/tests/OpenApiTestCase.php index d7bc316ab..108f48568 100644 --- a/tests/OpenApiTestCase.php +++ b/tests/OpenApiTestCase.php @@ -8,13 +8,18 @@ use DirectoryIterator; use Exception; -use OpenApi\Analyser; +use OpenApi\Analysers\AnalyserInterface; +use OpenApi\Analysers\AttributeAnnotationFactory; +use OpenApi\Analysers\DocBlockAnnotationFactory; +use OpenApi\Analysers\DocBlockParser; +use OpenApi\Analysers\ReflectionAnalyser; use OpenApi\Analysis; use OpenApi\Annotations\Info; use OpenApi\Annotations\OpenApi; use OpenApi\Annotations\PathItem; use OpenApi\Context; -use OpenApi\StaticAnalyser; +use OpenApi\Analysers\TokenAnalyser; +use OpenApi\Generator; use PHPUnit\Framework\TestCase; use Psr\Log\AbstractLogger; use Psr\Log\LoggerInterface; @@ -66,18 +71,27 @@ public function log($level, $message, array $context = []) list($assertion, $needle) = array_shift($this->testCase->expectedLogMessages); $assertion($message, $level); } else { - $this->testCase->fail('Unexpected log line ::' . $level . '("' . $message . '")'); + $this->testCase->fail('Unexpected log line: ' . $level . '("' . $message . '")'); } } }; } - public function getContext(array $properties = []) + public function getContext(array $properties = []): Context { return new Context(['logger' => $this->getTrackingLogger()] + $properties); } - public function assertOpenApiLogEntryContains($needle, $message = '') + public function getAnalyzer(): AnalyserInterface + { + $legacyAnalyser = getenv('PHPUNIT_ANALYSER') === 'legacy'; + + return $legacyAnalyser + ? new TokenAnalyser() + : new ReflectionAnalyser([new DocBlockAnnotationFactory(), new AttributeAnnotationFactory()]); + } + + public function assertOpenApiLogEntryContains($needle, $message = ''): void { $this->expectedLogMessages[] = [function ($entry, $type) use ($needle, $message) { if ($entry instanceof Exception) { @@ -95,12 +109,30 @@ public function assertOpenApiLogEntryContains($needle, $message = '') * @param string $message * @param bool $normalized flag indicating whether the inputs are already normalized or not */ - protected function assertSpecEquals($actual, $expected, $message = '', $normalized = false) + protected function assertSpecEquals($actual, $expected, string $message = '', bool $normalized = false): void { - $normalize = function ($in) { + $formattedValue = function ($value) { + if (is_bool($value)) { + return $value ? 'true' : 'false'; + } + if (is_numeric($value)) { + return (string) $value; + } + if (is_string($value)) { + return '"' . $value . '"'; + } + if (is_object($value)) { + return get_class($value); + } + + return gettype($value); + }; + + $normalizeIn = function ($in) { if ($in instanceof OpenApi) { $in = $in->toYaml(); } + if (is_string($in)) { // assume YAML try { @@ -114,13 +146,13 @@ protected function assertSpecEquals($actual, $expected, $message = '', $normaliz }; if (!$normalized) { - $actual = $normalize($actual); - $expected = $normalize($expected); + $actual = $normalizeIn($actual); + $expected = $normalizeIn($expected); } if (is_iterable($actual) && is_iterable($expected)) { foreach ($actual as $key => $value) { - $this->assertArrayHasKey($key, (array) $expected, $message . ': property: "' . $key . '" should be absent, but has value: ' . $this->formattedValue($value)); + $this->assertArrayHasKey($key, (array) $expected, $message . ': property: "' . $key . '" should be absent, but has value: ' . $formattedValue($value)); $this->assertSpecEquals($value, ((array) $expected)[$key], $message . ' > ' . $key, true); } foreach ($expected as $key => $value) { @@ -132,36 +164,10 @@ protected function assertSpecEquals($actual, $expected, $message = '', $normaliz } } - private function formattedValue($value) - { - if (is_bool($value)) { - return $value ? 'true' : 'false'; - } - if (is_numeric($value)) { - return (string) $value; - } - if (is_string($value)) { - return '"' . $value . '"'; - } - if (is_object($value)) { - return get_class($value); - } - - return gettype($value); - } - - protected function parseComment($comment, ?Context $context = null) - { - $analyser = new Analyser(); - $context = $context ?: $this->getContext(); - - return $analyser->fromComment(" new Info([ @@ -176,48 +182,52 @@ protected function createOpenApiWithInfo() ]); } + public function fixture(string $file): ?string + { + $fixtures = $this->fixtures([$file]); + + return $fixtures ? $fixtures[0] : null; + } + /** * Resolve fixture filenames. * - * @param array|string $files one ore more files - * * @return array resolved filenames for loading scanning etc */ - public function fixtures($files): array + public function fixtures(array $files): array { return array_map(function ($file) { return __DIR__ . '/Fixtures/' . $file; }, (array) $files); } - public function analysisFromFixtures($files): Analysis + public function analysisFromFixtures(array $files, array $processors = [], ?AnalyserInterface $analyzer = null): Analysis { - $analyser = new StaticAnalyser(); $analysis = new Analysis([], $this->getContext()); - foreach ((array) $files as $file) { - $analysis->addAnalysis($analyser->fromFile($this->fixtures($file)[0], $this->getContext())); - } + (new Generator($this->getTrackingLogger())) + ->setAnalyser($analyzer ?: $this->getAnalyzer()) + ->setProcessors($processors) + ->generate($this->fixtures($files), $analysis, false); return $analysis; } - public function analysisFromCode(string $code, ?Context $context = null) + protected function annotationsFromDocBlockParser(string $docBlock, array $extraAliases = []): array { - return (new StaticAnalyser())->fromCode("getContext()); - } + return (new Generator())->withContext(function (Generator $generator, Analysis $analysis, Context $context) use ($docBlock, $extraAliases) { + $docBlockParser = new DocBlockParser($generator->getAliases() + $extraAliases); - public function analysisFromDockBlock($comment) - { - return (new Analyser())->fromComment($comment, $this->getContext()); + return $docBlockParser->fromComment($docBlock, $this->getContext()); + }); } /** - * Collect list of all non abstract annotation classes. + * Collect list of all non-abstract annotation classes. * * @return array */ - public function allAnnotationClasses() + public function allAnnotationClasses(): array { $classes = []; $dir = new DirectoryIterator(__DIR__ . '/../src/Annotations'); diff --git a/tests/Processors/AugmentParameterTest.php b/tests/Processors/AugmentParameterTest.php index c5a420a87..8da1ee7fa 100644 --- a/tests/Processors/AugmentParameterTest.php +++ b/tests/Processors/AugmentParameterTest.php @@ -6,6 +6,7 @@ namespace OpenApi\Tests\Processors; +use OpenApi\Analysers\TokenAnalyser; use OpenApi\Generator; use OpenApi\Tests\OpenApiTestCase; @@ -13,7 +14,9 @@ class AugmentParameterTest extends OpenApiTestCase { public function testAugmentParameter() { - $openapi = Generator::scan($this->fixtures('UsingRefs.php')); + $openapi = (new Generator()) + ->setAnalyser(new TokenAnalyser()) + ->generate([$this->fixture('UsingRefs.php')]); $this->assertCount(1, $openapi->components->parameters, 'OpenApi contains 1 reusable parameter specification'); $this->assertEquals('ItemName', $openapi->components->parameters[0]->parameter, 'When no @OA\Parameter()->parameter is specified, use @OA\Parameter()->name'); } diff --git a/tests/Processors/AugmentPropertiesTest.php b/tests/Processors/AugmentPropertiesTest.php index d2ba9ac5e..90e4dd22c 100644 --- a/tests/Processors/AugmentPropertiesTest.php +++ b/tests/Processors/AugmentPropertiesTest.php @@ -6,6 +6,7 @@ namespace OpenApi\Tests\Processors; +use OpenApi\Analysers\ReflectionAnalyser; use OpenApi\Annotations\Property; use OpenApi\Generator; use OpenApi\Processors\AugmentProperties; @@ -21,10 +22,13 @@ class AugmentPropertiesTest extends OpenApiTestCase { public function testAugmentProperties() { - $analysis = $this->analysisFromFixtures('Customer.php'); - $analysis->process(new MergeIntoOpenApi()); - $analysis->process(new MergeIntoComponents()); - $analysis->process(new AugmentSchemas()); + $analysis = $this->analysisFromFixtures(['Customer.php']); + $analysis->process([ + new MergeIntoOpenApi(), + new MergeIntoComponents(), + new AugmentSchemas(), + ]); + $customer = $analysis->openapi->components->schemas[0]; $firstName = $customer->properties[0]; $secondName = $customer->properties[1]; @@ -132,10 +136,16 @@ public function testAugmentProperties() public function testTypedProperties() { - $analysis = $this->analysisFromFixtures('TypedProperties.php'); - $analysis->process(new MergeIntoOpenApi()); - $analysis->process(new MergeIntoComponents()); - $analysis->process(new AugmentSchemas()); + if ($this->getAnalyzer() instanceof ReflectionAnalyser && PHP_VERSION_ID < 70400) { + $this->markTestSkipped(); + } + + $analysis = $this->analysisFromFixtures(['TypedProperties.php']); + $analysis->process([ + new MergeIntoOpenApi(), + new MergeIntoComponents(), + new AugmentSchemas(), + ]); [ $stringType, $intType, @@ -154,6 +164,7 @@ public function testTypedProperties() $staticUndefined, $staticString, $staticNullableString, + $nativeArray, ] = $analysis->openapi->components->schemas[0]->properties; $this->assertName($stringType, [ @@ -224,8 +235,12 @@ public function testTypedProperties() 'property' => Generator::UNDEFINED, 'type' => Generator::UNDEFINED, ]); + $this->assertName($nativeArray, [ + 'property' => Generator::UNDEFINED, + 'type' => Generator::UNDEFINED, + ]); - $analysis->process(new AugmentProperties()); + $analysis->process([new AugmentProperties()]); $this->assertName($stringType, [ 'property' => 'stringType', @@ -305,6 +320,18 @@ public function testTypedProperties() 'property' => 'staticNullableString', 'type' => 'string', ]); + $this->assertName($nativeArray, [ + 'property' => 'nativeArray', + 'type' => 'array', + ]); + $this->assertObjectHasAttribute( + 'ref', + $nativeArray->items + ); + $this->assertEquals( + 'string', + $nativeArray->items->type + ); } protected function assertName(Property $property, array $expectedValues) diff --git a/tests/Processors/AugmentSchemasTest.php b/tests/Processors/AugmentSchemasTest.php index 94d5acf8d..7cfce1e98 100644 --- a/tests/Processors/AugmentSchemasTest.php +++ b/tests/Processors/AugmentSchemasTest.php @@ -16,29 +16,39 @@ class AugmentSchemasTest extends OpenApiTestCase { public function testAugmentSchemas() { - $analysis = $this->analysisFromFixtures('Customer.php'); - $analysis->process(new MergeIntoOpenApi()); // create openapi->components - $analysis->process(new MergeIntoComponents()); // Merge standalone Scheme's into openapi->components + $analysis = $this->analysisFromFixtures(['Customer.php']); + $analysis->process([ + // create openapi->components + new MergeIntoOpenApi(), + // Merge standalone Scheme's into openapi->components + new MergeIntoComponents(), + ]); $this->assertCount(1, $analysis->openapi->components->schemas); $customer = $analysis->openapi->components->schemas[0]; $this->assertSame(Generator::UNDEFINED, $customer->schema, 'Sanity check. No scheme was defined'); $this->assertSame(Generator::UNDEFINED, $customer->properties, 'Sanity check. @OA\Property\'s not yet merged '); - $analysis->process(new AugmentSchemas()); + $analysis->process([new AugmentSchemas()]); + $this->assertSame('Customer', $customer->schema, '@OA\Schema()->schema based on classname'); $this->assertCount(10, $customer->properties, '@OA\Property()s are merged into the @OA\Schema of the class'); } public function testAugmentSchemasForInterface() { - $analysis = $this->analysisFromFixtures('CustomerInterface.php'); - $analysis->process(new MergeIntoOpenApi()); // create openapi->components - $analysis->process(new MergeIntoComponents()); // Merge standalone Scheme's into openapi->components + $analysis = $this->analysisFromFixtures(['CustomerInterface.php']); + $analysis->process([ + // create openapi->components + new MergeIntoOpenApi(), + // Merge standalone Scheme's into openapi->components + new MergeIntoComponents(), + ]); $this->assertCount(1, $analysis->openapi->components->schemas); $customer = $analysis->openapi->components->schemas[0]; $this->assertSame(Generator::UNDEFINED, $customer->properties, 'Sanity check. @OA\Property\'s not yet merged '); - $analysis->process(new AugmentSchemas()); + $analysis->process([new AugmentSchemas()]); + $this->assertCount(9, $customer->properties, '@OA\Property()s are merged into the @OA\Schema of the class'); } } diff --git a/tests/Processors/BuildPathsTest.php b/tests/Processors/BuildPathsTest.php index 432e5c552..baeada265 100644 --- a/tests/Processors/BuildPathsTest.php +++ b/tests/Processors/BuildPathsTest.php @@ -27,7 +27,8 @@ public function testMergePathsWithSamePath() ]; $analysis = new Analysis([$openapi], $this->getContext()); $analysis->openapi = $openapi; - $analysis->process(new BuildPaths()); + $analysis->process([new BuildPaths()]); + $this->assertCount(1, $openapi->paths); $this->assertSame('/comments', $openapi->paths[0]->path); } @@ -43,8 +44,10 @@ public function testMergeOperationsWithSamePath() ], $this->getContext() ); - $analysis->process(new MergeIntoOpenApi()); - $analysis->process(new BuildPaths()); + $analysis->process([ + new MergeIntoOpenApi(), + new BuildPaths(), + ]); $this->assertCount(1, $openapi->paths); $path = $openapi->paths[0]; $this->assertSame('/comments', $path->path); diff --git a/tests/Processors/CleanUnmergedTest.php b/tests/Processors/CleanUnmergedTest.php index a38240653..47899e5d9 100644 --- a/tests/Processors/CleanUnmergedTest.php +++ b/tests/Processors/CleanUnmergedTest.php @@ -31,9 +31,10 @@ public function testCleanUnmergedProcessor() ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); $this->assertCount(4, $analysis->annotations); - $analysis->process(new MergeIntoOpenApi()); + $analysis->process([new MergeIntoOpenApi()]); + $this->assertCount(5, $analysis->annotations); $before = $analysis->split(); $this->assertCount(3, $before->merged->annotations, 'Generated @OA\OpenApi, @OA\PathItem and @OA\Info'); @@ -42,7 +43,8 @@ public function testCleanUnmergedProcessor() $analysis->validate(); // Validation fails to detect the unmerged annotations. // CleanUnmerged should place the unmerged annotions into the swagger->_unmerged array. - $analysis->process(new CleanUnmerged()); + $analysis->process([new CleanUnmerged()]); + $between = $analysis->split(); $this->assertCount(3, $between->merged->annotations, 'Generated @OA\OpenApi, @OA\PathItem and @OA\Info'); $this->assertCount(2, $between->unmerged->annotations, '@OA\License + @OA\Contact'); @@ -53,10 +55,12 @@ public function testCleanUnmergedProcessor() // When a processor places a previously unmerged annotation into the swagger obect. $license = $analysis->getAnnotationsOfType(License::class)[0]; + /** @var Contact $contact */ $contact = $analysis->getAnnotationsOfType(Contact::class)[0]; $analysis->openapi->info->contact = $contact; $this->assertCount(1, $license->_unmerged); - $analysis->process(new CleanUnmerged()); + $analysis->process([new CleanUnmerged()]); + $this->assertCount(0, $license->_unmerged); $after = $analysis->split(); $this->assertCount(4, $after->merged->annotations, 'Generated @OA\OpenApi, @OA\PathItem, @OA\Info and @OA\Contact'); diff --git a/tests/Processors/DocBlockDescriptionsTest.php b/tests/Processors/DocBlockDescriptionsTest.php index 98490231f..e8ce8cb74 100644 --- a/tests/Processors/DocBlockDescriptionsTest.php +++ b/tests/Processors/DocBlockDescriptionsTest.php @@ -15,12 +15,10 @@ class DocBlockDescriptionsTest extends OpenApiTestCase { public function testDocBlockDescription() { - $analysis = $this->analysisFromFixtures('UsingPhpDoc.php'); - $analysis->process( - [ + $analysis = $this->analysisFromFixtures(['UsingPhpDoc.php']); + $analysis->process([ new DocBlockDescriptions(), - ] - ); + ]); $operations = $analysis->getAnnotationsOfType(Operation::class); $this->assertSame('api/test1', $operations[0]->path); diff --git a/tests/Processors/ExpandClassesTest.php b/tests/Processors/ExpandClassesTest.php index c1ea759c9..989a85365 100644 --- a/tests/Processors/ExpandClassesTest.php +++ b/tests/Processors/ExpandClassesTest.php @@ -16,9 +16,9 @@ use OpenApi\Processors\AugmentSchemas; use OpenApi\Processors\BuildPaths; use OpenApi\Processors\CleanUnmerged; +use OpenApi\Processors\ExpandClasses; use OpenApi\Processors\ExpandInterfaces; use OpenApi\Processors\ExpandTraits; -use OpenApi\Processors\InheritProperties; use OpenApi\Processors\MergeIntoComponents; use OpenApi\Processors\MergeIntoOpenApi; use OpenApi\Tests\OpenApiTestCase; @@ -32,13 +32,13 @@ protected function validate(Analysis $analysis) $analysis->validate(); } - public function testInheritProperties() + public function testExpandClasses() { $analysis = $this->analysisFromFixtures( [ 'AnotherNamespace/Child.php', - 'InheritProperties/GrandAncestor.php', - 'InheritProperties/Ancestor.php', + 'ExpandClasses/GrandAncestor.php', + 'ExpandClasses/Ancestor.php', ] ); $analysis->process([ @@ -52,13 +52,14 @@ public function testInheritProperties() ]); $this->validate($analysis); + /** @var Schema[] $schemas */ $schemas = $analysis->getAnnotationsOfType(Schema::class); $childSchema = $schemas[0]; $this->assertSame('Child', $childSchema->schema); $this->assertCount(1, $childSchema->properties); $analysis->process([ - new InheritProperties(), + new ExpandClasses(), new CleanUnmerged(), ]); $this->validate($analysis); @@ -67,67 +68,48 @@ public function testInheritProperties() } /** - * Tests, if InheritProperties works even without any + * Tests, if ExpandClasses works even without any * docBlocks at all in the parent class. */ - public function testInheritPropertiesWithoutDocBlocks() + public function testExpandClassesWithoutDocBlocks() { $analysis = $this->analysisFromFixtures([ // this class has docblocks 'AnotherNamespace/ChildWithDocBlocks.php', // this one doesn't - 'InheritProperties/AncestorWithoutDocBlocks.php', - ]); - $analysis->process([ - new MergeIntoOpenApi(), - new MergeIntoComponents(), - new ExpandInterfaces(), - new ExpandTraits(), - new AugmentSchemas(), - new AugmentProperties(), - new BuildPaths(), - new InheritProperties(), - new CleanUnmerged(), + 'ExpandClasses/AncestorWithoutDocBlocks.php', ]); + $analysis->process((new Generator())->getProcessors()); $this->validate($analysis); + /** @var Schema[] $schemas */ $schemas = $analysis->getAnnotationsOfType(Schema::class); $childSchema = $schemas[0]; $this->assertSame('ChildWithDocBlocks', $childSchema->schema); $this->assertCount(1, $childSchema->properties); // no error occurs - $analysis->process(new InheritProperties()); + $analysis->process([new ExpandClasses()]); $this->assertCount(1, $childSchema->properties); } /** * Tests inherit properties with all of block. */ - public function testInheritPropertiesWithAllOf() + public function testExpandClassesWithAllOf() { $analysis = $this->analysisFromFixtures([ // this class has all of - 'InheritProperties/Extended.php', - 'InheritProperties/Base.php', - ]); - $analysis->process([ - new MergeIntoOpenApi(), - new MergeIntoComponents(), - new ExpandInterfaces(), - new ExpandTraits(), - new AugmentSchemas(), - new AugmentProperties(), - new BuildPaths(), - new InheritProperties(), - new CleanUnmerged(), + 'ExpandClasses/Extended.php', + 'ExpandClasses/Base.php', ]); -// $this->validate($analysis); + $analysis->process((new Generator())->getProcessors()); + $this->validate($analysis); + /** @var Schema[] $schemas */ $schemas = $analysis->getAnnotationsOfType(Schema::class, true); $this->assertCount(3, $schemas); - /* @var Schema $extendedSchema */ $extendedSchema = $schemas[0]; $this->assertSame('ExtendedModel', $extendedSchema->schema); $this->assertSame(Generator::UNDEFINED, $extendedSchema->properties); @@ -135,7 +117,6 @@ public function testInheritPropertiesWithAllOf() $this->assertArrayHasKey(0, $extendedSchema->allOf); $this->assertEquals($extendedSchema->allOf[0]->properties[0]->property, 'extendedProperty'); - /* @var $includeSchemaWithRef Schema */ $includeSchemaWithRef = $schemas[1]; $this->assertSame(Generator::UNDEFINED, $includeSchemaWithRef->properties); } @@ -143,81 +124,60 @@ public function testInheritPropertiesWithAllOf() /** * Tests for inherit properties without all of block. */ - public function testInheritPropertiesWithOutAllOf() + public function testExpandClassesWithOutAllOf() { $analysis = $this->analysisFromFixtures([ // this class has all of - 'InheritProperties/ExtendedWithoutAllOf.php', - 'InheritProperties/Base.php', - ]); - $analysis->process([ - new MergeIntoOpenApi(), - new MergeIntoComponents(), - new ExpandInterfaces(), - new ExpandTraits(), - new AugmentSchemas(), - new AugmentProperties(), - new BuildPaths(), - new InheritProperties(), - new CleanUnmerged(), + 'ExpandClasses/ExtendedWithoutAllOf.php', + 'ExpandClasses/Base.php', ]); + $analysis->process((new Generator())->getProcessors()); $this->validate($analysis); + /** @var Schema[] $schemas */ $schemas = $analysis->getAnnotationsOfType(Schema::class, true); $this->assertCount(2, $schemas); - /* @var Schema $extendedSchema */ $extendedSchema = $schemas[0]; $this->assertSame('ExtendedWithoutAllOf', $extendedSchema->schema); $this->assertSame(Generator::UNDEFINED, $extendedSchema->properties); $this->assertCount(2, $extendedSchema->allOf); - $this->assertEquals($extendedSchema->allOf[0]->ref, Components::SCHEMA_REF . 'Base'); - $this->assertEquals($extendedSchema->allOf[1]->properties[0]->property, 'extendedProperty'); + $this->assertEquals(Components::SCHEMA_REF . 'Base', $extendedSchema->allOf[1]->ref); + $this->assertEquals('extendedProperty', $extendedSchema->allOf[0]->properties[0]->property); } /** * Tests for inherit properties in object with two schemas in the same context. */ - public function testInheritPropertiesWitTwoChildSchemas() + public function testExpandClassesWithTwoChildSchemas() { $analysis = $this->analysisFromFixtures([ // this class has all of - 'InheritProperties/ExtendedWithTwoSchemas.php', - 'InheritProperties/Base.php', - ]); - $analysis->process([ - new MergeIntoOpenApi(), - new MergeIntoComponents(), - new ExpandInterfaces(), - new ExpandTraits(), - new AugmentSchemas(), - new AugmentProperties(), - new BuildPaths(), - new InheritProperties(), - new CleanUnmerged(), + 'ExpandClasses/ExtendedWithTwoSchemas.php', + 'ExpandClasses/Base.php', ]); + $analysis->process((new Generator())->getProcessors()); $this->validate($analysis); + /** @var Schema[] $schemas */ $schemas = $analysis->getAnnotationsOfType(Schema::class, true); $this->assertCount(3, $schemas); - /* @var Schema $extendedSchema */ $extendedSchema = $schemas[0]; $this->assertSame('ExtendedWithTwoSchemas', $extendedSchema->schema); $this->assertSame(Generator::UNDEFINED, $extendedSchema->properties); $this->assertCount(2, $extendedSchema->allOf); - $this->assertEquals($extendedSchema->allOf[0]->ref, Components::SCHEMA_REF . 'Base'); - $this->assertEquals($extendedSchema->allOf[1]->properties[0]->property, 'nested'); - $this->assertEquals($extendedSchema->allOf[1]->properties[1]->property, 'extendedProperty'); + $this->assertEquals(Components::SCHEMA_REF . 'Base', $extendedSchema->allOf[1]->ref); + $this->assertEquals('nested', $extendedSchema->allOf[0]->properties[1]->property); + $this->assertEquals('extendedProperty', $extendedSchema->allOf[0]->properties[0]->property); - /* @var $nestedSchema Schema */ $nestedSchema = $schemas[1]; - $this->assertSame(Generator::UNDEFINED, $nestedSchema->allOf); - $this->assertCount(1, $nestedSchema->properties); - $this->assertEquals($nestedSchema->properties[0]->property, 'nestedProperty'); + $this->assertCount(2, $nestedSchema->allOf); + $this->assertCount(1, $nestedSchema->allOf[0]->properties); + $this->assertEquals('nestedProperty', $nestedSchema->allOf[0]->properties[0]->property); } /** @@ -226,22 +186,12 @@ public function testInheritPropertiesWitTwoChildSchemas() public function testPreserveExistingAllOf() { $analysis = $this->analysisFromFixtures([ - 'InheritProperties/BaseInterface.php', - 'InheritProperties/ExtendsBaseThatImplements.php', - 'InheritProperties/BaseThatImplements.php', - 'InheritProperties/TraitUsedByExtendsBaseThatImplements.php', - ]); - $analysis->process([ - new MergeIntoOpenApi(), - new MergeIntoComponents(), - new ExpandInterfaces(), - new ExpandTraits(), - new AugmentSchemas(), - new AugmentProperties(), - new BuildPaths(), - new InheritProperties(), - new CleanUnmerged(), + 'ExpandClasses/BaseInterface.php', + 'ExpandClasses/ExtendsBaseThatImplements.php', + 'ExpandClasses/BaseThatImplements.php', + 'ExpandClasses/TraitUsedByExtendsBaseThatImplements.php', ]); + $analysis->process((new Generator())->getProcessors()); $this->validate($analysis); $analysis->openapi->info = new Info(['title' => 'test', 'version' => '1.0.0', '_context' => $this->getContext()]); diff --git a/tests/Processors/MergeIntoComponentsTest.php b/tests/Processors/MergeIntoComponentsTest.php index 2f07626d5..d379f7e13 100644 --- a/tests/Processors/MergeIntoComponentsTest.php +++ b/tests/Processors/MergeIntoComponentsTest.php @@ -27,7 +27,8 @@ public function testProcessor() $this->getContext() ); $this->assertSame(Generator::UNDEFINED, $openapi->components); - $analysis->process(new MergeIntoComponents()); + $analysis->process([new MergeIntoComponents()]); + $this->assertCount(1, $openapi->components->responses); $this->assertSame($response, $openapi->components->responses[0]); $this->assertCount(0, $analysis->unmerged()->annotations); diff --git a/tests/Processors/MergeIntoOpenApiTest.php b/tests/Processors/MergeIntoOpenApiTest.php index 544e45ae4..4166474c9 100644 --- a/tests/Processors/MergeIntoOpenApiTest.php +++ b/tests/Processors/MergeIntoOpenApiTest.php @@ -28,7 +28,8 @@ public function testProcessor() ); $this->assertSame($openapi, $analysis->openapi); $this->assertSame(Generator::UNDEFINED, $openapi->info); - $analysis->process(new MergeIntoOpenApi()); + $analysis->process([new MergeIntoOpenApi()]); + $this->assertSame($openapi, $analysis->openapi); $this->assertSame($info, $openapi->info); $this->assertCount(0, $analysis->unmerged()->annotations); diff --git a/tests/Processors/MergeJsonContentTest.php b/tests/Processors/MergeJsonContentTest.php index 04075b906..102ad4a0a 100644 --- a/tests/Processors/MergeJsonContentTest.php +++ b/tests/Processors/MergeJsonContentTest.php @@ -24,12 +24,14 @@ public function testJsonContent() ) ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); $this->assertCount(3, $analysis->annotations); + /** @var Response $response */ $response = $analysis->getAnnotationsOfType(Response::class)[0]; $this->assertSame(Generator::UNDEFINED, $response->content); $this->assertCount(1, $response->_unmerged); - $analysis->process(new MergeJsonContent()); + $analysis->process([new MergeJsonContent()]); + $this->assertCount(1, $response->content); $this->assertCount(0, $response->_unmerged); $json = json_decode(json_encode($response), true); @@ -46,10 +48,12 @@ public function testMultipleMediaTypes() ) ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); + /** @var Response $response */ $response = $analysis->getAnnotationsOfType(Response::class)[0]; $this->assertCount(1, $response->content); - $analysis->process(new MergeJsonContent()); + $analysis->process([new MergeJsonContent()]); + $this->assertCount(2, $response->content); } @@ -61,12 +65,14 @@ public function testParameter() @OA\Property(property="color", type="string") )) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); $this->assertCount(4, $analysis->annotations); + /** @var Parameter $parameter */ $parameter = $analysis->getAnnotationsOfType(Parameter::class)[0]; $this->assertSame(Generator::UNDEFINED, $parameter->content); $this->assertCount(1, $parameter->_unmerged); - $analysis->process(new MergeJsonContent()); + $analysis->process([new MergeJsonContent()]); + $this->assertCount(1, $parameter->content); $this->assertCount(0, $parameter->_unmerged); $json = json_decode(json_encode($parameter), true); @@ -83,8 +89,8 @@ public function testNoParent() @OA\Items(ref="#/components/schemas/repository") ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); - $analysis->process(new MergeJsonContent()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); + $analysis->process([new MergeJsonContent()]); } public function testInvalidParent() @@ -97,7 +103,7 @@ public function testInvalidParent() ) ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); - $analysis->process(new MergeJsonContent()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); + $analysis->process([new MergeJsonContent()]); } } diff --git a/tests/Processors/MergeXmlContentTest.php b/tests/Processors/MergeXmlContentTest.php index d7de9934b..01826c8be 100644 --- a/tests/Processors/MergeXmlContentTest.php +++ b/tests/Processors/MergeXmlContentTest.php @@ -24,12 +24,14 @@ public function testXmlContent() ) ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); $this->assertCount(3, $analysis->annotations); + /** @var Response $response */ $response = $analysis->getAnnotationsOfType(Response::class)[0]; $this->assertSame(Generator::UNDEFINED, $response->content); $this->assertCount(1, $response->_unmerged); - $analysis->process(new MergeXmlContent()); + $analysis->process([new MergeXmlContent()]); + $this->assertCount(1, $response->content); $this->assertCount(0, $response->_unmerged); $json = json_decode(json_encode($response), true); @@ -46,10 +48,11 @@ public function testMultipleMediaTypes() ) ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); + /** @var Response $response */ $response = $analysis->getAnnotationsOfType(Response::class)[0]; $this->assertCount(1, $response->content); - $analysis->process(new MergeXmlContent()); + $analysis->process([new MergeXmlContent()]); $this->assertCount(2, $response->content); } @@ -61,12 +64,14 @@ public function testParameter() @OA\Property(property="color", type="string") )) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); $this->assertCount(4, $analysis->annotations); + /** @var Parameter $parameter */ $parameter = $analysis->getAnnotationsOfType(Parameter::class)[0]; $this->assertSame(Generator::UNDEFINED, $parameter->content); $this->assertCount(1, $parameter->_unmerged); - $analysis->process(new MergeXmlContent()); + $analysis->process([new MergeXmlContent()]); + $this->assertCount(1, $parameter->content); $this->assertCount(0, $parameter->_unmerged); $json = json_decode(json_encode($parameter), true); @@ -83,8 +88,8 @@ public function testNoParent() @OA\Items(ref="#/components/schemas/repository") ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); - $analysis->process(new MergeXmlContent()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); + $analysis->process([new MergeXmlContent()]); } public function testInvalidParent() @@ -97,7 +102,7 @@ public function testInvalidParent() ) ) END; - $analysis = new Analysis($this->parseComment($comment), $this->getContext()); - $analysis->process(new MergeXmlContent()); + $analysis = new Analysis($this->annotationsFromDocBlockParser($comment), $this->getContext()); + $analysis->process([new MergeXmlContent()]); } } diff --git a/tests/RefTest.php b/tests/RefTest.php index 36788e973..f3b7fe885 100644 --- a/tests/RefTest.php +++ b/tests/RefTest.php @@ -9,6 +9,7 @@ use OpenApi\Analysis; use OpenApi\Annotations\Info; use OpenApi\Annotations\Response; +use OpenApi\Generator; class RefTest extends OpenApiTestCase { @@ -24,10 +25,10 @@ public function testRef() @OA\Response(response="default", description="A response") ) END; - $openapi->merge($this->parseComment($comment)); + $openapi->merge($this->annotationsFromDocBlockParser($comment)); $analysis = new Analysis([], $this->getContext()); $analysis->addAnnotation($openapi, $this->getContext()); - $analysis->process(); + $analysis->process((new Generator())->getProcessors()); $analysis->validate(); // escape / as ~1 diff --git a/tests/UtilTest.php b/tests/UtilTest.php index 8bf6b23e7..341542f48 100644 --- a/tests/UtilTest.php +++ b/tests/UtilTest.php @@ -6,6 +6,7 @@ namespace OpenApi\Tests; +use OpenApi\Analysers\TokenAnalyser; use OpenApi\Annotations\Get; use OpenApi\Annotations\Post; use OpenApi\Generator; @@ -21,12 +22,18 @@ public function testExclude() 'CustomerInterface.php', 'GrandAncestor.php', 'InheritProperties', - 'Parser', + 'Apis', + 'PHP', + 'Analysers', 'Processors', 'UsingRefs.php', 'UsingPhpDoc.php', + 'UsingCustomAttachables', + ]; - $openapi = Generator::scan(Util::finder(__DIR__ . '/Fixtures', $exclude)); + $openapi = (new Generator()) + ->setAnalyser(new TokenAnalyser()) + ->generate(Util::finder(__DIR__ . '/Fixtures', $exclude)); $this->assertSame('Fixture for ParserTest', $openapi->info->title, 'No errors about duplicate @OA\Info() annotations'); }