Skip to content

Commit f98914a

Browse files
authored
Merge pull request mpociot#570 from mpociot/plugin-arch
Switch to plugin architecture
2 parents 248a052 + ec8f257 commit f98914a

29 files changed

+1504
-567
lines changed

config/apidoc.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,24 @@
179179
],
180180
],
181181

182+
'strategies' => [
183+
'metadata' => [
184+
\Mpociot\ApiDoc\Strategies\Metadata\GetFromDocBlocks::class,
185+
],
186+
'bodyParameters' => [
187+
\Mpociot\ApiDoc\Strategies\BodyParameters\GetFromBodyParamTag::class,
188+
],
189+
'queryParameters' => [
190+
\Mpociot\ApiDoc\Strategies\QueryParameters\GetFromQueryParamTag::class,
191+
],
192+
'responses' => [
193+
\Mpociot\ApiDoc\Strategies\Responses\UseResponseTag::class,
194+
\Mpociot\ApiDoc\Strategies\Responses\UseResponseFileTag::class,
195+
\Mpociot\ApiDoc\Strategies\Responses\UseTransformerTags::class,
196+
\Mpociot\ApiDoc\Strategies\Responses\ResponseCalls::class,
197+
],
198+
],
199+
182200
/*
183201
* Custom logo path. The logo will be copied from this location
184202
* during the generate process. Set this to false to use the default logo.

docs/index.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ Automatically generate your API documentation from your existing Laravel/Lumen/[
99
* [Configuration](config.md)
1010
* [Generating Documentation](generating-documentation.md)
1111
* [Documenting Your API](documenting.md)
12+
* [Extending functionality with plugins](plugins.md)
1213
* [Internal Architecture](architecture.md)
1314

1415
## Installation

docs/plugins.md

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
# Extending functionality with plugins
2+
You can use plugins to alter how the Generator fetches data about your routes. For instance, suppose all your routes have a body parameter `organizationId`, and you don't want to annotate this with `@queryParam` on each method. You can create a plugin that adds this to all your body parameters. Let's see how to do this.
3+
4+
## The stages of route processing
5+
Route processing is performed in four stages:
6+
- metadata (this covers route `title`, route `description`, route `groupName`, route `groupDescription`, and authentication status (`authenticated`))
7+
- bodyParameters
8+
- queryParameters
9+
- responses
10+
11+
For each stage, the Generator attempts one or more configured strategies to fetch data. The Generator will call of the strategies configured, progressively combining their results together before to produce the final output of that stage.
12+
13+
## Strategies
14+
To create a strategy, create a class that extends `\Mpociot\ApiDoc\Strategies\Strategy`.
15+
16+
The `__invoke` method of the strategy is where you perform your actions and return data. It receives the following arguments:
17+
- the route (instance of `\Illuminate\Routing\Route`)
18+
- the controller class handling the route (`\ReflectionClass`)
19+
- the controller method (`\ReflectionMethod $method`)
20+
- the rules specified in the apidoc.php config file for the group this route belongs to, under the `apply` section (array)
21+
- the context. This contains all data for the route that has been parsed thus far in the previous stages. This means, by the `responses` stage, the context will contain the following keys: `metadata`, `bodyParameters` and `queryParameters`.
22+
23+
Here's what your strategy in our example would look like:
24+
25+
```php
26+
<?php
27+
28+
use Illuminate\Routing\Route;
29+
use Mpociot\ApiDoc\Strategies\Strategy;
30+
31+
class AddOrganizationIdBodyParameter extends Strategy
32+
{
33+
public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
34+
{
35+
return [
36+
'organizationId' => [
37+
'type' => 'integer',
38+
'description' => 'The ID of the organization',
39+
'required' => true,
40+
'value' => 2,
41+
]
42+
];
43+
}
44+
}
45+
```
46+
47+
The last thing to do is to register the strategy. Strategies are registered in a `strategies` key in the `apidoc.php` file. Here's what the file looks like by default:
48+
49+
```php
50+
...
51+
'strategies' => [
52+
'metadata' => [
53+
\Mpociot\ApiDoc\Strategies\Metadata\GetFromDocBlocks::class,
54+
],
55+
'bodyParameters' => [
56+
\Mpociot\ApiDoc\Strategies\BodyParameters\GetFromBodyParamTag::class,
57+
],
58+
'queryParameters' => [
59+
\Mpociot\ApiDoc\Strategies\QueryParameters\GetFromQueryParamTag::class,
60+
],
61+
'responses' => [
62+
\Mpociot\ApiDoc\Strategies\Responses\UseResponseTag::class,
63+
\Mpociot\ApiDoc\Strategies\Responses\UseResponseFileTag::class,
64+
\Mpociot\ApiDoc\Strategies\Responses\UseTransformerTags::class,
65+
\Mpociot\ApiDoc\Strategies\Responses\ResponseCalls::class,
66+
],
67+
],
68+
...
69+
```
70+
71+
You can add, replace or remove strategies from here. In our case, we're adding our bodyParameter strategy:
72+
73+
```php
74+
75+
'bodyParameters' => [
76+
\Mpociot\ApiDoc\Strategies\BodyParameters\GetFromBodyParamTag::class,
77+
AddOrganizationIdBodyParameter::class,
78+
],
79+
```
80+
81+
And we're done. Now, when we run `php artisan docs:generate`, all our routes will have this bodyParameter added.
82+
83+
84+
We could go further and modify our strategy so it doesn't add this parameter if the route is a GET route or is authenticated:
85+
86+
```php
87+
public function __invoke(Route $route, \ReflectionClass $controller, \ReflectionMethod $method, array $routeRules, array $context = [])
88+
{
89+
if (in_array('GET', $route->methods()) {
90+
return null;
91+
}
92+
93+
if ($context['metadata']['authenticated']) {
94+
return null;
95+
}
96+
97+
return [
98+
'organizationId' => [
99+
'type' => 'integer',
100+
'description' => 'The ID of the organization',
101+
'required' => true,
102+
'value' => 2,
103+
]
104+
];
105+
}
106+
```
107+
108+
The strategy class also has access to the current apidoc configuration via its `config` property. For instance, you can retrieve the default group with `$this->config->get('default_group')`.
109+
110+
Yopu are also provided with the instance pproperty `stage`, which is set to the name of the currently executing stage.
111+
112+
113+
## Utilities
114+
You have access to a number of tools when developing strategies. They include:
115+
116+
- The `RouteDocBlocker` class (in the `\Mpociot\ApiDoc\Tools` namespace) has a single public static method, `getDocBlocksFromRoute(Route $route)`. It allows you to retrieve the docblocks for a given route. It returns an array of with two keys: `method` and `class` containing the docblocks for the method and controller handling the route respectively. Both are instances of `\Mpociot\Reflection\DocBlock`.
117+
118+
- The `ParamsHelper` trait (in the `\Mpociot\ApiDoc\Tools` namespace) can be included in your strategies. It contains a number of useful methods for working with parameters, including type casting and generating dummy values.
119+
120+
## API
121+
Each strategy class must implement the __invoke method with the parameters as described above. This method must return the needed data for the intended stage, or `null` to indicate failure.
122+
- In the `metadata` stage, strategies should return an array. These are the expected keys (you may omit some, or all):
123+
124+
```
125+
'groupName'
126+
'groupDescription'
127+
'title'
128+
'description'
129+
'authenticated' // boolean
130+
```
131+
132+
- In the `bodyParameters` and `queryParameters` stages, you can return an array with arbitrary keys. These keys will serve as the names of your parameters. Array keys can be indicated with Laravel's dot notation. The value of each key should be an array with the following keys:
133+
134+
```
135+
'type', // Only used in bodyParameters
136+
'description',
137+
'required', // boolean
138+
'value', // An example value for the parameter
139+
```
140+
- In the `responses` stage, your strategy should return an array containing the responses for different status codes. Each key in the array should be a HTTP status code, and each value should be a string containing the response.

src/Commands/GenerateDocumentation.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ private function processRoutes(Generator $generator, array $routes)
247247
*/
248248
private function isValidRoute(Route $route)
249249
{
250-
$action = Utils::getRouteActionUses($route->getAction());
250+
$action = Utils::getRouteClassAndMethodNames($route->getAction());
251251
if (is_array($action)) {
252252
$action = implode('@', $action);
253253
}
@@ -264,7 +264,7 @@ private function isValidRoute(Route $route)
264264
*/
265265
private function isRouteVisibleForDocumentation(array $action)
266266
{
267-
list($class, $method) = Utils::getRouteActionUses($action);
267+
list($class, $method) = Utils::getRouteClassAndMethodNames($action);
268268
$reflection = new ReflectionClass($class);
269269

270270
if (! $reflection->hasMethod($method)) {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
<?php
2+
3+
namespace Mpociot\ApiDoc\Strategies\BodyParameters;
4+
5+
use ReflectionClass;
6+
use ReflectionMethod;
7+
use Illuminate\Routing\Route;
8+
use Mpociot\Reflection\DocBlock;
9+
use Mpociot\Reflection\DocBlock\Tag;
10+
use Mpociot\ApiDoc\Strategies\Strategy;
11+
use Mpociot\ApiDoc\Tools\RouteDocBlocker;
12+
use Dingo\Api\Http\FormRequest as DingoFormRequest;
13+
use Mpociot\ApiDoc\Tools\Traits\DocBlockParamHelpers;
14+
use Illuminate\Foundation\Http\FormRequest as LaravelFormRequest;
15+
16+
class GetFromBodyParamTag extends Strategy
17+
{
18+
use DocBlockParamHelpers;
19+
20+
public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
21+
{
22+
foreach ($method->getParameters() as $param) {
23+
$paramType = $param->getType();
24+
if ($paramType === null) {
25+
continue;
26+
}
27+
28+
$parameterClassName = version_compare(phpversion(), '7.1.0', '<')
29+
? $paramType->__toString()
30+
: $paramType->getName();
31+
32+
try {
33+
$parameterClass = new ReflectionClass($parameterClassName);
34+
} catch (\ReflectionException $e) {
35+
continue;
36+
}
37+
38+
// If there's a FormRequest, we check there for @bodyParam tags.
39+
if (class_exists(LaravelFormRequest::class) && $parameterClass->isSubclassOf(LaravelFormRequest::class)
40+
|| class_exists(DingoFormRequest::class) && $parameterClass->isSubclassOf(DingoFormRequest::class)) {
41+
$formRequestDocBlock = new DocBlock($parameterClass->getDocComment());
42+
$bodyParametersFromDocBlock = $this->getBodyParametersFromDocBlock($formRequestDocBlock->getTags());
43+
44+
if (count($bodyParametersFromDocBlock)) {
45+
return $bodyParametersFromDocBlock;
46+
}
47+
}
48+
}
49+
50+
$methodDocBlock = RouteDocBlocker::getDocBlocksFromRoute($route)['method'];
51+
52+
return $this->getBodyParametersFromDocBlock($methodDocBlock->getTags());
53+
}
54+
55+
private function getBodyParametersFromDocBlock($tags)
56+
{
57+
$parameters = collect($tags)
58+
->filter(function ($tag) {
59+
return $tag instanceof Tag && $tag->getName() === 'bodyParam';
60+
})
61+
->mapWithKeys(function ($tag) {
62+
preg_match('/(.+?)\s+(.+?)\s+(required\s+)?(.*)/', $tag->getContent(), $content);
63+
$content = preg_replace('/\s?No-example.?/', '', $content);
64+
if (empty($content)) {
65+
// this means only name and type were supplied
66+
list($name, $type) = preg_split('/\s+/', $tag->getContent());
67+
$required = false;
68+
$description = '';
69+
} else {
70+
list($_, $name, $type, $required, $description) = $content;
71+
$description = trim($description);
72+
if ($description == 'required' && empty(trim($required))) {
73+
$required = $description;
74+
$description = '';
75+
}
76+
$required = trim($required) == 'required' ? true : false;
77+
}
78+
79+
$type = $this->normalizeParameterType($type);
80+
list($description, $example) = $this->parseParamDescription($description, $type);
81+
$value = is_null($example) && ! $this->shouldExcludeExample($tag)
82+
? $this->generateDummyValue($type)
83+
: $example;
84+
85+
return [$name => compact('type', 'description', 'required', 'value')];
86+
})->toArray();
87+
88+
return $parameters;
89+
}
90+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
<?php
2+
3+
namespace Mpociot\ApiDoc\Strategies\Metadata;
4+
5+
use ReflectionClass;
6+
use ReflectionMethod;
7+
use Illuminate\Routing\Route;
8+
use Mpociot\Reflection\DocBlock;
9+
use Mpociot\Reflection\DocBlock\Tag;
10+
use Mpociot\ApiDoc\Strategies\Strategy;
11+
use Mpociot\ApiDoc\Tools\RouteDocBlocker;
12+
13+
class GetFromDocBlocks extends Strategy
14+
{
15+
public function __invoke(Route $route, ReflectionClass $controller, ReflectionMethod $method, array $routeRules, array $context = [])
16+
{
17+
$docBlocks = RouteDocBlocker::getDocBlocksFromRoute($route);
18+
/** @var DocBlock $methodDocBlock */
19+
$methodDocBlock = $docBlocks['method'];
20+
21+
list($routeGroupName, $routeGroupDescription, $routeTitle) = $this->getRouteGroupDescriptionAndTitle($methodDocBlock, $docBlocks['class']);
22+
23+
return [
24+
'groupName' => $routeGroupName,
25+
'groupDescription' => $routeGroupDescription,
26+
'title' => $routeTitle ?: $methodDocBlock->getShortDescription(),
27+
'description' => $methodDocBlock->getLongDescription()->getContents(),
28+
'authenticated' => $this->getAuthStatusFromDocBlock($methodDocBlock->getTags()),
29+
];
30+
}
31+
32+
/**
33+
* @param array $tags Tags in the method doc block
34+
*
35+
* @return bool
36+
*/
37+
protected function getAuthStatusFromDocBlock(array $tags)
38+
{
39+
$authTag = collect($tags)
40+
->first(function ($tag) {
41+
return $tag instanceof Tag && strtolower($tag->getName()) === 'authenticated';
42+
});
43+
44+
return (bool) $authTag;
45+
}
46+
47+
/**
48+
* @param DocBlock $methodDocBlock
49+
* @param DocBlock $controllerDocBlock
50+
*
51+
* @return array The route group name, the group description, ad the route title
52+
*/
53+
protected function getRouteGroupDescriptionAndTitle(DocBlock $methodDocBlock, DocBlock $controllerDocBlock)
54+
{
55+
// @group tag on the method overrides that on the controller
56+
if (! empty($methodDocBlock->getTags())) {
57+
foreach ($methodDocBlock->getTags() as $tag) {
58+
if ($tag->getName() === 'group') {
59+
$routeGroupParts = explode("\n", trim($tag->getContent()));
60+
$routeGroupName = array_shift($routeGroupParts);
61+
$routeGroupDescription = trim(implode("\n", $routeGroupParts));
62+
63+
// If the route has no title (the methodDocBlock's "short description"),
64+
// we'll assume the routeGroupDescription is actually the title
65+
// Something like this:
66+
// /**
67+
// * Fetch cars. <-- This is route title.
68+
// * @group Cars <-- This is group name.
69+
// * APIs for cars. <-- This is group description (not required).
70+
// **/
71+
// VS
72+
// /**
73+
// * @group Cars <-- This is group name.
74+
// * Fetch cars. <-- This is route title, NOT group description.
75+
// **/
76+
77+
// BTW, this is a spaghetti way of doing this.
78+
// It shall be refactored soon. Deus vult!💪
79+
if (empty($methodDocBlock->getShortDescription())) {
80+
return [$routeGroupName, '', $routeGroupDescription];
81+
}
82+
83+
return [$routeGroupName, $routeGroupDescription, $methodDocBlock->getShortDescription()];
84+
}
85+
}
86+
}
87+
88+
foreach ($controllerDocBlock->getTags() as $tag) {
89+
if ($tag->getName() === 'group') {
90+
$routeGroupParts = explode("\n", trim($tag->getContent()));
91+
$routeGroupName = array_shift($routeGroupParts);
92+
$routeGroupDescription = implode("\n", $routeGroupParts);
93+
94+
return [$routeGroupName, $routeGroupDescription, $methodDocBlock->getShortDescription()];
95+
}
96+
}
97+
98+
return [$this->config->get('default_group'), '', $methodDocBlock->getShortDescription()];
99+
}
100+
}

0 commit comments

Comments
 (0)