├── src ├── Parser │ ├── Annotations │ │ ├── QueryParamAnnotation.php │ │ ├── LabelAnnotation.php │ │ ├── DescriptionAnnotation.php │ │ ├── OperationIdAnnotation.php │ │ ├── ContentTypeAnnotation.php │ │ ├── GroupAnnotation.php │ │ ├── VendorTagAnnotation.php │ │ ├── PathParamAnnotation.php │ │ ├── MaxVersionAnnotation.php │ │ ├── MinVersionAnnotation.php │ │ ├── ScopeAnnotation.php │ │ ├── PathAnnotation.php │ │ ├── ReturnAnnotation.php │ │ ├── Traits │ │ │ └── HasHttpCodeResponseTrait.php │ │ ├── ErrorAnnotation.php │ │ ├── ParamAnnotation.php │ │ └── DataAnnotation.php │ ├── Version.php │ ├── Resource │ │ └── Documentation.php │ └── Representation │ │ ├── Documentation.php │ │ └── RepresentationParser.php ├── Contracts │ └── Arrayable.php ├── Exceptions │ ├── Resource │ │ ├── ResourceExceptionTrait.php │ │ ├── NoAnnotationsException.php │ │ ├── TooManyAliasedPathsException.php │ │ ├── MissingVisibilityDecoratorException.php │ │ ├── PublicDecoratorOnPrivateActionException.php │ │ └── UnsupportedDecoratorException.php │ ├── Representation │ │ ├── RepresentationExceptionTrait.php │ │ ├── DuplicateFieldException.php │ │ └── RestrictedFieldNameException.php │ ├── MethodNotSuppliedException.php │ ├── Config │ │ ├── ValidationException.php │ │ ├── UncallableRepresentationException.php │ │ ├── UncallableErrorRepresentationException.php │ │ ├── UnconfiguredRepresentationException.php │ │ └── UnconfiguredErrorRepresentationException.php │ ├── MethodNotImplementedException.php │ ├── BaseException.php │ ├── Annotations │ │ ├── RequiredAnnotationException.php │ │ ├── AnnotationExceptionTrait.php │ │ ├── UnsupportedTypeException.php │ │ ├── UnknownRepresentationException.php │ │ ├── MultipleAnnotationsException.php │ │ ├── UnknownReturnCodeException.php │ │ ├── UnknownErrorRepresentationException.php │ │ ├── AbsoluteVersionException.php │ │ ├── MissingRepresentationErrorCodeException.php │ │ ├── InvalidScopeSuppliedException.php │ │ ├── InvalidGroupSuppliedException.php │ │ ├── MissingRequiredFieldException.php │ │ ├── InvalidMSONSyntaxException.php │ │ ├── InvalidVendorTagSuppliedException.php │ │ └── BadOptionsListException.php │ ├── MSON │ │ ├── ImproperlyWrittenEnumException.php │ │ ├── MissingOptionsException.php │ │ └── MissingSubtypeException.php │ └── Version │ │ └── UnrecognizedSchemaException.php ├── Compiler │ ├── Traits │ │ ├── Markdown.php │ │ └── ChangelogTemplate.php │ ├── Changelog │ │ ├── Changeset.php │ │ ├── Changesets │ │ │ ├── ContentType.php │ │ │ ├── Action.php │ │ │ ├── RepresentationData.php │ │ │ ├── ActionError.php │ │ │ ├── ActionParam.php │ │ │ └── ActionReturn.php │ │ └── Formats │ │ │ ├── Markdown.php │ │ │ └── Json.php │ ├── Specification.php │ ├── ErrorMap │ │ └── Formats │ │ │ └── Markdown.php │ ├── ErrorMap.php │ └── Specification │ │ └── OpenApi │ │ └── TagReducer.php ├── Provider │ ├── Filesystem.php │ ├── Config.php │ └── Reader.php ├── Command │ ├── Changelog.php │ ├── BaseCompiler.php │ ├── ErrorMap.php │ └── Compile.php ├── Command.php ├── Application.php ├── Container.php ├── Reader.php └── Parser.php ├── bin └── mill ├── README.md ├── logo.svg ├── .github └── workflows │ └── php.yml ├── LICENSE ├── CONTRIBUTING.md ├── composer.json └── Makefile /src/Parser/Annotations/QueryParamAnnotation.php: -------------------------------------------------------------------------------- 1 | decorator; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exceptions/Representation/RepresentationExceptionTrait.php: -------------------------------------------------------------------------------- 1 | field; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/Exceptions/MethodNotSuppliedException.php: -------------------------------------------------------------------------------- 1 | class = $class; 19 | 20 | return $exception; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Compiler/Traits/Markdown.php: -------------------------------------------------------------------------------- 1 | class = $class; 21 | $exception->method = $method; 22 | 23 | return $exception; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /bin/mill: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | add(new Mill\Command\Changelog); 22 | $application->add(new Mill\Command\Compile); 23 | $application->add(new Mill\Command\ErrorMap); 24 | 25 | $application->run(); 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ☴ Mill 2 | === 3 | 4 | [![Packagist](https://img.shields.io/packagist/v/erunion/mill.svg)](https://packagist.org/packages/erunion/mill) 5 | [![Build](https://github.com/erunion/mill/workflows/CI/badge.svg)](https://github.com/erunion/mill) 6 | 7 | Mill is an annotation-based DSL for documenting a REST API and assisting you in creating [OpenAPI](https://swagger.io/) or [API Blueprint](https://apiblueprint.org/) specifications for your API. 8 | 9 | For detailed documentation, check out the [wiki](https://github.com/erunion/mill/wiki). 10 | 11 | ## Features 12 | 13 | * Compile versioned API documentation into OpenAPI or API Blueprint specifications. 14 | * Automatically generate API changelogs 15 | * Production ready 16 | 17 | ## Installation 18 | ``` 19 | composer require erunion/mill 20 | ``` 21 | -------------------------------------------------------------------------------- /src/Exceptions/Config/UncallableRepresentationException.php: -------------------------------------------------------------------------------- 1 | representation = $representation; 24 | 25 | return $exception; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/Config/UncallableErrorRepresentationException.php: -------------------------------------------------------------------------------- 1 | representation = $representation; 24 | 25 | return $exception; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/Config/UnconfiguredRepresentationException.php: -------------------------------------------------------------------------------- 1 | representation = $representation; 24 | 25 | return $exception; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Exceptions/Config/UnconfiguredErrorRepresentationException.php: -------------------------------------------------------------------------------- 1 | representation = $representation; 24 | 25 | return $exception; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /src/Exceptions/Representation/DuplicateFieldException.php: -------------------------------------------------------------------------------- 1 | field = $field; 27 | $exception->class = $class; 28 | $exception->method = $method; 29 | 30 | return $exception; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Exceptions/Resource/NoAnnotationsException.php: -------------------------------------------------------------------------------- 1 | class = $class; 25 | $exception->method = $method; 26 | 27 | return $exception; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Exceptions/Resource/TooManyAliasedPathsException.php: -------------------------------------------------------------------------------- 1 | class = $class; 26 | $exception->method = $method; 27 | 28 | return $exception; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Exceptions/Representation/RestrictedFieldNameException.php: -------------------------------------------------------------------------------- 1 | class = $class; 27 | $exception->method = $method; 28 | 29 | return $exception; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Exceptions/BaseException.php: -------------------------------------------------------------------------------- 1 | class; 23 | } 24 | 25 | /** 26 | * Get the class method that this exception occurred in. 27 | * 28 | * @return null|string 29 | */ 30 | public function getMethod(): ?string 31 | { 32 | return $this->method; 33 | } 34 | 35 | /** 36 | * Get the name of the annotation that this exception is for. 37 | * 38 | * @return null|string 39 | */ 40 | public function getAnnotation(): ?string 41 | { 42 | return $this->annotation; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/RequiredAnnotationException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 27 | $exception->class = $class; 28 | $exception->method = $method; 29 | 30 | return $exception; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /.github/workflows/php.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v1 12 | 13 | # Using this because intl isn't available by default right now. 14 | # https://github.com/actions/virtual-environments/issues/3 15 | - uses: shivammathur/setup-php@master 16 | with: 17 | php-version: '7.3' 18 | extensions: intl 19 | 20 | - name: Validate composer.json and composer.lock 21 | run: composer validate 22 | 23 | - name: Install dependencies 24 | run: composer install 25 | 26 | - name: Verify code standards 27 | run: make phpcs 28 | 29 | - name: Run static analysis 30 | run: make psalm 31 | 32 | - name: Unit tests 33 | run: make phpunit 34 | 35 | # Add a test script to composer.json, for instance: "test": "vendor/bin/phpunit" 36 | # Docs: https://getcomposer.org/doc/articles/scripts.md 37 | 38 | # - name: Run test suite 39 | # run: composer run-script test 40 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/AnnotationExceptionTrait.php: -------------------------------------------------------------------------------- 1 | docblock; 23 | } 24 | 25 | /** 26 | * Get the required field that this annotation is missing. 27 | * 28 | * @return null|string 29 | */ 30 | public function getRequiredField(): ?string 31 | { 32 | return $this->required_field; 33 | } 34 | 35 | /** 36 | * Get the array of values that this exception allows. 37 | * 38 | * @return array 39 | */ 40 | public function getValues(): array 41 | { 42 | return $this->values; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/UnsupportedTypeException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 27 | $exception->class = $class; 28 | $exception->method = $method; 29 | 30 | return $exception; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Parser/Annotations/LabelAnnotation.php: -------------------------------------------------------------------------------- 1 | $this->docblock 22 | ]; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected function interpreter(): void 29 | { 30 | $this->label = $this->required('label'); 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function getLabel(): string 37 | { 38 | return $this->label; 39 | } 40 | 41 | /** 42 | * @param string $label 43 | * @return LabelAnnotation 44 | */ 45 | public function setLabel(string $label): self 46 | { 47 | $this->label = $label; 48 | return $this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Exceptions/Resource/MissingVisibilityDecoratorException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 30 | $exception->class = $class; 31 | $exception->method = $method; 32 | 33 | return $exception; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/UnknownRepresentationException.php: -------------------------------------------------------------------------------- 1 | docblock = $representation; 30 | $exception->class = $class; 31 | $exception->method = $method; 32 | 33 | return $exception; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exceptions/MSON/ImproperlyWrittenEnumException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 28 | $exception->class = $class; 29 | $exception->method = $method; 30 | 31 | return $exception; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/Exceptions/Resource/PublicDecoratorOnPrivateActionException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 30 | $exception->class = $class; 31 | $exception->method = $method; 32 | 33 | return $exception; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/MultipleAnnotationsException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 30 | $exception->class = $class; 31 | $exception->method = $method; 32 | 33 | return $exception; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/UnknownReturnCodeException.php: -------------------------------------------------------------------------------- 1 | docblock = $docblock; 33 | $exception->class = $class; 34 | $exception->method = $method; 35 | 36 | return $exception; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/UnknownErrorRepresentationException.php: -------------------------------------------------------------------------------- 1 | docblock = $representation; 30 | $exception->class = $class; 31 | $exception->method = $method; 32 | 33 | return $exception; 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Jon Ursenbach 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/AbsoluteVersionException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 33 | $exception->class = $class; 34 | $exception->method = $method; 35 | 36 | return $exception; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Parser/Annotations/DescriptionAnnotation.php: -------------------------------------------------------------------------------- 1 | $this->docblock 22 | ]; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected function interpreter(): void 29 | { 30 | $this->description = $this->required('description'); 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function getDescription(): string 37 | { 38 | return $this->description; 39 | } 40 | 41 | /** 42 | * @param string $description 43 | * @return DescriptionAnnotation 44 | */ 45 | public function setDescription(string $description): self 46 | { 47 | $this->description = $description; 48 | return $this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Parser/Annotations/OperationIdAnnotation.php: -------------------------------------------------------------------------------- 1 | $this->docblock 22 | ]; 23 | } 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected function interpreter(): void 29 | { 30 | $this->operation_id = $this->required('operation_id'); 31 | } 32 | 33 | /** 34 | * @return string 35 | */ 36 | public function getOperationId(): string 37 | { 38 | return $this->operation_id; 39 | } 40 | 41 | /** 42 | * @param string $operation_id 43 | * @return OperationIdAnnotation 44 | */ 45 | public function setOperationId(string $operation_id): self 46 | { 47 | $this->operation_id = $operation_id; 48 | return $this; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Exceptions/Resource/UnsupportedDecoratorException.php: -------------------------------------------------------------------------------- 1 | decorator = $decorator; 33 | $exception->annotation = $annotation; 34 | $exception->class = $class; 35 | $exception->method = $method; 36 | 37 | return $exception; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Exceptions/MSON/MissingOptionsException.php: -------------------------------------------------------------------------------- 1 | type = $type; 28 | $exception->class = $class; 29 | $exception->method = $method; 30 | 31 | return $exception; 32 | } 33 | 34 | /** 35 | * Get the type that this response exception was triggered for. 36 | * 37 | * @return null|string 38 | */ 39 | public function getType(): ?string 40 | { 41 | return $this->type; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Parser/Annotations/ContentTypeAnnotation.php: -------------------------------------------------------------------------------- 1 | $this->docblock 24 | ]; 25 | } 26 | 27 | /** 28 | * {@inheritdoc} 29 | */ 30 | protected function interpreter(): void 31 | { 32 | $this->content_type = $this->required('content_type'); 33 | } 34 | 35 | /** 36 | * @return string 37 | */ 38 | public function getContentType(): string 39 | { 40 | return $this->content_type; 41 | } 42 | 43 | /** 44 | * @param string $content_type 45 | * @return ContentTypeAnnotation 46 | */ 47 | public function setContentType(string $content_type): self 48 | { 49 | $this->content_type = $content_type; 50 | return $this; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/MissingRepresentationErrorCodeException.php: -------------------------------------------------------------------------------- 1 | docblock = $representation; 32 | $exception->class = $class; 33 | $exception->method = $method; 34 | 35 | return $exception; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Exceptions/MSON/MissingSubtypeException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 28 | $exception->class = $class; 29 | $exception->method = $method; 30 | 31 | return $exception; 32 | } 33 | 34 | /** 35 | * Get the annotation that this MSON exception was triggered for. 36 | * 37 | * @return null|string 38 | */ 39 | public function getAnnotation(): ?string 40 | { 41 | return $this->annotation; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/InvalidScopeSuppliedException.php: -------------------------------------------------------------------------------- 1 | scope = $scope; 30 | $exception->class = $class; 31 | $exception->method = $method; 32 | 33 | return $exception; 34 | } 35 | 36 | /** 37 | * Get the scope that this exception occurred for. 38 | * 39 | * @return string 40 | */ 41 | public function getScope(): string 42 | { 43 | return $this->scope; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/InvalidGroupSuppliedException.php: -------------------------------------------------------------------------------- 1 | group = $group; 33 | $exception->class = $class; 34 | $exception->method = $method; 35 | 36 | return $exception; 37 | } 38 | 39 | /** 40 | * Get the vendor tag that this exception occurred with. 41 | * 42 | * @return string 43 | */ 44 | public function getGroup(): string 45 | { 46 | return $this->group; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/MissingRequiredFieldException.php: -------------------------------------------------------------------------------- 1 | required_field = $required_field; 36 | $exception->annotation = $annotation; 37 | $exception->docblock = $docblock; 38 | $exception->class = $class; 39 | $exception->method = $method; 40 | 41 | return $exception; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/InvalidMSONSyntaxException.php: -------------------------------------------------------------------------------- 1 | required_field = $required_field; 36 | $exception->annotation = $annotation; 37 | $exception->docblock = $docblock; 38 | $exception->class = $class; 39 | $exception->method = $method; 40 | 41 | return $exception; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/InvalidVendorTagSuppliedException.php: -------------------------------------------------------------------------------- 1 | vendor_tag = $vendor_tag; 33 | $exception->class = $class; 34 | $exception->method = $method; 35 | 36 | return $exception; 37 | } 38 | 39 | /** 40 | * Get the vendor tag that this exception occurred with. 41 | * 42 | * @return string 43 | */ 44 | public function getVendorTag(): string 45 | { 46 | return $this->vendor_tag; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Provider/Reader.php: -------------------------------------------------------------------------------- 1 | getAnnotations($class, $method); 25 | }; 26 | }; 27 | 28 | $container['reader.annotations.representation'] = function (Container $c): Closure { 29 | return 30 | /** 31 | * @psalm-param class-string $class 32 | */ 33 | function (string $class, string $method): string { 34 | return (new \Mill\Reader)->getRepresentationAnnotations($class, $method); 35 | }; 36 | }; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Exceptions/Annotations/BadOptionsListException.php: -------------------------------------------------------------------------------- 1 | annotation = $annotation; 37 | $exception->docblock = $docblock; 38 | $exception->values = $values; 39 | $exception->class = $class; 40 | $exception->method = $method; 41 | 42 | return $exception; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Changeset.php: -------------------------------------------------------------------------------- 1 | setName('changelog') 18 | ->setDescription('Compiles a changelog from your API documentation.'); 19 | } 20 | 21 | /** 22 | * @param InputInterface $input 23 | * @param OutputInterface $output 24 | * @return int 25 | * @throws \Exception 26 | */ 27 | protected function execute(InputInterface $input, OutputInterface $output) 28 | { 29 | parent::execute($input, $output); 30 | 31 | /** @var \League\Flysystem\Filesystem $filesystem */ 32 | $filesystem = $this->container['filesystem']; 33 | 34 | $output->writeln('Compiling a changelog...'); 35 | 36 | $changelog = new Compiler\Changelog($this->app); 37 | $changelog->setLoadPrivateDocs($this->private_docs); 38 | $changelog->setLoadVendorTagDocs($this->vendor_tags); 39 | $markdown = $changelog->toMarkdown(); 40 | 41 | $filesystem->put($this->output_dir . DIRECTORY_SEPARATOR . 'changelog.md', trim($markdown)); 42 | 43 | $output->writeln(['', 'Done!']); 44 | 45 | return 0; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Command.php: -------------------------------------------------------------------------------- 1 | addOption( 25 | 'config', 26 | null, 27 | InputOption::VALUE_OPTIONAL, 28 | 'Path to your `mill.xml` config file.', 29 | 'mill.xml' 30 | ); 31 | } 32 | 33 | /** 34 | * @param InputInterface $input 35 | * @param OutputInterface $output 36 | * @return int 37 | */ 38 | protected function execute(InputInterface $input, OutputInterface $output) 39 | { 40 | $style = new OutputFormatterStyle('green', null, ['bold']); 41 | $output->getFormatter()->setStyle('success', $style); 42 | 43 | /** @var string $config_file */ 44 | $config_file = $input->getOption('config'); 45 | $config_file = realpath($config_file); 46 | 47 | $this->app = new Application($config_file); 48 | $this->container = $this->app->getContainer(); 49 | 50 | return 0; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Contributing 2 | === 3 | 4 | Contributions are **welcome** and will be fully **credited**. 5 | 6 | We accept contributions via Pull Requests on [Github](https://github.com/erunion/mill). 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with `make phpcs`. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and our [documentation](https://github.com/erunion/mill/master/docs) are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | ## Running Tests 25 | 26 | ```bash 27 | make test 28 | ``` 29 | 30 | ### Static analysis checks 31 | To keep code tidy, and also prevent common unseen issues, you should also run static analysis checks with [Psalm](https://github.com/vimeo/psalm): 32 | 33 | ```bash 34 | make psalm 35 | ``` 36 | 37 | **Happy coding**! 38 | -------------------------------------------------------------------------------- /src/Parser/Annotations/GroupAnnotation.php: -------------------------------------------------------------------------------- 1 | docblock); 22 | 23 | if (!empty($group)) { 24 | // Validate the supplied vendor tag with what has been configured as allowable. 25 | $tags = $this->application->getConfig()->getTags(); 26 | if (!array_key_exists($group, $tags)) { 27 | /** @var string $method */ 28 | $method = $this->method; 29 | throw InvalidGroupSuppliedException::create($group, $this->class, $method); 30 | } 31 | } 32 | 33 | return [ 34 | 'group' => $group 35 | ]; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | protected function interpreter(): void 42 | { 43 | $this->group = $this->required('group'); 44 | } 45 | 46 | /** 47 | * @return string 48 | */ 49 | public function getGroup(): string 50 | { 51 | return $this->group; 52 | } 53 | 54 | /** 55 | * @param string $group 56 | * @return GroupAnnotation 57 | */ 58 | public function setGroup(string $group): self 59 | { 60 | $this->group = $group; 61 | return $this; 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/Application.php: -------------------------------------------------------------------------------- 1 | container = new Container([ 32 | 'config.path' => $config_path, 33 | 'config.load_bootstrap' => $load_bootstrap 34 | ]); 35 | } 36 | 37 | /** 38 | * @return Container 39 | */ 40 | public function getContainer(): Container 41 | { 42 | return $this->container; 43 | } 44 | 45 | /** 46 | * @param Container $container 47 | */ 48 | public function setContainer(Container $container): void 49 | { 50 | $this->container = $container; 51 | } 52 | 53 | /** 54 | * @return Config 55 | */ 56 | public function getConfig(): Config 57 | { 58 | return $this->container->getConfig(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Compiler/Specification.php: -------------------------------------------------------------------------------- 1 | specifications)) { 19 | $this->compile(); 20 | } 21 | 22 | return $this->specifications; 23 | } 24 | 25 | /** 26 | * Convert an MSON sample data into a piece of data for that appropriate field type. 27 | * 28 | * @param bool|string $data 29 | * @param string $type 30 | * @return bool|string 31 | * 32 | * @psalm-suppress InvalidOperand Suppressing this because we're intentionally converting a string to an int/float. 33 | */ 34 | protected function convertSampleDataToCompatibleDataType($data, string $type) 35 | { 36 | if ($type === 'boolean') { 37 | if ($data === '0') { 38 | return 'false'; 39 | } elseif ($data === '1') { 40 | return 'true'; 41 | } 42 | } elseif ($type === 'number' && $data !== false) { 43 | // This is really gross, but there's no standard way in PHP to take a string that can either be a float or 44 | // an int and convert it into a strictly typed float or int. 45 | // 46 | // Adding zero to the string is the only real way to force this type conversion. 47 | return $data + 0; 48 | } 49 | 50 | return $data; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Changesets/ContentType.php: -------------------------------------------------------------------------------- 1 | [ 16 | Changelog::DEFINITION_CHANGED => 'On {path}, {method} requests now return a {content_type} ' . 17 | 'Content-Type header.' 18 | ] 19 | ]; 20 | } 21 | 22 | /** 23 | * {@inheritDoc} 24 | */ 25 | public function compileAddedOrRemovedChangeset(string $definition, array $changes = []) 26 | { 27 | throw new \Exception($definition . ' content type changes are not yet supported.'); 28 | } 29 | 30 | /** 31 | * {@inheritDoc} 32 | */ 33 | public function compileChangedChangeset(string $definition, array $changes = []) 34 | { 35 | $templates = $this->getTemplates(); 36 | 37 | if (count($changes) > 1) { 38 | $paths = array_map(function (array $change): string { 39 | return $change['path']; 40 | }, $changes); 41 | 42 | // Changes are hashed and grouped by their hashes (sans path), so it's safe to just pass along this change 43 | // into the template engine to build a string. 44 | $change = array_shift($changes); 45 | $change['path'] = $paths; 46 | } else { 47 | $change = array_shift($changes); 48 | } 49 | 50 | $template = $templates['singular'][$definition]; 51 | return $this->renderText($template, $change); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Exceptions/Version/UnrecognizedSchemaException.php: -------------------------------------------------------------------------------- 1 | version = $version; 28 | $exception->class = $class; 29 | $exception->method = $method; 30 | 31 | return $exception; 32 | } 33 | 34 | /** 35 | * Get a clean error message for this exception that can be used in inline-validation use cases. 36 | * 37 | * @psalm-suppress InvalidNullableReturnType This will always return a string. 38 | * @return string 39 | */ 40 | public function getValidationMessage(): string 41 | { 42 | return sprintf( 43 | 'The supplied version, `%s`, has an unrecognized schema. Please consult the versioning documentation.', 44 | $this->version 45 | ); 46 | } 47 | 48 | /** 49 | * Get the version that an annotation exception was triggered for. 50 | * 51 | * @return string 52 | */ 53 | public function getVersion(): string 54 | { 55 | return $this->version; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "erunion/mill", 3 | "description": "☴ An annotation-based DSL for documenting a REST API.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Jon Ursenbach", 8 | "email": "jon@ursenba.ch" 9 | } 10 | ], 11 | "homepage": "https://github.com/erunion/mill", 12 | "support": { 13 | "issues": "https://github.com/erunion/mill/issues", 14 | "source": "https://github.com/erunion/mill", 15 | "wiki": "https://github.com/erunion/mill/wiki" 16 | }, 17 | "minimum-stability": "RC", 18 | "bin": ["bin/mill"], 19 | "require": { 20 | "php": ">=7.2.0", 21 | "ext-intl": "*", 22 | "ext-xml": "*", 23 | "composer/semver": "^3.0", 24 | "cocur/slugify": "^4.0", 25 | "dflydev/dot-access-data": "^2.0", 26 | "gossi/docblock": "^2.0", 27 | "league/flysystem": "^1.0", 28 | "nicmart/string-template": "^0.1.1", 29 | "ocramius/package-versions": "^1.1", 30 | "pimple/pimple": "^3.0", 31 | "symfony/console": "^3.2 || ^4.0 || ^5.0" 32 | }, 33 | "require-dev": { 34 | "league/flysystem-memory": "^1.0", 35 | "phpunit/phpunit": "^8.5", 36 | "squizlabs/php_codesniffer": "^3.0", 37 | "vimeo/psalm": "^3.0" 38 | }, 39 | "suggest": { 40 | "ext-xml": "Required for config file processing." 41 | }, 42 | "config": { 43 | "optimize-autoloader": true 44 | }, 45 | "autoload": { 46 | "psr-4": { 47 | "Mill\\": "src/" 48 | } 49 | }, 50 | "autoload-dev": { 51 | "psr-4": { 52 | "Mill\\Examples\\": "examples", 53 | "Mill\\Tests\\": "tests/", 54 | "Mill\\Tests\\Fixtures\\": "tests/_fixtures" 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Parser/Annotations/VendorTagAnnotation.php: -------------------------------------------------------------------------------- 1 | docblock); 24 | 25 | if (!empty($vendor_tag)) { 26 | // Validate the supplied vendor tag with what has been configured as allowable. 27 | $vendor_tags = $this->application->getConfig()->getVendorTags(); 28 | if (!in_array($vendor_tag, $vendor_tags)) { 29 | /** @var string $method */ 30 | $method = $this->method; 31 | throw InvalidVendorTagSuppliedException::create($vendor_tag, $this->class, $method); 32 | } 33 | } 34 | 35 | return [ 36 | 'vendor_tag' => $vendor_tag 37 | ]; 38 | } 39 | 40 | /** 41 | * {@inheritdoc} 42 | */ 43 | protected function interpreter(): void 44 | { 45 | $this->vendor_tag = $this->required('vendor_tag'); 46 | } 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getVendorTag(): string 52 | { 53 | return $this->vendor_tag; 54 | } 55 | 56 | /** 57 | * @param string $vendor_tag 58 | * @return VendorTagAnnotation 59 | */ 60 | public function setVendorTag(string $vendor_tag): self 61 | { 62 | $this->vendor_tag = $vendor_tag; 63 | return $this; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Changesets/Action.php: -------------------------------------------------------------------------------- 1 | [ 16 | Changelog::DEFINITION_ADDED => '{path} has been added with support for the following HTTP methods:' 17 | ], 18 | 'singular' => [ 19 | Changelog::DEFINITION_ADDED => '{method} on {path} was added.' 20 | ] 21 | ]; 22 | } 23 | 24 | /** 25 | * {@inheritDoc} 26 | */ 27 | public function compileAddedOrRemovedChangeset(string $definition, array $changes = []) 28 | { 29 | $templates = $this->getTemplates(); 30 | 31 | if (count($changes) === 1) { 32 | $change = array_shift($changes); 33 | $template = $templates['singular'][$definition]; 34 | return $this->renderText($template, $change); 35 | } 36 | 37 | $methods = []; 38 | foreach ($changes as $change) { 39 | $methods[] = $this->renderText('{method}', $change); 40 | } 41 | 42 | $template = $templates['plural'][$definition]; 43 | return [ 44 | [ 45 | // Changes are grouped by paths so it's safe to just pull the first path here. 46 | $this->renderText($template, [ 47 | 'resource_group' => $changes[0]['resource_group'], 48 | 'path' => $changes[0]['path'] 49 | ]), 50 | $methods 51 | ] 52 | ]; 53 | } 54 | 55 | /** 56 | * {@inheritDoc} 57 | */ 58 | public function compileChangedChangeset(string $definition, array $changes = []) 59 | { 60 | throw new \Exception($definition . ' action changes are not yet supported.'); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Changesets/RepresentationData.php: -------------------------------------------------------------------------------- 1 | [ 16 | Changelog::DEFINITION_ADDED => 'The {representation} representation has added the following fields:', 17 | Changelog::DEFINITION_REMOVED => 'The {representation} representation has removed the following fields:' 18 | ], 19 | 'singular' => [ 20 | Changelog::DEFINITION_ADDED => '{field} has been added to the {representation} representation.', 21 | Changelog::DEFINITION_REMOVED => '{field} has been removed from the {representation} representation.' 22 | ] 23 | ]; 24 | } 25 | 26 | /** 27 | * {@inheritDoc} 28 | */ 29 | public function compileAddedOrRemovedChangeset(string $definition, array $changes = []) 30 | { 31 | $templates = $this->getTemplates(); 32 | 33 | if (count($changes) === 1) { 34 | $change = array_shift($changes); 35 | $template = $templates['singular'][$definition]; 36 | return $this->renderText($template, $change); 37 | } 38 | 39 | $fields = []; 40 | foreach ($changes as $change) { 41 | $fields[] = $this->renderText('{field}', $change); 42 | } 43 | 44 | $template = $templates['plural'][$definition]; 45 | return [ 46 | $this->renderText($template, array_shift($changes)), 47 | $fields 48 | ]; 49 | } 50 | 51 | /** 52 | * {@inheritDoc} 53 | */ 54 | public function compileChangedChangeset(string $definition, array $changes = []) 55 | { 56 | throw new \Exception($definition . ' representation data changes are not yet supported.'); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Container.php: -------------------------------------------------------------------------------- 1 | register(new Filesystem); 31 | $this->register(new Config); 32 | $this->register(new Reader); 33 | } 34 | 35 | /** 36 | * Return the current instance of the configuration system. 37 | * 38 | * @return \Mill\Config 39 | */ 40 | public function getConfig(): \Mill\Config 41 | { 42 | return $this['config']; 43 | } 44 | 45 | /** 46 | * Return the current instance of the filesystem. 47 | * 48 | * @return \League\Flysystem\Filesystem 49 | */ 50 | public function getFilesystem(): \League\Flysystem\Filesystem 51 | { 52 | return $this['filesystem']; 53 | } 54 | 55 | /** 56 | * Return the current instance of the annotation reader. 57 | * 58 | * @return \Closure 59 | */ 60 | public function getAnnotationReader(): \Closure 61 | { 62 | return $this['reader.annotations']; 63 | } 64 | 65 | /** 66 | * Return the current instance of the annotation reader for representations. 67 | * 68 | * @return \Closure 69 | */ 70 | public function getRepresentationAnnotationReader(): \Closure 71 | { 72 | return $this['reader.annotations.representation']; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Parser/Version.php: -------------------------------------------------------------------------------- 1 | class = $class; 30 | $this->method = $method; 31 | 32 | try { 33 | $parser = new VersionParser; 34 | $this->constraint = $parser->parseConstraints($constraint); 35 | } catch (\UnexpectedValueException $e) { 36 | throw UnrecognizedSchemaException::create($constraint, $this->class, $this->method); 37 | } 38 | } 39 | 40 | /** 41 | * Assert that a given version string matches the current parsed range. 42 | * 43 | * @param string $version 44 | * @return bool 45 | */ 46 | public function matches(string $version): bool 47 | { 48 | return Semver::satisfies($version, $this->getConstraint()); 49 | } 50 | 51 | /** 52 | * @return string 53 | */ 54 | public function getConstraint(): string 55 | { 56 | return $this->constraint->getPrettyString(); 57 | } 58 | 59 | /** 60 | * Is the parsed version constraint a range (i.e. a multi constraint)? 61 | * 62 | * @return bool 63 | */ 64 | public function isRange(): bool 65 | { 66 | return $this->constraint instanceof MultiConstraint; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/Parser/Annotations/PathParamAnnotation.php: -------------------------------------------------------------------------------- 1 | application->getConfig(); 32 | $content = trim($this->docblock); 33 | 34 | /** @var string $method */ 35 | $method = $this->method; 36 | $mson = (new MSON($this->class, $method, $config))->parse($content); 37 | $parsed = [ 38 | 'field' => $mson->getField(), 39 | 'sample_data' => $mson->getSampleData(), 40 | 'type' => $mson->getType(), 41 | 'description' => $mson->getDescription(), 42 | 'values' => $mson->getValues() 43 | ]; 44 | 45 | if (!empty($parsed['field'])) { 46 | // If we have any path param translations configured, let's process them. 47 | $translations = $config->getPathParamTranslations(); 48 | if (isset($translations[$parsed['field']])) { 49 | $parsed['field'] = $translations[$parsed['field']]; 50 | } 51 | } 52 | 53 | return $parsed; 54 | } 55 | 56 | /** 57 | * {@inheritdoc} 58 | */ 59 | protected function interpreter(): void 60 | { 61 | $this->required = true; 62 | 63 | $this->field = $this->required('field'); 64 | $this->sample_data = $this->optional('sample_data'); 65 | $this->type = $this->required('type'); 66 | $this->description = $this->required('description'); 67 | 68 | $this->values = $this->optional('values'); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/Parser/Annotations/MaxVersionAnnotation.php: -------------------------------------------------------------------------------- 1 | method; 27 | 28 | $parsed = new Version($this->docblock, $this->class, $method); 29 | if ($parsed->isRange()) { 30 | throw AbsoluteVersionException::create('max', $this->docblock, $this->class, $method); 31 | } 32 | 33 | return [ 34 | 'maximum_version' => $parsed->getConstraint() 35 | ]; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | protected function interpreter(): void 42 | { 43 | // The Version class already does all of our validation, so if we're at this point, we have a good version and 44 | // don't need to run it through `$this->required()` again. 45 | $this->maximum_version = $this->parsed_data['maximum_version']; 46 | } 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getMaximumVersion(): string 52 | { 53 | return $this->maximum_version; 54 | } 55 | 56 | /** 57 | * @param string $maximum_version 58 | * @return MaxVersionAnnotation 59 | */ 60 | public function setMaximumVersion(string $maximum_version): self 61 | { 62 | $this->maximum_version = $maximum_version; 63 | return $this; 64 | } 65 | 66 | /** 67 | * Assert that a given version string is less than the maximum version. 68 | * 69 | * @param string $version 70 | * @return bool 71 | */ 72 | public function matches(string $version): bool 73 | { 74 | return Semver::satisfies($version, '<=' . $this->maximum_version); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Parser/Annotations/MinVersionAnnotation.php: -------------------------------------------------------------------------------- 1 | method; 27 | 28 | $parsed = new Version($this->docblock, $this->class, $method); 29 | if ($parsed->isRange()) { 30 | throw AbsoluteVersionException::create('min', $this->docblock, $this->class, $method); 31 | } 32 | 33 | return [ 34 | 'minimum_version' => $parsed->getConstraint() 35 | ]; 36 | } 37 | 38 | /** 39 | * {@inheritdoc} 40 | */ 41 | protected function interpreter(): void 42 | { 43 | // The Version class already does all of our validation, so if we're at this point, we have a good version and 44 | // don't need to run it through `$this->required()` again. 45 | $this->minimum_version = $this->parsed_data['minimum_version']; 46 | } 47 | 48 | /** 49 | * @return string 50 | */ 51 | public function getMinimumVersion(): string 52 | { 53 | return $this->minimum_version; 54 | } 55 | 56 | /** 57 | * @param string $minimum_version 58 | * @return MinVersionAnnotation 59 | */ 60 | public function setMinimumVersion(string $minimum_version): self 61 | { 62 | $this->minimum_version = $minimum_version; 63 | return $this; 64 | } 65 | 66 | /** 67 | * Assert that a given version string is greater than the minimum version. 68 | * 69 | * @param string $version 70 | * @return bool 71 | */ 72 | public function matches(string $version): bool 73 | { 74 | return Semver::satisfies($version, '>=' . $this->minimum_version); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/Compiler/ErrorMap/Formats/Markdown.php: -------------------------------------------------------------------------------- 1 | error_map as $version => $groups) { 23 | $api_name = $this->config->getName(); 24 | 25 | $content = ''; 26 | $content .= sprintf('# Errors: %s', (!empty($api_name)) ? $api_name : ''); 27 | $content .= $this->line(2); 28 | 29 | foreach ($groups as $group => $actions) { 30 | $content .= sprintf('## %s', $group); 31 | $content .= $this->line(1); 32 | 33 | $content .= '| Error Code | Path | Method | HTTP Code | Description |'; 34 | $content .= $this->line(1); 35 | $content .= '| :--- | :--- | :--- | :--- | :--- |'; 36 | $content .= $this->line(1); 37 | 38 | foreach ($actions as $errors) { 39 | foreach ($errors as $error) { 40 | $content .= sprintf( 41 | '| %s | %s | %s | %s | %s |', 42 | $error['error_code'], 43 | $error['path'], 44 | $error['method'], 45 | $error['http_code'], 46 | $error['description'] 47 | ); 48 | 49 | $content .= $this->line(1); 50 | } 51 | } 52 | 53 | $content .= $this->line(1); 54 | } 55 | 56 | $this->markdown[$version] = trim($content); 57 | } 58 | } 59 | 60 | /** 61 | * @return array 62 | */ 63 | public function getCompiled(): array 64 | { 65 | if (empty($this->markdown)) { 66 | $this->compile(); 67 | } 68 | 69 | return $this->markdown; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Command/BaseCompiler.php: -------------------------------------------------------------------------------- 1 | addOption( 28 | 'private', 29 | null, 30 | InputOption::VALUE_OPTIONAL, 31 | "Flag designating if you want to include documentation that's marked as private.", 32 | true 33 | ); 34 | 35 | $this->addOption( 36 | 'vendor_tag', 37 | null, 38 | InputOption::VALUE_OPTIONAL | InputOption::VALUE_IS_ARRAY, 39 | 'The name of a vendor tag if you want to incorporate documentation that includes vendor tag-bound ' . 40 | 'annotations documentation. If omitted, all vendor tagged-documentation will be incorporated.' 41 | ); 42 | 43 | $this->addArgument('output', InputArgument::REQUIRED, 'Directory to output into.'); 44 | } 45 | 46 | /** 47 | * @param InputInterface $input 48 | * @param OutputInterface $output 49 | * @return int 50 | */ 51 | protected function execute(InputInterface $input, OutputInterface $output) 52 | { 53 | parent::execute($input, $output); 54 | 55 | /** @var array|null $vendor_tags */ 56 | $vendor_tags = $input->getOption('vendor_tag'); 57 | $this->vendor_tags = (!empty($vendor_tags)) ? $vendor_tags : null; 58 | 59 | /** @var string $output_dir */ 60 | $output_dir = $input->getArgument('output'); 61 | $this->output_dir = realpath($output_dir); 62 | 63 | $private_docs = $input->getOption('private'); 64 | if (is_bool($private_docs) && $private_docs === true) { 65 | $this->private_docs = true; 66 | } elseif (is_string($private_docs) && strtolower($private_docs) == 'true') { 67 | $this->private_docs = true; 68 | } else { 69 | $this->private_docs = false; 70 | } 71 | 72 | return 0; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /src/Reader.php: -------------------------------------------------------------------------------- 1 | getDocComment(); 25 | } else { 26 | if (!$reflection->hasMethod($method)) { 27 | throw MethodNotImplementedException::create($class, $method); 28 | } 29 | 30 | /** @var \ReflectionMethod $method */ 31 | $method = $reflection->getMethod($method); 32 | $comments = $method->getDocComment(); 33 | } 34 | 35 | return $comments; 36 | } 37 | 38 | /** 39 | * Given a class and method, pull out any code annotation docblocks that may exist within it. 40 | * 41 | * @psalm-param class-string $class 42 | * @param string $class 43 | * @param string $method 44 | * @return string 45 | * @throws MethodNotImplementedException If the supplied method does not exist on the supplied class. 46 | * @throws \ReflectionException 47 | */ 48 | public function getRepresentationAnnotations(string $class, string $method): string 49 | { 50 | $reflection = new ReflectionClass($class); 51 | if (!$reflection->hasMethod($method)) { 52 | throw MethodNotImplementedException::create($class, $method); 53 | } 54 | 55 | /** @var \ReflectionMethod $method */ 56 | $method = $reflection->getMethod($method); 57 | 58 | /** @var string $filename */ 59 | $filename = $method->getFileName(); 60 | 61 | // The start line is actually `- 1`, otherwise you wont get the function() block. 62 | $start_line = $method->getStartLine() - 1; 63 | $end_line = $method->getEndLine(); 64 | $length = $end_line - $start_line; 65 | 66 | /** @var array $source */ 67 | $source = file($filename); 68 | 69 | return implode('', array_slice($source, $start_line, $length)); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/Parser/Annotations/ScopeAnnotation.php: -------------------------------------------------------------------------------- 1 | docblock); 28 | 29 | $scope = array_shift($parts); 30 | $description = trim(implode(' ', $parts)); 31 | 32 | if (!empty($scope)) { 33 | // Validate the supplied scope with what has been configured as allowable. 34 | if (!$this->application->getConfig()->hasScope($scope)) { 35 | /** @var string $method */ 36 | $method = $this->method; 37 | throw InvalidScopeSuppliedException::create($scope, $this->class, $method); 38 | } 39 | } 40 | 41 | return [ 42 | 'scope' => $scope, 43 | 'description' => (!empty($description)) ? $description : null 44 | ]; 45 | } 46 | 47 | /** 48 | * {@inheritdoc} 49 | */ 50 | protected function interpreter(): void 51 | { 52 | $this->scope = $this->required('scope'); 53 | $this->description = $this->optional('description'); 54 | } 55 | 56 | /** 57 | * @return string 58 | */ 59 | public function getScope(): string 60 | { 61 | return $this->scope; 62 | } 63 | 64 | /** 65 | * @param string $scope 66 | * @return ScopeAnnotation 67 | */ 68 | public function setScope(string $scope): self 69 | { 70 | $this->scope = $scope; 71 | return $this; 72 | } 73 | 74 | /** 75 | * @return false|null|string 76 | */ 77 | public function getDescription() 78 | { 79 | return $this->description; 80 | } 81 | 82 | /** 83 | * @param false|null|string $description 84 | * @return ScopeAnnotation 85 | */ 86 | public function setDescription($description): self 87 | { 88 | $this->description = $description; 89 | return $this; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Changesets/ActionError.php: -------------------------------------------------------------------------------- 1 | [ 16 | Changelog::DEFINITION_ADDED => '{path} now returns the following errors on {method} requests:', 17 | Changelog::DEFINITION_REMOVED => '{path} no longer returns the following errors on {method} requests:' 18 | ], 19 | 'singular' => [ 20 | Changelog::DEFINITION_ADDED => 'On {method} requests to {path}, a {http_code} with a ' . 21 | '{representation} representation is now returned: {description}', 22 | Changelog::DEFINITION_REMOVED => '{method} requests to {path} no longer returns a {http_code} with a ' . 23 | '{representation} representation: {description}' 24 | ] 25 | ]; 26 | } 27 | 28 | /** 29 | * {@inheritDoc} 30 | */ 31 | public function compileAddedOrRemovedChangeset(string $definition, array $changes = []) 32 | { 33 | $templates = $this->getTemplates(); 34 | 35 | if (count($changes) === 1) { 36 | $change = array_shift($changes); 37 | $template = $templates['singular'][$definition]; 38 | return $this->renderText($template, $change); 39 | } 40 | 41 | $methods = []; 42 | foreach ($changes as $change) { 43 | $methods[$change['method']][] = $change; 44 | } 45 | 46 | $entries = []; 47 | foreach ($methods as $method => $changes) { 48 | if (count($changes) > 1) { 49 | $errors = []; 50 | foreach ($changes as $change) { 51 | $errors[] = $this->renderText( 52 | '{http_code} with a {representation} representation: {description}', 53 | $change 54 | ); 55 | } 56 | 57 | $change = array_shift($changes); 58 | 59 | $template = $templates['plural'][$definition]; 60 | $entries[] = [ 61 | $this->renderText($template, [ 62 | 'resource_group' => $change['resource_group'], 63 | 'method' => $method, 64 | 'path' => $change['path'] 65 | ]), 66 | array_unique($errors) 67 | ]; 68 | continue; 69 | } 70 | 71 | $change = array_shift($changes); 72 | $template = $templates['singular'][$definition]; 73 | $entries[] = $this->renderText($template, $change); 74 | } 75 | 76 | return $entries; 77 | } 78 | 79 | /** 80 | * {@inheritDoc} 81 | */ 82 | public function compileChangedChangeset(string $definition, array $changes = []) 83 | { 84 | throw new \Exception($definition . ' action error changes are not yet supported.'); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/Command/ErrorMap.php: -------------------------------------------------------------------------------- 1 | setName('errors') 21 | ->setDescription('Compiles an error map from your documented API errors.') 22 | ->addOption( 23 | 'constraint', 24 | null, 25 | InputOption::VALUE_OPTIONAL, 26 | 'Version constraint to compile documentation for. eg. "3.*", "3.1 - 3.2"', 27 | null 28 | ) 29 | ->addOption( 30 | 'default', 31 | null, 32 | InputOption::VALUE_OPTIONAL, 33 | 'Compile just the configured default API version documentation. `defaultApiVersion` in your ' . 34 | '`mill.xml` file.', 35 | false 36 | ); 37 | } 38 | 39 | /** 40 | * @param InputInterface $input 41 | * @param OutputInterface $output 42 | * @return int 43 | * @throws \Exception 44 | */ 45 | protected function execute(InputInterface $input, OutputInterface $output) 46 | { 47 | parent::execute($input, $output); 48 | 49 | /** @var string|null */ 50 | $version = $input->getOption('constraint'); 51 | 52 | if ($input->getOption('default')) { 53 | /** @var string|null */ 54 | $version = $this->container['config']->getDefaultApiVersion(); 55 | } 56 | 57 | // Validate the current version constraint. 58 | if (!empty($version)) { 59 | try { 60 | $version = new Version($version, __CLASS__, __METHOD__); 61 | } catch (UnrecognizedSchemaException $e) { 62 | $output->writeLn('' . $e->getValidationMessage() . ''); 63 | return 1; 64 | } 65 | } 66 | 67 | /** @var \League\Flysystem\Filesystem $filesystem */ 68 | $filesystem = $this->container['filesystem']; 69 | 70 | $output->writeln('Compiling an error map...'); 71 | 72 | /** @psalm-suppress PossiblyInvalidArgument */ 73 | $error_map = new Compiler\ErrorMap($this->app, $version); 74 | $error_map->setLoadPrivateDocs($this->private_docs); 75 | $error_map->setLoadVendorTagDocs($this->vendor_tags); 76 | $markdown = $error_map->toMarkdown(); 77 | 78 | foreach ($markdown as $version => $content) { 79 | $output->writeLn(' - API version: ' . $version . ''); 80 | 81 | $filesystem->put( 82 | $this->output_dir . DIRECTORY_SEPARATOR . $version . DIRECTORY_SEPARATOR . 'errors.md', 83 | trim($content) 84 | ); 85 | } 86 | 87 | $output->writeln(['', 'Done!']); 88 | 89 | return 0; 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Changesets/ActionParam.php: -------------------------------------------------------------------------------- 1 | [ 16 | Changelog::DEFINITION_ADDED => 'The following parameters have been added to {method} on {path}:', 17 | Changelog::DEFINITION_REMOVED => 'The following parameters have been removed from {method} on {path}:' 18 | ], 19 | 'singular' => [ 20 | Changelog::DEFINITION_ADDED => 'A {parameter} request parameter was added to {method} on {path}.', 21 | Changelog::DEFINITION_REMOVED => 'The {parameter} request parameter has been removed from {method} ' . 22 | 'requests on {path}.' 23 | ] 24 | ]; 25 | } 26 | 27 | /** 28 | * {@inheritDoc} 29 | */ 30 | public function compileAddedOrRemovedChangeset(string $definition, array $changes = []) 31 | { 32 | $templates = $this->getTemplates(); 33 | 34 | if (count($changes) === 1) { 35 | $change = array_shift($changes); 36 | $template = $templates['singular'][$definition]; 37 | return $this->renderText($template, $change); 38 | } 39 | 40 | $methods = []; 41 | foreach ($changes as $change) { 42 | $methods[$change['method']][] = $change['parameter']; 43 | } 44 | 45 | $entry = []; 46 | foreach ($methods as $method => $params) { 47 | if (count($params) > 1) { 48 | // Templatize the parameters before passing them into the entries array. Would prefer to do this as an 49 | // array_map call, but you can't pass `$this` into closures. 50 | foreach ($params as $k => $param) { 51 | $params[$k] = $this->renderText('{parameter}', [ 52 | 'parameter' => $param, 53 | 'resource_group' => $changes[0]['resource_group'], 54 | 'method' => $method, 55 | 'path' => $changes[0]['path'] 56 | ]); 57 | } 58 | 59 | $template = $templates['plural'][$definition]; 60 | $entry[] = [ 61 | $this->renderText($template, [ 62 | 'resource_group' => $changes[0]['resource_group'], 63 | 'method' => $method, 64 | 'path' => $changes[0]['path'] 65 | ]), 66 | $params 67 | ]; 68 | 69 | continue; 70 | } 71 | 72 | $template = $templates['singular'][$definition]; 73 | $entry[] = $this->renderText($template, [ 74 | 'resource_group' => $changes[0]['resource_group'], 75 | 'parameter' => array_shift($params), 76 | 'method' => $method, 77 | 'path' => $changes[0]['path'] 78 | ]); 79 | } 80 | 81 | return $entry; 82 | } 83 | 84 | /** 85 | * {@inheritDoc} 86 | */ 87 | public function compileChangedChangeset(string $definition, array $changes = []) 88 | { 89 | throw new \Exception($definition . ' action param changes are not yet supported.'); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Changesets/ActionReturn.php: -------------------------------------------------------------------------------- 1 | [ 16 | Changelog::DEFINITION_ADDED => 'The {method} on {path} now returns the following responses:', 17 | Changelog::DEFINITION_REMOVED => 'The {method} on {path} no longer returns the following responses:' 18 | ], 19 | 'singular' => [ 20 | Changelog::DEFINITION_ADDED => 'On {path}, {method} requests now return a {http_code} with a ' . 21 | '{representation} representation.', 22 | Changelog::DEFINITION_REMOVED => 'On {path}, {method} requests no longer return a {http_code} with a ' . 23 | '{representation} representation.', 24 | 25 | // Representations are optional on returns, so we need special strings for those cases. 26 | 'no_representation' => [ 27 | Changelog::DEFINITION_ADDED => '{method} on {path} now returns a {http_code}.', 28 | Changelog::DEFINITION_REMOVED => '{method} on {path} no longer returns a {http_code}.' 29 | ] 30 | ] 31 | ]; 32 | } 33 | 34 | /** 35 | * {@inheritDoc} 36 | */ 37 | public function compileAddedOrRemovedChangeset(string $definition, array $changes = []) 38 | { 39 | $templates = $this->getTemplates(); 40 | 41 | if (count($changes) === 1) { 42 | $change = array_shift($changes); 43 | if ($change['representation']) { 44 | $template = $templates['singular'][$definition]; 45 | } else { 46 | $template = $templates['singular']['no_representation'][$definition]; 47 | } 48 | 49 | return $this->renderText($template, $change); 50 | } 51 | 52 | $methods = []; 53 | foreach ($changes as $change) { 54 | $methods[$change['method']][] = $change; 55 | } 56 | 57 | $entries = []; 58 | foreach ($methods as $method => $changes) { 59 | if (count($changes) > 1) { 60 | $returns = []; 61 | foreach ($changes as $change) { 62 | if ($change['representation']) { 63 | $returns[] = $this->renderText( 64 | '{http_code} with a {representation} representation', 65 | $change 66 | ); 67 | } else { 68 | $returns[] = $this->renderText('{http_code}', $change); 69 | } 70 | } 71 | 72 | $template = $templates['plural'][$definition]; 73 | $entries[] = [ 74 | $this->renderText($template, [ 75 | 'resource_group' => $changes[0]['resource_group'], 76 | 'method' => $method, 77 | 'path' => $changes[0]['path'] 78 | ]), 79 | $returns 80 | ]; 81 | 82 | continue; 83 | } 84 | 85 | $change = array_shift($changes); 86 | $template = $templates['singular'][$definition]; 87 | $entries[] = $this->renderText($template, $change); 88 | } 89 | 90 | return $entries; 91 | } 92 | 93 | /** 94 | * {@inheritDoc} 95 | */ 96 | public function compileChangedChangeset(string $definition, array $changes = []) 97 | { 98 | throw new \Exception($definition . ' action return changes are not yet supported.'); 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Compiler/ErrorMap.php: -------------------------------------------------------------------------------- 1 | error_map); 25 | 26 | foreach ($this->error_map as $version => $groups) { 27 | foreach ($groups as $group => $resources) { 28 | foreach ($resources as $identifier => $errors) { 29 | usort($this->error_map[$version][$group][$identifier], function (array $a, array $b): int { 30 | // If the error codes match, then fallback to sorting by the path. 31 | if ($a['error_code'] == $b['error_code']) { 32 | // If the paths match, then fallback to sorting by their methods. 33 | if ($a['path'] == $b['path']) { 34 | return ($a['method'] < $b['method']) ? -1 : 1; 35 | } 36 | 37 | return ($a['path'] < $b['path']) ? -1 : 1; 38 | } 39 | 40 | return ($a['error_code'] < $b['error_code']) ? -1 : 1; 41 | }); 42 | } 43 | 44 | ksort($this->error_map[$version][$group]); 45 | } 46 | } 47 | } 48 | 49 | /** 50 | * {{@inheritdoc}} 51 | */ 52 | protected function transposeAction( 53 | string $version, 54 | string $group, 55 | string $identifier, 56 | Action\Documentation $action 57 | ): void { 58 | // Groups can have children via the `\` delimiter, but for the error map we only care about the top-level group. 59 | if (strpos($group, '\\') != false) { 60 | $parts = explode('\\', $group); 61 | $group = array_shift($parts); 62 | } 63 | 64 | /** @var ReturnAnnotation|ErrorAnnotation $response */ 65 | foreach ($action->getResponses() as $response) { 66 | if (!$response instanceof ErrorAnnotation) { 67 | continue; 68 | } 69 | 70 | $error_code = $response->getErrorCode(); 71 | if (empty($error_code)) { 72 | continue; 73 | } 74 | 75 | $path = $action->getPath(); 76 | $this->error_map[$version][$group][$error_code][] = [ 77 | 'path' => $path->getCleanPath(), 78 | 'method' => $action->getMethod(), 79 | 'http_code' => $response->getHttpCode(), 80 | 'error_code' => $error_code, 81 | 'description' => $response->getDescription() 82 | ]; 83 | } 84 | } 85 | 86 | /** 87 | * @return array 88 | */ 89 | public function getCompiled(): array 90 | { 91 | if (empty($this->error_map)) { 92 | $this->compile(); 93 | } 94 | 95 | return $this->error_map; 96 | } 97 | 98 | /** 99 | * Take compiled API documentation and convert it into a Markdown-based changelog over the life of the API. 100 | * 101 | * @return array 102 | * @throws \Exception 103 | */ 104 | public function toMarkdown(): array 105 | { 106 | $markdown = new Markdown($this->application, $this->version); 107 | $markdown->setLoadPrivateDocs($this->load_private_docs); 108 | $markdown->setLoadVendorTagDocs($this->load_vendor_tag_docs); 109 | 110 | return $markdown->getCompiled(); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: docs examples 2 | 3 | MILL := ./bin/mill 4 | EXAMPLE_MILL_CONFIG := examples/mill.xml 5 | EXAMPLES_DIR := examples/Showtimes/compiled 6 | EXAMPLES_DIR_PUBLIC := $(EXAMPLES_DIR)/public 7 | 8 | code-coverage: ## Run code coverage. 9 | ./vendor/bin/phpunit --coverage-html reports/ 10 | 11 | phpcs: ## Verify code standards. 12 | ./vendor/bin/phpcs --standard=PSR2 bin/ src/ tests/ 13 | 14 | phpunit: ## Run unit tests. 15 | ./vendor/bin/phpunit 16 | 17 | psalm: ## Run static analysis checks. 18 | ./vendor/bin/psalm 19 | 20 | test: phpcs psalm phpunit ## Run all checks and unit tests. 21 | npm test 22 | 23 | examples: ## Compile examples. 24 | rm -rf $(EXAMPLES_DIR) 25 | mkdir $(EXAMPLES_DIR) 26 | mkdir $(EXAMPLES_DIR_PUBLIC) 27 | make examples-apiblueprint 28 | make examples-openapi 29 | make examples-changelogs 30 | make examples-errors 31 | 32 | examples-apiblueprint: ## Compile example API Blueprint definitions. 33 | $(MILL) compile --config=$(EXAMPLE_MILL_CONFIG) --format=apiblueprint --for_public_consumption=true $(EXAMPLES_DIR_PUBLIC) 34 | $(MILL) compile --config=$(EXAMPLE_MILL_CONFIG) --format=apiblueprint $(EXAMPLES_DIR) 35 | 36 | examples-openapi: ## Compile example OpenAPI definitions. 37 | $(MILL) compile --config=$(EXAMPLE_MILL_CONFIG) --format=openapi --for_public_consumption=true $(EXAMPLES_DIR_PUBLIC) 38 | $(MILL) compile --config=$(EXAMPLE_MILL_CONFIG) --format=openapi $(EXAMPLES_DIR) 39 | 40 | examples-changelogs: ## Compile example changelogs. 41 | $(MILL) changelog --config=$(EXAMPLE_MILL_CONFIG) --private=false $(EXAMPLES_DIR) 42 | @mv $(EXAMPLES_DIR)/changelog.md $(EXAMPLES_DIR)/changelog-public-only-all-vendor-tags.md 43 | 44 | $(MILL) changelog --config=$(EXAMPLE_MILL_CONFIG) --private=false --vendor_tag='tag:BUY_TICKETS' --vendor_tag='tag:FEATURE_FLAG' $(EXAMPLES_DIR) 45 | @mv $(EXAMPLES_DIR)/changelog.md $(EXAMPLES_DIR)/changelog-public-only-matched-with-tickets-and-feature-vendor-tags.md 46 | 47 | $(MILL) changelog --config=$(EXAMPLE_MILL_CONFIG) --private=false --vendor_tag='tag:DELETE_CONTENT' $(EXAMPLES_DIR) 48 | @mv $(EXAMPLES_DIR)/changelog.md $(EXAMPLES_DIR)/changelog-public-only-matched-with-delete-vendor-tags.md 49 | 50 | $(MILL) changelog --config=$(EXAMPLE_MILL_CONFIG) $(EXAMPLES_DIR) 51 | 52 | examples-errors: ## Compile example error compilations. 53 | $(MILL) errors --config=$(EXAMPLE_MILL_CONFIG) --private=false $(EXAMPLES_DIR) 54 | @mv $(EXAMPLES_DIR)/1.0/errors.md $(EXAMPLES_DIR)/1.0/errors-public-only-all-vendor-tags.md 55 | @mv $(EXAMPLES_DIR)/1.1/errors.md $(EXAMPLES_DIR)/1.1/errors-public-only-all-vendor-tags.md 56 | @mv $(EXAMPLES_DIR)/1.1.1/errors.md $(EXAMPLES_DIR)/1.1.1/errors-public-only-all-vendor-tags.md 57 | @mv $(EXAMPLES_DIR)/1.1.3/errors.md $(EXAMPLES_DIR)/1.1.3/errors-public-only-all-vendor-tags.md 58 | 59 | $(MILL) errors --config=$(EXAMPLE_MILL_CONFIG) --private=false --vendor_tag='tag:BUY_TICKETS' --vendor_tag='tag:FEATURE_FLAG' $(EXAMPLES_DIR) 60 | @mv $(EXAMPLES_DIR)/1.0/errors.md $(EXAMPLES_DIR)/1.0/errors-public-only-unmatched-vendor-tags.md 61 | @mv $(EXAMPLES_DIR)/1.1/errors.md $(EXAMPLES_DIR)/1.1/errors-public-only-unmatched-vendor-tags.md 62 | @mv $(EXAMPLES_DIR)/1.1.1/errors.md $(EXAMPLES_DIR)/1.1.1/errors-public-only-unmatched-vendor-tags.md 63 | @mv $(EXAMPLES_DIR)/1.1.3/errors.md $(EXAMPLES_DIR)/1.1.3/errors-public-only-unmatched-vendor-tags.md 64 | 65 | $(MILL) errors --config=$(EXAMPLE_MILL_CONFIG) --private=false --vendor_tag='tag:DELETE_CONTENT' $(EXAMPLES_DIR) 66 | @mv $(EXAMPLES_DIR)/1.0/errors.md $(EXAMPLES_DIR)/1.0/errors-public-only-matched-vendor-tags.md 67 | @mv $(EXAMPLES_DIR)/1.1/errors.md $(EXAMPLES_DIR)/1.1/errors-public-only-matched-vendor-tags.md 68 | @mv $(EXAMPLES_DIR)/1.1.1/errors.md $(EXAMPLES_DIR)/1.1.1/errors-public-only-matched-vendor-tags.md 69 | @mv $(EXAMPLES_DIR)/1.1.3/errors.md $(EXAMPLES_DIR)/1.1.3/errors-public-only-matched-vendor-tags.md 70 | 71 | $(MILL) errors --config=$(EXAMPLE_MILL_CONFIG) $(EXAMPLES_DIR) 72 | 73 | help: ## Show this help. 74 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 75 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Formats/Markdown.php: -------------------------------------------------------------------------------- 1 | config->getName(); 27 | 28 | $this->markdown .= sprintf('# Changelog: %s', (!empty($api_name)) ? $api_name : ''); 29 | $this->markdown .= $this->line(2); 30 | 31 | $changelog = parent::getCompiled(); 32 | $changelog = array_shift($changelog); 33 | $changelog = json_decode($changelog, true); 34 | foreach ($changelog as $version => $data) { 35 | $this->markdown .= sprintf('## %s (%s)', $version, $data['_details']['release_date']); 36 | $this->markdown .= $this->line(); 37 | 38 | if (isset($data['_details']['description'])) { 39 | $this->markdown .= sprintf('%s', $data['_details']['description']); 40 | $this->markdown .= $this->line(2); 41 | } 42 | 43 | $this->markdown .= '### Reference'; 44 | $this->markdown .= $this->line(); 45 | 46 | foreach ($data as $definition => $changes) { 47 | if ($definition === '_details') { 48 | continue; 49 | } 50 | 51 | $this->markdown .= sprintf('#### %s', ucwords($definition)); 52 | $this->markdown .= $this->line(); 53 | 54 | foreach ($changes as $type => $changesets) { 55 | $this->markdown .= sprintf('##### %s', ucwords($type)); 56 | $this->markdown .= $this->line(); 57 | 58 | foreach ($changesets as $changeset) { 59 | $this->markdown .= $this->getChangesetMarkdown($changeset); 60 | } 61 | 62 | $this->markdown .= $this->line(); 63 | } 64 | } 65 | } 66 | } 67 | 68 | /** 69 | * Get Markdown syntax for a given changeset. 70 | * 71 | * @param array|string $changeset 72 | * @param int $tab 73 | * @return string 74 | */ 75 | private function getChangesetMarkdown($changeset, int $tab = 0): string 76 | { 77 | $markdown = ''; 78 | if (!is_array($changeset)) { 79 | $markdown .= $this->tab($tab); 80 | $markdown .= sprintf('- %s', $changeset); 81 | $markdown .= $this->line(); 82 | return $markdown; 83 | } 84 | 85 | foreach ($changeset as $change) { 86 | if (is_array($change)) { 87 | foreach ($change as $item) { 88 | if (is_array($item)) { 89 | $markdown .= $this->getChangesetMarkdown($item, $tab + 1); 90 | continue; 91 | } 92 | 93 | $markdown .= $this->tab($tab + 1); 94 | $markdown .= sprintf('- %s', $item); 95 | $markdown .= $this->line(); 96 | } 97 | 98 | continue; 99 | } 100 | 101 | $markdown .= $this->tab($tab); 102 | $markdown .= sprintf('- %s', $change); 103 | $markdown .= $this->line(); 104 | } 105 | 106 | return $markdown; 107 | } 108 | 109 | /** 110 | * @return array 111 | */ 112 | public function getCompiled(): array 113 | { 114 | if (empty($this->markdown)) { 115 | $this->compile(); 116 | } 117 | 118 | return [$this->markdown]; 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/Parser/Annotations/PathAnnotation.php: -------------------------------------------------------------------------------- 1 | Array of all available aliases for this annotation. */ 20 | protected $aliases = []; 21 | 22 | /** @var bool Flag designating that this annotation is aliased or not. */ 23 | protected $aliased = false; 24 | 25 | /** 26 | * {@inheritdoc} 27 | */ 28 | protected function parser(): array 29 | { 30 | $path = trim($this->docblock); 31 | if (!empty($path)) { 32 | } 33 | 34 | return [ 35 | 'path' => $path 36 | ]; 37 | } 38 | 39 | /** 40 | * {@inheritdoc} 41 | */ 42 | protected function interpreter(): void 43 | { 44 | $this->path = $this->required('path'); 45 | 46 | // If we have any path param translations configured, let's process them. 47 | $translations = $this->application->getConfig()->getPathParamTranslations(); 48 | foreach ($translations as $from => $to) { 49 | if (preg_match('/([@#+*!~])' . $from . '(\/|$)/', $this->path, $matches)) { 50 | $this->path = preg_replace('/([@#+*!~])' . $from . '(\/|$)/', '$1' . $to . '$2', $this->path); 51 | } 52 | } 53 | } 54 | 55 | /** 56 | * @return string 57 | */ 58 | public function getPath(): string 59 | { 60 | return $this->path; 61 | } 62 | 63 | /** 64 | * @param string $path 65 | * @return PathAnnotation 66 | */ 67 | public function setPath(string $path): self 68 | { 69 | $this->path = $path; 70 | return $this; 71 | } 72 | 73 | /** 74 | * @return string 75 | */ 76 | public function getCleanPath(): string 77 | { 78 | return preg_replace('/[@#+*!~]((\w|_)+)(\/|$)/', '{$1}$3', $this->getPath()); 79 | } 80 | 81 | /** 82 | * @param PathParamAnnotation $param 83 | * @return bool 84 | */ 85 | public function doesPathHaveParam(PathParamAnnotation $param): bool 86 | { 87 | return strpos($this->getCleanPath(), '{' . $param->getField() . '}') !== false; 88 | } 89 | 90 | /** 91 | * Is this annotation an alias? 92 | * 93 | * @return bool 94 | */ 95 | public function isAliased(): bool 96 | { 97 | return $this->aliased; 98 | } 99 | 100 | /** 101 | * Set if this annotation is an alias or not. 102 | * 103 | * @param bool $aliased 104 | * @return PathAnnotation 105 | */ 106 | public function setAliased(bool $aliased): self 107 | { 108 | $this->aliased = $aliased; 109 | return $this; 110 | } 111 | 112 | /** 113 | * Set any aliases to this annotation. 114 | * 115 | * @param array $aliases 116 | * @return PathAnnotation 117 | */ 118 | public function setAliases(array $aliases): self 119 | { 120 | $this->aliases = $aliases; 121 | return $this; 122 | } 123 | 124 | /** 125 | * Get all available aliases for this annotation. 126 | * 127 | * @return array 128 | */ 129 | public function getAliases(): array 130 | { 131 | return $this->aliases; 132 | } 133 | 134 | /** 135 | * {{@inheritdoc}} 136 | */ 137 | public function toArray(): array 138 | { 139 | $arr = parent::toArray(); 140 | $arr['aliased'] = $this->isAliased(); 141 | $arr['aliases'] = []; 142 | 143 | /** @var Annotation $alias */ 144 | foreach ($this->getAliases() as $alias) { 145 | $arr['aliases'][] = $alias->toArray(); 146 | } 147 | 148 | ksort($arr); 149 | 150 | return $arr; 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/Parser/Annotations/ReturnAnnotation.php: -------------------------------------------------------------------------------- 1 | application->getConfig(); 40 | $content = trim($this->docblock); 41 | 42 | /** @var string $method */ 43 | $method = $this->method; 44 | try { 45 | $mson = new MSON($this->class, $method, $config); 46 | $mson = $mson->parse($content); 47 | } catch (UnsupportedTypeException $e) { 48 | throw UnknownRepresentationException::create($content, $this->class, $method); 49 | } 50 | 51 | $field = $mson->getField(); 52 | $parsed = [ 53 | 'type' => $field, 54 | 'description' => $mson->getDescription(), 55 | 'representation' => $mson->getType() 56 | ]; 57 | 58 | if (!empty($field)) { 59 | $code = $this->findReturnCodeForType($field); 60 | $parsed['http_code'] = $code . ' ' . $this->getHttpCodeMessage($code); 61 | } 62 | 63 | return $parsed; 64 | } 65 | 66 | /** 67 | * {@inheritdoc} 68 | */ 69 | protected function interpreter(): void 70 | { 71 | $this->http_code = $this->required('http_code'); 72 | $this->representation = $this->optional('representation'); 73 | $this->type = $this->required('type'); 74 | 75 | // Descriptions are only required for non-200 responses. 76 | if ($this->isNon200HttpCode()) { 77 | $this->description = $this->required('description'); 78 | } else { 79 | $this->description = $this->optional('description'); 80 | } 81 | } 82 | 83 | /** 84 | * Grab the HTTP code for a given response type. 85 | * 86 | * @param string $type 87 | * @return int 88 | * @throws UnknownReturnCodeException If an unrecognized return code is found. 89 | */ 90 | private function findReturnCodeForType(string $type): int 91 | { 92 | switch ($type) { 93 | case 'collection': 94 | case 'directory': 95 | case 'object': 96 | case 'ok': 97 | return 200; 98 | 99 | case 'created': 100 | return 201; 101 | 102 | case 'accepted': 103 | return 202; 104 | 105 | case 'added': 106 | case 'deleted': 107 | case 'exists': 108 | case 'updated': 109 | return 204; 110 | 111 | case 'notmodified': 112 | return 304; 113 | 114 | default: 115 | /** @var string $method */ 116 | $method = $this->method; 117 | throw UnknownReturnCodeException::create('return', $this->docblock, $this->class, $method); 118 | } 119 | } 120 | 121 | /** 122 | * @return false|null|string 123 | */ 124 | public function getDescription() 125 | { 126 | return $this->description; 127 | } 128 | 129 | /** 130 | * @param false|null|string $description 131 | * @return ReturnAnnotation 132 | */ 133 | public function setDescription($description): self 134 | { 135 | $this->description = $description; 136 | return $this; 137 | } 138 | 139 | /** 140 | * @return string 141 | */ 142 | public function getType(): string 143 | { 144 | return $this->type; 145 | } 146 | 147 | /** 148 | * @param string $type 149 | * @return ReturnAnnotation 150 | */ 151 | public function setType(string $type): self 152 | { 153 | $this->type = $type; 154 | return $this; 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /src/Parser/Annotations/Traits/HasHttpCodeResponseTrait.php: -------------------------------------------------------------------------------- 1 | 'OK', 21 | 201 => 'Created', 22 | 202 => 'Accepted', 23 | 203 => 'Non-Authoritative Information', 24 | 204 => 'No Content', 25 | 205 => 'Reset Content', 26 | 206 => 'Partial Content', 27 | 207 => 'Multi-Status', 28 | 208 => 'Already Reported', 29 | 226 => 'IM Used', 30 | 31 | // 3xx Redirection 32 | 300 => 'Multiple Choices', 33 | 301 => 'Moved Permanently', 34 | 302 => 'Found', 35 | 303 => 'See Other', 36 | 304 => 'Not Modified', 37 | 305 => 'Use Proxy', 38 | 306 => 'Switch Proxy', 39 | 307 => 'Temporary Redirect', 40 | 308 => 'Permanent Redirect', 41 | 42 | // 4xx Client Error 43 | 400 => 'Bad Request', 44 | 401 => 'Unauthorized', 45 | 402 => 'Payment Required', 46 | 403 => 'Forbidden', 47 | 404 => 'Not Found', 48 | 405 => 'Method Not Allowed', 49 | 406 => 'Not Acceptable', 50 | 407 => 'Proxy Authentication Required', 51 | 408 => 'Request Time-out', 52 | 409 => 'Conflict', 53 | 410 => 'Gone', 54 | 411 => 'Length Required', 55 | 412 => 'Precondition Failed', 56 | 413 => 'Payload Too Large', 57 | 414 => 'URI Too Long', 58 | 415 => 'Unsupported Media Type', 59 | 416 => 'Range Not Satisfiable', 60 | 417 => 'Expectation Failed', 61 | 418 => 'I\'m a teapot', 62 | 421 => 'Misdirected Request', 63 | 422 => 'Unprocessable Entity', 64 | 423 => 'Locked', 65 | 424 => 'Failed Dependency', 66 | 426 => 'Upgrade Required', 67 | 428 => 'Precondition Required', 68 | 429 => 'Too Many Requests', 69 | 431 => 'Request Header Fields Too Large', 70 | 451 => 'Unavailable For Legal Reasons', 71 | 72 | // 5xx Server Error 73 | 500 => 'Internal Server Error', 74 | 501 => 'Not Implemented', 75 | 502 => 'Bad Gateway', 76 | 503 => 'Service Unavailable', 77 | 504 => 'Gateway Time-out', 78 | 505 => 'HTTP Version Not Supported', 79 | 506 => 'Variant Also Negotiates', 80 | 507 => 'Insufficient Storage', 81 | 508 => 'Loop Detected', 82 | 510 => 'Not Extended', 83 | 511 => 'Network Authentication Required' 84 | ]; 85 | 86 | /** 87 | * Get the HTTP code that this response throws. 88 | * 89 | * @return string 90 | */ 91 | public function getHttpCode(): string 92 | { 93 | return $this->http_code; 94 | } 95 | 96 | /** 97 | * Set the HTTP code that this response throws. 98 | * 99 | * @param string $http_code 100 | * @return self 101 | */ 102 | public function setHttpCode(string $http_code): self 103 | { 104 | $this->http_code = $http_code; 105 | return $this; 106 | } 107 | 108 | /** 109 | * Get the HTTP message for a specific HTTP code. 110 | * 111 | * @param int|string $http_code 112 | * @return string 113 | */ 114 | public function getHttpCodeMessage($http_code): string 115 | { 116 | return self::$http_codes[$http_code]; 117 | } 118 | 119 | /** 120 | * Is this HTTP code a non-200? 121 | * 122 | * @return bool 123 | */ 124 | public function isNon200HttpCode(): bool 125 | { 126 | $message = explode(' ', $this->http_code); 127 | $code = array_shift($message); 128 | 129 | return $code >= 300; 130 | } 131 | 132 | /** 133 | * Is a given HTTP code valid? 134 | * 135 | * @param string $http_code 136 | * @return bool 137 | */ 138 | public function isValidHttpCode(string $http_code): bool 139 | { 140 | return isset(self::$http_codes[$http_code]); 141 | } 142 | 143 | /** 144 | * Get the representation that this response returns data in. 145 | * 146 | * @return false|string 147 | */ 148 | public function getRepresentation() 149 | { 150 | return $this->representation; 151 | } 152 | 153 | /** 154 | * Set the representation that this response returns data in. 155 | * 156 | * @param false|string $representation 157 | * @return self 158 | */ 159 | public function setRepresentation($representation): self 160 | { 161 | $this->representation = $representation; 162 | return $this; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/Parser/Resource/Documentation.php: -------------------------------------------------------------------------------- 1 | class = $class; 36 | $this->application = $application; 37 | $this->parser = new Parser($this->class, $application); 38 | } 39 | 40 | /** 41 | * This is a chaining accessory to help you do one-liner instances of this class. 42 | * 43 | * Example: `$documentation = (new Documentation($class))->parseMethods()->toArray();` 44 | * 45 | * @return Documentation 46 | * @throws MultipleAnnotationsException 47 | * @throws RequiredAnnotationException 48 | * @throws \Mill\Exceptions\Resource\MissingVisibilityDecoratorException 49 | * @throws \Mill\Exceptions\Resource\NoAnnotationsException 50 | * @throws \Mill\Exceptions\Resource\PublicDecoratorOnPrivateActionException 51 | * @throws \Mill\Exceptions\Resource\TooManyAliasedPathsException 52 | * @throws \Mill\Exceptions\Resource\UnsupportedDecoratorException 53 | * @throws \ReflectionException 54 | */ 55 | public function parseMethods(): self 56 | { 57 | $this->getMethods(); 58 | return $this; 59 | } 60 | 61 | /** 62 | * Return the parsed method documentation for HTTP Methods that are implemented on the current class. 63 | * 64 | * @return array 65 | * @throws MultipleAnnotationsException 66 | * @throws RequiredAnnotationException 67 | * @throws \Mill\Exceptions\Resource\MissingVisibilityDecoratorException 68 | * @throws \Mill\Exceptions\Resource\NoAnnotationsException 69 | * @throws \Mill\Exceptions\Resource\PublicDecoratorOnPrivateActionException 70 | * @throws \Mill\Exceptions\Resource\TooManyAliasedPathsException 71 | * @throws \Mill\Exceptions\Resource\UnsupportedDecoratorException 72 | * @throws \ReflectionException 73 | */ 74 | public function getMethods(): array 75 | { 76 | if (!empty($this->methods)) { 77 | return $this->methods; 78 | } 79 | 80 | $this->methods = array_flip($this->parser->getHttpMethods()); 81 | foreach ($this->methods as $method => $val) { 82 | $this->methods[$method] = (new Action\Documentation($this->class, $method, $this->application))->parse(); 83 | } 84 | 85 | return $this->methods; 86 | } 87 | 88 | /** 89 | * Get the class name of the class we're parsing for documentation. 90 | * 91 | * @return string 92 | */ 93 | public function getClass(): string 94 | { 95 | return $this->class; 96 | } 97 | 98 | /** 99 | * Pull a parsed MethodDocumentation object for a given method on this class. 100 | * 101 | * @param string $method 102 | * @return Action\Documentation 103 | * @throws MethodNotImplementedException 104 | * @throws MultipleAnnotationsException 105 | * @throws RequiredAnnotationException 106 | * @throws \Mill\Exceptions\Resource\MissingVisibilityDecoratorException 107 | * @throws \Mill\Exceptions\Resource\NoAnnotationsException 108 | * @throws \Mill\Exceptions\Resource\PublicDecoratorOnPrivateActionException 109 | * @throws \Mill\Exceptions\Resource\TooManyAliasedPathsException 110 | * @throws \Mill\Exceptions\Resource\UnsupportedDecoratorException 111 | * @throws \ReflectionException 112 | */ 113 | public function getMethod(string $method): Action\Documentation 114 | { 115 | if (empty($this->methods)) { 116 | $this->getMethods(); 117 | } 118 | 119 | if (empty($this->methods[$method])) { 120 | throw MethodNotImplementedException::create($this->getClass(), $method); 121 | } 122 | 123 | return $this->methods[$method]; 124 | } 125 | 126 | /** 127 | * {{@inheritdoc}} 128 | */ 129 | public function toArray(): array 130 | { 131 | $data = [ 132 | 'class' => $this->class, 133 | 'methods' => [] 134 | ]; 135 | 136 | /** @var Action\Documentation $object */ 137 | foreach ($this->methods as $method => $object) { 138 | $data['methods'][$method] = $object->toArray(); 139 | } 140 | 141 | return $data; 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/Compiler/Specification/OpenApi/TagReducer.php: -------------------------------------------------------------------------------- 1 | specification = $specification; 15 | } 16 | 17 | /** 18 | * @return array 19 | */ 20 | public function reduce(): array 21 | { 22 | $tagged_specs = []; 23 | foreach ($this->specification['tags'] as $tag) { 24 | $tag = $tag['name']; 25 | 26 | $tagged_specs[$tag] = $this->reduceForTag($tag); 27 | } 28 | 29 | return $tagged_specs; 30 | } 31 | 32 | /** 33 | * @param string $tag 34 | * @param bool $match_prefix_only 35 | * @return array 36 | */ 37 | public function reduceForTag(string $tag, $match_prefix_only = false): array 38 | { 39 | $tag = strtolower($tag); 40 | $specification = $this->specification; 41 | 42 | // Filter tags down to just what we're looking for. 43 | $specification['tags'] = array_filter( 44 | $specification['tags'], 45 | function (array $spec_tag) use ($tag, $match_prefix_only): bool { 46 | $spec_tag = strtolower($spec_tag['name']); 47 | 48 | return $spec_tag === $tag || ($match_prefix_only && current(explode('\\', $tag)) === $spec_tag); 49 | } 50 | ); 51 | 52 | sort($specification['tags']); 53 | 54 | // Search component schemas and construct a linked-list of refs. 55 | $linked_refs = []; 56 | $schemas = $specification['components']['schemas']; 57 | foreach ($schemas as $name => $schema) { 58 | $linked_refs[$name] = $this->getSchemaRefsFromResource($schema); 59 | } 60 | 61 | // Filter paths down to just those that contain the tag we're looking for. 62 | $path_refs = []; 63 | $paths = $specification['paths']; 64 | foreach ($paths as $path => $methods) { 65 | foreach ($methods as $method => $schema) { 66 | $tags = array_map(function (string $path_tag): string { 67 | return strtolower($path_tag); 68 | }, $schema['tags']); 69 | 70 | if ($match_prefix_only) { 71 | $tag_matches = array_filter($tags, function (string $path_tag) use ($tag): bool { 72 | return ($path_tag === $tag || strpos($path_tag, $tag . '\\') !== false); 73 | }); 74 | 75 | if (empty($tag_matches)) { 76 | unset($paths[$path][$method]); 77 | continue; 78 | } 79 | } elseif (!in_array($tag, $tags)) { 80 | unset($paths[$path][$method]); 81 | continue; 82 | } 83 | 84 | // Locate all used components so we can eliminate ones that aren't utilized from the filtered 85 | // specification. 86 | $path_refs = $this->getSchemaRefsFromResource($schema, $path_refs); 87 | } 88 | 89 | if (empty($paths[$path])) { 90 | unset($paths[$path]); 91 | } 92 | } 93 | 94 | $specification['paths'] = $paths; 95 | 96 | // Combine the path refs with the linked refs and see what refs we can filter to. 97 | $refs = []; 98 | foreach ($path_refs as $ref) { 99 | $refs[] = $ref; 100 | $refs = $this->getLinkedRefs($ref, $linked_refs, $refs); 101 | } 102 | 103 | $refs = array_unique($refs); 104 | 105 | // Filter down component schemas to just what we need for this tag. 106 | foreach ($specification['components']['schemas'] as $name => $schema) { 107 | if (!in_array($name, $refs)) { 108 | unset($specification['components']['schemas'][$name]); 109 | } 110 | } 111 | 112 | return $specification; 113 | } 114 | 115 | /** 116 | * @param string $ref 117 | * @param array $refs 118 | * @param array $linked 119 | * @return array 120 | */ 121 | private function getLinkedRefs(string $ref, array $refs, array &$linked = []): array 122 | { 123 | foreach ($refs[$ref] as $linked_ref) { 124 | if (in_array($linked_ref, $linked)) { 125 | continue; 126 | } 127 | 128 | $linked[] = $linked_ref; 129 | $linked = $this->getLinkedRefs($linked_ref, $refs, $linked); 130 | } 131 | 132 | return $linked; 133 | } 134 | 135 | /** 136 | * @param array $resource 137 | * @param array $refs 138 | * @return array 139 | */ 140 | protected function getSchemaRefsFromResource(array $resource, array $refs = []): array 141 | { 142 | foreach ($resource as $k => $v) { 143 | if ($k === '$ref') { 144 | $ref = str_replace('#/components/schemas/', '', $v); 145 | if (!in_array($ref, $refs)) { 146 | $refs[] = $ref; 147 | } 148 | } elseif (is_array($v)) { 149 | $refs = $this->getSchemaRefsFromResource($v, $refs); 150 | } 151 | } 152 | 153 | return $refs; 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/Parser/Annotations/ErrorAnnotation.php: -------------------------------------------------------------------------------- 1 | application->getConfig(); 46 | $content = trim($this->docblock); 47 | 48 | /** @var string $method */ 49 | $method = $this->method; 50 | $mson = (new MSON($this->class, $method, $config))->allowAllSubtypes()->parse($content); 51 | $parsed = [ 52 | 'http_code' => $mson->getField(), 53 | 'representation' => $mson->getType(), 54 | 'error_code' => $mson->getSubtype(), 55 | 'vendor_tags' => $mson->getVendorTags(), 56 | 'description' => $mson->getDescription() 57 | ]; 58 | 59 | if (!empty($parsed['http_code'])) { 60 | if (!$this->isValidHttpCode($parsed['http_code'])) { 61 | throw UnknownReturnCodeException::create('error', $this->docblock, $this->class, $method); 62 | } 63 | 64 | $parsed['http_code'] .= ' ' . $this->getHttpCodeMessage($parsed['http_code']); 65 | } 66 | 67 | if (!empty($parsed['vendor_tags'])) { 68 | $parsed['vendor_tags'] = array_map( 69 | /** @return Annotation */ 70 | function (string $tag) use ($method) { 71 | return (new VendorTagAnnotation( 72 | $this->application, 73 | $tag, 74 | $this->class, 75 | $method 76 | ))->process(); 77 | }, 78 | $parsed['vendor_tags'] 79 | ); 80 | } 81 | 82 | // Now that we've parsed out both the representation and error code, make sure that a representation that 83 | // requires an error code, actually has one. 84 | if (!empty($parsed['representation'])) { 85 | // If this representation requires an error code (as defined in the config file), but we don't have one, 86 | // throw an error. 87 | if ($config->doesErrorRepresentationNeedAnErrorCode($parsed['representation']) && 88 | empty($parsed['error_code']) 89 | ) { 90 | throw MissingRepresentationErrorCodeException::create( 91 | $parsed['representation'], 92 | $this->class, 93 | $method 94 | ); 95 | } 96 | } 97 | 98 | return $parsed; 99 | } 100 | 101 | /** 102 | * {@inheritdoc} 103 | */ 104 | protected function interpreter(): void 105 | { 106 | $this->http_code = $this->required('http_code'); 107 | $this->representation = $this->required('representation'); 108 | 109 | $this->error_code = $this->optional('error_code'); 110 | if ($this->error_code) { 111 | $this->error_code = (string)$this->error_code; 112 | } 113 | 114 | $this->vendor_tags = $this->optional('vendor_tags'); 115 | $this->description = $this->required('description'); 116 | } 117 | 118 | /** 119 | * @return string 120 | */ 121 | public function getDescription(): string 122 | { 123 | return $this->description; 124 | } 125 | 126 | /** 127 | * @param string $description 128 | * @return ErrorAnnotation 129 | */ 130 | public function setDescription(string $description): self 131 | { 132 | $this->description = $description; 133 | return $this; 134 | } 135 | 136 | /** 137 | * @return false|null|string 138 | */ 139 | public function getErrorCode() 140 | { 141 | return $this->error_code; 142 | } 143 | 144 | /** 145 | * @param false|null|string $error_code 146 | * @return ErrorAnnotation 147 | */ 148 | public function setErrorCode($error_code): self 149 | { 150 | $this->error_code = $error_code; 151 | return $this; 152 | } 153 | } 154 | -------------------------------------------------------------------------------- /src/Compiler/Traits/ChangelogTemplate.php: -------------------------------------------------------------------------------- 1 | template_engine)) { 25 | $this->template_engine = new Engine; 26 | } 27 | 28 | if ($this->output_format === Changelog::FORMAT_JSON) { 29 | list($template, $content) = $this->transformTemplateIntoHtml($template, $content); 30 | } else { 31 | list($template, $content) = $this->transformTemplateIntoMarkdown($template, $content); 32 | } 33 | 34 | return $this->template_engine->render($template, $content); 35 | } 36 | 37 | /** 38 | * Transform a template by wrapping specific content in styleable HTML elements. 39 | * 40 | * @param string $template 41 | * @param array $content 42 | * @return array 43 | */ 44 | protected function transformTemplateIntoHtml(string $template, array $content = []): array 45 | { 46 | $data_attributes = []; 47 | foreach ($content as $key => $value) { 48 | if ($key === 'description') { 49 | continue; 50 | } 51 | 52 | $data_attributes[] = sprintf( 53 | 'data-mill-%s="%s"', 54 | str_replace('_', '-', $key), 55 | $value 56 | ); 57 | } 58 | 59 | $html = '%s'; 60 | $data_attributes = implode(' ', $data_attributes); 61 | 62 | $searches = []; 63 | $replacements = []; 64 | foreach ($content as $key => $value) { 65 | switch ($key) { 66 | case 'content_type': 67 | case 'field': 68 | case 'http_code': 69 | case 'method': 70 | case 'parameter': 71 | case 'representation': 72 | case 'resource_group': 73 | case 'path': 74 | $searches[] = '{' . $key . '}'; 75 | if (is_array($value)) { 76 | $replacements[] = $this->joinWords( 77 | array_map(function (string $value) use ($html, $key, $data_attributes): string { 78 | return sprintf($html, $key, $data_attributes, $value); 79 | }, $value) 80 | ); 81 | } else { 82 | $replacements[] = sprintf($html, $key, $data_attributes, $value); 83 | } 84 | break; 85 | 86 | case 'description': 87 | default: 88 | // do nothing 89 | } 90 | } 91 | 92 | $template = str_replace($searches, $replacements, $template); 93 | 94 | $content['css_namespace'] = 'mill-changelog'; 95 | 96 | return [$template, $content]; 97 | } 98 | 99 | /** 100 | * Transform a template into Markdown by wrapping specific content in code-like backticks. 101 | * 102 | * @param string $template 103 | * @param array $content 104 | * @return array 105 | */ 106 | protected function transformTemplateIntoMarkdown(string $template, array $content = []): array 107 | { 108 | $searches = []; 109 | $replacements = []; 110 | foreach ($content as $key => $value) { 111 | switch ($key) { 112 | case 'content_type': 113 | case 'field': 114 | case 'http_code': 115 | case 'method': 116 | case 'parameter': 117 | case 'representation': 118 | case 'path': 119 | $searches[] = '{' . $key . '}'; 120 | if (is_array($value)) { 121 | $replacements[] = $this->joinWords( 122 | array_map(function (string $val): string { 123 | return sprintf('`%s`', $val); 124 | }, $value) 125 | ); 126 | } else { 127 | $replacements[] = sprintf('`{%s}`', $key); 128 | } 129 | break; 130 | 131 | case 'description': 132 | default: 133 | // do nothing 134 | } 135 | } 136 | 137 | $template = str_replace($searches, $replacements, $template); 138 | 139 | return [$template, $content]; 140 | } 141 | 142 | /** 143 | * Join an array of words into a structure for use in a sentence. 144 | * 145 | * - [word1, word2] -> "word1 and word 2" 146 | * - [word1, word2, word3] -> "word1, word2 and word 3" 147 | * 148 | * @param array $words 149 | * @return string 150 | */ 151 | protected function joinWords(array $words): string 152 | { 153 | if (count($words) <= 2) { 154 | return implode(' and ', $words); 155 | } 156 | 157 | $last = array_pop($words); 158 | return implode(', ', $words) . ' and ' . $last; 159 | } 160 | 161 | /** 162 | * Set the current changelog template output format. 163 | * 164 | * @param string $format 165 | */ 166 | public function setOutputFormat($format = 'json'): void 167 | { 168 | $this->output_format = $format; 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/Compiler/Changelog/Formats/Json.php: -------------------------------------------------------------------------------- 1 | changelog as $version => $version_changes) { 26 | foreach ($version_changes as $definition => $data) { 27 | if ($definition === '_details') { 28 | $json[$version][$definition] = $data; 29 | continue; 30 | } 31 | 32 | foreach ($data as $type => $changesets) { 33 | if ($type === 'resources') { 34 | $entries = $this->parseResourceChangesets($definition, $changesets); 35 | } else { 36 | $entries = $this->parseRepresentationChangesets($definition, $changesets); 37 | } 38 | 39 | $json[$version][$definition][$type] = $entries; 40 | } 41 | } 42 | } 43 | 44 | $this->json = [ 45 | json_encode($json) 46 | ]; 47 | } 48 | 49 | /** 50 | * Parse representation changesets. 51 | * 52 | * @param string $definition 53 | * @param array $changesets 54 | * @return array 55 | * @throws \Exception 56 | */ 57 | private function parseRepresentationChangesets(string $definition, array $changesets = []): array 58 | { 59 | $entries = []; 60 | foreach ($changesets as $representation => $change_types) { 61 | foreach ($change_types as $change_type => $hashes) { 62 | foreach ($hashes as $hash => $changes) { 63 | if (in_array($definition, [ 64 | Changelog::DEFINITION_ADDED, 65 | Changelog::DEFINITION_REMOVED 66 | ])) { 67 | $entry = $this->getAddedOrRemovedChangesetFactory($definition, $change_type, $changes); 68 | } else { 69 | $entry = $this->getChangedChangesetFactory($definition, $change_type, $changes); 70 | } 71 | 72 | // Reduce some unnecessary nesting of changeset strings. 73 | if (is_array($entry) && count($entry) === 1) { 74 | $entry = array_shift($entry); 75 | } 76 | 77 | $entries[] = $entry; 78 | } 79 | } 80 | } 81 | 82 | return $entries; 83 | } 84 | 85 | /** 86 | * Parse resource changesets. 87 | * 88 | * @param string $definition 89 | * @param array $changesets 90 | * @return array 91 | * @throws \Exception 92 | */ 93 | private function parseResourceChangesets(string $definition, array $changesets = []): array 94 | { 95 | $entries = []; 96 | foreach ($changesets as $group => $data) { 97 | $group_entry = [ 98 | $this->renderText('The following {resource_group} resources have ' . $definition . ':', [ 99 | 'resource_group' => $group 100 | ]), 101 | [] // Group-related entries will be nested here. 102 | ]; 103 | 104 | foreach ($data as $path => $change_types) { 105 | foreach ($change_types as $change_type => $hashes) { 106 | foreach ($hashes as $hash => $changes) { 107 | if (in_array($definition, [ 108 | Changelog::DEFINITION_ADDED, 109 | Changelog::DEFINITION_REMOVED 110 | ])) { 111 | $entry = $this->getAddedOrRemovedChangesetFactory($definition, $change_type, $changes); 112 | } else { 113 | $entry = $this->getChangedChangesetFactory($definition, $change_type, $changes); 114 | } 115 | 116 | // Reduce some unnecessary nesting of changeset strings. 117 | if (is_array($entry) && count($entry) === 1) { 118 | $entry = array_shift($entry); 119 | } 120 | 121 | $group_entry[1][] = $entry; 122 | } 123 | } 124 | } 125 | 126 | $entries[] = $group_entry; 127 | } 128 | 129 | return $entries; 130 | } 131 | 132 | /** 133 | * Get a changelog entry for a changeset that was added into the API. 134 | * 135 | * @param string $definition 136 | * @param string $change_type 137 | * @param array $changes 138 | * @return string|array 139 | * @throws \Exception If an unsupported definition + change type was supplied. 140 | */ 141 | private function getAddedOrRemovedChangesetFactory(string $definition, string $change_type, array $changes) 142 | { 143 | switch ($change_type) { 144 | case Changelog::CHANGESET_TYPE_ACTION: 145 | $changeset = new Changelog\Changesets\Action; 146 | break; 147 | 148 | case Changelog::CHANGESET_TYPE_ACTION_PARAM: 149 | $changeset = new Changelog\Changesets\ActionParam; 150 | break; 151 | 152 | case Changelog::CHANGESET_TYPE_ACTION_RETURN: 153 | $changeset = new Changelog\Changesets\ActionReturn; 154 | break; 155 | 156 | case Changelog::CHANGESET_TYPE_ACTION_ERROR: 157 | $changeset = new Changelog\Changesets\ActionError; 158 | break; 159 | 160 | case Changelog::CHANGESET_TYPE_REPRESENTATION_DATA: 161 | $changeset = new Changelog\Changesets\RepresentationData; 162 | break; 163 | 164 | default: 165 | throw new \Exception($definition . ' `' . $change_type . '` changes are not yet supported.'); 166 | } 167 | 168 | $changeset->setOutputFormat($this->output_format); 169 | return $changeset->compileAddedOrRemovedChangeset($definition, $changes); 170 | } 171 | 172 | /** 173 | * Get a changelog entry for a changeset that was changed in the API. 174 | * 175 | * @param string $definition 176 | * @param string $change_type 177 | * @param array $changes 178 | * @return string|array 179 | * @throws \Exception If an unsupported definition + change type was supplied. 180 | */ 181 | private function getChangedChangesetFactory(string $definition, string $change_type, array $changes) 182 | { 183 | // Due to versioning restrictions in the Mill syntax (that will be fixed), only `@api-contenttype` annotations 184 | // will create a "changed" entry in the changelog. 185 | switch ($change_type) { 186 | case Changelog::CHANGESET_TYPE_CONTENT_TYPE: 187 | $changeset = new Changelog\Changesets\ContentType; 188 | break; 189 | 190 | default: 191 | throw new \Exception($definition . ' `' . $change_type . '` changes are not yet supported.'); 192 | } 193 | 194 | $changeset->setOutputFormat($this->output_format); 195 | return $changeset->compileChangedChangeset($definition, $changes); 196 | } 197 | 198 | /** 199 | * @return array 200 | */ 201 | public function getCompiled(): array 202 | { 203 | if (empty($this->json)) { 204 | $this->compile(); 205 | } 206 | 207 | return $this->json; 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /src/Parser/Annotations/ParamAnnotation.php: -------------------------------------------------------------------------------- 1 | application->getConfig(); 68 | $content = trim($this->docblock); 69 | 70 | // Swap in shortcode tokens (if present). 71 | $tokens = $config->getParameterTokens(); 72 | if (!empty($tokens)) { 73 | $content = str_replace(array_keys($tokens), array_values($tokens), $content); 74 | } 75 | 76 | /** @var string $method */ 77 | $method = $this->method; 78 | $mson = (new MSON($this->class, $method, $config))->parse($content); 79 | $parsed = [ 80 | 'field' => $mson->getField(), 81 | 'sample_data' => $mson->getSampleData(), 82 | 'type' => $mson->getType(), 83 | 'subtype' => $mson->getSubtype(), 84 | 'required' => $mson->isRequired(), 85 | 'nullable' => $mson->isNullable(), 86 | 'vendor_tags' => $mson->getVendorTags(), 87 | 'description' => $mson->getDescription(), 88 | 'values' => $mson->getValues() 89 | ]; 90 | 91 | if (!empty($parsed['field'])) { 92 | if (strtoupper($parsed['field']) === Application::DOT_NOTATION_ANNOTATION_DATA_KEY) { 93 | throw RestrictedFieldNameException::create($this->class, $this->method); 94 | } 95 | } 96 | 97 | if (!empty($parsed['vendor_tags'])) { 98 | $parsed['vendor_tags'] = array_map( 99 | /** @return Annotation */ 100 | function (string $tag) use ($method) { 101 | return (new VendorTagAnnotation( 102 | $this->application, 103 | $tag, 104 | $this->class, 105 | $method 106 | ))->process(); 107 | }, 108 | $parsed['vendor_tags'] 109 | ); 110 | } 111 | 112 | return $parsed; 113 | } 114 | 115 | /** 116 | * {@inheritdoc} 117 | */ 118 | protected function interpreter(): void 119 | { 120 | $this->field = $this->required('field'); 121 | $this->sample_data = $this->optional('sample_data'); 122 | $this->type = $this->required('type'); 123 | $this->subtype = $this->optional('subtype'); 124 | $this->description = $this->required('description'); 125 | $this->required = $this->boolean('required'); 126 | 127 | $this->values = $this->optional('values'); 128 | $this->vendor_tags = $this->optional('vendor_tags'); 129 | $this->nullable = $this->optional('nullable'); 130 | } 131 | 132 | /** 133 | * @return string 134 | */ 135 | public function getPayloadFormat(): string 136 | { 137 | return static::PAYLOAD_FORMAT; 138 | } 139 | 140 | /** 141 | * @return string 142 | */ 143 | public function getField(): string 144 | { 145 | return $this->field; 146 | } 147 | 148 | /** 149 | * @param string $field 150 | * @return ParamAnnotation 151 | */ 152 | public function setField(string $field): self 153 | { 154 | $this->field = $field; 155 | return $this; 156 | } 157 | 158 | /** 159 | * @return false|string 160 | */ 161 | public function getSampleData() 162 | { 163 | return $this->sample_data; 164 | } 165 | 166 | /** 167 | * @param false|string $sample_data 168 | * @return ParamAnnotation 169 | */ 170 | public function setSampleData($sample_data): self 171 | { 172 | $this->sample_data = $sample_data; 173 | return $this; 174 | } 175 | 176 | /** 177 | * @return string 178 | */ 179 | public function getType(): string 180 | { 181 | return $this->type; 182 | } 183 | 184 | /** 185 | * @param string $type 186 | * @return ParamAnnotation 187 | */ 188 | public function setType(string $type): self 189 | { 190 | $this->type = $type; 191 | return $this; 192 | } 193 | 194 | /** 195 | * @return false|string 196 | */ 197 | public function getSubtype() 198 | { 199 | return $this->subtype; 200 | } 201 | 202 | /** 203 | * @param false|string $subtype 204 | * @return ParamAnnotation 205 | */ 206 | public function setSubtype($subtype): self 207 | { 208 | $this->subtype = $subtype; 209 | return $this; 210 | } 211 | 212 | /** 213 | * @return string 214 | */ 215 | public function getDescription(): string 216 | { 217 | return $this->description; 218 | } 219 | 220 | /** 221 | * @param string $description 222 | * @return ParamAnnotation 223 | */ 224 | public function setDescription(string $description): self 225 | { 226 | $this->description = $description; 227 | return $this; 228 | } 229 | 230 | /** 231 | * @return bool 232 | */ 233 | public function isRequired(): bool 234 | { 235 | return $this->required; 236 | } 237 | 238 | /** 239 | * @param bool $required 240 | * @return ParamAnnotation 241 | */ 242 | public function setRequired(bool $required): self 243 | { 244 | $this->required = $required; 245 | return $this; 246 | } 247 | 248 | /** 249 | * @return bool 250 | */ 251 | public function isNullable(): bool 252 | { 253 | return $this->nullable; 254 | } 255 | 256 | /** 257 | * @param bool $nullable 258 | * @return ParamAnnotation 259 | */ 260 | public function setNullable(bool $nullable): self 261 | { 262 | $this->nullable = $nullable; 263 | return $this; 264 | } 265 | 266 | /** 267 | * @return array|null 268 | */ 269 | public function getValues() 270 | { 271 | return $this->values; 272 | } 273 | 274 | /** 275 | * @param array|null $values 276 | * @return ParamAnnotation 277 | */ 278 | public function setValues($values): self 279 | { 280 | $this->values = $values; 281 | return $this; 282 | } 283 | } 284 | -------------------------------------------------------------------------------- /src/Parser/Annotations/DataAnnotation.php: -------------------------------------------------------------------------------- 1 | docblock); 64 | 65 | /** @var string $method */ 66 | $method = $this->method; 67 | 68 | $mson = (new MSON($this->class, $method, $this->application->getConfig())) 69 | ->requiredByDefault() 70 | ->parse($content); 71 | 72 | $parsed = [ 73 | 'identifier' => $mson->getField(), 74 | 'sample_data' => $mson->getSampleData(), 75 | 'type' => $mson->getType(), 76 | 'subtype' => $mson->getSubtype(), 77 | 'required' => $mson->isRequired(), 78 | 'nullable' => $mson->isNullable(), 79 | 'vendor_tags' => $mson->getVendorTags(), 80 | 'description' => $mson->getDescription(), 81 | 'values' => $mson->getValues() 82 | ]; 83 | 84 | if (!empty($parsed['vendor_tags'])) { 85 | $parsed['vendor_tags'] = array_map( 86 | /** @return Annotation */ 87 | function (string $tag) use ($method) { 88 | return (new VendorTagAnnotation( 89 | $this->application, 90 | $tag, 91 | $this->class, 92 | $method 93 | ))->process(); 94 | }, 95 | $parsed['vendor_tags'] 96 | ); 97 | } 98 | 99 | if (!empty($parsed['identifier'])) { 100 | if (strtoupper($parsed['identifier']) === Application::DOT_NOTATION_ANNOTATION_DATA_KEY) { 101 | throw RestrictedFieldNameException::create($this->class, $this->method); 102 | } 103 | } 104 | 105 | // If we have values present, but no sample data, set the sample as the first item in the values list. 106 | if (!empty($parsed['values']) && empty($parsed['sample_data'])) { 107 | $parsed['sample_data'] = array_keys($parsed['values'])[0]; 108 | } 109 | 110 | return $parsed; 111 | } 112 | 113 | /** 114 | * {@inheritdoc} 115 | */ 116 | protected function interpreter(): void 117 | { 118 | $this->identifier = $this->required('identifier'); 119 | $this->sample_data = $this->optional('sample_data', true); 120 | $this->type = $this->required('type'); 121 | $this->subtype = $this->optional('subtype'); 122 | $this->description = $this->required('description'); 123 | 124 | $this->values = $this->optional('values'); 125 | $this->vendor_tags = $this->optional('vendor_tags'); 126 | $this->required = $this->optional('required'); 127 | $this->nullable = $this->optional('nullable'); 128 | } 129 | 130 | /** 131 | * @return string 132 | */ 133 | public function getDescription(): string 134 | { 135 | return $this->description; 136 | } 137 | 138 | /** 139 | * @param string $description 140 | * @return DataAnnotation 141 | */ 142 | public function setDescription(string $description): self 143 | { 144 | $this->description = $description; 145 | return $this; 146 | } 147 | 148 | /** 149 | * @return string 150 | */ 151 | public function getIdentifier(): string 152 | { 153 | return $this->identifier; 154 | } 155 | 156 | /** 157 | * @param string $identifier 158 | * @return DataAnnotation 159 | */ 160 | public function setIdentifier(string $identifier): self 161 | { 162 | $this->identifier = $identifier; 163 | return $this; 164 | } 165 | 166 | /** 167 | * Set a dot notation prefix on the identifier. 168 | * 169 | * @param string $prefix 170 | * @return DataAnnotation 171 | */ 172 | public function setIdentifierPrefix(string $prefix): self 173 | { 174 | $this->identifier = $prefix . '.' . $this->identifier; 175 | return $this; 176 | } 177 | 178 | /** 179 | * @return bool 180 | */ 181 | public function isRequired(): bool 182 | { 183 | return $this->required; 184 | } 185 | 186 | /** 187 | * @param bool $required 188 | * @return DataAnnotation 189 | */ 190 | public function setRequired(bool $required): self 191 | { 192 | $this->required = $required; 193 | return $this; 194 | } 195 | 196 | /** 197 | * @return bool 198 | */ 199 | public function isNullable(): bool 200 | { 201 | return $this->nullable; 202 | } 203 | 204 | /** 205 | * @param bool $nullable 206 | * @return DataAnnotation 207 | */ 208 | public function setNullable(bool $nullable): self 209 | { 210 | $this->nullable = $nullable; 211 | return $this; 212 | } 213 | 214 | /** 215 | * @return false|string 216 | */ 217 | public function getSampleData() 218 | { 219 | return $this->sample_data; 220 | } 221 | 222 | /** 223 | * @param false|string $sample_data 224 | * @return DataAnnotation 225 | */ 226 | public function setSampleData($sample_data): self 227 | { 228 | $this->sample_data = $sample_data; 229 | return $this; 230 | } 231 | 232 | /** 233 | * @return string 234 | */ 235 | public function getType(): string 236 | { 237 | return $this->type; 238 | } 239 | 240 | /** 241 | * @param string $type 242 | * @return DataAnnotation 243 | */ 244 | public function setType(string $type): self 245 | { 246 | $this->type = $type; 247 | return $this; 248 | } 249 | 250 | /** 251 | * @return false|string 252 | */ 253 | public function getSubtype() 254 | { 255 | return $this->subtype; 256 | } 257 | 258 | /** 259 | * @param false|string $subtype 260 | * @return DataAnnotation 261 | */ 262 | public function setSubtype($subtype): self 263 | { 264 | $this->subtype = $subtype; 265 | return $this; 266 | } 267 | 268 | /** 269 | * @return array|false|null 270 | */ 271 | public function getValues() 272 | { 273 | return $this->values; 274 | } 275 | 276 | /** 277 | * @param array|false|null $values 278 | * @return DataAnnotation 279 | */ 280 | public function setValues($values): self 281 | { 282 | $this->values = $values; 283 | return $this; 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/Parser/Representation/Documentation.php: -------------------------------------------------------------------------------- 1 | class = $class; 48 | $this->method = $method; 49 | $this->application = $application; 50 | } 51 | 52 | /** 53 | * Parse the instance controller and method into actionable annotations and documentation. 54 | * 55 | * @return Documentation 56 | * @throws MultipleAnnotationsException 57 | * @throws NoAnnotationsException 58 | * @throws RequiredAnnotationException 59 | * @throws \Mill\Exceptions\MethodNotSuppliedException 60 | * @throws \Mill\Exceptions\Representation\DuplicateFieldException 61 | * @throws \Mill\Exceptions\Resource\UnsupportedDecoratorException 62 | */ 63 | public function parse(): self 64 | { 65 | $annotations = (new Parser($this->class, $this->application))->setMethod($this->method)->getAnnotations(); 66 | 67 | $this->representation = (new RepresentationParser($this->class, $this->application)) 68 | ->getAnnotations($this->method); 69 | 70 | if (empty($annotations)) { 71 | throw NoAnnotationsException::create($this->class, null); 72 | } elseif (empty($this->representation)) { 73 | throw NoAnnotationsException::create($this->class, $this->method); 74 | } 75 | 76 | // Parse out the `@api-label` annotation. 77 | if (!isset($annotations['label'])) { 78 | throw RequiredAnnotationException::create('label', $this->class, $this->method); 79 | } elseif (count($annotations['label']) > 1) { 80 | throw MultipleAnnotationsException::create('label', $this->class, $this->method); 81 | } else { 82 | /** @var \Mill\Parser\Annotations\LabelAnnotation $annotation */ 83 | $annotation = reset($annotations['label']); 84 | $this->label = $annotation->getLabel(); 85 | } 86 | 87 | // Parse out the description block, if it's present. 88 | if (!empty($annotations['description'])) { 89 | /** @var \Mill\Parser\Annotations\DescriptionAnnotation $annotation */ 90 | $annotation = reset($annotations['description']); 91 | $this->description = $annotation->getDescription(); 92 | } 93 | 94 | return $this; 95 | } 96 | 97 | /** 98 | * Filter down, and return, all annotations on this representation to a specific version. 99 | * 100 | * @param string $version 101 | * @return array 102 | */ 103 | public function filterRepresentationForVersion(string $version): array 104 | { 105 | /** @var Parser\Annotation $annotation */ 106 | foreach ($this->representation as $name => $annotation) { 107 | // If this annotation has a set version, but that version doesn't match what we're looking for, filter it 108 | // out. 109 | $annotation_version = $annotation->getVersion(); 110 | if ($annotation_version) { 111 | if (!$annotation_version->matches($version)) { 112 | unset($this->representation[$name]); 113 | } 114 | } 115 | } 116 | 117 | return $this->representation; 118 | } 119 | 120 | /** 121 | * Filter down, and return, all annotations on this representation that match a specific vendor tag. 122 | * 123 | * @param array|null $only_vendor_tags 124 | * @return array 125 | */ 126 | public function filterAnnotationsForVisibility(?array $only_vendor_tags): array 127 | { 128 | if (is_null($only_vendor_tags)) { 129 | return $this->representation; 130 | } 131 | 132 | /** @var Parser\Annotation $annotation */ 133 | foreach ($this->representation as $name => $annotation) { 134 | // If this annotation has vendor tags, but those vendor tags aren't in the set of vendor tags we're 135 | // compiling documentation for, filter it out. 136 | $vendor_tags = $annotation->getVendorTags(); 137 | if (!empty($vendor_tags)) { 138 | // If we don't even have vendor tags to look for, then filter this annotation out completely. 139 | if (empty($only_vendor_tags)) { 140 | unset($this->representation[$name]); 141 | continue; 142 | } 143 | 144 | $all_found = true; 145 | 146 | /** @var Parser\Annotations\VendorTagAnnotation $vendor_tag */ 147 | foreach ($vendor_tags as $vendor_tag) { 148 | $vendor_tag = $vendor_tag->getVendorTag(); 149 | 150 | if (!in_array($vendor_tag, $only_vendor_tags)) { 151 | $all_found = false; 152 | } 153 | } 154 | 155 | if (!$all_found) { 156 | unset($this->representation[$name]); 157 | continue; 158 | } 159 | 160 | // Vendor tags requirements override individual annotation visibility. 161 | continue; 162 | } 163 | } 164 | 165 | return $this->representation; 166 | } 167 | 168 | /** 169 | * Get the representation class that we're parsing. 170 | * 171 | * @return string 172 | */ 173 | public function getClass(): string 174 | { 175 | return $this->class; 176 | } 177 | 178 | /** 179 | * Get the representation class method that we're parsing. 180 | * 181 | * @return string 182 | */ 183 | public function getMethod(): string 184 | { 185 | return $this->method; 186 | } 187 | 188 | /** 189 | * Get the label of this representation. 190 | * 191 | * @return string 192 | */ 193 | public function getLabel(): string 194 | { 195 | return $this->label; 196 | } 197 | 198 | /** 199 | * Pull the raw content of this representation. This will be an array of Annotation objects. 200 | * 201 | * @return array 202 | */ 203 | public function getRawContent(): array 204 | { 205 | return $this->representation; 206 | } 207 | 208 | /** 209 | * Pull the content of this representation. This will be an array of `toArray`'d Annotation objects. 210 | * 211 | * @return array 212 | */ 213 | public function getContent(): array 214 | { 215 | return $this->toArray()['content']; 216 | } 217 | 218 | /** 219 | * Convert the parsed representation documentation content dot notation field names into an exploded array. 220 | * 221 | * @return array 222 | */ 223 | public function getExplodedContentDotNotation(): array 224 | { 225 | $content = new Data; 226 | 227 | $arr = $this->toArray(); 228 | foreach ($arr['content'] as $field => $data) { 229 | $content->set($field, [ 230 | Application::DOT_NOTATION_ANNOTATION_DATA_KEY => $data 231 | ]); 232 | } 233 | 234 | return $content->export(); 235 | } 236 | 237 | /** 238 | * {{@inheritdoc}} 239 | */ 240 | public function toArray(): array 241 | { 242 | $data = [ 243 | 'label' => $this->label, 244 | 'description' => $this->description, 245 | 'content' => [] 246 | ]; 247 | 248 | foreach ($this->representation as $key => $annotation) { 249 | /** @var \Mill\Parser\Annotation $annotation */ 250 | $data['content'][$key] = $annotation->toArray(); 251 | } 252 | 253 | ksort($data['content']); 254 | 255 | return $data; 256 | } 257 | } 258 | -------------------------------------------------------------------------------- /src/Parser/Representation/RepresentationParser.php: -------------------------------------------------------------------------------- 1 | class); 37 | } 38 | 39 | /** @var string method */ 40 | $this->method = $method_name; 41 | 42 | $reader = $this->application->getContainer()->getRepresentationAnnotationReader(); 43 | $code = $reader($this->class, $this->method); 44 | 45 | $annotations = $this->parse($code); 46 | 47 | if (count($annotations) > 1) { 48 | ksort($annotations); 49 | 50 | // Run through all created annotations and cascade any versioning down into any present child annotations. 51 | /** @var DataAnnotation $annotation */ 52 | foreach ($annotations as $identifier => $annotation) { 53 | if (!$annotation->getVersion() && !$annotation->getVendorTags() && !$annotation->getScopes()) { 54 | continue; 55 | } 56 | 57 | $this->carryAnnotationSettingsToChildren($annotation, $annotations); 58 | } 59 | } 60 | 61 | return $annotations; 62 | } 63 | 64 | /** 65 | * Parse a group of our custom annotations. 66 | * 67 | * @param array $tags 68 | * @param string $original_content 69 | * @return array 70 | * @throws DuplicateFieldException 71 | * @throws MethodNotSuppliedException 72 | * @throws \Mill\Exceptions\Version\UnrecognizedSchemaException 73 | */ 74 | public function parseAnnotations(array $tags, string $original_content): array 75 | { 76 | $scopes = []; 77 | $see_pointers = []; 78 | $annotations = []; 79 | $data = []; 80 | 81 | /** @var Version|null $version */ 82 | $version = null; 83 | 84 | /** @var string $method */ 85 | $method = $this->method; 86 | 87 | // Does this have any `@api-see` pointers or a `@api-version` declaration? 88 | /** @var UnknownTag $tag */ 89 | foreach ($tags as $tag) { 90 | $annotation = $this->getAnnotationNameFromTag($tag); 91 | $content = $tag->getDescription(); 92 | $content = trim($content); 93 | 94 | switch ($annotation) { 95 | case 'data': 96 | $data[] = $content; 97 | break; 98 | 99 | case 'scope': 100 | $scopes[] = (new ScopeAnnotation($this->application, $content, $this->class, $method))->process(); 101 | break; 102 | 103 | case 'see': 104 | $see_pointers[] = explode(' ', $content); 105 | break; 106 | 107 | case 'version': 108 | $version = new Version($content, $this->class, $method); 109 | break; 110 | } 111 | } 112 | 113 | foreach ($data as $content) { 114 | $annotation = new DataAnnotation($this->application, $content, $this->class, $method, $version); 115 | $annotation->process(); 116 | if (!empty($scopes)) { 117 | $annotation->setScopes($scopes); 118 | } 119 | 120 | $annotations[$annotation->getIdentifier()] = $annotation; 121 | } 122 | 123 | // If we matched an `@api-see` annotation, then let's parse it out into viable annotations. 124 | if (!empty($see_pointers)) { 125 | foreach ($see_pointers as $pointer) { 126 | /** @psalm-var class-string $see_class */ 127 | list($see_class, $see_method) = explode('::', array_shift($pointer)); 128 | if (in_array(strtolower($see_class), ['self', 'static'])) { 129 | $see_class = $this->class; 130 | } 131 | 132 | $prefix = array_shift($pointer); 133 | 134 | // Pass in the current array (by reference) of found annotations that we have so we can do depth 135 | // traversal for version and requirements of any implied children, by way of dot-notation. 136 | $parser = new self($see_class, $this->application); 137 | $see_annotations = $parser->getAnnotations($see_method); 138 | 139 | /** @var DataAnnotation $annotation */ 140 | foreach ($see_annotations as $name => $annotation) { 141 | // If this `@api-see` is being used with an `@api-version`, then the version here should always be 142 | // applied to any annotations we're including with the `@api-see`. 143 | // 144 | // If, however, an annotation we're loading has its own versioning set, we'll combine the pointers 145 | // version with the annotations version to create a new constraint specifically for that annotation. 146 | // 147 | // For example, if `external_urls` is versioned at `>=1.1`, and points to a method to load 148 | // `external_urls.tickets`, but that's versioned at `<1.1.3`, the new parsed constraint for 149 | // `external_urls.tickets` will be `>=1.1 <1.1.3`. 150 | if ($version) { 151 | $annotation_version = $annotation->getVersion(); 152 | if ($annotation_version) { 153 | $new_constraint = implode(' ', [ 154 | $version->getConstraint(), 155 | $annotation_version->getConstraint() 156 | ]); 157 | 158 | $updated_version = new Version($new_constraint, $this->class, $method); 159 | $annotation->setVersion($updated_version); 160 | } else { 161 | $annotation->setVersion($version); 162 | } 163 | } 164 | 165 | // If this `@api-see` is being used with `@api-scope` annotations, the scope should filter down 166 | // the pipe. 167 | if (!empty($scopes)) { 168 | $annotation->setScopes($scopes); 169 | } 170 | 171 | // If this `@api-see` has a prefix to attach to found annotation identifiers, do so. 172 | if (!empty($prefix)) { 173 | $see_annotations[$prefix . '.' . $name] = $annotation->setIdentifierPrefix($prefix); 174 | unset($see_annotations[$name]); 175 | } 176 | } 177 | 178 | $annotations += $see_annotations; 179 | } 180 | } 181 | 182 | return $annotations; 183 | } 184 | 185 | /** 186 | * Parse a block of code for representation documentation and return an array of annotations. 187 | * 188 | * @param string $code 189 | * @return array 190 | * @throws DuplicateFieldException 191 | * @throws \Mill\Exceptions\Resource\UnsupportedDecoratorException 192 | */ 193 | public function parse(string $code): array 194 | { 195 | $representation = []; 196 | 197 | if (preg_match_all(self::DOC_PATTERN, $code, $matches)) { 198 | foreach ($matches[1] as $block) { 199 | $annotations = $this->parseDocblock($block, false); 200 | if (empty($annotations)) { 201 | continue; 202 | } 203 | 204 | foreach ($annotations as $field_name => $annotation) { 205 | if (isset($representation[$field_name])) { 206 | /** @var string $method */ 207 | $method = $this->method; 208 | throw DuplicateFieldException::create($field_name, $this->class, $method); 209 | } 210 | 211 | $representation[$field_name] = $annotations[$field_name]; 212 | } 213 | } 214 | } 215 | 216 | return $representation; 217 | } 218 | 219 | /** 220 | * Given a DataAnnotation object, carry any versioning or vendor tags on it down to any dot-notation children in 221 | * an array of other annotations. 222 | * 223 | * @param DataAnnotation $parent 224 | * @param array $annotations 225 | */ 226 | private function carryAnnotationSettingsToChildren(DataAnnotation $parent, array &$annotations = []): void 227 | { 228 | $parent_identifier = $parent->getIdentifier(); 229 | $parent_version = $parent->getVersion(); 230 | $parent_vendor_tags = $parent->getVendorTags(); 231 | 232 | /** @var array $parent_scopes */ 233 | $parent_scopes = $parent->getScopes(); 234 | 235 | /** @var DataAnnotation $annotation */ 236 | foreach ($annotations as $identifier => $annotation) { 237 | if ($identifier === $parent_identifier) { 238 | continue; 239 | } 240 | 241 | // Is this annotation a child of what we're looking for? 242 | if ($parent_identifier . '.' !== substr($identifier, 0, strlen($parent_identifier . '.'))) { 243 | continue; 244 | } 245 | 246 | if (!empty($parent_version) && !$annotation->getVersion()) { 247 | $annotation->setVersion($parent_version); 248 | } 249 | 250 | if (!empty($parent_vendor_tags) && !$annotation->getVendorTags()) { 251 | $annotation->setVendorTags($parent_vendor_tags); 252 | } 253 | 254 | if (!empty($parent_scopes) && !$annotation->getScopes()) { 255 | $annotation->setScopes($parent_scopes); 256 | } 257 | } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/Command/Compile.php: -------------------------------------------------------------------------------- 1 | setName('compile') 38 | ->setDescription('Compile API documentation.') 39 | ->addOption( 40 | 'format', 41 | null, 42 | InputOption::VALUE_OPTIONAL, 43 | 'API specification format to compile documentation into. Available formats: ' . implode( 44 | ', ', 45 | array_map(function (string $format): string { 46 | return '`' . $format . '`'; 47 | }, self::FORMATS) 48 | ), 49 | self::FORMAT_OPENAPI 50 | ) 51 | ->addOption( 52 | 'constraint', 53 | null, 54 | InputOption::VALUE_OPTIONAL, 55 | 'Version constraint to compile documentation for. eg. "3.*", "3.1 - 3.2"', 56 | null 57 | ) 58 | ->addOption( 59 | 'default', 60 | null, 61 | InputOption::VALUE_OPTIONAL, 62 | 'Compile just the configured default API version documentation.', 63 | false 64 | ) 65 | ->addOption( 66 | 'latest', 67 | null, 68 | InputOption::VALUE_OPTIONAL, 69 | 'Compile just the **latest** configured API version documentation.', 70 | false 71 | ) 72 | ->addOption( 73 | 'environment', 74 | null, 75 | InputOption::VALUE_OPTIONAL, 76 | 'Compile documentation for a specific server environment. Only available for `openapi` compilations.' 77 | ) 78 | ->addOption( 79 | 'for_public_consumption', 80 | null, 81 | InputOption::VALUE_OPTIONAL, 82 | 'Flag designating if you want compiled documentation be ready for the public to consume. This will ' . 83 | 'forgo compiling in `x-mill-*` extensions, bypass creating group-specific specifications, as ' . 84 | 'as not create specification-specific directories.', 85 | false 86 | ); 87 | } 88 | 89 | /** 90 | * @param InputInterface $input 91 | * @param OutputInterface $output 92 | * @return int 93 | * @throws \Exception 94 | */ 95 | protected function execute(InputInterface $input, OutputInterface $output) 96 | { 97 | parent::execute($input, $output); 98 | 99 | /** @var string $environment */ 100 | $environment = $input->getOption('environment'); 101 | 102 | /** @var string $format */ 103 | $format = $input->getOption('format'); 104 | $format = strtolower($format); 105 | 106 | if (!in_array($format, ['apiblueprint', 'openapi'])) { 107 | $output->writeLn('`' . $format . '` is an unknown compilation format.'); 108 | return 1; 109 | } 110 | 111 | $for_public_consumption = $input->getOption('for_public_consumption'); 112 | if (is_bool($for_public_consumption) && $for_public_consumption === true) { 113 | $this->for_public_consumption = true; 114 | } elseif (is_string($for_public_consumption) && strtolower($for_public_consumption) == 'true') { 115 | $this->for_public_consumption = true; 116 | } else { 117 | $this->for_public_consumption = false; 118 | } 119 | 120 | // If we're compiling for public consumption, then ignore the `vendor_tag` and `private` arguments that may 121 | // have been supplied. 122 | if ($this->for_public_consumption) { 123 | $this->vendor_tags = []; 124 | $this->private_docs = false; 125 | } 126 | 127 | /** @var Config $config */ 128 | $config = $this->container['config']; 129 | 130 | if ($input->getOption('default')) { 131 | $version_opt = $config->getDefaultApiVersion(); 132 | } elseif ($input->getOption('latest')) { 133 | $version_opt = $config->getLatestApiVersion(); 134 | } else { 135 | /** @var string|null $version_opt */ 136 | $version_opt = $input->getOption('constraint'); 137 | } 138 | 139 | // Validate the current version constraint. 140 | if (!empty($version_opt)) { 141 | try { 142 | $version = new Version($version_opt, __CLASS__, __METHOD__); 143 | } catch (UnrecognizedSchemaException $e) { 144 | $output->writeLn('' . $e->getValidationMessage() . ''); 145 | return 1; 146 | } 147 | } else { 148 | $version = null; 149 | } 150 | 151 | $this->filesystem = $this->container['filesystem']; 152 | 153 | if (!empty($environment) && $format === self::FORMAT_OPENAPI) { 154 | if (!$config->hasServerEnvironment($environment)) { 155 | $output->writeLn('The `' . $environment . '` environment has not been configured.'); 156 | return 1; 157 | } 158 | 159 | $output->writeln( 160 | 'Compiling documentation for the `' . $environment . '` environment...' 161 | ); 162 | } 163 | 164 | $output->writeln('Compiling controllers and representations...'); 165 | if ($format === self::FORMAT_API_BLUEPRINT) { 166 | $compiler = new ApiBlueprint($this->app, $version); 167 | } else { 168 | $compiler = new OpenApi($this->app, $version); 169 | if (!empty($environment)) { 170 | $compiler->setEnvironment($environment); 171 | } 172 | } 173 | 174 | $compiler->setLoadPrivateDocs($this->private_docs); 175 | $compiler->setLoadVendorTagDocs($this->vendor_tags); 176 | 177 | if ($this->for_public_consumption) { 178 | $compiler->setCompileWithExtensions(false); 179 | } 180 | 181 | $output->writeln( 182 | sprintf( 183 | 'Compiling %s files...', 184 | ($format === self::FORMAT_API_BLUEPRINT) ? 'API Blueprint' : 'OpenAPI' 185 | ) 186 | ); 187 | 188 | $compiled = $compiler->getCompiled(); 189 | foreach ($compiled as $version => $spec) { 190 | $version_dir = $this->output_dir . DIRECTORY_SEPARATOR . $version . DIRECTORY_SEPARATOR; 191 | 192 | $output->writeLn(' - API version: ' . $version . ''); 193 | 194 | switch ($format) { 195 | case self::FORMAT_API_BLUEPRINT: 196 | $this->saveApiBlueprint($output, $version_dir, $spec); 197 | break; 198 | 199 | case self::FORMAT_OPENAPI: 200 | $this->saveOpenApi($output, $version_dir, $spec); 201 | break; 202 | } 203 | } 204 | 205 | $output->writeln(['', 'Done!']); 206 | 207 | return 0; 208 | } 209 | 210 | /** 211 | * @param OutputInterface $output 212 | * @param string $version_dir 213 | * @param array $spec 214 | */ 215 | private function saveApiBlueprint(OutputInterface $output, string $version_dir, array $spec): void 216 | { 217 | // If we aren't compiling specifications for public consumption, then go ahead and put these specs under a 218 | // specification-specific subdirectory. 219 | if (!$this->for_public_consumption) { 220 | $version_dir .= self::FORMAT_API_BLUEPRINT . DIRECTORY_SEPARATOR; 221 | } 222 | 223 | // Save a, single, combined API Blueprint file. 224 | $this->filesystem->put($version_dir . 'api.apib', $spec['combined']); 225 | 226 | if (!$this->for_public_consumption) { 227 | // Process resource groups. 228 | if (isset($spec['groups'])) { 229 | foreach ($spec['groups'] as $group => $markdown) { 230 | // Convert any nested groups, like `Users\Videos`, into a proper directory structure: 231 | // `Users/Videos`. 232 | $group = str_replace('\\', DIRECTORY_SEPARATOR, $group); 233 | 234 | $this->filesystem->put( 235 | $version_dir . 'resources' . DIRECTORY_SEPARATOR . $group . '.apib', 236 | trim($markdown) 237 | ); 238 | } 239 | } 240 | 241 | // Process data structures. 242 | if (isset($spec['structures'])) { 243 | foreach ($spec['structures'] as $structure => $markdown) { 244 | // Sanitize any structure names with forward slashes to avoid them from being nested in directories 245 | // by Flysystem. 246 | $structure = str_replace('/', '-', $structure); 247 | 248 | $this->filesystem->put( 249 | $version_dir . 'representations' . DIRECTORY_SEPARATOR . $structure . '.apib', 250 | trim($markdown) 251 | ); 252 | } 253 | } 254 | } 255 | } 256 | 257 | /** 258 | * @param OutputInterface $output 259 | * @param string $version_dir 260 | * @param array $spec 261 | */ 262 | private function saveOpenApi(OutputInterface $output, string $version_dir, array $spec): void 263 | { 264 | // If we aren't compiling specifications for public consumption, then go ahead and put these specs under a 265 | // specification-specific subdirectory. 266 | if (!$this->for_public_consumption) { 267 | $version_dir .= self::FORMAT_OPENAPI . DIRECTORY_SEPARATOR; 268 | } 269 | 270 | // Save the full specification. 271 | $this->filesystem->put($version_dir . 'api.json', OpenApi::getJson($spec)); 272 | 273 | if (!$this->for_public_consumption) { 274 | // Save individual specs for each tag. 275 | $reducer = new OpenApi\TagReducer($spec); 276 | $reduced = $reducer->reduce(); 277 | foreach ($reduced as $tag => $tagged_spec) { 278 | // Convert any nested tags, like `Users\Videos`, into a proper directory structure: `Users/Videos`. 279 | $tag = str_replace('\\', DIRECTORY_SEPARATOR, $tag); 280 | $tag = str_replace('/', DIRECTORY_SEPARATOR, $tag); 281 | 282 | $this->filesystem->put( 283 | $version_dir . 'tags' . DIRECTORY_SEPARATOR . $tag . '.json', 284 | OpenApi::getJson($tagged_spec) 285 | ); 286 | } 287 | } 288 | } 289 | } 290 | -------------------------------------------------------------------------------- /src/Parser.php: -------------------------------------------------------------------------------- 1 | (:\w+)+)?/u'; 16 | 17 | /** 18 | * @psalm-var class-string 19 | * @var string The current class that we're going to be parsing. 20 | */ 21 | protected $class; 22 | 23 | /** @var null|string The current class method that we're parsing. Used to give better error messaging. */ 24 | protected $method; 25 | 26 | /** @var Application */ 27 | protected $application; 28 | 29 | /** 30 | * @psalm-param class-string $class 31 | * @param string $class 32 | * @param Application $application 33 | */ 34 | public function __construct(string $class, Application $application) 35 | { 36 | $this->class = $class; 37 | $this->application = $application; 38 | } 39 | 40 | /** 41 | * Get an array of HTTP (GET, POST, PUT, PATCH, DELETE) methods that are implemented on the current class. 42 | * 43 | * @return array 44 | * @throws \ReflectionException 45 | */ 46 | public function getHttpMethods() 47 | { 48 | $methods = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']; 49 | 50 | $reflection = new ReflectionClass($this->class); 51 | 52 | $valid_methods = []; 53 | foreach ($methods as $method) { 54 | if ($reflection->hasMethod($method)) { 55 | $valid_methods[] = $method; 56 | } 57 | } 58 | 59 | return $valid_methods; 60 | } 61 | 62 | /** 63 | * Locate, and parse, the annotations for a class or method. 64 | * 65 | * @param string|null $method_name 66 | * @return array 67 | * @throws UnsupportedDecoratorException 68 | */ 69 | public function getAnnotations(string $method_name = null): array 70 | { 71 | if (!empty($method_name)) { 72 | $this->method = $method_name; 73 | } 74 | 75 | $reader = $this->application->getContainer()->getAnnotationReader(); 76 | $comments = $reader($this->class, $method_name); 77 | 78 | if (empty($comments)) { 79 | return []; 80 | } 81 | 82 | return $this->parseDocblock($comments); 83 | } 84 | 85 | /** 86 | * Parse a docblock comment into its parts. 87 | * 88 | * @link https://github.com/facebook/libphutil/blob/master/src/parser/docblock/PhutilDocblockParser.php 89 | * @param string $docblock 90 | * @param bool $parse_description If we want to parse out an unstructured `description` annotation. 91 | * @return array 92 | * @throws UnsupportedDecoratorException 93 | */ 94 | protected function parseDocblock(string $docblock, bool $parse_description = true): array 95 | { 96 | $original_docblock = $docblock; 97 | $annotations = []; 98 | $annotation_tags = []; 99 | $matches = null; 100 | 101 | $parser = self::getAnnotationsFromDocblock($docblock); 102 | $tags = $parser->getTags(); 103 | 104 | /** 105 | * @psalm-suppress InternalMethod "phootwork\collection\AbstractCollection::current has been marked as internal" 106 | * @var UnknownTag $tag 107 | */ 108 | foreach ($tags as $tag) { 109 | // If this isn't a Mill annotation, then ignore it. 110 | $annotation = $tag->getTagName(); 111 | if (substr($annotation, 0, 4) !== 'api-') { 112 | continue; 113 | } 114 | 115 | $annotation_tags[] = $tag; 116 | } 117 | 118 | if (!empty($annotation_tags)) { 119 | $annotations = $this->parseAnnotations($annotation_tags, $original_docblock); 120 | } 121 | 122 | // Only parse out a `description` annotation if we need to (like in the instance of not parsing a 123 | // representation). 124 | if (!$parse_description) { 125 | return $annotations; 126 | } 127 | 128 | // Reconstruct the description as the developer wrote it. 129 | $description = implode("\n\n", array_filter([ 130 | $parser->getShortDescription(), 131 | $parser->getLongDescription() 132 | ])); 133 | 134 | if (!empty($description)) { 135 | $annotations['description'][] = $this->buildAnnotation('description', null, $description); 136 | } 137 | 138 | return $annotations; 139 | } 140 | 141 | /** 142 | * Parse a group of our custom annotations. 143 | * 144 | * @param array $tags 145 | * @param string $original_content 146 | * @return array 147 | * @throws Exceptions\Version\UnrecognizedSchemaException 148 | * @throws UnsupportedDecoratorException 149 | */ 150 | protected function parseAnnotations(array $tags, string $original_content): array 151 | { 152 | $annotations = []; 153 | $version = null; 154 | 155 | /** @var \gossi\docblock\tags\UnknownTag $tag */ 156 | foreach ($tags as $tag) { 157 | $annotation = $this->getAnnotationNameFromTag($tag); 158 | $content = $tag->getDescription(); 159 | $decorators = null; 160 | 161 | preg_match_all(self::REGEX_DECORATOR, $content, $matches); 162 | if (!empty($matches['decorator'][0])) { 163 | $decorators = $matches['decorator'][0]; 164 | $content = preg_replace(self::REGEX_DECORATOR, '', $content); 165 | } 166 | 167 | $content = trim($content); 168 | switch ($annotation) { 169 | // Handle the `@api-version` annotation block. 170 | case 'version': 171 | /** @var string $method */ 172 | $method = $this->method; 173 | $version = new Version($content, $this->class, $method); 174 | break; 175 | 176 | // Parse all other annotations. 177 | default: 178 | $annotations[$annotation][] = $this->buildAnnotation( 179 | $annotation, 180 | $decorators, 181 | $content, 182 | $version 183 | ); 184 | } 185 | } 186 | 187 | return $annotations; 188 | } 189 | 190 | /** 191 | * Build up an array of annotation data. 192 | * 193 | * @psalm-suppress InvalidStringClass getAnnotationClass returning a string is funky. 194 | * @param string $name 195 | * @param null|string $decorators 196 | * @param string $content 197 | * @param null|Version $version 198 | * @return Annotation 199 | * @throws UnsupportedDecoratorException If an unsupported decorator is found on an annotation. 200 | */ 201 | private function buildAnnotation( 202 | string $name, 203 | ?string $decorators, 204 | string $content, 205 | Version $version = null 206 | ): Annotation { 207 | $class = $this->getAnnotationClass($name); 208 | 209 | // If this annotation class does not support MSON, then let's clean up any multi-line content within its data. 210 | if (!$class::SUPPORTS_MSON) { 211 | // Don't remove line breaks from a description annotation. 212 | if ($class !== '\Mill\Parser\Annotations\\DescriptionAnnotation') { 213 | $content = preg_replace(MSON::REGEX_CLEAN_MULTILINE, ' ', $content); 214 | } 215 | } 216 | 217 | /** @var Annotation $annotation */ 218 | $annotation = (new $class($this->application, $content, $this->class, $this->method, $version))->process(); 219 | 220 | if (!empty($decorators)) { 221 | $decorators = explode(':', ltrim($decorators, ':')); 222 | foreach ($decorators as $decorator) { 223 | switch ($decorator) { 224 | // Acceptable decorators 225 | case 'private': 226 | case 'public': 227 | $annotation->setVisibility(($decorator === 'public') ? true : false); 228 | break; 229 | 230 | case 'deprecated': 231 | $annotation->setDeprecated(true); 232 | break; 233 | 234 | case 'alias': 235 | if ($annotation instanceof PathAnnotation) { 236 | $annotation->setAliased(true); 237 | } 238 | break; 239 | 240 | default: 241 | /** @var string $method */ 242 | $method = $this->method; 243 | throw UnsupportedDecoratorException::create( 244 | $decorator, 245 | $name, 246 | $this->class, 247 | $method 248 | ); 249 | } 250 | } 251 | } 252 | 253 | return $annotation; 254 | } 255 | 256 | /** 257 | * Get the class name of a given annotation. 258 | * 259 | * @param string $annotation 260 | * @return string 261 | */ 262 | private function getAnnotationClass(string $annotation): string 263 | { 264 | // Not all filesystems support case-insensitive file loading, so we need to map multi-word annotations to the 265 | // properly capitalized class name. 266 | $annotation = strtolower($annotation); 267 | switch ($annotation) { 268 | case 'contenttype': 269 | $annotation = 'ContentType'; 270 | break; 271 | 272 | case 'maxversion': 273 | $annotation = 'MaxVersion'; 274 | break; 275 | 276 | case 'minversion': 277 | $annotation = 'MinVersion'; 278 | break; 279 | 280 | case 'operationid': 281 | $annotation = 'OperationId'; 282 | break; 283 | 284 | case 'pathparam': 285 | $annotation = 'PathParam'; 286 | break; 287 | 288 | case 'queryparam': 289 | $annotation = 'QueryParam'; 290 | break; 291 | 292 | case 'vendortag': 293 | $annotation = 'VendorTag'; 294 | break; 295 | 296 | default: 297 | $annotation = ucfirst($annotation); 298 | } 299 | 300 | return '\Mill\Parser\Annotations\\' . $annotation . 'Annotation'; 301 | } 302 | 303 | /** 304 | * Parse out annotations from a supplied docblock. 305 | * 306 | * @param string $docblock 307 | * @return Docblock 308 | */ 309 | public static function getAnnotationsFromDocblock(string $docblock): Docblock 310 | { 311 | return new Docblock($docblock); 312 | } 313 | 314 | /** 315 | * @param string|null $method 316 | * @return Parser 317 | */ 318 | public function setMethod(string $method = null): self 319 | { 320 | $this->method = $method; 321 | return $this; 322 | } 323 | 324 | /** 325 | * Given an UnknownTag object, get back the Mill annotation name from it. 326 | * 327 | * @param UnknownTag $tag 328 | * @return string 329 | */ 330 | protected function getAnnotationNameFromTag(UnknownTag $tag): string 331 | { 332 | $annotation = $tag->getTagName(); 333 | return strtolower(substr($annotation, 4)); 334 | } 335 | } 336 | --------------------------------------------------------------------------------