From 0e25996b83eed39c3efc6c1119af4191fad97a32 Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Tue, 3 May 2016 22:21:20 +0200 Subject: [PATCH] Initial commit --- .gitignore | 4 + LICENSE.md | 21 + README.md | 33 ++ composer.json | 25 ++ .../ApiDoc/ApiDocGeneratorServiceProvider.php | 35 ++ .../ApiDoc/Commands/GenerateDocumentation.php | 414 ++++++++++++++++++ src/resources/views/whiteboard.blade.php | 83 ++++ 7 files changed, 615 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 src/Mpociot/ApiDoc/ApiDocGeneratorServiceProvider.php create mode 100644 src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php create mode 100644 src/resources/views/whiteboard.blade.php diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..2a54e344 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.DS_Store +composer.lock +.php_cs.cache +/vendor/ \ No newline at end of file diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 00000000..c1d452d8 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 Marcel Pociot + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 00000000..2156cc61 --- /dev/null +++ b/README.md @@ -0,0 +1,33 @@ +## Laravel API Documentation Generator (WIP) + +`php artisan api:gen --routePrefix=settings/api/*` + + +### Install + +Require this package with composer using the following command: + +```bash +composer require mpociot/laravel-apidoc-generator +``` +Go to your `config/app.php` and add the service provider: + +```php +Mpociot\ApiDoc\ApiDocGeneratorServiceProvider::class +``` + +### Usage + + +``` +php artisan api:generate + {--output=public/docs : The output path for the generated documentation} + {--routePrefix= : The route prefix to use for generation - * can be used as a wildcard} + {--routes=* : The route names to use for generation - if no routePrefix is provided} + {--actAsUserId= : The user ID to use for API response calls} +``` + + +### License + +The Laravel API Documentation Generator is free software licensed under the MIT license. diff --git a/composer.json b/composer.json new file mode 100644 index 00000000..18d740b7 --- /dev/null +++ b/composer.json @@ -0,0 +1,25 @@ +{ + "name": "mpociot/laravel-apidoc-generator", + "license": "MIT", + "description": "Generate beautiful API documentation from your Laravel / Lumen application", + "keywords": ["API","Documentation","Laravel"], + "homepage": "http://github.com/mpociot/apidoc", + "authors": [ + { + "name": "Marcel Pociot", + "email": "m.pociot@gmail.com" + } + ], + "require": { + "php": ">=7.0.0", + "laravel/framework": "~5.0", + "phpdocumentor/reflection-docblock": "~2.0" + }, + "require-dev": { + }, + "autoload": { + "psr-0": { + "Mpociot\\ApiDoc": "src/" + } + } +} diff --git a/src/Mpociot/ApiDoc/ApiDocGeneratorServiceProvider.php b/src/Mpociot/ApiDoc/ApiDocGeneratorServiceProvider.php new file mode 100644 index 00000000..f4c97bd4 --- /dev/null +++ b/src/Mpociot/ApiDoc/ApiDocGeneratorServiceProvider.php @@ -0,0 +1,35 @@ +loadViewsFrom(__DIR__.'/../../resources/views/', 'apidoc'); + } + + /** + * Register the API doc commands + */ + public function register() + { + $this->app['apidoc.generate'] = $this->app->share(function () { + return new GenerateDocumentation(); + }); + + $this->commands( + 'apidoc.generate' + ); + } + +} diff --git a/src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php b/src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php new file mode 100644 index 00000000..092a473f --- /dev/null +++ b/src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php @@ -0,0 +1,414 @@ +option('routes'); + $routePrefix = $this->option('routePrefix'); + $actAs = $this->option('actAsUserId'); + + if ($routePrefix === null && !count($allowedRoutes)) { + $this->error('You must provide either a route prefix or a route to generate the documentation.'); + return false; + } + + if ($actAs !== null) { + $userModel = config('auth.providers.users.model'); + $user = $userModel::find($actAs); + $this->laravel['auth']->guard()->setUser($user); + } + + $routes = Route::getRoutes(); + + /** @var \Illuminate\Routing\Route $route */ + $parsedRoutes = []; + foreach ($routes as $route) { + if (in_array($route->getName(), $allowedRoutes) || str_is($routePrefix, $route->getUri())) { + $parsedRoutes[] = $this->processRoute($route); + $this->info('Processed route: ' . $route->getUri()); + } + } + + $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 + */ + private function writeMarkdown($parsedRoutes) + { + $outputPath = $this->option('output'); + + $markdown = view('apidoc::whiteboard')->with('parsedRoutes', $parsedRoutes); + + if (!is_dir($outputPath)) { + $this->cloneWhiteboardRepository(); + + if ($this->confirm('Would you like to install the NPM dependencies?', true)) { + $process = (new Process('npm set progress=false && npm install', $outputPath))->setTimeout(null); + if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { + $process->setTty(true); + } + $process->run(function ($type, $line) { + $this->info($line); + }); + } + } + + file_put_contents($outputPath . DIRECTORY_SEPARATOR . 'source' . DIRECTORY_SEPARATOR . 'index.md', $markdown); + + $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); + } + $process->run(function ($type, $line) { + $this->info($line); + }); + + $this->info('Wrote HTML documentation to: ' . $outputPath . '/public/index.html'); + } + + /** + * Clone the Whiteboard nodejs repository + */ + private function cloneWhiteboardRepository() + { + $outputPath = $this->option('output'); + + mkdir($outputPath, 0777, true); + + // Clone whiteboard + $this->info('Cloning whiteboard repository.'); + + $process = (new Process('git clone ' . self::WHITEBOARD_REPOSITORY . ' ' . $outputPath))->setTimeout(null); + if ('\\' !== DIRECTORY_SEPARATOR && file_exists('/dev/tty') && is_readable('/dev/tty')) { + $process->setTty(true); + } + $process->run(function ($type, $line) { + $this->info($line); + }); + } + + + /** + * @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; + } + } +} diff --git a/src/resources/views/whiteboard.blade.php b/src/resources/views/whiteboard.blade.php new file mode 100644 index 00000000..53f99c81 --- /dev/null +++ b/src/resources/views/whiteboard.blade.php @@ -0,0 +1,83 @@ +--- +title: API Reference + +language_tabs: +- bash +- javascript + +includes: + +search: true + +toc_footers: +- Documentation Powered by Whiteboard +--- + +# Info + +Welcome to the generated API reference. + +# Available routes +@foreach($parsedRoutes as $parsedRoute) +@if($parsedRoute['title'] != '')## {{ $parsedRoute['title']}} +@else## {{$parsedRoute['uri']}} +@endif +@if($parsedRoute['description']) + +{{$parsedRoute['description']}} +@endif + +> Example request: + +```bash +curl "{{config('app.url')}}/{{$parsedRoute['uri']}}" \ +-H "Accept: application/json"@if(count($parsedRoute['parameters'])) \ +@foreach($parsedRoute['parameters'] as $attribute => $parameter) +-d "{{$attribute}}"="dummy" \ +@endforeach +@endif +``` + +```javascript +var settings = { + "async": true, + "crossDomain": true, + "url": "{{config('app.url')}}/{{$parsedRoute['uri']}}", + "method": "{{$parsedRoute['methods'][0]}}", +@if(count($parsedRoute['parameters'])) + "data": {!! str_replace(' ',' ',json_encode(array_fill_keys(array_keys($parsedRoute['parameters']), 'dummy'), JSON_PRETTY_PRINT)) !!}, +@endif + "headers": { + "accept": "application/json" + } +} + +$.ajax(settings).done(function (response) { + console.log(response); +}); +``` + +@if(in_array('GET',$parsedRoute['methods'])) +> Example response: + +```json +{!! $parsedRoute['response'] !!} +``` +@endif + +### HTTP Request +@foreach($parsedRoute['methods'] as $method) +`{{$method}} {{$parsedRoute['uri']}}` +@endforeach +@if(count($parsedRoute['parameters'])) + +#### Parameters + +Parameter | Type | Status | Description +--------- | ------- | ------- | ------- | ----------- +@foreach($parsedRoute['parameters'] as $attribute => $parameter) +{{$attribute}} | {{$parameter['type']}} | @if($parameter['required']) required @else optional @endif | {!! implode(' ',$parameter['description']) !!} +@endforeach +@endif + +@endforeach