From 4682bc93a23a71bc7abfd31318dd8b67077f0ae7 Mon Sep 17 00:00:00 2001 From: Pulkit Kathuria Date: Sat, 2 Apr 2022 16:41:23 +0900 Subject: [PATCH] open api 3.0.0 support. Fixes #10 --- README.md | 21 +- config/request-docs.php | 95 +++++++ examples/postman.sample.json | 242 ++++++++++++++++++ resources/views/index.blade.php | 8 + src/Commands/LaravelRequestDocsCommand.php | 10 +- .../LaravelRequestDocsController.php | 22 +- src/LaravelRequestDocsToOpenApi.php | 128 +++++++++ tests/LRDOpenApiTest.php | 23 ++ tests/TestCase.php | 4 +- 9 files changed, 541 insertions(+), 12 deletions(-) create mode 100644 examples/postman.sample.json create mode 100644 src/LaravelRequestDocsToOpenApi.php create mode 100644 tests/LRDOpenApiTest.php diff --git a/README.md b/README.md index 0c6e7bf..4ed3372 100644 --- a/README.md +++ b/README.md @@ -6,14 +6,17 @@

The Hassle-Free automatic API documentation generation for Laravel.
- A Swagger alernative. + A Swagger alernative.
+ Supports Open API 3.0.0

