Added first tests

This commit is contained in:
Marcel Pociot
2016-05-03 23:53:46 +02:00
parent 0e25996b83
commit 589014e954
5 changed files with 389 additions and 273 deletions

View File

@@ -16,6 +16,8 @@
"phpdocumentor/reflection-docblock": "~2.0"
},
"require-dev": {
"phpunit/phpunit": "~5.0",
"orchestra/testbench": "~3.0"
},
"autoload": {
"psr-0": {

22
phpunit.xml Normal file
View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit backupGlobals="false"
backupStaticAttributes="false"
bootstrap="vendor/autoload.php"
colors="true"
convertErrorsToExceptions="true"
convertNoticesToExceptions="true"
convertWarningsToExceptions="true"
processIsolation="false"
stopOnFailure="true"
syntaxCheck="false">
<testsuites>
<testsuite name="Versionable Suite">
<directory>tests/</directory>
</testsuite>
</testsuites>
<filter>
<whitelist>
<directory suffix=".php">src/Mpociot/</directory>
</whitelist>
</filter>
</phpunit>

View File

@@ -0,0 +1,282 @@
<?php
namespace Mpociot\ApiDoc;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Routing\Route;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use phpDocumentor\Reflection\DocBlock;
use ReflectionClass;
class ApiDocGenerator
{
/**
* @param Route $route
* @return array
*/
public function processRoute(Route $route)
{
$routeAction = $route->getAction();
$response = $this->getRouteResponse($route);
$routeDescription = $this->getRouteDescription($routeAction['uses']);
$routeData = [
'title' => $routeDescription['short'],
'description' => $routeDescription['long'],
'methods' => $route->getMethods(),
'uri' => $route->getUri(),
'parameters' => [],
'response' => ($response->headers->get('Content-Type') === 'application/json') ? json_encode(json_decode($response->getContent()), JSON_PRETTY_PRINT) : $response->getContent()
];
$validator = Validator::make([], $this->getRouteRules($routeAction['uses']));
foreach ($validator->getRules() as $attribute => $rules) {
$attributeData = [
'required' => false,
'type' => 'string',
'default' => '',
'description' => []
];
foreach ($rules as $rule) {
$this->parseRule($rule, $attributeData);
}
$routeData['parameters'][$attribute] = $attributeData;
}
return $routeData;
}
/**
* @param \Illuminate\Routing\Route $route
* @return \Illuminate\Http\Response
*/
private function getRouteResponse(Route $route)
{
$methods = $route->getMethods();
$response = $this->callRoute(array_shift($methods), $route->getUri());
return $response;
}
/**
* @param $route
* @return string
*/
private function getRouteDescription($route)
{
list($class, $method) = explode('@', $route);
$reflection = new ReflectionClass($class);
$reflectionMethod = $reflection->getMethod($method);
$comment = $reflectionMethod->getDocComment();
$phpdoc = new DocBlock($comment);
return [
'short' => $phpdoc->getShortDescription(),
'long' => $phpdoc->getLongDescription()->getContents()
];
}
/**
* @param $route
* @return array
*/
private function getRouteRules($route)
{
list($class, $method) = explode('@', $route);
$reflection = new ReflectionClass($class);
$reflectionMethod = $reflection->getMethod($method);
foreach ($reflectionMethod->getParameters() as $parameter) {
$parameterType = $parameter->getType();
if (!is_null($parameterType) && class_exists($parameterType)) {
$className = $parameterType->__toString();
$parameterReflection = new $className;
if ($parameterReflection instanceof FormRequest) {
if (method_exists($parameterReflection, 'validator')) {
return $parameterReflection->validator()->getRules();
} else {
return $parameterReflection->rules();
}
}
}
}
return [];
}
/**
* @param $rule
* @param $attributeData
*/
protected function parseRule($rule, &$attributeData)
{
$parsedRule = $this->parseStringRule($rule);
$parsedRule[0] = $this->normalizeRule($parsedRule[0]);
list($rule, $parameters) = $parsedRule;
switch ($rule) {
case 'required':
$attributeData['required'] = true;
break;
case 'in':
$attributeData['description'][] = implode(' or ', $parameters);
break;
case 'not_in':
$attributeData['description'][] = 'Not in: ' . implode(' or ', $parameters);
break;
case 'min':
$attributeData['description'][] = 'Minimum: `' . $parameters[0] . '`';
break;
case 'max':
$attributeData['description'][] = 'Maximum: `' . $parameters[0] . '`';
break;
case 'between':
$attributeData['description'][] = 'Between: `' . $parameters[0] . '` and ' . $parameters[1];
break;
case 'date_format':
$attributeData['description'][] = 'Date format: ' . $parameters[0];
break;
case 'mimetypes':
case 'mimes':
$attributeData['description'][] = 'Allowed mime types: ' . implode(', ', $parameters);
break;
case 'required_if':
$attributeData['description'][] = 'Required if `' . $parameters[0] . '` is `' . $parameters[1] . '`';
break;
case 'exists':
$attributeData['description'][] = 'Valid ' . Str::singular($parameters[0]) . ' ' . $parameters[1];
break;
case 'active_url':
$attributeData['type'] = 'url';
break;
case 'boolean':
case 'email':
case 'image':
case 'string':
case 'integer':
case 'json':
case 'numeric':
case 'url':
case 'ip':
$attributeData['type'] = $rule;
break;
}
}
/**
* Call the given URI and return the Response.
*
* @param string $method
* @param string $uri
* @param array $parameters
* @param array $cookies
* @param array $files
* @param array $server
* @param string $content
* @return \Illuminate\Http\Response
*/
public function callRoute($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
{
$kernel = App::make('Illuminate\Contracts\Http\Kernel');
App::instance('middleware.disable', true);
$server = [
'CONTENT_TYPE' => 'application/json',
'Accept' => 'application/json',
];
$request = Request::create(
$uri, $method, $parameters,
$cookies, $files, $this->transformHeadersToServerVars($server), $content
);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
return $response;
}
/**
* Transform headers array to array of $_SERVER vars with HTTP_* format.
*
* @param array $headers
* @return array
*/
protected function transformHeadersToServerVars(array $headers)
{
$server = [];
$prefix = 'HTTP_';
foreach ($headers as $name => $value) {
$name = strtr(strtoupper($name), '-', '_');
if (!starts_with($name, $prefix) && $name != 'CONTENT_TYPE') {
$name = $prefix . $name;
}
$server[$name] = $value;
}
return $server;
}
/**
* Parse a string based rule.
*
* @param string $rules
* @return array
*/
protected function parseStringRule($rules)
{
$parameters = [];
// The format for specifying validation rules and parameters follows an
// easy {rule}:{parameters} formatting convention. For instance the
// rule "Max:3" states that the value may only be three letters.
if (strpos($rules, ':') !== false) {
list($rules, $parameter) = explode(':', $rules, 2);
$parameters = $this->parseParameters($rules, $parameter);
}
return [strtolower(trim($rules)), $parameters];
}
/**
* Parse a parameter list.
*
* @param string $rule
* @param string $parameter
* @return array
*/
protected function parseParameters($rule, $parameter)
{
if (strtolower($rule) == 'regex') {
return [$parameter];
}
return str_getcsv($parameter);
}
/**
* Normalizes a rule so that we can accept short types.
*
* @param string $rule
* @return string
*/
protected function normalizeRule($rule)
{
switch ($rule) {
case 'int':
return 'integer';
case 'bool':
return 'boolean';
default:
return $rule;
}
}
}

View File

@@ -3,14 +3,9 @@
namespace Mpociot\ApiDoc\Commands;
use Illuminate\Console\Command;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use Mpociot\ApiDoc\ApiDocGenerator;
use phpDocumentor\Reflection\DocBlock;
use Sami\Parser\DocBlockParser;
use Symfony\Component\Process\Process;
class GenerateDocumentation extends Command
@@ -56,6 +51,7 @@ class GenerateDocumentation extends Command
*/
public function handle()
{
$generator = new ApiDocGenerator();
$allowedRoutes = $this->option('routes');
$routePrefix = $this->option('routePrefix');
$actAs = $this->option('actAsUserId');
@@ -77,7 +73,7 @@ class GenerateDocumentation extends Command
$parsedRoutes = [];
foreach ($routes as $route) {
if (in_array($route->getName(), $allowedRoutes) || str_is($routePrefix, $route->getUri())) {
$parsedRoutes[] = $this->processRoute($route);
$parsedRoutes[] = $generator->processRoute($route);
$this->info('Processed route: ' . $route->getUri());
}
}
@@ -85,41 +81,6 @@ class GenerateDocumentation extends Command
$this->writeMarkdown($parsedRoutes);
}
/**
* @param \Illuminate\Routing\Route $route
* @return array
*/
private function processRoute(\Illuminate\Routing\Route $route)
{
$routeAction = $route->getAction();
$response = $this->getRouteResponse($route);
$routeDescription = $this->getRouteDescription($routeAction['uses']);
$routeData = [
'title' => $routeDescription['short'],
'description' => $routeDescription['long'],
'methods' => $route->getMethods(),
'uri' => $route->getUri(),
'parameters' => [],
'response' => ($response->headers->get('Content-Type') === 'application/json') ? json_encode(json_decode($response->getContent()), JSON_PRETTY_PRINT) : $response->getContent()
];
$validator = Validator::make([], $this->getRouteRules($routeAction['uses']));
foreach ($validator->getRules() as $attribute => $rules) {
$attributeData = [
'required' => false,
'type' => 'string',
'default' => '',
'description' => []
];
foreach ($rules as $rule) {
$this->parseRule($rule, $attributeData);
}
$routeData['parameters'][$attribute] = $attributeData;
}
return $routeData;
}
/**
* @param $parsedRoutes
*/
@@ -148,7 +109,7 @@ class GenerateDocumentation extends Command
$this->info('Wrote index.md to: ' . $outputPath);
$this->info('Generating API HTML code');
$process = (new Process('npm run-script generate', $outputPath))->setTimeout(null);
if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) {
$process->setTty(true);
@@ -181,234 +142,4 @@ class GenerateDocumentation extends Command
});
}
/**
* @param $rule
* @param $attributeData
*/
protected function parseRule($rule, &$attributeData)
{
$parsedRule = $this->parseStringRule($rule);
$parsedRule[0] = $this->normalizeRule($parsedRule[0]);
list($rule, $parameters) = $parsedRule;
switch ($rule) {
case 'required':
$attributeData['required'] = true;
break;
case 'in':
$attributeData['description'][] = implode(' or ', $parameters);
break;
case 'not_in':
$attributeData['description'][] = 'Not in: ' . implode(' or ', $parameters);
break;
case 'min':
$attributeData['description'][] = 'Minimum: `' . $parameters[0] . '`';
break;
case 'max':
$attributeData['description'][] = 'Maximum: `' . $parameters[0] . '`';
break;
case 'between':
$attributeData['description'][] = 'Between: `' . $parameters[0] . '` and ' . $parameters[1];
break;
case 'date_format':
$attributeData['description'][] = 'Date format: ' . $parameters[0];
break;
case 'mimetypes':
case 'mimes':
$attributeData['description'][] = 'Allowed mime types: ' . implode(', ', $parameters);
break;
case 'required_if':
$attributeData['description'][] = 'Required if `' . $parameters[0] . '` is `' . $parameters[1] . '`';
break;
case 'exists':
$attributeData['description'][] = 'Valid ' . Str::singular($parameters[0]) . ' ' . $parameters[1];
break;
case 'active_url':
$attributeData['type'] = 'url';
break;
case 'boolean':
case 'email':
case 'image':
case 'string':
case 'integer':
case 'json':
case 'numeric':
case 'url':
case 'ip':
$attributeData['type'] = $rule;
break;
}
}
/**
* @param $route
* @return array
*/
private function getRouteRules($route)
{
list($class, $method) = explode('@', $route);
$reflection = new \ReflectionClass($class);
$reflectionMethod = $reflection->getMethod($method);
foreach ($reflectionMethod->getParameters() as $parameter) {
$parameterType = $parameter->getType();
if (!is_null($parameterType) && class_exists($parameterType)) {
$className = $parameterType->__toString();
$parameterReflection = new $className;
if ($parameterReflection instanceof FormRequest) {
if (method_exists($parameterReflection, 'validator')) {
return $parameterReflection->validator()->getRules();
} else {
return $parameterReflection->rules();
}
}
}
}
return [];
}
/**
* @param $route
* @return string
*/
private function getRouteDescription($route)
{
list($class, $method) = explode('@', $route);
$reflection = new \ReflectionClass($class);
$reflectionMethod = $reflection->getMethod($method);
$comment = $reflectionMethod->getDocComment();
$phpdoc = new DocBlock($comment);
return [
'short' => $phpdoc->getShortDescription(),
'long' => $phpdoc->getLongDescription()->getContents()
];
}
/**
* @param \Illuminate\Routing\Route $route
* @return \Illuminate\Http\Response
*/
private function getRouteResponse(\Illuminate\Routing\Route $route)
{
$methods = $route->getMethods();
$response = $this->callRoute(array_shift($methods), $route->getUri());
return $response;
}
/**
* Call the given URI and return the Response.
*
* @param string $method
* @param string $uri
* @param array $parameters
* @param array $cookies
* @param array $files
* @param array $server
* @param string $content
* @return \Illuminate\Http\Response
*/
public function callRoute($method, $uri, $parameters = [], $cookies = [], $files = [], $server = [], $content = null)
{
$kernel = App::make('Illuminate\Contracts\Http\Kernel');
App::instance('middleware.disable', true);
$server = [
'CONTENT_TYPE' => 'application/json',
'Accept' => 'application/json',
];
$request = Request::create(
$uri, $method, $parameters,
$cookies, $files, $this->transformHeadersToServerVars($server), $content
);
$response = $kernel->handle($request);
$kernel->terminate($request, $response);
return $response;
}
/**
* Transform headers array to array of $_SERVER vars with HTTP_* format.
*
* @param array $headers
* @return array
*/
protected function transformHeadersToServerVars(array $headers)
{
$server = [];
$prefix = 'HTTP_';
foreach ($headers as $name => $value) {
$name = strtr(strtoupper($name), '-', '_');
if (!starts_with($name, $prefix) && $name != 'CONTENT_TYPE') {
$name = $prefix . $name;
}
$server[$name] = $value;
}
return $server;
}
/**
* Parse a string based rule.
*
* @param string $rules
* @return array
*/
protected function parseStringRule($rules)
{
$parameters = [];
// The format for specifying validation rules and parameters follows an
// easy {rule}:{parameters} formatting convention. For instance the
// rule "Max:3" states that the value may only be three letters.
if (strpos($rules, ':') !== false) {
list($rules, $parameter) = explode(':', $rules, 2);
$parameters = $this->parseParameters($rules, $parameter);
}
return [strtolower(trim($rules)), $parameters];
}
/**
* Parse a parameter list.
*
* @param string $rule
* @param string $parameter
* @return array
*/
protected function parseParameters($rule, $parameter)
{
if (strtolower($rule) == 'regex') {
return [$parameter];
}
return str_getcsv($parameter);
}
/**
* Normalizes a rule so that we can accept short types.
*
* @param string $rule
* @return string
*/
protected function normalizeRule($rule)
{
switch ($rule) {
case 'int':
return 'integer';
case 'bool':
return 'boolean';
default:
return $rule;
}
}
}

