├── 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 | [](https://packagist.org/packages/erunion/mill)
5 | [](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 |
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 |
--------------------------------------------------------------------------------