**Fast:** Install on any Laravel Project **Hassle Free:** Auto Generate API Documentation for request rules and parameters -**Analyze:** In build SQL query time analyzer, response time and headers output. +**Analyze:** In built SQL query time analyzer, response time and headers output. + +**Supports:** Postmane and OpenAPI 3.0.0 exports. ## Features @@ -23,6 +26,7 @@ - Support for SQL query, response time and memory consumption by request on Laravel - Intelligent auto request builder using ``faker.js`` - Display extra documentation using markdown +- Export laravel API, routes, rules and documentation to Postman and OpenAPI 3.0.0 # Read on Medium Automatically generate api documentation for Laravel without writing annotations. @@ -31,9 +35,9 @@ Read more: https://medium.com/web-developer/laravel-automatically-generate-api-d ## Requirements -| Lang | Version | +| Lang | Versions | | :------ | :---------------- | -| PHP | 7.4 or 8.0 | +| PHP | 7.4 or 8.0 or 8.1 | | Laravel | 6.* or 8.* or 9.* | # Installation @@ -53,9 +57,11 @@ php artisan vendor:publish --tag=request-docs-config # Usage +## Dashboard + View in the browser on ``/request-docs/`` -or generate a static HTML +Generate a static HTML and open api specification ```php php artisan lrd:generate @@ -63,6 +69,10 @@ php artisan lrd:generate Docs HTML is generated inside ``docs/``. +## Just want Open API + +View in the browser on ``/request-docs/?openapi=true`` + # Design pattern In order for this plugin to work, you need to follow the design pattern by injecting the request class inside the controller. @@ -140,4 +150,5 @@ Fixing lints - v1.18 Fix where prism had fixed height. Allow text area resize. - v1.18 Updated UI and pushed unit tests - v1.19 Exception -> Throwable for type error +- v1.20 Feature support open api 3.0.0 #10 diff --git a/config/request-docs.php b/config/request-docs.php index 5f3e52b..2fe782d 100644 --- a/config/request-docs.php +++ b/config/request-docs.php @@ -32,5 +32,100 @@ "#^telescope#", "#^docs#", "#^request-docs#", + ], + + "open_api" => [ + // default version that this library provides + "version" => "3.0.0", + // changeable + "document_version" => "1.0.0", + // license that you want to display + "license" => "Apache 2.0", + "license_url" => "https://www.apache.org/licenses/LICENSE-2.0.html", + "server_url" => env('APP_URL', 'http://localhost'), + + // for now putting default responses for all. This can be changed later based on specific needs + "responses" => [ + '200' => [ + 'description' => 'Successful operation', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + '400' => [ + 'description' => 'Bad Request', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + '401' => [ + 'description' => 'Unauthorized', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + '403' => [ + 'description' => 'Forbidden', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + '404' => [ + 'description' => 'Not Found', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + '422' => [ + 'description' => 'Unprocessable Entity', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + '500' => [ + 'description' => 'Internal Server Error', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + 'default' => [ + 'description' => 'Unexpected error', + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + ], + ], + ], + ], + ], ] ]; diff --git a/examples/postman.sample.json b/examples/postman.sample.json new file mode 100644 index 0000000..6b75b27 --- /dev/null +++ b/examples/postman.sample.json @@ -0,0 +1,242 @@ +{ + "openapi": "3.0.0", + "info": { + "version": "1.0.0", + "title": "Swagger Petstore", + "description": "A sample API that uses a petstore as an example to demonstrate features in the OpenAPI 3.0 specification", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "Swagger API Team", + "email": "apiteam@swagger.io", + "url": "http://swagger.io" + }, + "license": { + "name": "Apache 2.0", + "url": "https://www.apache.org/licenses/LICENSE-2.0.html" + } + }, + "servers": [ + { + "url": "http://petstore.swagger.io/api" + } + ], + "paths": { + "/pets": { + "get": { + "description": "Returns all pets from the system that the user has access to\nNam sed condimentum est. Maecenas tempor sagittis sapien, nec rhoncus sem sagittis sit amet. Aenean at gravida augue, ac iaculis sem. Curabitur odio lorem, ornare eget elementum nec, cursus id lectus. Duis mi turpis, pulvinar ac eros ac, tincidunt varius justo. In hac habitasse platea dictumst. Integer at adipiscing ante, a sagittis ligula. Aenean pharetra tempor ante molestie imperdiet. Vivamus id aliquam diam. Cras quis velit non tortor eleifend sagittis. Praesent at enim pharetra urna volutpat venenatis eget eget mauris. In eleifend fermentum facilisis. Praesent enim enim, gravida ac sodales sed, placerat id erat. Suspendisse lacus dolor, consectetur non augue vel, vehicula interdum libero. Morbi euismod sagittis libero sed lacinia.\n\nSed tempus felis lobortis leo pulvinar rutrum. Nam mattis velit nisl, eu condimentum ligula luctus nec. Phasellus semper velit eget aliquet faucibus. In a mattis elit. Phasellus vel urna viverra, condimentum lorem id, rhoncus nibh. Ut pellentesque posuere elementum. Sed a varius odio. Morbi rhoncus ligula libero, vel eleifend nunc tristique vitae. Fusce et sem dui. Aenean nec scelerisque tortor. Fusce malesuada accumsan magna vel tempus. Quisque mollis felis eu dolor tristique, sit amet auctor felis gravida. Sed libero lorem, molestie sed nisl in, accumsan tempor nisi. Fusce sollicitudin massa ut lacinia mattis. Sed vel eleifend lorem. Pellentesque vitae felis pretium, pulvinar elit eu, euismod sapien.\n", + "operationId": "findPets", + "parameters": [ + { + "name": "tags", + "in": "query", + "description": "tags to filter by", + "required": false, + "style": "form", + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "name": "limit", + "in": "query", + "description": "maximum number of results to return", + "required": false, + "schema": { + "type": "integer", + "format": "int32" + } + } + ], + "responses": { + "200": { + "description": "pet response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Pet" + } + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "post": { + "description": "Creates a new pet in the store. Duplicates are allowed", + "operationId": "addPet", + "requestBody": { + "description": "Pet to add to the store", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NewPet" + } + } + } + }, + "responses": { + "200": { + "description": "pet response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + }, + "/pets/{id}": { + "get": { + "description": "Returns a user based on a single ID, if the user does not have access to the pet", + "operationId": "find pet by id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of pet to fetch", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "200": { + "description": "pet response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + }, + "delete": { + "description": "deletes a single pet based on the ID supplied", + "operationId": "deletePet", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "ID of pet to delete", + "required": true, + "schema": { + "type": "integer", + "format": "int64" + } + } + ], + "responses": { + "204": { + "description": "pet deleted" + }, + "default": { + "description": "unexpected error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "allOf": [ + { + "$ref": "#/components/schemas/NewPet" + }, + { + "type": "object", + "required": [ + "id" + ], + "properties": { + "id": { + "type": "integer", + "format": "int64" + } + } + } + ] + }, + "NewPet": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + }, + "tag": { + "type": "string" + } + } + }, + "Error": { + "type": "object", + "required": [ + "code", + "message" + ], + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + } + } + } + } + } +} diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index b38dbe0..5bc98ab 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -96,6 +96,14 @@
{{ config('request-docs.document_name') }}
+
+ | +
+
+ + Export to Postman (Open API {{config('request-docs.open_api.version', '3.0.0')}}) + +
diff --git a/src/Commands/LaravelRequestDocsCommand.php b/src/Commands/LaravelRequestDocsCommand.php index d5b96a7..413bb08 100644 --- a/src/Commands/LaravelRequestDocsCommand.php +++ b/src/Commands/LaravelRequestDocsCommand.php @@ -4,6 +4,7 @@ use Illuminate\Console\Command; use Rakutentech\LaravelRequestDocs\LaravelRequestDocs; +use Rakutentech\LaravelRequestDocs\LaravelRequestDocsToOpenApi; use File; @@ -15,9 +16,10 @@ class LaravelRequestDocsCommand extends Command private $laravelRequestDocs; - public function __construct(LaravelRequestDocs $laravelRequestDocs) + public function __construct(LaravelRequestDocs $laravelRequestDocs, LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi) { $this->laravelRequestDocs = $laravelRequestDocs; + $this->laravelRequestDocsToOpenApi = $laravelRequestDocsToOpenApi; parent::__construct(); } @@ -37,6 +39,12 @@ public function handle() ->with(compact('docs')) ->render() ); + + File::put( + $destinationPath . '/lrd-openapi.json', + $this->laravelRequestDocsToOpenApi->openApi($docs)->toJson() + ); $this->comment("Static HTML generated: $destinationPath"); + $this->comment("OpenApi JSON generated: $destinationPath"); } } diff --git a/src/Controllers/LaravelRequestDocsController.php b/src/Controllers/LaravelRequestDocsController.php index 5077951..cc42156 100644 --- a/src/Controllers/LaravelRequestDocsController.php +++ b/src/Controllers/LaravelRequestDocsController.php @@ -2,24 +2,36 @@ namespace Rakutentech\LaravelRequestDocs\Controllers; -use Route; -use Closure; +use Illuminate\Http\Request; +use Illuminate\Http\Response; use Rakutentech\LaravelRequestDocs\LaravelRequestDocs; +use Rakutentech\LaravelRequestDocs\LaravelRequestDocsToOpenApi; use Illuminate\Routing\Controller; class LaravelRequestDocsController extends Controller { private $laravelRequestDocs; - public function __construct(LaravelRequestDocs $laravelRequestDocs) + public function __construct(LaravelRequestDocs $laravelRequestDoc, LaravelRequestDocsToOpenApi $laravelRequestDocsToOpenApi) { - $this->laravelRequestDocs = $laravelRequestDocs; + $this->laravelRequestDocs = $laravelRequestDoc; + $this->laravelRequestDocsToOpenApi = $laravelRequestDocsToOpenApi; } - public function index() + public function index(Request $request) { $docs = $this->laravelRequestDocs->getDocs(); $docs = $this->laravelRequestDocs->sortDocs($docs, config('request-docs.sort_by', 'default')); + if ($request->openapi) { + return response()->json( + $this->laravelRequestDocsToOpenApi->openApi($docs)->toArray(), + Response::HTTP_OK, + [ + 'Content-type'=> 'application/json; charset=utf-8' + ], + JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE + ); + } return view('request-docs::index')->with(compact('docs')); } } diff --git a/src/LaravelRequestDocsToOpenApi.php b/src/LaravelRequestDocsToOpenApi.php new file mode 100644 index 0000000..7d193d8 --- /dev/null +++ b/src/LaravelRequestDocsToOpenApi.php @@ -0,0 +1,128 @@ +laravelRequestDocs->getDocs(); + public function openApi(array $docs): LaravelRequestDocsToOpenApi + { + $this->openApi['openapi'] = config('request-docs.open_api.version', '3.0.0'); + $this->openApi['info']['version'] = config('request-docs.open_api.document_version', '1.0.0'); + $this->openApi['info']['title'] = config('request-docs.document_name', 'LRD'); + $this->openApi['info']['description'] = config('request-docs.document_name', 'LRD'); + $this->openApi['info']['license']['name'] = config('request-docs.open_api.license', 'Apache 2.0'); + $this->openApi['info']['license']['url'] = config('request-docs.open_api.license_url', 'https://www.apache.org/licenses/LICENSE-2.0.html'); + $this->openApi['servers'][] = [ + 'url' => config('request-docs.open_api.server_url', config('app.url')) + ]; + + $this->docsToOpenApi($docs); + return $this; + } + + private function docsToOpenApi(array $docs) + { + $this->openApi['paths'] = []; + foreach ($docs as $doc) { + $httpMethod = strtolower($doc['httpMethod']); + $isGet = $httpMethod == 'get'; + $isPost = $httpMethod == 'post'; + $isPut = $httpMethod == 'put'; + $isDelete = $httpMethod == 'delete'; + + $this->openApi['paths'][$doc['uri']][$httpMethod]['description'] = $doc['docBlock']; + $this->openApi['paths'][$doc['uri']][$httpMethod]['parameters'] = []; + + $this->openApi['paths'][$doc['uri']][$httpMethod]['responses'] = config('request-docs.open_api.responses', []); + + if ($isGet) { + $this->openApi['paths'][$doc['uri']][$httpMethod]['parameters'] = []; + } + if ($isPost || $isPut || $isDelete) { + $this->openApi['paths'][$doc['uri']][$httpMethod]['requestBody'] = $this->makeRequestBodyItem(); + } + foreach ($doc['rules'] as $attribute => $rules) { + foreach ($rules as $rule) { + if ($isGet) { + $parameter = $this->makeQueryParameterItem($attribute, $rule); + $this->openApi['paths'][$doc['uri']][$httpMethod]['parameters'][] = $parameter; + } + if ($isPost || $isPut || $isDelete) { + $this->openApi['paths'][$doc['uri']][$httpMethod]['requestBody']['content']['application/json']['schema']['properties'][$attribute] = $this->makeRequestBodyContentPropertyItem($rule); + } + } + } + } + } + + + protected function makeQueryParameterItem(string $attribute, string $rule): array + { + $parameter = [ + 'name' => $attribute, + 'description' => $rule, + 'in' => 'query', + 'style' => 'form', + 'required' => str_contains($rule, 'required'), + 'schema' => [ + 'type' => $this->getAttributeType($rule), + ], + ]; + return $parameter; + } + + protected function makeRequestBodyItem(): array + { + $requestBody = [ + 'description' => "Request body", + 'content' => [ + 'application/json' => [ + 'schema' => [ + 'type' => 'object', + 'properties' => [], + ], + ], + ], + ]; + return $requestBody; + } + + protected function makeRequestBodyContentPropertyItem(string $rule): array + { + return [ + 'type' => $this->getAttributeType($rule), + ]; + } + + + protected function getAttributeType(string $rule): string + { + if (str_contains($rule, 'string')) { + return 'string'; + } + if (str_contains($rule, 'array')) { + return 'array'; + } + if (str_contains($rule, 'integer')) { + return 'integer'; + } + return "object"; + } + + public function toJson() : string { + return collect($this->openApi)->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + public function toArray() : array { + return $this->openApi; + } +} diff --git a/tests/LRDOpenApiTest.php b/tests/LRDOpenApiTest.php new file mode 100644 index 0000000..e309b0c --- /dev/null +++ b/tests/LRDOpenApiTest.php @@ -0,0 +1,23 @@ +lrd->getDocs(); + $openApi = $this->lrdToOpenApi->openApi($docs)->toArray(); + $routes = collect(Route::getRoutes()); + + $this->assertSame($routes->count(), count($docs)); + + $countRoutes = 0; + foreach ($openApi["paths"] as $path) { + $countRoutes += count(array_keys($path)); + } + + $this->assertSame($routes->count(), $countRoutes); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php index 2dbac3a..653fd5f 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,6 +6,7 @@ use Rakutentech\LaravelRequestDocs\LaravelRequestDocsServiceProvider; use Rakutentech\LaravelRequestDocs\LaravelRequestDocs; use Illuminate\Support\Facades\Route; +use Rakutentech\LaravelRequestDocs\LaravelRequestDocsToOpenApi; use Rakutentech\LaravelRequestDocs\Tests\TestControllers; class TestCase extends Orchestra @@ -14,7 +15,8 @@ class TestCase extends Orchestra public function setUp(): void { parent::setUp(); - $this->lrd = new LaravelRequestDocs(); + $this->lrd = new LaravelRequestDocs(); + $this->lrdToOpenApi = new LaravelRequestDocsToOpenApi(); $this->registerRoutes(); }