View File

@@ -0,0 +1,79 @@
<?php
use Illuminate\Routing\Controller;
use Illuminate\Routing\Route;
use Mpociot\ApiDoc\ApiDocGenerator;
class ApiDocGeneratorTest extends Orchestra\Testbench\TestCase
{
/**
* @var \Mpociot\ApiDoc\ApiDocGenerator
*/
protected $generator;
/**
* Setup the test environment.
*/
public function setUp()
{
parent::setUp();
$this->generator = new ApiDocGenerator();
}
public function testCanParseMethodDescription()
{
\Illuminate\Support\Facades\Route::get('/api/test', 'TestController@parseMethodDescription');
$route = new Route(['GET'], '/api/test', ['uses' => 'TestController@parseMethodDescription']);
$parsed = $this->generator->processRoute($route);
$this->assertEquals('Example title', $parsed['title']);
$this->assertEquals("This will be the long description.\nIt can also be multiple lines long.", $parsed['description']);
}
public function testCanParseRouteMethods()
{
\Illuminate\Support\Facades\Route::get('/get', 'TestController@dummy');
\Illuminate\Support\Facades\Route::post('/post', 'TestController@dummy');
\Illuminate\Support\Facades\Route::put('/put', 'TestController@dummy');
\Illuminate\Support\Facades\Route::delete('/delete', 'TestController@dummy');
$route = new Route(['GET'], '/get', ['uses' => 'TestController@parseMethodDescription']);
$parsed = $this->generator->processRoute($route);
$this->assertEquals(['GET','HEAD'], $parsed['methods']);
$route = new Route(['POST'], '/post', ['uses' => 'TestController@parseMethodDescription']);
$parsed = $this->generator->processRoute($route);
$this->assertEquals(['POST'], $parsed['methods']);
$route = new Route(['PUT'], '/put', ['uses' => 'TestController@parseMethodDescription']);
$parsed = $this->generator->processRoute($route);
$this->assertEquals(['PUT'], $parsed['methods']);
$route = new Route(['DELETE'], '/delete', ['uses' => 'TestController@parseMethodDescription']);
$parsed = $this->generator->processRoute($route);
$this->assertEquals(['DELETE'], $parsed['methods']);
}
}
class TestController extends Controller
{
public function dummy()
{
return '';
}
/**
* Example title
*
* This will be the long description.
* It can also be multiple lines long.
*/
public function parseMethodDescription()
{
return '';
}
}