Browse Source

Added OpenAPI spec generation support

shalvah 4 years ago
parent
commit
1e15d01910

+ 11 - 0
README.md

@@ -10,6 +10,17 @@ Generate API documentation for humans from your Laravel codebase. [Here's what t
 
 > Looking to document your Node.js APIs? Check out [Scribe for JS](https://github.com/knuckleswtf/scribe-js).
 
+## Features
+- Pretty HTML documentation page, with included code samples and friendly text
+- Markdown source files that can be edited to modify docs
+- Extracts body parameters information from FormRequests
+- Safely calls API endpoints to generate sample responses, with authentication and other custom configuration supported
+- Supports generating responses from Transformers or Eloquent API Resources
+- Supports Postman collection and OpenAPI (Swagger) spec generation
+- Included UI components for additional styling
+- Easily customisable with custom views
+- Easily extensible with custom strategies
+
 ## Documentation
 > Scribe is a fork of [mpociot/laravel-apidoc-generator](https://github.com/mpociot/laravel-apidoc-generator), so see the [migration guide](https://scribe.rtfd.io/en/latest/migrating.html) if you're coming from there.
 

+ 2 - 1
composer.dingo.json

@@ -30,7 +30,8 @@
         "nunomaduro/collision": "^3.0|^4.0|^5.0",
         "ramsey/uuid": "^3.8|^4.0",
         "shalvah/clara": "^2.6",
-        "symfony/var-exporter": "^4.0|^5.0"
+        "symfony/var-exporter": "^4.0|^5.0",
+        "symfony/yaml": "^4.0|^5.0"
     },
     "require-dev": {
         "dms/phpunit-arraysubset-asserts": "^0.1.0",

+ 2 - 1
composer.json

@@ -29,7 +29,8 @@
         "nunomaduro/collision": "^3.0|^4.0|^5.0",
         "ramsey/uuid": "^3.8|^4.0",
         "shalvah/clara": "^2.6",
-        "symfony/var-exporter": "^4.0|^5.0"
+        "symfony/var-exporter": "^4.0|^5.0",
+        "symfony/yaml": "^4.0|^5.0"
     },
     "require-dev": {
         "brianium/paratest": "^4.0",

+ 6 - 0
config/scribe.php

@@ -110,6 +110,8 @@ INTRO
      */
     'title' => null,
 
+    'description' => '',
+
     /*
      * Generate a Postman collection in addition to HTML docs.
      * For 'static' docs, the collection will be generated to public/docs/collection.json.
@@ -140,6 +142,10 @@ INTRO
         'auth' => null,
     ],
 
+    'openapi' => [
+        'enabled' => true,
+    ],
+
     /*
      * Name for the group of endpoints which do not have a @group set.
      */

+ 2 - 1
docs/architecture.md

@@ -13,4 +13,5 @@ Read this page if you want a deeper understanding of how Scribe works (for insta
   - sample responses
 - Next, the Writer uses information from these parsed routes and other configuration to generate a Markdown file via Blade templating.
 - This Markdown file is passed to [Pastel](https://github.com/knuckleswtf/pastel), which wraps them in a theme and converts them into HTML, CSS and JS.
-- If enabled, a Postman collection is generated as well, via the PostmanCollectionWriter.
+- If enabled, a Postman collection is also generated, via the PostmanCollectionWriter.
+- If enabled, an OpenAPI specification is also generated, via the OpenAPISpecWriter.

+ 7 - 0
docs/config.md

@@ -67,6 +67,13 @@ For `static` output, the collection will be created in `public/docs/collection.j
 
 - `auth`: The "Auth" section that should appear in the postman collection. See the [Postman schema docs](https://schema.getpostman.com/json/collection/v2.0.0/docs/index.html) for more information.
 
+### `openapi`
+Scribe can also generate an OpenAPI (Swagger) spec for your routes. This section is where you can configure or disable that.
+
+For `static` output, the spec will be created in `public/docs/openapi.yaml`. For `laravel` output, the spec will be generated to `storage/app/scribe/openapi.yaml`. Setting `laravel.add_routes` to `true` will add a `/docs.openapi` endpoint to fetch it.
+
+- `enabled`: Whether or not to generate an OpenAPI spec. Default: `false`
+
 ## Extraction settings
 ### `router`
 The router to use when processing your routes. Can be `laravel` or `dingo`. Defaults to `laravel`.

+ 1 - 1
docs/documenting/documenting-api-information.md

@@ -62,7 +62,7 @@ For more information, see the [reference documentation on the auth section](conf
 The `intro_text` key in `scribe.php` is where you can set the text shown to readers in the "Introduction" section. If your text is too long to be put in a config file, you can create a `prepend.md` containing the intro text and put it in the `resources/docs` folder.
 
 ## Title
-You can set the HTML `<title>` for the generated documentation, and the name of the generated Postman collection by setting the `title` key in `scribe.php`. If you leave it as null, Scribe will infer it from the value of `config('app.name')`.
+You can set the HTML `<title>` for the generated docs webpage, Postman collection and OpenAPI spec by setting the `title` key in `scribe.php`. If you leave it as null, Scribe will infer it from the value of `config('app.name')`.
 
 ## Logo
 Maybe you've got a pretty logo for your API or company, and you'd like to display that on your documentation page. No worries! To add a logo, set the `logo` key in `scribe.php` to the path of the logo. Here are your options:

+ 7 - 0
docs/generating-documentation.md

@@ -29,6 +29,13 @@ You can configure Postman collection generation in the `postman` section of your
 
 - You can add descriptions and auth information for the collection in the `postman.description` and `postman.auth` keys. 
 
+## OpenAPI (Swagger) spec generation
+Scribe can also generate an OpenAPI spec file. This is disabled by default. You can configure this in the `openapi` section of your `scribe.php` file.
+
+- To enable it, set the `openapi.enabled` config option to `true`.
+
+You can view the generated spec by visiting `public/docs/openapi.yaml` for `static` type, and `<your-app>/docs.openapi` for `laravel` type. This link will also be added to the sidebar of your docs.
+
 ## Customising the environment with `--env`
 You can pass the `--env` option to run this command in a specific env. For instance, if you have a `.env.test` file, running `scribe:generate --env test` will make Laravel use that file to populate the env for this command. This can be very useful to customise the behaviour of your app for documentation purposes and disable things like notifications when response calls are running. 
 

+ 2 - 0
docs/guide-getting-started.md

@@ -36,6 +36,8 @@ Visit your newly generated docs:
 
 There's also a Postman collection generated for you by default. You can get it by visiting `public/docs/collection.json` for `static` type, and `<your-app>/docs.json` for `laravel` type.
 
+If you'd like an OpenAPI (Swagger) spec, Scribe can do that too. Set `openapi.enabled` in your config to `true`, then run the `generate` command. You can get the generated spec by visiting `public/docs/openapi.yaml` for `static` type, and `<your-app>/docs.openapi` for `laravel` type.
+
 Great! You've seen what Scribe can do. Now, let's refine our docs to match what we want.
 
 ## Add general information about your API

+ 11 - 0
docs/index.md

@@ -31,6 +31,17 @@ Generate API documentation for humans from your Laravel/Lumen/[Dingo](https://gi
    contributing
 ```
 
+## Features
+- Pretty HTML documentation page, with included code samples and friendly text
+- Markdown source files that can be edited to modify docs
+- Extracts body parameters information from FormRequests
+- Safely calls API endpoints to generate sample responses, with authentication and other custom configuration supported
+- Supports generating responses from Transformers or Eloquent API Resources
+- Supports Postman collection and OpenAPI (Swagger) spec generation
+- Included UI components for additional styling
+- Easily customisable with custom views
+- Easily extensible with custom strategies
+
 ## Installation
 PHP 7.2.5 and Laravel/Lumen 5.8 or higher are required.
 

+ 1 - 0
phpunit.xml

@@ -27,6 +27,7 @@
         </testsuite>
         <testsuite name="Other Unit Tests">
             <file>tests/Unit/PostmanCollectionWriterTest.php</file>
+            <file>tests/Unit/OpenAPISpecWriterTest.php</file>
             <file>tests/Unit/AnnotationParserTest.php</file>
         </testsuite>
     </testsuites>

+ 3 - 0
resources/views/partials/frontmatter.blade.php

@@ -18,4 +18,7 @@ toc_footers:
 @if($showPostmanCollectionButton)
 - <a href="{{ $postmanCollectionLink }}">View Postman Collection</a>
 @endif
+@if($showOpenAPISpecButton)
+- <a href="{{ $openAPISpecLink }}">View OpenAPI (Swagger) Spec</a>
+@endif
 - <a href='http://github.com/knuckleswtf/scribe'>Documentation powered by Scribe ✍</a>

+ 3 - 2
routes/laravel.php

@@ -8,6 +8,7 @@ $middleware = config('scribe.laravel.middleware', []);
 Route::namespace('\Knuckles\Scribe\Http')
     ->middleware($middleware)
     ->group(function () use ($prefix) {
-        Route::get($prefix, 'Controller@html')->name('scribe');
-        Route::get("$prefix.json", 'Controller@json')->name('scribe.json');
+        Route::get($prefix, 'Controller@webpage')->name('scribe');
+        Route::get("$prefix.json", 'Controller@postman')->name('scribe.json'); // todo rename to scribe.postman in future versions
+        Route::get("$prefix.openapi", 'Controller@openapi')->name('scribe.openapi');
     });

+ 9 - 2
src/Http/Controller.php

@@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Storage;
 
 class Controller
 {
-    public function html()
+    public function webpage()
     {
         return view('scribe.index');
     }
@@ -16,10 +16,17 @@ class Controller
      *
      * @return \Illuminate\Http\JsonResponse
      */
-    public function json()
+    public function postman()
     {
         return response()->json(
             json_decode(Storage::disk('local')->get('scribe/collection.json'))
         );
     }
+
+    public function openapi()
+    {
+        return response()->json(
+            json_decode(Storage::disk('local')->get('scribe/openapi.yaml'))
+        );
+    }
 }

+ 459 - 0
src/Writing/OpenAPISpecWriter.php

@@ -0,0 +1,459 @@
+<?php
+
+namespace Knuckles\Scribe\Writing;
+
+use Illuminate\Support\Collection;
+use Illuminate\Support\Str;
+use Knuckles\Scribe\Tools\DocumentationConfig;
+
+class OpenAPISpecWriter
+{
+    const VERSION = '3.0.3';
+
+    /**
+     * @var DocumentationConfig
+     */
+    private $config;
+
+    /**
+     * Object to represent empty values, since empty arrays get serialised as objects.
+     * Can't use a constant because of initialisation expression.
+     *
+     * @var \stdClass
+     */
+    public $EMPTY;
+
+    public function __construct(DocumentationConfig $config = null)
+    {
+        $this->config = $config ?: new DocumentationConfig(config('scribe'));
+        $this->EMPTY = new \stdClass();
+    }
+
+    /**
+     * See https://swagger.io/specification/
+     *
+     * @param Collection $groupedEndpoints
+     *
+     * @return array
+     */
+    public function generateSpecContent(Collection $groupedEndpoints)
+    {
+        return array_merge([
+            'openapi' => self::VERSION,
+            'info' => [
+                'title' => $this->config->get('title', config('app.name', '') . ' API'),
+                'description' => $this->config->get('description', ''),
+                'version' => '1.0.0',
+            ],
+            'servers' => [
+                [
+                    'url' => rtrim($this->config->get('base_url') ?? config('app.url'), '/'),
+                ],
+            ],
+            'paths' => $this->generatePathsSpec($groupedEndpoints),
+        ], $this->generateSecurityPartialSpec());
+    }
+
+    protected function generatePathsSpec(Collection $groupedEndpoints)
+    {
+        $allEndpoints = $groupedEndpoints->flatten(1);
+        // OpenAPI groups endpoints by path, then method
+        $groupedByPath = $allEndpoints->groupBy(function ($endpoint) {
+            $path = str_replace("?}", "}", $endpoint['uri']); // Remove optional parameters indicator in path
+            return '/' . ltrim($path, '/');
+        });
+        return $groupedByPath->mapWithKeys(function (Collection $endpoints, $path) {
+            $operations = $endpoints->mapWithKeys(function ($endpoint) {
+                $spec = [
+                    'summary' => $endpoint['metadata']['title'],
+                    'description' => $endpoint['metadata']['description'] ?? '',
+                    'parameters' => $this->generateEndpointParametersSpec($endpoint),
+                    'responses' => $this->generateEndpointResponsesSpec($endpoint),
+                    'tags' => [$endpoint['metadata']['groupName']],
+                ];
+
+                if (count($endpoint['bodyParameters'])) {
+                    $spec['requestBody'] = $this->generateEndpointRequestBodySpec($endpoint);
+                }
+
+                if (!($endpoint['metadata']['authenticated'] ?? false)) {
+                    // Make sure to exclude non-auth endpoints from auth
+                    $spec['security'] = [];
+                }
+
+                return [strtolower($endpoint['methods'][0]) => $spec];
+            });
+
+            $pathItem = $operations;
+
+            // Placing all URL parameters at the path level, since it's the same path anyway
+            if (count($endpoints[0]['urlParameters'])) {
+                $parameters = [];
+                foreach ($endpoints[0]['urlParameters'] as $name => $details) {
+                    $parameterData = [
+                        'in' => 'path',
+                        'name' => $name,
+                        'description' => $details['description'] ?? '',
+                        'example' => $details['value'] ?? null,
+                        // Currently, Swagger requires path parameters to be required
+                        'required' => true,
+                        'schema' => [
+                            'type' => $details['type'] ?? 'string',
+                        ],
+                    ];
+                    // Workaround for optional parameters
+                    if (empty($details['required'])) {
+                        $parameterData['description'] = rtrim('Optional parameter. ' . $parameterData['description']);
+                        $parameterData['examples'] = [
+                            'omitted' => [
+                                'summary' => 'When the value is omitted',
+                                'value' => '',
+                            ],
+                        ];
+
+                        if ($parameterData['example'] !== null) {
+                            $parameterData['examples']['present'] = [
+                                'summary' => 'When the value is present',
+                                'value' => $parameterData['example'],
+                            ];
+                        }
+
+                        // Can't have `example` and `examples`
+                        unset($parameterData['example']);
+                    }
+                    $parameters[] = $parameterData;
+                }
+                $pathItem['parameters'] = $parameters;
+            }
+
+            return [$path => $pathItem];
+        })->toArray();
+    }
+
+    /**
+     * Add query parameters and headers.
+     *
+     * @param $endpoint
+     *
+     * @return array|\stdClass
+     */
+    protected function generateEndpointParametersSpec($endpoint)
+    {
+        $parameters = [];
+
+        if (count($endpoint['queryParameters'])) {
+            foreach ($endpoint['queryParameters'] as $name => $details) {
+                $parameterData = [
+                    'in' => 'query',
+                    'name' => $name,
+                    'description' => $details['description'] ?? '',
+                    'example' => $details['value'] ?? null,
+                    'required' => $details['required'] ?? false,
+                    'schema' => [
+                        'type' => $details['type'] ?? 'string',
+                    ],
+                ];
+                $parameters[] = $parameterData;
+            }
+        }
+
+        if (count($endpoint['headers'])) {
+            foreach ($endpoint['headers'] as $name => $value) {
+                $parameters[] = [
+                    'in' => 'header',
+                    'name' => $name,
+                    'description' => '',
+                    'example' => $value,
+                    'schema' => [
+                        'type' => 'string',
+                    ],
+                ];
+            }
+        }
+
+        return $parameters;
+    }
+
+    protected function generateEndpointRequestBodySpec($endpoint)
+    {
+        $body = [];
+
+        if (count($endpoint['bodyParameters'])) {
+            $schema = [
+                'type' => 'object',
+                'properties' => [],
+            ];
+
+            $hasRequiredParameter = false;
+            $hasFileParameter = false;
+
+            foreach ($endpoint['bodyParameters'] as $name => $details) {
+                if ($details['required']) {
+                    $hasRequiredParameter = true;
+                    // Don't declare this earlier.
+                    // Can't have an empty `required` array. Must have something there.
+                    $schema['required'][] = $name;
+                }
+
+
+                if ($details['type'] === 'file') {
+                    // See https://swagger.io/docs/specification/describing-request-body/file-upload/
+                    $hasFileParameter = true;
+                    $fieldData = [
+                        'type' => 'string',
+                        'format' => 'binary',
+                        'description' => $details['description'] ?? '',
+                    ];
+                } else {
+                    $fieldData = [
+                        'type' => $this->convertScribeOrPHPTypeToOpenAPIType($details['type']),
+                        'description' => $details['description'] ?? '',
+                        'example' => $details['value'] ?? null,
+                    ];
+                    if ($fieldData['type'] === 'array') {
+                        $fieldData['items'] = [
+                            'type' => empty($details['value'] ?? null) ? 'object' : $this->convertScribeOrPHPTypeToOpenAPIType(gettype($details['value'][0])),
+                        ];
+                    }
+                }
+
+                $schema['properties'][$name] = $fieldData;
+            }
+
+            $body['required'] = $hasRequiredParameter;
+
+            if ($hasFileParameter) {
+                // If there are file parameters, content type changes to multipart
+                $contentType = 'multipart/form-data';
+            } elseif (isset($endpoint['headers']['Content-Type'])) {
+                $contentType = $endpoint['headers']['Content-Type'];
+            } else {
+                $contentType = 'application/json';
+            }
+
+            $body['content'][$contentType]['schema'] = $schema;
+
+        }
+
+        // return object rather than empty array, so can get properly serialised as object
+        return count($body) > 0 ? $body : $this->EMPTY;
+    }
+
+    protected function generateEndpointResponsesSpec($endpoint)
+    {
+        // See https://swagger.io/docs/specification/describing-responses/
+        $responses = [];
+
+        foreach ($endpoint['responses'] as $response) {
+            // OpenAPI groups responses by status code
+            // Only one response type per status code, so only the last one will be used
+            if (intval($response['status']) === 204) {
+                // Must not add content for 204
+                $responses[204] = [
+                    'description' => $this->getResponseDescription($response),
+                ];
+            } else {
+                $responses[$response['status']] = [
+                    'description' => $this->getResponseDescription($response),
+                    'content' => $this->generateResponseContentSpec($response['content'], $endpoint),
+                ];
+            }
+        }
+
+        // return object rather than empty array, so can get properly serialised as object
+        return count($responses) > 0 ? $responses : $this->EMPTY;
+    }
+
+    protected function getResponseDescription($response)
+    {
+        if (Str::startsWith($response['content'], "<<binary>>")) {
+            return trim(str_replace("<<binary>>", "", $response['content']));
+        }
+
+        return strval($response['description'] ?? '');
+    }
+
+    protected function generateResponseContentSpec($responseContent, $endpoint)
+    {
+        if (Str::startsWith($responseContent, '<<binary>>')) {
+            return [
+                'application/octet-stream' => [
+                    'schema' => [
+                        'type' => 'string',
+                        'format' => 'binary',
+                    ],
+                ],
+            ];
+        }
+
+        if ($responseContent === null) {
+            return [
+                'application/json' => [
+                    'schema' => [
+                        'type' => 'object',
+                        // Sww https://swagger.io/docs/specification/data-models/data-types/#null
+                        'nullable' => true,
+                    ],
+                ],
+            ];
+        }
+
+        $decoded = json_decode($responseContent);
+        if ($decoded === null) { // Decoding failed, so we return the content string as is
+            return [
+                'text/plain' => [
+                    'schema' => [
+                        'type' => 'string',
+                        'example' => $responseContent,
+                    ],
+                ],
+            ];
+        }
+
+        switch ($type = gettype($decoded)) {
+            case 'string':
+            case 'boolean':
+            case 'integer':
+            case 'double':
+                return [
+                    'application/json' => [
+                        'schema' => [
+                            'type' => $type === 'double' ? 'number' : $type,
+                            'example' => $decoded,
+                        ],
+                    ],
+                ];
+
+            case 'array':
+                if (!count($decoded)) {
+                    // empty array
+                    return [
+                        'application/json' => [
+                            'schema' => [
+                                'type' => 'array',
+                                'items' => [
+                                    'type' => 'object', // No better idea what to put here
+                                ],
+                                'example' => $decoded,
+                            ],
+                        ],
+                    ];
+                }
+
+                // Non-empty array
+                return [
+                    'application/json' => [
+                        'schema' => [
+                            'type' => 'array',
+                            'items' => [
+                                'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($decoded[0])),
+                            ],
+                            'example' => $decoded,
+                        ],
+                    ],
+                ];
+
+            case 'object':
+                $properties = collect($decoded)->mapWithKeys(function ($value, $key) use ($endpoint) {
+                    $spec = [
+                        // Note that we aren't recursing for nested objects. We stop at one level.
+                        'type' => $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value)),
+                        'example' => $value,
+
+                    ];
+                    if (isset($endpoint['responseFields'][$key]['description'])) {
+                        $spec['description'] = $endpoint['responseFields'][$key]['description'];
+                    }
+                    if ($spec['type'] === 'array') {
+                        $spec['items']['type'] = $this->convertScribeOrPHPTypeToOpenAPIType(gettype($value[0]));
+                    }
+
+                    return [
+                        $key => $spec,
+                    ];
+                })->toArray();
+
+                if (!count($properties)) {
+                    $properties = $this->EMPTY;
+                }
+
+                return [
+                    'application/json' => [
+                        'schema' => [
+                            'type' => 'object',
+                            'example' => $decoded,
+                            'properties' => $properties,
+                        ],
+                    ],
+                ];
+        }
+    }
+
+    protected function generateSecurityPartialSpec()
+    {
+        $isApiAuthed = $this->config->get('auth.enabled', false);
+        if (!$isApiAuthed) {
+            return [];
+        }
+
+        $location = $this->config->get('auth.in');
+        $parameterName = $this->config->get('auth.name');
+
+        switch ($location) {
+            case 'query':
+                $scheme = [
+                    'type' => 'apiKey',
+                    'name' => $parameterName,
+                    'in' => 'query',
+                    'description' => '',
+                ];
+                break;
+
+            case 'bearer':
+            case 'basic':
+                $scheme = [
+                    'type' => 'http',
+                    'scheme' => $location,
+                    'description' => '',
+                ];
+                break;
+
+            case 'header':
+                $scheme = [
+                    'type' => 'header',
+                    'name' => $parameterName,
+                    'in' => 'header',
+                    'description' => '',
+                ];
+                break;
+                // OpenAPI doesn't support auth with body parameter
+        }
+
+        return [
+            // All security schemes must be registered in `components.securitySchemes`...
+            'components' => [
+                'securitySchemes' => [
+                    // 'default' is an arbitrary name for the auth scheme. Can be anything, really.
+                    'default' => $scheme,
+                ],
+            ],
+            // ...and then can be applied in `security`
+            'security' => [
+                [
+                    'default' => [],
+                ],
+            ],
+        ];
+    }
+
+    protected function convertScribeOrPHPTypeToOpenAPIType($type)
+    {
+        switch ($type) {
+            case 'float':
+            case 'double':
+                return 'number';
+            default:
+                return $type;
+        }
+    }
+}

+ 44 - 1
src/Writing/Writer.php

@@ -10,6 +10,7 @@ use Knuckles\Pastel\Pastel;
 use Knuckles\Scribe\Tools\ConsoleOutputUtils;
 use Knuckles\Scribe\Tools\DocumentationConfig;
 use Knuckles\Scribe\Tools\Utils;
+use Symfony\Component\Yaml\Yaml;
 
 class Writer
 {
@@ -36,7 +37,12 @@ class Writer
     /**
      * @var bool
      */
-    private $shouldGeneratePostmanCollection = true;
+    private $shouldGeneratePostmanCollection = false;
+
+    /**
+     * @var bool
+     */
+    private $shouldGenerateOpenAPISpec = false;
 
     /**
      * @var Pastel
@@ -81,6 +87,7 @@ class Writer
         $this->postmanBaseUrl = $this->config->get('postman.base_url') ?? $this->baseUrl;
         $this->shouldOverwrite = $shouldOverwrite;
         $this->shouldGeneratePostmanCollection = $this->config->get('postman.enabled', false);
+        $this->shouldGenerateOpenAPISpec = $this->config->get('openapi.enabled', false);
         $this->pastel = new Pastel();
 
         $this->isStatic = $this->config->get('type') === 'static';
@@ -98,11 +105,15 @@ class Writer
         // For 'laravel' docs, the output files (index.blade.php, collection.json)
         // go in resources/views/scribe/ and storage/app/scribe/ respectively.
 
+        // When running with --no-extraction, $routes will be null.
+        // In that case, we only want to write HTMl docs again, hence the conditionals below
         $routes && $this->writeMarkdownAndSourceFiles($routes);
 
         $this->writeHtmlDocs();
 
         $routes && $this->writePostmanCollection($routes);
+
+        $routes && $this->writeOpenAPISpec($routes);
     }
 
     /**
@@ -174,6 +185,24 @@ class Writer
         }
     }
 
+    protected function writeOpenAPISpec(Collection $parsedRoutes): void
+    {
+        if ($this->shouldGenerateOpenAPISpec) {
+            ConsoleOutputUtils::info('Generating OpenAPI specification');
+
+            $spec = $this->generateOpenAPISpec($parsedRoutes);
+            if ($this->isStatic) {
+                $specPath = "{$this->staticTypeOutputPath}/openapi.yaml";
+                file_put_contents($specPath, $spec);
+            } else {
+                Storage::disk('local')->put('scribe/openapi.yaml', $spec);
+                $specPath = 'storage/app/scribe/openapi.yaml';
+            }
+
+            ConsoleOutputUtils::success("Wrote OpenAPI specification to: {$specPath}");
+        }
+    }
+
     /**
      * Generate Postman collection JSON file.
      *
@@ -192,6 +221,17 @@ class Writer
         return $writer->makePostmanCollection();
     }
 
+    public function generateOpenAPISpec(Collection $groupedEndpoints)
+    {
+        /** @var OpenAPISpecWriter $writer */
+        $writer = app()->makeWith(
+            OpenAPISpecWriter::class,
+            ['config' => $this->config]
+        );
+
+        return Yaml::dump($writer->generateSpecContent($groupedEndpoints), 10, 4, Yaml::DUMP_EMPTY_ARRAY_AS_SEQUENCE | Yaml::DUMP_OBJECT_AS_MAP);
+    }
+
     protected function performFinalTasksForLaravelType(): void
     {
         if (!is_dir($this->laravelTypeOutputPath)) {
@@ -215,6 +255,7 @@ class Writer
         $contents = preg_replace('#href="css/(.+?)"#', 'href="{{ asset("vendor/scribe/css/$1") }}"', $contents);
         $contents = preg_replace('#src="(js|images)/(.+?)"#', 'src="{{ asset("vendor/scribe/$1/$2") }}"', $contents);
         $contents = str_replace('href="./collection.json"', 'href="{{ route("scribe.json") }}"', $contents);
+        $contents = str_replace('href="./openapi.yaml"', 'href="{{ route("scribe.openapi") }}"', $contents);
 
         file_put_contents("$this->laravelTypeOutputPath/index.blade.php", $contents);
     }
@@ -246,8 +287,10 @@ class Writer
 
         $frontmatter = view('scribe::partials.frontmatter')
             ->with('showPostmanCollectionButton', $this->shouldGeneratePostmanCollection)
+            ->with('showOpenAPISpecButton', $this->shouldGenerateOpenAPISpec)
             // This path is wrong for laravel type but will be replaced in post
             ->with('postmanCollectionLink', './collection.json')
+            ->with('openAPISpecLink', './openapi.yaml')
             ->with('outputPath', 'docs')
             ->with('settings', $settings);
 

+ 579 - 0
tests/Fixtures/openapi.yaml

@@ -0,0 +1,579 @@
+openapi: 3.0.3
+info:
+    title: null
+    description: ''
+    version: 1.0.0
+servers:
+    -
+        url: 'http://localhost'
+paths:
+    /api/withDescription:
+        get:
+            summary: 'Example title.'
+            description: "This will be the long description.\nIt can also be multiple lines long."
+            parameters:
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses: {  }
+            tags:
+                - 'Group A'
+            security: []
+    /api/withResponseTag:
+        get:
+            summary: ''
+            description: ''
+            parameters:
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses:
+                200:
+                    description: '200'
+                    content:
+                        application/json:
+                            schema:
+                                type: object
+                                example:
+                                    id: 4
+                                    name: banana
+                                    color: red
+                                    weight: '1 kg'
+                                    delicious: true
+                                    responseTag: true
+                                properties:
+                                    id: { type: integer, example: 4 }
+                                    name: { type: string, example: banana }
+                                    color: { type: string, example: red }
+                                    weight: { type: string, example: '1 kg' }
+                                    delicious: { type: boolean, example: true }
+                                    responseTag: { type: boolean, example: true }
+            tags:
+                - 'Group A'
+            security: []
+    /api/withBodyParameters:
+        post:
+            summary: 'Endpoint with body parameters.'
+            description: ''
+            parameters:
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses: {  }
+            tags:
+                - 'Group A'
+            requestBody:
+                required: true
+                content:
+                    application/json:
+                        schema:
+                            type: object
+                            properties:
+                                user_id:
+                                    type: integer
+                                    description: 'The id of the user.'
+                                    example: 9
+                                room_id:
+                                    type: string
+                                    description: 'The id of the room.'
+                                    example: consequatur
+                                forever:
+                                    type: boolean
+                                    description: 'Whether to ban the user forever.'
+                                    example: false
+                                another_one:
+                                    type: number
+                                    description: 'Just need something here.'
+                                    example: 11613.31890586
+                                yet_another_param:
+                                    type: object
+                                    description: 'Some object params.'
+                                    example: {  }
+                                yet_another_param.name:
+                                    type: string
+                                    description: 'Subkey in the object param.'
+                                    example: consequatur
+                                even_more_param:
+                                    type: array
+                                    description: 'Some array params.'
+                                    example: []
+                                    items: { type: object }
+                                'even_more_param.*':
+                                    type: number
+                                    description: 'Subkey in the array param.'
+                                    example: 11613.31890586
+                                book.name:
+                                    type: string
+                                    description: ''
+                                    example: consequatur
+                                book.author_id:
+                                    type: integer
+                                    description: ''
+                                    example: 17
+                                'book[pages_count]':
+                                    type: integer
+                                    description: ''
+                                    example: 17
+                                'ids.*':
+                                    type: integer
+                                    description: ''
+                                    example: 17
+                                'users.*.first_name':
+                                    type: string
+                                    description: 'The first name of the user.'
+                                    example: John
+                                'users.*.last_name':
+                                    type: string
+                                    description: 'The last name of the user.'
+                                    example: Doe
+                            required:
+                                - user_id
+                                - yet_another_param
+                                - yet_another_param.name
+            security: []
+    /api/withQueryParameters:
+        get:
+            summary: ''
+            description: ''
+            parameters:
+                -
+                    in: query
+                    name: location_id
+                    description: 'The id of the location.'
+                    example: consequatur
+                    required: true
+                    schema:
+                        type: string
+                -
+                    in: query
+                    name: user_id
+                    description: 'The id of the user.'
+                    example: me
+                    required: true
+                    schema:
+                        type: string
+                -
+                    in: query
+                    name: page
+                    description: 'The page number.'
+                    example: '4'
+                    required: true
+                    schema:
+                        type: string
+                -
+                    in: query
+                    name: 'filters.*'
+                    description: 'The filters.'
+                    example: consequatur
+                    required: false
+                    schema:
+                        type: string
+                -
+                    in: query
+                    name: url_encoded
+                    description: 'Used for testing that URL parameters will be URL-encoded where needed.'
+                    example: '+ []&='
+                    required: false
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses: {  }
+            tags:
+                - 'Group A'
+            security: []
+    /api/withAuthTag:
+        get:
+            summary: ''
+            description: ''
+            parameters:
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses: {  }
+            tags:
+                - 'Group A'
+    /api/withEloquentApiResource:
+        get:
+            summary: ''
+            description: ''
+            parameters:
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses:
+                200:
+                    description: ''
+                    content:
+                        application/json:
+                            schema:
+                                type: object
+                                example:
+                                    data: { id: 4, name: 'Tested Again', email: a@b.com }
+                                properties:
+                                    data: { type: object, example: { id: 4, name: 'Tested Again', email: a@b.com } }
+            tags:
+                - 'Group A'
+            security: []
+    /api/withMultipleResponseTagsAndStatusCode:
+        post:
+            summary: ''
+            description: ''
+            parameters:
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses:
+                200:
+                    description: '200'
+                    content:
+                        application/json:
+                            schema:
+                                type: object
+                                example:
+                                    id: 4
+                                    name: banana
+                                    color: red
+                                    weight: '1 kg'
+                                    delicious: true
+                                    multipleResponseTagsAndStatusCodes: true
+                                properties:
+                                    id: { type: integer, example: 4 }
+                                    name: { type: string, example: banana }
+                                    color: { type: string, example: red }
+                                    weight: { type: string, example: '1 kg' }
+                                    delicious: { type: boolean, example: true }
+                                    multipleResponseTagsAndStatusCodes: { type: boolean, example: true }
+                401:
+                    description: '401'
+                    content:
+                        application/json:
+                            schema:
+                                type: object
+                                example:
+                                    message: Unauthorized
+                                properties:
+                                    message: { type: string, example: Unauthorized }
+            tags:
+                - 'Group A'
+            security: []
+    /api/withEloquentApiResourceCollectionClass:
+        get:
+            summary: ''
+            description: ''
+            parameters:
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses:
+                200:
+                    description: ''
+                    content:
+                        application/json:
+                            schema:
+                                type: object
+                                example:
+                                    data: [{ id: 4, name: 'Tested Again', email: a@b.com }, { id: 4, name: 'Tested Again', email: a@b.com }]
+                                    links: { self: link-value }
+                                properties:
+                                    data: { type: array, example: [{ id: 4, name: 'Tested Again', email: a@b.com }, { id: 4, name: 'Tested Again', email: a@b.com }], items: { type: object } }
+                                    links: { type: object, example: { self: link-value } }
+            tags:
+                - Other😎
+            security: []
+    '/api/echoesUrlParameters/{param}-{param2}/{param3}':
+        get:
+            summary: ''
+            description: ''
+            parameters:
+                -
+                    in: query
+                    name: something
+                    description: ''
+                    example: consequatur
+                    required: false
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Authorization
+                    description: ''
+                    example: customAuthToken
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Custom-Header
+                    description: ''
+                    example: NotSoCustom
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Accept
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+                -
+                    in: header
+                    name: Content-Type
+                    description: ''
+                    example: application/json
+                    schema:
+                        type: string
+            responses:
+                200:
+                    description: ''
+                    content:
+                        application/json:
+                            schema:
+                                type: object
+                                example:
+                                    param: '4'
+                                    param2: consequatur
+                                    param3: null
+                                    param4: null
+                                properties:
+                                    param: { type: string, example: '4' }
+                                    param2: { type: string, example: consequatur }
+                                    param3: { type: 'NULL', example: null }
+                                    param4: { type: 'NULL', example: null }
+            tags:
+                - Other😎
+            security: []
+        parameters:
+            -
+                in: path
+                name: param
+                description: ''
+                example: '4'
+                required: true
+                schema:
+                    type: string
+            -
+                in: path
+                name: param2
+                description: 'Optional parameter.'
+                required: true
+                schema:
+                    type: string
+                examples:
+                    omitted:
+                        summary: 'When the value is omitted'
+                        value: ''
+                    present:
+                        summary: 'When the value is present'
+                        value: consequatur
+            -
+                in: path
+                name: param4
+                description: 'Optional parameter.'
+                required: true
+                schema:
+                    type: string
+                examples:
+                    omitted:
+                        summary: 'When the value is omitted'
+                        value: ''

+ 33 - 0
tests/GenerateDocumentationTest.php

@@ -260,6 +260,39 @@ class GenerateDocumentationTest extends TestCase
         $this->assertEquals($fixtureCollection, $generatedCollection);
     }
 
+    /** @test */
+    public function generated_openapi_spec_file_is_correct()
+    {
+        RouteFacade::get('/api/withDescription', [TestController::class, 'withEndpointDescription']);
+        RouteFacade::get('/api/withResponseTag', TestController::class . '@withResponseTag');
+        RouteFacade::post('/api/withBodyParameters', TestController::class . '@withBodyParameters');
+        RouteFacade::get('/api/withQueryParameters', TestController::class . '@withQueryParameters');
+        RouteFacade::get('/api/withAuthTag', TestController::class . '@withAuthenticatedTag');
+        RouteFacade::get('/api/withEloquentApiResource', [TestController::class, 'withEloquentApiResource']);
+        RouteFacade::get('/api/withEloquentApiResourceCollectionClass', [TestController::class, 'withEloquentApiResourceCollectionClass']);
+        RouteFacade::post('/api/withMultipleResponseTagsAndStatusCode', [TestController::class, 'withMultipleResponseTagsAndStatusCode']);
+        RouteFacade::get('/api/echoesUrlParameters/{param}-{param2}/{param3?}', [TestController::class, 'echoesUrlParameters']);
+
+        // We want to have the same values for params each time
+        config(['scribe.faker_seed' => 1234]);
+        config(['scribe.openapi.enabled' => true]);
+        config(['scribe.routes.0.match.prefixes' => ['api/*']]);
+        config([
+            'scribe.routes.0.apply.headers' => [
+                'Authorization' => 'customAuthToken',
+                'Custom-Header' => 'NotSoCustom',
+                'Accept' => 'application/json',
+                'Content-Type' => 'application/json',
+            ],
+        ]);
+
+        $this->artisan('scribe:generate');
+
+        $generatedCollection = json_decode(file_get_contents(__DIR__ . '/../public/docs/openapi.yaml'), true);
+        $fixtureCollection = json_decode(file_get_contents(__DIR__ . '/Fixtures/openapi.yaml'), true);
+        $this->assertEquals($fixtureCollection, $generatedCollection);
+    }
+
     /** @test */
     public function generated_postman_collection_domain_is_correct()
     {

+ 445 - 0
tests/Unit/OpenAPISpecWriterTest.php

@@ -0,0 +1,445 @@
+<?php
+
+namespace Knuckles\Scribe\Tests\Unit;
+
+use DMS\PHPUnitExtensions\ArraySubset\ArraySubsetAsserts;
+use Faker\Factory;
+use Knuckles\Scribe\Tools\DocumentationConfig;
+use Knuckles\Scribe\Writing\OpenAPISpecWriter;
+use Orchestra\Testbench\TestCase;
+
+/**
+ * See https://swagger.io/specification/
+ */
+class OpenAPISpecWriterTest extends TestCase
+{
+    use ArraySubsetAsserts;
+
+    protected $config = [
+        'title' => 'My Testy Testes API',
+        'description' => 'All about testy testes.',
+        'base_url' => 'http://api.api.dev',
+    ];
+
+    /** @test */
+    public function follows_correct_spec_structure()
+    {
+        $fakeRoute1 = $this->createMockRouteData();
+        $fakeRoute2 = $this->createMockRouteData();
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2])->groupBy('metadata.groupName');
+
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($this->config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertEquals(OpenAPISpecWriter::VERSION, $results['openapi']);
+        $this->assertEquals($this->config['title'], $results['info']['title']);
+        $this->assertEquals($this->config['description'], $results['info']['description']);
+        $this->assertNotEmpty($results['info']['version']);
+        $this->assertEquals($this->config['base_url'], $results['servers'][0]['url']);
+        $this->assertIsArray($results['paths']);
+        $this->assertGreaterThan(0, count($results['paths']));
+    }
+
+    /** @test */
+    public function adds_endpoints_correctly_as_operations_under_paths()
+    {
+        $fakeRoute1 = $this->createMockRouteData(['uri' => 'path1', 'methods' => ['GET']]);
+        $fakeRoute2 = $this->createMockRouteData(['uri' => 'path1', 'methods' => ['POST']]);
+        $fakeRoute3 = $this->createMockRouteData(['uri' => 'path1/path2']);
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2, $fakeRoute3])->groupBy('metadata.groupName');
+
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($this->config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertIsArray($results['paths']);
+        $this->assertCount(2, $results['paths']);
+        $this->assertCount(2, $results['paths']['/path1']);
+        $this->assertCount(1, $results['paths']['/path1/path2']);
+        $this->assertArrayHasKey('get', $results['paths']['/path1']);
+        $this->assertArrayHasKey('post', $results['paths']['/path1']);
+        $this->assertArrayHasKey(strtolower($fakeRoute3['methods'][0]), $results['paths']['/path1/path2']);
+
+        collect([$fakeRoute1, $fakeRoute2, $fakeRoute3])->each(function ($endpoint) use ($results) {
+            $method = strtolower($endpoint['methods'][0]);
+            $this->assertEquals([$endpoint['metadata']['groupName']], $results['paths']['/' . $endpoint['uri']][$method]['tags']);
+            $this->assertEquals($endpoint['metadata']['title'], $results['paths']['/' . $endpoint['uri']][$method]['summary']);
+            $this->assertEquals($endpoint['metadata']['description'], $results['paths']['/' . $endpoint['uri']][$method]['description']);
+        });
+    }
+
+    /** @test */
+    public function adds_authentication_details_correctly_as_security_info()
+    {
+        $fakeRoute1 = $this->createMockRouteData(['uri' => 'path1', 'methods' => ['GET'], 'metadata.authenticated' => true]);
+        $fakeRoute2 = $this->createMockRouteData(['uri' => 'path1', 'methods' => ['POST'], 'metadata.authenticated' => false]);
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2])->groupBy('metadata.groupName');
+
+        $config = array_merge($this->config, ['auth' => ['enabled' => true, 'in' => 'bearer']]);
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertCount(1, $results['components']['securitySchemes']);
+        $this->assertArrayHasKey('default', $results['components']['securitySchemes']);
+        $this->assertEquals('http', $results['components']['securitySchemes']['default']['type']);
+        $this->assertEquals('bearer', $results['components']['securitySchemes']['default']['scheme']);
+        $this->assertCount(1, $results['security']);
+        $this->assertCount(1, $results['security'][0]);
+        $this->assertArrayHasKey('default', $results['security'][0]);
+        $this->assertArrayNotHasKey('security', $results['paths']['/path1']['get']);
+        $this->assertArrayHasKey('security', $results['paths']['/path1']['post']);
+        $this->assertCount(0, $results['paths']['/path1']['post']['security']);
+
+        // Next try: auth with a query parameter
+        $config = array_merge($this->config, ['auth' => ['enabled' => true, 'in' => 'query', 'name' => 'token']]);
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertCount(1, $results['components']['securitySchemes']);
+        $this->assertArrayHasKey('default', $results['components']['securitySchemes']);
+        $this->assertEquals('apiKey', $results['components']['securitySchemes']['default']['type']);
+        $this->assertEquals($config['auth']['name'], $results['components']['securitySchemes']['default']['name']);
+        $this->assertEquals('query', $results['components']['securitySchemes']['default']['in']);
+        $this->assertCount(1, $results['security']);
+        $this->assertCount(1, $results['security'][0]);
+        $this->assertArrayHasKey('default', $results['security'][0]);
+        $this->assertArrayNotHasKey('security', $results['paths']['/path1']['get']);
+        $this->assertArrayHasKey('security', $results['paths']['/path1']['post']);
+        $this->assertCount(0, $results['paths']['/path1']['post']['security']);
+    }
+
+    /** @test */
+    public function adds_url_parameters_correctly_as_parameters_on_path_item_object()
+    {
+        $fakeRoute1 = $this->createMockRouteData([
+            'methods' => ['POST'],
+            'uri' => 'path1/{param}/{optionalParam?}',
+            'urlParameters.param' => [
+                'description' => 'Something',
+                'required' => true,
+                'value' => '56',
+            ],
+            'urlParameters.optionalParam' => [
+                'description' => 'Another',
+                'required' => false,
+                'value' => '69',
+            ],
+        ]);
+        $fakeRoute2 = $this->createMockRouteData(['uri' => 'path1', 'methods' => ['POST']]);
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2])->groupBy('metadata.groupName');
+
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($this->config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertArrayNotHasKey('parameters', $results['paths']['/path1']);
+        $this->assertCount(2, $results['paths']['/path1/{param}/{optionalParam}']['parameters']);
+        $this->assertEquals([
+            'in' => 'path',
+            'required' => true,
+            'name' => 'param',
+            'description' => 'Something',
+            'example' => '56',
+            'schema' => ['type' => 'string'],
+        ], $results['paths']['/path1/{param}/{optionalParam}']['parameters'][0]);
+        $this->assertEquals([
+            'in' => 'path',
+            'required' => true,
+            'name' => 'optionalParam',
+            'description' => 'Optional parameter. Another',
+            'examples' => [
+                'omitted' => ['summary' => 'When the value is omitted', 'value' => ''],
+                'present' => [
+                    'summary' => 'When the value is present', 'value' => '69'],
+            ],
+            'schema' => ['type' => 'string'],
+        ], $results['paths']['/path1/{param}/{optionalParam}']['parameters'][1]);
+    }
+
+    /** @test */
+    public function adds_headers_correctly_as_parameters_on_operation_object()
+    {
+        $fakeRoute1 = $this->createMockRouteData(['methods' => ['POST'], 'uri' => 'path1', 'headers.Extra-Header' => 'Some-Value']);
+        $fakeRoute2 = $this->createMockRouteData(['uri' => 'path1', 'methods' => ['GET'], 'headers' => []]);
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2])->groupBy('metadata.groupName');
+
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($this->config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertEquals([], $results['paths']['/path1']['get']['parameters']);
+        $this->assertCount(2, $results['paths']['/path1']['post']['parameters']);
+        $this->assertEquals([
+            'in' => 'header',
+            'name' => 'Content-Type',
+            'description' => '',
+            'example' => 'application/json',
+            'schema' => ['type' => 'string'],
+        ], $results['paths']['/path1']['post']['parameters'][0]);
+        $this->assertEquals([
+            'in' => 'header',
+            'name' => 'Extra-Header',
+            'description' => '',
+            'example' => 'Some-Value',
+            'schema' => ['type' => 'string'],
+        ], $results['paths']['/path1']['post']['parameters'][1]);
+    }
+
+    /** @test */
+    public function adds_query_parameters_correctly_as_parameters_on_operation_object()
+    {
+        $fakeRoute1 = $this->createMockRouteData([
+            'methods' => ['GET'],
+            'uri' => '/path1',
+            'headers' => [], // Emptying headers so it doesn't interfere with parameters object
+            'queryParameters' => [
+                'param' => [
+                    'description' => 'A query param',
+                    'required' => false,
+                    'value' => 'hahoho',
+                ],
+            ],
+        ]);
+        $fakeRoute2 = $this->createMockRouteData(['queryParameters' => [], 'headers' => [], 'methods' => ['POST'], 'uri' => '/path1',]);
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2])->groupBy('metadata.groupName');
+
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($this->config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertEquals([], $results['paths']['/path1']['post']['parameters']);
+        $this->assertArrayHasKey('parameters', $results['paths']['/path1']['get']);
+        $this->assertCount(1, $results['paths']['/path1']['get']['parameters']);
+        $this->assertEquals([
+            'in' => 'query',
+            'required' => false,
+            'name' => 'param',
+            'description' => 'A query param',
+            'example' => 'hahoho',
+            'schema' => ['type' => 'string'],
+        ], $results['paths']['/path1']['get']['parameters'][0]);
+    }
+
+    /** @test */
+    public function adds_body_parameters_correctly_as_requestBody_on_operation_object()
+    {
+        $fakeRoute1 = $this->createMockRouteData([
+            'methods' => ['POST'],
+            'uri' => '/path1',
+            'bodyParameters' => [
+                'stringParam' => [
+                    'description' => 'String param',
+                    'required' => false,
+                    'value' => 'hahoho',
+                    'type' => 'string',
+                ],
+                'integerParam' => [
+                    'description' => 'Integer param',
+                    'required' => true,
+                    'value' => 99,
+                    'type' => 'integer',
+                ],
+                'booleanParam' => [
+                    'description' => 'Boolean param',
+                    'required' => true,
+                    'value' => false,
+                    'type' => 'boolean',
+                ],
+            ],
+        ]);
+        $fakeRoute2 = $this->createMockRouteData(['methods' => ['GET'], 'uri' => '/path1']);
+        $fakeRoute3 = $this->createMockRouteData([
+            'methods' => ['PUT'],
+            'uri' => '/path2',
+            'bodyParameters' => [
+                'fileParam' => [
+                    'description' => 'File param',
+                    'required' => false,
+                    'value' => null,
+                    'type' => 'file',
+                ],
+                'numberParam' => [
+                    'description' => 'Number param',
+                    'required' => false,
+                    'value' => 186.9,
+                    'type' => 'float',
+                ],
+            ],
+        ]);
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2, $fakeRoute3])->groupBy('metadata.groupName');
+
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($this->config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertArrayNotHasKey('requestBody', $results['paths']['/path1']['get']);
+        $this->assertArrayHasKey('requestBody', $results['paths']['/path1']['post']);
+        $this->assertEquals([
+            'required' => true,
+            'content' => [
+                'application/json' => [
+                    'schema' => [
+                        'type' => 'object',
+                        'properties' => [
+                            'stringParam' => [
+                                'description' => 'String param',
+                                'example' => 'hahoho',
+                                'type' => 'string',
+                            ],
+                            'booleanParam' => [
+                                'description' => 'Boolean param',
+                                'example' => false,
+                                'type' => 'boolean',
+                            ],
+                            'integerParam' => [
+                                'description' => 'Integer param',
+                                'example' => 99,
+                                'type' => 'integer',
+                            ],
+                        ],
+                        'required' => [
+                            'integerParam',
+                            'booleanParam',
+                        ],
+                    ],
+                ],
+            ],
+        ], $results['paths']['/path1']['post']['requestBody']);
+        $this->assertEquals([
+            'required' => false,
+            'content' => [
+                'multipart/form-data' => [
+                    'schema' => [
+                        'type' => 'object',
+                        'properties' => [
+                            'fileParam' => [
+                                'description' => 'File param',
+                                'type' => 'string',
+                                'format' => 'binary',
+                            ],
+                            'numberParam' => [
+                                'description' => 'Number param',
+                                'example' => 186.9,
+                                'type' => 'number',
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ], $results['paths']['/path2']['put']['requestBody']);
+    }
+
+    /** @test */
+    public function adds_responses_correctly_as_responses_on_operation_object()
+    {
+        $fakeRoute1 = $this->createMockRouteData([
+            'methods' => ['POST'],
+            'uri' => '/path1',
+            'responses' => [
+                [
+                    'status' => '204',
+                    'description' => 'Successfully updated.',
+                    'content' => '{"this": "should be ignored"}',
+                ],
+                [
+                    'status' => '201',
+                    'description' => '',
+                    'content' => '{"this": "shouldn\'t be ignored", "and this": "too"}',
+                ],
+            ],
+            'responseFields' => [
+                'and this' => [
+                    'type' => 'string',
+                    'description' => 'Parameter description, ha!',
+                ],
+            ],
+        ]);
+        $fakeRoute2 = $this->createMockRouteData([
+            'methods' => ['PUT'],
+            'uri' => '/path2',
+            'responses' => [
+                [
+                    'status' => '200',
+                    'description' => '',
+                    'content' => '<<binary>> The cropped image',
+                ],
+            ],
+        ]);
+        $groupedEndpoints = collect([$fakeRoute1, $fakeRoute2])->groupBy('metadata.groupName');
+
+        $writer = new OpenAPISpecWriter(new DocumentationConfig($this->config));
+        $results = $writer->generateSpecContent($groupedEndpoints);
+
+        $this->assertCount(2, $results['paths']['/path1']['post']['responses']);
+        $this->assertArraySubset([
+            '204' => [
+                'description' => 'Successfully updated.',
+            ],
+            '201' => [
+                'content' => [
+                    'application/json' => [
+                        'schema' => [
+                            'type' => 'object',
+                            'properties' => [
+                                'this' => [
+                                    'example' => "shouldn't be ignored",
+                                    'type' => 'string',
+                                ],
+                                'and this' => [
+                                    'description' => 'Parameter description, ha!',
+                                    'example' => "too",
+                                    'type' => 'string',
+                                ],
+                            ],
+                        ],
+                    ],
+                ],
+            ],
+        ], $results['paths']['/path1']['post']['responses']);
+        $this->assertCount(1, $results['paths']['/path2']['put']['responses']);
+        $this->assertEquals([
+            '200' => [
+                'description' => 'The cropped image',
+                'content' => [
+                    'application/octet-stream' => [
+                        'schema' => [
+                            'type' => 'string',
+                            'format' => 'binary',
+                        ],
+                    ],
+                ],
+            ],
+        ], $results['paths']['/path2']['put']['responses']);
+    }
+
+    protected function createMockRouteData(array $custom = [])
+    {
+        $faker = Factory::create();
+        $data = [
+            'uri' => '/' . $faker->word,
+            'methods' => $faker->randomElements(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], 1),
+            'headers' => [
+                'Content-Type' => 'application/json',
+            ],
+            'metadata' => [
+                'groupDescription' => '',
+                'groupName' => $faker->randomElement(['Endpoints', 'Group A', 'Group B']),
+                'title' => $faker->sentence,
+                'description' => $faker->randomElement([$faker->sentence, '']),
+                'authenticated' => $faker->boolean,
+            ],
+            'urlParameters' => [], // Should be set by caller (along with custom path)
+            'queryParameters' => [],
+            'bodyParameters' => [],
+            'responses' => [
+                [
+                    'status' => 200,
+                    'content' => '{"random": "json"}',
+                    'description' => 'Okayy',
+                ],
+            ],
+            'responseFields' => [],
+        ];
+
+        foreach ($custom as $key => $value) {
+            data_set($data, $key, $value);
+        }
+
+        return $data;
+    }
+}