From 02d34fdb004d5fc7e60348c73807da9a59401dca Mon Sep 17 00:00:00 2001 From: Marcel Pociot Date: Wed, 11 May 2016 21:09:53 +0200 Subject: [PATCH] Moved from Whiteboard to Documentarian --- README.md | 39 ++- composer.json | 5 +- src/Mpociot/ApiDoc/ApiDocGenerator.php | 95 +++++- .../ApiDoc/Commands/GenerateDocumentation.php | 46 +-- ...oard.blade.php => documentarian.blade.php} | 4 +- tests/ApiDocGeneratorTest.php | 275 +++++++++++++++++- 6 files changed, 388 insertions(+), 76 deletions(-) rename src/resources/views/{whiteboard.blade.php => documentarian.blade.php} (95%) diff --git a/README.md b/README.md index 2156cc61..5bfbec64 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,30 @@ -## Laravel API Documentation Generator (WIP) +# Documentarian +#### Simply write beautiful API documentation. +======== -`php artisan api:gen --routePrefix=settings/api/*` +This project is a PHP port of the popular [Slate](https://github.com/tripit/slate) API documentation tool. +> If PHP is not your thing and you're more into nodeJS, why not give [Whiteboard](https://github.com/mpociot/whiteboard) a try? -### Install +Documentarian -Require this package with composer using the following command: +Check out a Documentarian [example API documentation](http://www.marcelpociot.com/whiteboard/). -```bash -composer require mpociot/laravel-apidoc-generator -``` -Go to your `config/app.php` and add the service provider: +The documentation is available at [http://marcelpociot.com/documentarian/installation](http://marcelpociot.com/documentarian/installation) -```php -Mpociot\ApiDoc\ApiDocGeneratorServiceProvider::class -``` -### Usage +### Slate / Whiteboard compatibility +Since both Documentarian and Slate use regular markdown files to render the API documentation, your existing Slate API documentation should work just fine. If you encounter any issues, please [submit an issue](https://github.com/mpociot/documentarian/issues). +### In depth documentation +For further documentation, read the [Slate Wiki](https://github.com/tripit/slate/wiki). -``` -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} -``` +### Documentations built with Documentarian +Feel free to submit a PR with a link to your documentation. -### License +### Contributors -The Laravel API Documentation Generator is free software licensed under the MIT license. +Slate was built by [Robert Lord](https://lord.io) while at [TripIt](http://tripit.com). + +Documentarian was built by Marcel Pociot. \ No newline at end of file diff --git a/composer.json b/composer.json index b3486abf..94a5d07c 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "mpociot/laravel-apidoc-generator", + "name": "mpociot/laravel-apidoc-generators", "license": "MIT", "description": "Generate beautiful API documentation from your Laravel / Lumen application", "keywords": ["API","Documentation","Laravel"], @@ -13,7 +13,8 @@ "require": { "php": ">=7.0.0", "laravel/framework": "~5.0", - "phpdocumentor/reflection-docblock": "~2.0" + "phpdocumentor/reflection-docblock": "~2.0", + "mpociot/documentarian": "dev-master" }, "require-dev": { "phpunit/phpunit": "~5.0", diff --git a/src/Mpociot/ApiDoc/ApiDocGenerator.php b/src/Mpociot/ApiDoc/ApiDocGenerator.php index 4d5305c3..80188cb3 100644 --- a/src/Mpociot/ApiDoc/ApiDocGenerator.php +++ b/src/Mpociot/ApiDoc/ApiDocGenerator.php @@ -107,6 +107,18 @@ private function getRouteRules($route) return []; } + /** + * @param $arr + * @param $first + * @param $last + * @return string + */ + protected function fancy_implode($arr, $first, $last) + { + array_push($arr, implode($last, array_splice($arr, -2))); + return implode($first, $arr); + } + /** * @param $rule * @param $attributeData @@ -121,43 +133,114 @@ protected function parseRule($rule, &$attributeData) case 'required': $attributeData['required'] = true; break; + case 'accepted': + $attributeData['type'] = 'boolean'; + break; + case 'after': + $attributeData['type'] = 'date'; + $attributeData['description'][] = 'Must be a date after: `' . date(DATE_RFC850, strtotime($parameters[0])) . '`'; + break; + case 'alpha': + $attributeData['description'][] = 'Only alphabetic characters allowed'; + break; + case 'alpha_dash': + $attributeData['description'][] = 'Allowed: alpha-numeric characters, as well as dashes and underscores.'; + break; + case 'alpha_num': + $attributeData['description'][] = 'Only alpha-numeric characters allowed'; + break; case 'in': - $attributeData['description'][] = implode(' or ', $parameters); + $attributeData['description'][] = $this->fancy_implode($parameters, ', ', ' or '); break; case 'not_in': - $attributeData['description'][] = 'Not in: ' . implode(' or ', $parameters); + $attributeData['description'][] = 'Not in: ' . $this->fancy_implode($parameters, ', ', ' or '); break; case 'min': + $attributeData['type'] = 'numeric'; $attributeData['description'][] = 'Minimum: `' . $parameters[0] . '`'; break; case 'max': + $attributeData['type'] = 'numeric'; $attributeData['description'][] = 'Maximum: `' . $parameters[0] . '`'; break; case 'between': - $attributeData['description'][] = 'Between: `' . $parameters[0] . '` and ' . $parameters[1]; + $attributeData['type'] = 'numeric'; + $attributeData['description'][] = 'Between: `' . $parameters[0] . '` and `' . $parameters[1] . '`'; + break; + case 'before': + $attributeData['type'] = 'date'; + $attributeData['description'][] = 'Must be a date preceding: `' . date(DATE_RFC850, strtotime($parameters[0])) . '`'; break; case 'date_format': - $attributeData['description'][] = 'Date format: ' . $parameters[0]; + $attributeData['type'] = 'date'; + $attributeData['description'][] = 'Date format: `' . $parameters[0] . '`'; + break; + case 'different': + $attributeData['description'][] = 'Must have a different value than parameter: `' . $parameters[0] . '`'; + break; + case 'digits': + $attributeData['type'] = 'numeric'; + $attributeData['description'][] = 'Must have an exact length of `' . $parameters[0] . '`'; + break; + case 'digits_between': + $attributeData['type'] = 'numeric'; + $attributeData['description'][] = 'Must have a length between `' . $parameters[0] . '` and `' . $parameters[1] . '`'; + break; + case 'image': + $attributeData['description'][] = 'Must be an image (jpeg, png, bmp, gif, or svg)'; + break; + case 'json': + $attributeData['type'] = 'string'; + $attributeData['description'][] = 'Must be a valid JSON string.'; break; case 'mimetypes': case 'mimes': - $attributeData['description'][] = 'Allowed mime types: ' . implode(', ', $parameters); + $attributeData['description'][] = 'Allowed mime types: ' . $this->fancy_implode($parameters, ', ', ' or '); break; case 'required_if': $attributeData['description'][] = 'Required if `' . $parameters[0] . '` is `' . $parameters[1] . '`'; break; + case 'required_unless': + $attributeData['description'][] = 'Required unless `' . $parameters[0] . '` is `' . $parameters[1] . '`'; + break; + case 'required_with': + $attributeData['description'][] = 'Required if the parameters ' . $this->fancy_implode($parameters, ', ', ' or ') . ' are present.'; + break; + case 'required_with_all': + $attributeData['description'][] = 'Required if the parameters ' . $this->fancy_implode($parameters, ', ', ' and ') . ' are present.'; + break; + case 'required_without': + $attributeData['description'][] = 'Required if the parameters ' . $this->fancy_implode($parameters, ', ', ' or ') . ' are not present.'; + break; + case 'required_without_all': + $attributeData['description'][] = 'Required if the parameters ' . $this->fancy_implode($parameters, ', ', ' and ') . ' are not present.'; + break; + case 'same': + $attributeData['description'][] = 'Must be the same as `' . $parameters[0] . '`'; + break; + case 'size': + $attributeData['description'][] = 'Must have the size of `' . $parameters[0] . '`'; + break; + case 'timezone': + $attributeData['description'][] = 'Must be a valid timezone identifier'; + break; case 'exists': $attributeData['description'][] = 'Valid ' . Str::singular($parameters[0]) . ' ' . $parameters[1]; break; case 'active_url': $attributeData['type'] = 'url'; break; + case 'regex': + $attributeData['type'] = 'string'; + $attributeData['description'][] = 'Must match this regular expression: `' . $parameters[0] . '`'; + break; case 'boolean': + case 'array': + case 'date': case 'email': case 'image': case 'string': case 'integer': - case 'json': case 'numeric': case 'url': case 'ip': diff --git a/src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php b/src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php index 002aa791..3a70d1fa 100644 --- a/src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php +++ b/src/Mpociot/ApiDoc/Commands/GenerateDocumentation.php @@ -5,6 +5,7 @@ use Illuminate\Console\Command; use Illuminate\Support\Facades\Route; use Mpociot\ApiDoc\ApiDocGenerator; +use Mpociot\Documentarian\Documentarian; use phpDocumentor\Reflection\DocBlock; use Symfony\Component\Process\Process; @@ -94,20 +95,12 @@ private function writeMarkdown($parsedRoutes) { $outputPath = $this->option('output'); - $markdown = view('apidoc::whiteboard')->with('parsedRoutes', $parsedRoutes); + $documentarian = new Documentarian(); + + $markdown = view('apidoc::documentarian')->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); - }); - } + $documentarian->create($outputPath); } file_put_contents($outputPath . DIRECTORY_SEPARATOR . 'source' . DIRECTORY_SEPARATOR . 'index.md', $markdown); @@ -116,36 +109,9 @@ private function writeMarkdown($parsedRoutes) $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); - }); + $documentarian->generate($outputPath); $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); - }); - } - } diff --git a/src/resources/views/whiteboard.blade.php b/src/resources/views/documentarian.blade.php similarity index 95% rename from src/resources/views/whiteboard.blade.php rename to src/resources/views/documentarian.blade.php index 53f99c81..27fd46ef 100644 --- a/src/resources/views/whiteboard.blade.php +++ b/src/resources/views/documentarian.blade.php @@ -10,7 +10,7 @@ search: true toc_footers: -- Documentation Powered by Whiteboard +- Documentation Powered by Whiteboard --- # Info @@ -36,6 +36,7 @@ -d "{{$attribute}}"="dummy" \ @endforeach @endif + ``` ```javascript @@ -68,6 +69,7 @@ ### HTTP Request @foreach($parsedRoute['methods'] as $method) `{{$method}} {{$parsedRoute['uri']}}` + @endforeach @if(count($parsedRoute['parameters'])) diff --git a/tests/ApiDocGeneratorTest.php b/tests/ApiDocGeneratorTest.php index e8db27a0..8eb59af0 100644 --- a/tests/ApiDocGeneratorTest.php +++ b/tests/ApiDocGeneratorTest.php @@ -62,13 +62,240 @@ public function testCanParseFormRequestRules() $route = new Route(['POST'], '/post', ['uses' => 'TestController@parseFormRequestRules']); $parsed = $this->generator->processRoute($route); $parameters = $parsed['parameters']; - $this->assertArrayHasKey('required_attribute', $parameters); - $required_attribute = $parameters['required_attribute']; + $testRequest = new TestRequest(); + $rules = $testRequest->rules(); + + foreach ($rules as $name => $rule) { + $attribute = $parameters[$name]; + + switch ($name){ + + case 'required': + $this->assertTrue( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + + + case 'accepted': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'boolean', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + + case 'active_url': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'url', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'alpha': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Only alphabetic characters allowed', $attribute['description'][0]); + break; + case 'alpha_dash': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Allowed: alpha-numeric characters, as well as dashes and underscores.', $attribute['description'][0]); + break; + case 'alpha_num': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Only alpha-numeric characters allowed', $attribute['description'][0]); + break; + case 'array': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'array', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'between': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'numeric', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Between: `5` and `200`', $attribute['description'][0]); + break; + case 'before': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'date', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must be a date preceding: `Saturday, 23-Apr-16 14:31:00 UTC`', $attribute['description'][0]); + break; + case 'boolean': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'boolean', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'date': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'date', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'date_format': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'date', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Date format: `j.n.Y H:iP`', $attribute['description'][0]); + break; + case 'different': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must have a different value than parameter: `alpha_num`', $attribute['description'][0]); + break; + case 'digits': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'numeric', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must have an exact length of `2`', $attribute['description'][0]); + break; + case 'digits_between': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'numeric', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must have a length between `2` and `10`', $attribute['description'][0]); + break; + case 'email': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'email', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'exists': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Valid user firstname', $attribute['description'][0]); + break; + case 'image': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'image', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must be an image (jpeg, png, bmp, gif, or svg)', $attribute['description'][0]); + break; + case 'in': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('jpeg, png, bmp, gif or svg', $attribute['description'][0]); + break; + case 'integer': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'integer', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'ip': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'ip', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'json': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must be a valid JSON string.', $attribute['description'][0]); + break; + case 'max': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'numeric', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Maximum: `10`', $attribute['description'][0]); + break; + case 'min': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'numeric', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Minimum: `20`', $attribute['description'][0]); + break; + case 'mimes': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Allowed mime types: jpeg, bmp or png', $attribute['description'][0]); + break; + case 'not_in': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Not in: foo or bar', $attribute['description'][0]); + break; + case 'numeric': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'numeric', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + case 'regex': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must match this regular expression: `(.*)`', $attribute['description'][0]); + break; + case 'required_if': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Required if `foo` is `bar`', $attribute['description'][0]); + break; + case 'required_unless': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Required unless `foo` is `bar`', $attribute['description'][0]); + break; + case 'required_with': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Required if the parameters foo, bar or baz are present.', $attribute['description'][0]); + break; + case 'required_with_all': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Required if the parameters foo, bar and baz are present.', $attribute['description'][0]); + break; + case 'required_without': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Required if the parameters foo, bar or baz are not present.', $attribute['description'][0]); + break; + case 'required_without_all': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Required if the parameters foo, bar and baz are not present.', $attribute['description'][0]); + break; + case 'same': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must be the same as `foo`', $attribute['description'][0]); + break; + case 'size': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must have the size of `51`', $attribute['description'][0]); + break; + case 'timezone': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'string', $attribute['type'] ); + $this->assertCount( 1, $attribute['description'] ); + $this->assertEquals('Must be a valid timezone identifier', $attribute['description'][0]); + break; + case 'url': + $this->assertFalse( $attribute['required'] ); + $this->assertEquals( 'url', $attribute['type'] ); + $this->assertCount( 0, $attribute['description'] ); + break; + + } + + } - $this->assertTrue( $required_attribute['required'] ); - $this->assertEquals( 'string', $required_attribute['type'] ); - $this->assertCount( 0, $required_attribute['description'] ); } } @@ -104,7 +331,43 @@ class TestRequest extends FormRequest public function rules() { return [ - 'required_attribute' => 'required' + 'required' => 'required', + 'accepted' => 'accepted', + 'after' => 'after:2016-04-23 14:31:00', + 'active_url' => 'active_url', + 'alpha' => 'alpha', + 'alpha_dash' => 'alpha_dash', + 'alpha_num' => 'alpha_num', + 'array' => 'array', + 'before' => 'before:2016-04-23 14:31:00', + 'between' => 'between:5,200', + 'boolean' => 'boolean', + 'date' => 'date', + 'date_format' => 'date_format:j.n.Y H:iP', + 'different' => 'different:alpha_num', + 'digits' => 'digits:2', + 'digits_between' => 'digits_between:2,10', + 'exists' => 'exists:users,firstname', + 'in' => 'in:jpeg,png,bmp,gif,svg', + 'integer' => 'integer', + 'ip' => 'ip', + 'json' => 'json', + 'min' => 'min:20', + 'max' => 'max:10', + 'mimes' => 'mimes:jpeg,bmp,png', + 'not_in' => 'not_in:foo,bar', + 'numeric' => 'numeric', + 'regex' => 'regex:(.*)', + 'required_if' => 'required_if:foo,bar', + 'required_unless' => 'required_unless:foo,bar', + 'required_with' => 'required_with:foo,bar,baz', + 'required_with_all' => 'required_with_all:foo,bar,baz', + 'required_without' => 'required_without:foo,bar,baz', + 'required_without_all' => 'required_without_all:foo,bar,baz', + 'same' => 'same:foo', + 'size' => 'size:51', + 'timezone' => 'timezone', + 'url' => 'url', ]; } } \ No newline at end of file