Skip to content

Commit 7b7c37a

Browse files
Merge pull request #198 from rakutentech/feature/route-param
Get route path parameter types
2 parents e43dfc5 + 246031b commit 7b7c37a

File tree

14 files changed

+1425
-178
lines changed

14 files changed

+1425
-178
lines changed

src/Doc.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ class Doc implements Arrayable
8484
*/
8585
private array $responses;
8686

87+
/**
88+
* A list of route path parameters, such as `/users/{id}`.
89+
*
90+
* @var array<string, string>
91+
*/
92+
private array $pathParameters;
93+
8794
/**
8895
* The group name of the route.
8996
*
@@ -117,6 +124,7 @@ public function __construct(
117124
string $controllerFullPath,
118125
string $method,
119126
string $httpMethod,
127+
array $pathParameters,
120128
array $rules,
121129
string $docBlock
122130
) {
@@ -127,6 +135,7 @@ public function __construct(
127135
$this->controllerFullPath = $controllerFullPath;
128136
$this->method = $method;
129137
$this->httpMethod = $httpMethod;
138+
$this->pathParameters = $pathParameters;
130139
$this->rules = $rules;
131140
$this->docBlock = $docBlock;
132141
$this->responses = [];
@@ -316,6 +325,14 @@ public function setResponses(array $responses): void
316325
$this->responses = $responses;
317326
}
318327

328+
/**
329+
* @return array<string, string>
330+
*/
331+
public function getPathParameters(): array
332+
{
333+
return $this->pathParameters;
334+
}
335+
319336
public function clone(): Doc
320337
{
321338
return clone $this;
@@ -330,6 +347,7 @@ public function toArray(): array
330347
'controller_full_path' => $this->controllerFullPath,
331348
'method' => $this->method,
332349
'http_method' => $this->httpMethod,
350+
'path_parameters' => $this->pathParameters,
333351
'rules' => $this->rules,
334352
'doc_block' => $this->docBlock,
335353
'responses' => $this->responses,

src/LaravelRequestDocs.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313

1414
class LaravelRequestDocs
1515
{
16+
private RoutePath $routePath;
17+
18+
public function __construct(RoutePath $routePath)
19+
{
20+
$this->routePath = $routePath;
21+
}
22+
1623
/**
1724
* Get a collection of {@see \Rakutentech\LaravelRequestDocs\Doc} with route and rules information.
1825
*
@@ -182,6 +189,13 @@ public function getControllersInfo(array $onlyMethods): Collection
182189
$controllerName = (new ReflectionClass($controllerFullPath))->getShortName();
183190
}
184191

192+
$pathParameters = [];
193+
$pp = $this->routePath->getPathParameters($route);
194+
// same format as rules
195+
foreach ($pp as $k => $v) {
196+
$pathParameters[$k] = [$v];
197+
}
198+
185199
$doc = new Doc(
186200
$route->uri,
187201
$routeMethods,
@@ -190,6 +204,7 @@ public function getControllersInfo(array $onlyMethods): Collection
190204
config('request-docs.hide_meta_data') ? '' : $controllerFullPath,
191205
config('request-docs.hide_meta_data') ? '' : $method,
192206
'',
207+
$pathParameters,
193208
[],
194209
'',
195210
);

src/LaravelRequestDocsToOpenApi.php

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -34,17 +34,22 @@ private function docsToOpenApi(array $docs): void
3434
{
3535
$this->openApi['paths'] = [];
3636
foreach ($docs as $doc) {
37-
$requestHasFile = false;
38-
$httpMethod = strtolower($doc->getHttpMethod());
39-
$isGet = $httpMethod == 'get';
40-
$isPost = $httpMethod == 'post';
41-
$isPut = $httpMethod == 'put';
42-
$isDelete = $httpMethod == 'delete';
43-
44-
$this->openApi['paths'][$doc->getUri()][$httpMethod]['description'] = $doc->getDocBlock();
45-
$this->openApi['paths'][$doc->getUri()][$httpMethod]['parameters'] = [];
37+
$requestHasFile = false;
38+
$httpMethod = strtolower($doc->getHttpMethod());
39+
$isGet = $httpMethod == 'get';
40+
$isPost = $httpMethod == 'post';
41+
$isPut = $httpMethod == 'put';
42+
$isDelete = $httpMethod == 'delete';
43+
$uriLeadingSlash = '/' . $doc->getUri();
44+
45+
$this->openApi['paths'][$uriLeadingSlash][$httpMethod]['description'] = $doc->getDocBlock();
46+
$this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'] = [];
47+
48+
foreach ($doc->getPathParameters() as $parameter => $rule) {
49+
$this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'][] = $this->makeQueryParameterItem($parameter, $rule);
50+
}
4651

47-
$this->openApi['paths'][$doc->getUri()][$httpMethod]['responses'] = config('request-docs.open_api.responses', []);
52+
$this->openApi['paths'][$uriLeadingSlash][$httpMethod]['responses'] = config('request-docs.open_api.responses', []);
4853

4954
foreach ($doc->getRules() as $attribute => $rules) {
5055
foreach ($rules as $rule) {
@@ -60,21 +65,18 @@ private function docsToOpenApi(array $docs): void
6065

6166
$contentType = $requestHasFile ? 'multipart/form-data' : 'application/json';
6267

63-
if ($isGet) {
64-
$this->openApi['paths'][$doc->getUri()][$httpMethod]['parameters'] = [];
65-
}
6668
if ($isPost || $isPut || $isDelete) {
67-
$this->openApi['paths'][$doc->getUri()][$httpMethod]['requestBody'] = $this->makeRequestBodyItem($contentType);
69+
$this->openApi['paths'][$uriLeadingSlash][$httpMethod]['requestBody'] = $this->makeRequestBodyItem($contentType);
6870
}
6971

7072
foreach ($doc->getRules() as $attribute => $rules) {
7173
foreach ($rules as $rule) {
7274
if ($isGet) {
73-
$parameter = $this->makeQueryParameterItem($attribute, $rule);
74-
$this->openApi['paths'][$doc->getUri()][$httpMethod]['parameters'][] = $parameter;
75+
$parameter = $this->makeQueryParameterItem($attribute, $rule);
76+
$this->openApi['paths'][$uriLeadingSlash][$httpMethod]['parameters'][] = $parameter;
7577
}
7678
if ($isPost || $isPut || $isDelete) {
77-
$this->openApi['paths'][$doc->getUri()][$httpMethod]['requestBody']['content'][$contentType]['schema']['properties'][$attribute] = $this->makeRequestBodyContentPropertyItem($rule);
79+
$this->openApi['paths'][$uriLeadingSlash][$httpMethod]['requestBody']['content'][$contentType]['schema']['properties'][$attribute] = $this->makeRequestBodyContentPropertyItem($rule);
7880
}
7981
}
8082
}
@@ -86,8 +88,11 @@ protected function attributeIsFile(string $rule): bool
8688
return str_contains($rule, 'file') || str_contains($rule, 'image');
8789
}
8890

89-
protected function makeQueryParameterItem(string $attribute, string $rule): array
91+
protected function makeQueryParameterItem(string $attribute, $rule): array
9092
{
93+
if (is_array($rule)) {
94+
$rule = implode('|', $rule);
95+
}
9196
$parameter = [
9297
'name' => $attribute,
9398
'description' => $rule,

src/RoutePath.php

Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<?php
2+
3+
namespace Rakutentech\LaravelRequestDocs;
4+
5+
use Illuminate\Contracts\Routing\UrlRoutable;
6+
use Illuminate\Database\Eloquent\Model;
7+
use Illuminate\Routing\Route;
8+
use Illuminate\Support\Reflector;
9+
use ReflectionClass;
10+
use ReflectionNamedType;
11+
use ReflectionParameter;
12+
13+
class RoutePath
14+
{
15+
private const TYPE_MAP = [
16+
'bool' => 'boolean',
17+
'int' => 'integer',
18+
];
19+
20+
/**
21+
* @return array<string, string>
22+
* @throws \ReflectionException
23+
*/
24+
public function getPathParameters(Route $route): array
25+
{
26+
$pathParameters = $this->initAllParametersWithStringType($route);
27+
28+
$pathParameters = $this->setParameterType($route, $pathParameters);
29+
30+
$pathParameters = $this->setOptional($route, $pathParameters);
31+
32+
$pathParameters = $this->mutateKeyNameWithBindingField($route, $pathParameters);
33+
34+
return $this->setRegex($route, $pathParameters);
35+
}
36+
37+
/**
38+
* Set route path parameter type.
39+
* This method will overwrite `$pathParameters` type with the real types found from route declaration.
40+
*
41+
* @param \Illuminate\Routing\Route $route
42+
* @param array<string, string> $pathParameters
43+
* @return array<string, string>
44+
* @throws \ReflectionException
45+
*/
46+
private function setParameterType(Route $route, array $pathParameters): array
47+
{
48+
$bindableParameters = $this->getBindableParameters($route);
49+
50+
foreach ($route->parameterNames() as $position => $parameterName) {
51+
// Check `$bindableParameters` existence by comparing the position of route parameters.
52+
if (!isset($bindableParameters[$position])) {
53+
continue;
54+
}
55+
56+
$bindableParameter = $bindableParameters[$position];
57+
58+
// For builtin type, always get the type from reflection parameter.
59+
if ($bindableParameter['class'] === null) {
60+
$pathParameters[$parameterName] = $this->getParameterType($bindableParameter['parameter']);
61+
continue;
62+
}
63+
64+
$resolved = $bindableParameter['class'];
65+
66+
// Check if is model parameter?
67+
if (!$resolved->isSubclassOf(Model::class)) {
68+
continue;
69+
}
70+
71+
// Model and path parameter name must be the same.
72+
if ($bindableParameter['parameter']->getName() !== $parameterName) {
73+
continue;
74+
}
75+
76+
$model = $resolved->newInstance();
77+
78+
// Check if model binding using another column.
79+
// Skip if user defined column except than default key.
80+
// Since we do not have the binding column type information, we set to string type.
81+
$bindingField = $route->bindingFieldFor($parameterName);
82+
if ($bindingField !== null && $bindingField !== $model->getKeyName()) {
83+
continue;
84+
}
85+
86+
// Try set type from model key type.
87+
if ($model->getKeyName() === $model->getRouteKeyName()) {
88+
$pathParameters[$parameterName] = self::TYPE_MAP[$model->getKeyType()] ?? $model->getKeyType();
89+
}
90+
}
91+
return $pathParameters;
92+
}
93+
94+
private function getOptionalParameterNames(string $uri): array
95+
{
96+
preg_match_all('/\{(\w+?)\?\}/', $uri, $matches);
97+
98+
return $matches[1] ?? [];
99+
}
100+
101+
/**
102+
* Get bindable parameters in ordered position that are listed in the route / controller signature.
103+
* This method will filter {@see \Illuminate\Http\Request}.
104+
* The ordering of returned parameter should be maintained to match with route path parameter.
105+
*
106+
* @param \Illuminate\Routing\Route $route
107+
* @return array<int, array{parameter: \ReflectionParameter, class: \ReflectionClass|null}>
108+
* @throws \ReflectionException
109+
*/
110+
private function getBindableParameters(Route $route): array
111+
{
112+
/** @var array<int, array{parameter: \ReflectionParameter, class: \ReflectionClass|null}> $parameters */
113+
$parameters = [];
114+
115+
foreach ($route->signatureParameters() as $reflectionParameter) {
116+
$className = Reflector::getParameterClassName($reflectionParameter);
117+
118+
// Is native type.
119+
if ($className === null) {
120+
$parameters[] = [
121+
'parameter' => $reflectionParameter,
122+
'class' => null,
123+
];
124+
continue;
125+
}
126+
127+
// Check if the class name is a bindable objects, such as model. Skip if not.
128+
$reflectionClass = new ReflectionClass($className);
129+
if (!$reflectionClass->implementsInterface(UrlRoutable::class)) {
130+
continue;
131+
}
132+
133+
$parameters[] = [
134+
'parameter' => $reflectionParameter,
135+
'class' => $reflectionClass,
136+
];
137+
}
138+
return $parameters;
139+
}
140+
141+
/**
142+
* @param \Illuminate\Routing\Route $route
143+
* @param array<string, string> $pathParameters
144+
* @return array<string, string>
145+
*/
146+
private function setOptional(Route $route, array $pathParameters): array
147+
{
148+
$optionalParameters = $this->getOptionalParameterNames($route->uri);
149+
150+
foreach ($pathParameters as $parameter => $rule) {
151+
if (in_array($parameter, $optionalParameters)) {
152+
$pathParameters[$parameter] .= '|nullable';
153+
continue;
154+
}
155+
156+
$pathParameters[$parameter] .= '|required';
157+
}
158+
return $pathParameters;
159+
}
160+
161+
/**
162+
* @param \Illuminate\Routing\Route $route
163+
* @param array<string, string> $pathParameters
164+
* @return array<string, string>
165+
*/
166+
private function setRegex(Route $route, array $pathParameters): array
167+
{
168+
foreach ($pathParameters as $parameter => $rule) {
169+
if (!isset($route->wheres[$parameter])) {
170+
continue;
171+
}
172+
$pathParameters[$parameter] .= '|regex:/' . $route->wheres[$parameter] . '/';
173+
}
174+
175+
return $pathParameters;
176+
}
177+
178+
/**
179+
* Set and return route path parameters, with default string type.
180+
*
181+
* @param \Illuminate\Routing\Route $route
182+
* @return array<string, string>
183+
*/
184+
private function initAllParametersWithStringType(Route $route): array
185+
{
186+
return array_fill_keys($route->parameterNames(), 'string');
187+
}
188+
189+
/**
190+
* Get type from method reflection parameter.
191+
* Return string if type is not declared.
192+
*
193+
* @param \ReflectionParameter $methodParameter
194+
* @return string
195+
*/
196+
private function getParameterType(ReflectionParameter $methodParameter): string
197+
{
198+
$reflectionNamedType = $methodParameter->getType();
199+
200+
if ($reflectionNamedType === null) {
201+
return 'string';
202+
}
203+
204+
// See https://github.com/phpstan/phpstan/issues/3886
205+
if (!$reflectionNamedType instanceof ReflectionNamedType) {
206+
return 'string';
207+
}
208+
209+
return self::TYPE_MAP[$reflectionNamedType->getName()] ?? $reflectionNamedType->getName();
210+
}
211+
212+
/**
213+
* @param \Illuminate\Routing\Route $route
214+
* @param array<string, string> $pathParameters
215+
* @return array<string, string>
216+
*/
217+
private function mutateKeyNameWithBindingField(Route $route, array $pathParameters): array
218+
{
219+
$mutatedPath = [];
220+
221+
foreach ($route->parameterNames() as $name) {
222+
$bindingName = $route->bindingFieldFor($name);
223+
224+
if ($bindingName === null) {
225+
$mutatedPath[$name] = $pathParameters[$name];
226+
continue;
227+
}
228+
229+
$mutatedPath["$name:$bindingName"] = $pathParameters[$name];
230+
}
231+
232+
return $mutatedPath;
233+
}
234+
}

0 commit comments

Comments
 (0)