├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── composer.json ├── config └── config.php ├── sites.json └── src ├── Builders ├── Builder.php ├── InfoBuilder.php ├── Paths │ ├── Operation │ │ ├── ParameterBuilder.php │ │ ├── RequestBodyBuilder.php │ │ ├── ResponseBuilder.php │ │ └── SchemaBuilder.php │ └── OperationBuilder.php ├── PathsBuilder.php └── ServerBuilder.php ├── Commands └── GenerateCommand.php ├── ComponentsContainer.php ├── Concerns └── ResolvesActionTraitToDescriptor.php ├── Contracts ├── DescribesEndpoints.php └── Descriptors │ ├── ActionDescriptor.php │ ├── Descriptor.php │ ├── FilterDescriptor.php │ ├── PolicyDescriptor.php │ ├── RequestDescriptor.php │ ├── ResponseDescriptor.php │ ├── Schema │ ├── PaginationDescriptor.php │ └── SortablesDescriptor.php │ └── SchemaDescriptor.php ├── Descriptors ├── Actions │ ├── ActionDescriptor.php │ ├── Destroy.php │ ├── FetchMany.php │ ├── FetchOne.php │ ├── Relationship │ │ ├── Attach.php │ │ ├── Detach.php │ │ ├── Fetch.php │ │ ├── FetchRelated.php │ │ └── Update.php │ ├── Store.php │ └── Update.php ├── Descriptor.php ├── Requests │ ├── AttachRelationship.php │ ├── DetachRelationship.php │ ├── RequestDescriptor.php │ ├── Store.php │ ├── Update.php │ └── UpdateRelationship.php ├── Responses │ ├── AttachRelationship.php │ ├── Destroy.php │ ├── DetachRelationship.php │ ├── FetchMany.php │ ├── FetchOne.php │ ├── FetchRelated.php │ ├── FetchRelation.php │ ├── ResponseDescriptor.php │ └── UpdateRelationship.php ├── Schema │ ├── Filters │ │ ├── BooleanFilter.php │ │ ├── DefaultDescriptor.php │ │ ├── FilterDescriptor.php │ │ ├── Has.php │ │ ├── Scope.php │ │ ├── Where.php │ │ ├── WhereIdIn.php │ │ ├── WhereIn.php │ │ ├── WhereNull.php │ │ └── WithTrashed.php │ └── Schema.php └── Server.php ├── Facades └── GeneratorFacade.php ├── Generator.php ├── OpenApiGenerator.php ├── OpenApiServiceProvider.php ├── ResourceContainer.php └── Route.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to `openapi-spec-generator` will be documented in this file. 4 | 5 | Updates should follow the [Keep a CHANGELOG](https://keepachangelog.com/) principles. 6 | 7 | ## [Unreleased] 8 | 9 | - Nothing 10 | 11 | ## [0.8.0] - 2025-01-31 12 | 13 | ### Added 14 | - Add support for Laravel JSON:API v5 [#24](https://github.com/swisnl/openapi-spec-generator/pull/24) 15 | 16 | ### Changed 17 | - Dropped PHP 8.0 support. 18 | 19 | ## [0.7.0] - 2024-11-12 20 | 21 | ### Added 22 | - Add support for doc generation for non-eloquent resources [#18](https://github.com/swisnl/openapi-spec-generator/pull/18). 23 | 24 | ### Changed 25 | - Dropped PHP 7 support. 26 | 27 | ### Fixed 28 | - Use filter column name instead of filter key to retrieve example data [#20](https://github.com/swisnl/openapi-spec-generator/pull/20). 29 | 30 | 31 | ## [0.6.1] - 2024-05-15 32 | 33 | ### Added 34 | - Add support for Laravel JSON:API v4 and Laravel 11 [#19](https://github.com/swisnl/openapi-spec-generator/pull/19). 35 | 36 | 37 | ## [0.6.0] - 2023-03-29 38 | 39 | ### Added 40 | - Add support for enums in filter examples. 41 | 42 | ### Changed 43 | - A meaningful exception is thrown when you forget to seed the database [#11](https://github.com/swisnl/openapi-spec-generator/pull/11). 44 | 45 | ### Fixed 46 | - Fall back to a basic descriptor when no custom filter descriptor is found. 47 | - Use field column name to get example value. 48 | 49 | 50 | ## [0.5.1] - 2023-03-07 51 | 52 | ### Added 53 | - Add support for Laravel JSON:API v3 and Laravel 10 [#9](https://github.com/swisnl/openapi-spec-generator/pull/9). 54 | 55 | 56 | ## [0.5.0] - 2023-02-06 57 | 58 | ### Added 59 | - Allow customizing the storage disk to use [#6](https://github.com/swisnl/openapi-spec-generator/pull/6). 60 | - Add support for `Has`, `WhereNull` and `WhereNotNull` filters. 61 | 62 | ### Fixed 63 | - Use correct description for `WherePivotNotIn` filter. 64 | 65 | 66 | ## [0.4.0] - 2022-02-24 67 | 68 | ### Added 69 | - Allow developers to add descriptions to endpoints [#2](https://github.com/swisnl/openapi-spec-generator/pull/2). 70 | 71 | ### Changed 72 | - Require `laravel-json-api/laravel` version 2 [#2](https://github.com/swisnl/openapi-spec-generator/pull/2). 73 | 74 | ### Fixed 75 | - Fix a wrongly generated doc for to many relationships [#1](https://github.com/swisnl/openapi-spec-generator/pull/1). 76 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to make participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at `service@swis.nl`. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are **welcome** and will be fully **credited**. 4 | 5 | We accept contributions via Pull Requests on [GitHub](https://github.com/swisnl/openapi-spec-generator). 6 | 7 | 8 | ## Pull Requests 9 | 10 | - **[PSR-2 Coding Standard](https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md)** - Check the code style with ``$ composer check-style`` and fix it with ``$ composer fix-style``. 11 | 12 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 13 | 14 | - **Document any change in behaviour** - Make sure the `README.md` and any other relevant documentation are kept up-to-date. 15 | 16 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 17 | 18 | - **Create feature branches** - Don't ask us to pull from your master branch. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | ## Running Tests 26 | 27 | ``` bash 28 | $ composer test 29 | ``` 30 | 31 | 32 | **Happy coding**! 33 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2023 SWIS BV 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI v3 Spec Generator 2 | 3 | [![Latest Version on Packagist][ico-version]][link-packagist] 4 | [![Software License][ico-license]](LICENSE.md) 5 | [![Buy us a tree][ico-treeware]][link-treeware] 6 | [![Build Status][ico-github-actions]][link-github-actions] 7 | [![Total Downloads][ico-downloads]][link-downloads] 8 | [![Maintained by SWIS][ico-swis]][link-swis] 9 | 10 | Designed to work with [Laravel JSON:API](https://laraveljsonapi.io/) 11 | 12 | !!! Disclaimer: this project is work in progress and likely contains many bugs, etc !!! 13 | 14 | ## What it can and can't 15 | 16 | ### Can 17 | 18 | - [x] Generate Schemas/Responses/Request/Errors for all default [Laravel JSON:API](https://laraveljsonapi.io/) routes 19 | - [x] Use a seeded database to generate examples 20 | 21 | ### Can't yet 22 | - [ ] Customisation of the generation 23 | - [ ] Generation for custom actions 24 | - [ ] Generation for custom filters 25 | - [ ] Generation for anything custom 26 | - [ ] Generation for MorphTo relations (MorphToMany works) 27 | - [ ] Generation of Pagination Meta 28 | - [ ] Generation of Includes 29 | - [ ] Generation of Authentication/Authorization 30 | 31 | ## TODO 32 | 33 | - [x] Command to generate to storage folder 34 | - [x] Get basic test suite running with GitHub Actions 35 | - [x] Add extra operation descriptions via config 36 | - [x] Add in tags & x-tagGroups (via config) 37 | - [x] Add tests (Use the dummy by laraveljsonapi to integrate all features) 38 | - [ ] Add custom actions 39 | - [x] Split schemas/requests/responses by action 40 | - [ ] Consider field attributes 41 | - [x] bool readonly 42 | - [x] bool hidden 43 | - [ ] closure based readonly (create/update) 44 | - [ ] closure based hidden 45 | - [x] List sortable fields 46 | - [ ] Fix includes and relations 47 | - [x] Add relationship routes 48 | - [ ] Add includes 49 | - [ ] Add authentication 50 | - [ ] Add custom queries/filters 51 | - [ ] Add a way to document custom actions 52 | - [ ] Tidy up the code!! 53 | - [x] Replace `cebe/php-openapi` with `goldspecdigital/oooas` 54 | - [x] Move to an architecture inspired by `vyuldashev/laravel-openapi` 55 | - [ ] Use php8 attributes on actions/classes to generate custom docs 56 | 57 | 🙏 Based upon initial prototype by [martianatwork](https://github.com/martianatwork), [glennjacobs](https://github.com/glennjacobs) and [byte-it](https://github.com/byte-it). 58 | 59 | ## Install 60 | 61 | Via Composer 62 | ``` 63 | composer require swisnl/openapi-spec-generator 64 | ``` 65 | 66 | Publish the config file 67 | 68 | ``` 69 | php artisan vendor:publish --provider="LaravelJsonApi\OpenApiSpec\OpenApiServiceProvider" 70 | ``` 71 | 72 | ## Usage 73 | 74 | Generate the Open API spec 75 | ``` 76 | php artisan jsonapi:openapi:generate v1 77 | ``` 78 | 79 | Note that a seeded DB is required! The seeded data will be used to generate Samples. 80 | 81 | ### Descriptions 82 | 83 | It's possible to add descriptions to your endpoints by implementing the DescribesEndpoints interface. The added method 84 | receives the generated route name as a parameter. This can be used to generate descriptions for all your schema 85 | endpoints. 86 | ``` php 87 | class Post extends Schema implements DescribesEndpoints 88 | { 89 | public function describeEndpoint(string $endpoint) { 90 | if ($endpoint === 'v1.posts.index') { 91 | return 'Description for index method'; 92 | } 93 | 94 | return 'Default description'; 95 | } 96 | } 97 | ``` 98 | 99 | ## Generating Documentation 100 | 101 | ### [Speccy](https://github.com/wework/speccy) 102 | 103 | A quick way to preview your documentation is to use [`speccy serve` command](https://github.com/wework/speccy#serve-command). 104 | 105 | Ensure you have installed Speccy globally and then you can use the following command: 106 | 107 | ```sh 108 | speccy serve storage/app/v1_openapi.yaml 109 | ``` 110 | 111 | > Warning: Seems like [Speccy](https://speccy.io) is abandoned (https://github.com/wework/speccy/issues/485). 112 | 113 | ### [Laravel Stoplight Elements](https://github.com/JustSteveKing/laravel-stoplight-elements) 114 | 115 | Easily publish your API documentation in a local route by using your OpenAPI document in your Laravel Application directly. 116 | 117 | > For this to work, you have to generate your spec in a public-available location, like the local 'public' disk available in Laravel applications: 118 | > ```sh 119 | > OPEN_API_SPEC_GENERATOR_FILESYSTEM_DISK='public' 120 | > ``` 121 | 122 | After [installing it](https://github.com/JustSteveKing/laravel-stoplight-elements#laravel-stoplight-elements), you should set its url config: `STOPLIGHT_OPENAPI_PATH`. For example, if you're using the 'public' disk: 123 | 124 | ```sh 125 | OPEN_API_SPEC_GENERATOR_FILESYSTEM_DISK='public' 126 | 127 | # '/storage' is the default 'public' URL. 128 | STOPLIGHT_OPENAPI_PATH='/storage/v1_openapi.json' 129 | ``` 130 | 131 | > Note: If you need a more dynamic way to get access to the spec URL (for example, in S3 you may need to use [temporary URLs](https://laravel.com/docs/filesystem#temporary-urls)), you can publish its Blade template and [replace some lines ](https://github.com/JustSteveKing/laravel-stoplight-elements/blob/2.0.0/resources/views/docs.blade.php#L14) to generate your own URI. Also, you may need to add an Fetch interceptor to integrate it with your authentication methods. 132 | 133 | With its default route, ¡you just need to access to your `/api/docs` route to preview your specs! :bowtie: 134 | 135 | Check [its configuration docs](https://github.com/JustSteveKing/laravel-stoplight-elements#configuration) for further options. 136 | 137 | ### [Standalone Stoplight Elements Web Component](https://github.com/stoplightio/elements#web-component) 138 | 139 | In addition to previous Laravel package, you can use the Stoplight Elements by yourself. It is available as React Component, or Web Component, making it easier for integrating into existing Content Management Systems with their own navigation. 140 | 141 | This is useful when you need more advanced customizations in the routing system, integrate it in your existing Vue|React|Vanilla application, or publish it as a non-laravel static HTML site. But... you have to setup it manually. :sweat_smile: 142 | 143 | #### Web component integrated in your Vue application 144 | 145 | You can [follow the instructions](https://github.com/stoplightio/elements/blob/main/docs/getting-started/elements/html.md) to use the standalone Web Component, grab it into a blade template and armor your view. 146 | 147 | It has [advanced options](https://github.com/stoplightio/elements/blob/main/docs/getting-started/elements/elements-options.md), like `tryItCredentialPolicy="same-origin"` to use your cookie-based authentication (like [Sanctum](https://github.com/laravel/sanctum/)). 148 | 149 | Also, in your Blade view or Vue's app initializer, as this package uses [Fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) you can add interceptors to customize the "try it out" feature, like adding default headers for `Content-Type` and/or `Accept` to be `'application/vnd.api+json'` to your requests. 150 | 151 | ### Other options 152 | 153 | You can check a more exhaustive list of options available at https://openapi.tools/#documentation 154 | 155 | ## Change log 156 | 157 | Please see [CHANGELOG](CHANGELOG.md) for more information on what has changed recently. 158 | 159 | ## Testing 160 | 161 | ``` bash 162 | $ composer test 163 | ``` 164 | 165 | ## Contributing 166 | 167 | Please see [CONTRIBUTING](CONTRIBUTING.md) and [CODE_OF_CONDUCT](CODE_OF_CONDUCT.md) for details. 168 | 169 | ## Security 170 | 171 | If you discover any security related issues, please email security@swis.nl instead of using the issue tracker. 172 | 173 | ## Credits 174 | 175 | - [Glenn Jacobs](https://github.com/glennjacobs) 176 | - [Johannes Kees](https://github.com/byte-it) 177 | - [Björn Brala](https://github.com/bbrala) 178 | - [Rien van Velzen](https://github.com/Rockheep) 179 | - [All Contributors][link-contributors] 180 | 181 | ## License 182 | 183 | Apache License 2.0. All notable changes to the original work can be found in [CHANGELOG](CHANGELOG.md). Please see [License File](LICENSE.md) for more information. 184 | 185 | This package is [Treeware](https://treeware.earth). If you use it in production, then we ask that you [**buy the world a tree**][link-treeware] to thank us for our work. By contributing to the Treeware forest you’ll be creating employment for local families and restoring wildlife habitats. 186 | 187 | ## SWIS :heart: Open Source 188 | 189 | [SWIS][link-swis] is a web agency from Leiden, the Netherlands. We love working with open source software. 190 | 191 | [ico-version]: https://img.shields.io/packagist/v/swisnl/openapi-spec-generator.svg?style=flat-square 192 | [ico-license]: https://img.shields.io/packagist/l/swisnl/openapi-spec-generator?style=flat-square 193 | [ico-treeware]: https://img.shields.io/badge/Treeware-%F0%9F%8C%B3-lightgreen.svg?style=flat-square 194 | [ico-github-actions]: https://img.shields.io/github/actions/workflow/status/swisnl/openapi-spec-generator/tests.yml?label=tests&branch=master&style=flat-square 195 | [ico-downloads]: https://img.shields.io/packagist/dt/swisnl/openapi-spec-generator.svg?style=flat-square 196 | [ico-swis]: https://img.shields.io/badge/%F0%9F%9A%80-maintained%20by%20SWIS-%230737A9.svg?style=flat-square 197 | 198 | [link-packagist]: https://packagist.org/packages/swisnl/openapi-spec-generator 199 | [link-github-actions]: https://github.com/swisnl/openapi-spec-generator/actions/workflows/tests.yml 200 | [link-downloads]: https://packagist.org/packages/swisnl/openapi-spec-generator 201 | [link-treeware]: https://plant.treeware.earth/swisnl/openapi-spec-generator 202 | [link-contributors]: ../../contributors 203 | [link-swis]: https://www.swis.nl 204 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "swisnl/openapi-spec-generator", 3 | "type": "library", 4 | "description": "Creates Open API spec for a Laravel JSON:API", 5 | "keywords": [ 6 | "swisnl", 7 | "openapi-spec-generator", 8 | "openapi", 9 | "openapi-spec", 10 | "generator", 11 | "laravel", 12 | "json-api" 13 | ], 14 | "homepage": "https://github.com/swisnl/openapi-spec-generator", 15 | "license": "Apache-2.0", 16 | "authors": [ 17 | { 18 | "name": "Glenn Jacobs", 19 | "homepage": "https://github.com/glennjacobs", 20 | "role": "Original author" 21 | }, 22 | { 23 | "name": "Johannes Kees", 24 | "homepage": "https://github.com/byte-it", 25 | "email": "johannes@lets-byte.it" 26 | }, 27 | { 28 | "name": "Björn Brala", 29 | "email": "bjorn@swis.nl", 30 | "homepage": "https://github.com/bbrala" 31 | }, 32 | { 33 | "name": "Rien van Velzen", 34 | "email": "rvanvelzen@swis.nl", 35 | "homepage": "https://github.com/Rockheep", 36 | "role": "Developer" 37 | } 38 | ], 39 | "require": { 40 | "php": "^8.1", 41 | "goldspecdigital/oooas": "^2.8", 42 | "justinrainbow/json-schema": "^5.2", 43 | "laravel-json-api/hashids": "^1.1|^2.0|^3.0", 44 | "symfony/yaml": "^5.3|^6.0|^7.0" 45 | }, 46 | "require-dev": { 47 | "ext-json": "*", 48 | "laravel-json-api/laravel": "^2.0|^3.0|^4.0|^5.0", 49 | "laravel-json-api/non-eloquent": "^2.0|^3.0|^v4.0", 50 | "laravel/pint": "^1.20", 51 | "nesbot/carbon": "^2.63|^3.0", 52 | "orchestra/testbench": "^6.25|^7.21|^8.0|^9.0", 53 | "phpunit/phpunit": "^10.5" 54 | }, 55 | "autoload": { 56 | "psr-4": { 57 | "LaravelJsonApi\\OpenApiSpec\\": "src" 58 | } 59 | }, 60 | "autoload-dev": { 61 | "psr-4": { 62 | "LaravelJsonApi\\OpenApiSpec\\Tests\\": "tests" 63 | } 64 | }, 65 | "scripts": { 66 | "test": "phpunit", 67 | "test-coverage": "phpunit --coverage-html coverage", 68 | "check-style": "pint --test", 69 | "fix-style": "pint" 70 | }, 71 | "extra": { 72 | "laravel": { 73 | "providers": [ 74 | "LaravelJsonApi\\OpenApiSpec\\OpenApiServiceProvider" 75 | ], 76 | "aliases": { 77 | "OpenApiGenerator": "LaravelJsonApi\\OpenApiSpec\\Facades\\GeneratorFacade" 78 | } 79 | } 80 | }, 81 | "config": { 82 | "sort-packages": true 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /config/config.php: -------------------------------------------------------------------------------- 1 | [ 8 | 'v1' => [ 9 | 'info' => [ 10 | 'title' => 'My JSON:API', 11 | 'description' => 'JSON:API built using Laravel', 12 | 'version' => '1.0.0', 13 | ], 14 | ], 15 | ], 16 | 17 | /* 18 | * The storage disk to be used to place the generated `*_openapi.json` or `*_openapi.yaml` file. 19 | * 20 | * For example, if you use 'public' you can access the generated file as public web asset (after run `php artisan storage:link`). 21 | * 22 | * Supported: 'local', 'public' and (probably) any disk available in your filesystems (https://laravel.com/docs/9.x/filesystem#configuration). 23 | * Set it to `null` to use your default disk. 24 | */ 25 | 'filesystem_disk' => env('OPEN_API_SPEC_GENERATOR_FILESYSTEM_DISK', null), 26 | ]; 27 | -------------------------------------------------------------------------------- /sites.json: -------------------------------------------------------------------------------- 1 | { 2 | "example": { 3 | "domain": "johnsmith.com", 4 | "name": "Johns site" 5 | }, 6 | "foo": { 7 | "domain": "foo.bar", 8 | "name": "Foo Bar" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Builders/Builder.php: -------------------------------------------------------------------------------- 1 | generator = $generator; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/Builders/InfoBuilder.php: -------------------------------------------------------------------------------- 1 | generator))->info(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Builders/Paths/Operation/ParameterBuilder.php: -------------------------------------------------------------------------------- 1 | generator); 21 | $parameters = []; 22 | 23 | /* 24 | * Add pagination, filters & sorts 25 | */ 26 | if ($route->action() === 'index') { 27 | $parameters = [ 28 | ...$parameters, 29 | ...$schemaDescriptor->pagination($route), 30 | ...$schemaDescriptor->sortables($route), 31 | ...$schemaDescriptor->filters($route), 32 | ]; 33 | } 34 | 35 | /* 36 | * Add id path parameter 37 | */ 38 | if (isset($route->route()->defaults[\LaravelJsonApi\Laravel\Routing\Route::RESOURCE_ID_NAME])) { 39 | $id = $route->route()->defaults[\LaravelJsonApi\Laravel\Routing\Route::RESOURCE_ID_NAME]; 40 | $examples = collect($this->generator->resources() 41 | ->resources($route->schema()::model())) 42 | ->map(function ($resource) { 43 | $id = $resource->id(); 44 | 45 | return Example::create($id)->value($id); 46 | })->toArray(); 47 | 48 | $parameters[] = Parameter::path($id) 49 | ->name($id) 50 | ->required(true) 51 | ->allowEmptyValue(false) 52 | ->examples(...$examples) 53 | ->schema(OASchema::string()); 54 | } 55 | 56 | return $parameters; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Builders/Paths/Operation/RequestBodyBuilder.php: -------------------------------------------------------------------------------- 1 | Descriptors\Requests\Store::class, 22 | Controllers\Actions\Update::class => Descriptors\Requests\Update::class, 23 | Controllers\Actions\AttachRelationship::class => Descriptors\Requests\AttachRelationship::class, 24 | Controllers\Actions\DetachRelationship::class => Descriptors\Requests\DetachRelationship::class, 25 | Controllers\Actions\UpdateRelationship::class => Descriptors\Requests\UpdateRelationship::class, 26 | ]; 27 | 28 | public function __construct( 29 | Generator $generator, 30 | SchemaBuilder $schemaBuilder, 31 | ) { 32 | parent::__construct($generator); 33 | $this->schemaBuilder = $schemaBuilder; 34 | } 35 | 36 | public function build(Route $route): ?RequestBody 37 | { 38 | return $this->getDescriptor($route) !== null ? $this->getDescriptor($route)->request() : null; 39 | } 40 | 41 | /** 42 | * @return Descriptors\Actions\ActionDescriptor|null 43 | */ 44 | protected function getDescriptor(Route $route): ?RequestDescriptor 45 | { 46 | $class = $this->descriptorClass($route); 47 | if (isset($this->descriptors[$class])) { 48 | return new $this->descriptors[$class]( 49 | $this->generator, 50 | $route, 51 | $this->schemaBuilder 52 | ); 53 | } 54 | 55 | return null; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Builders/Paths/Operation/ResponseBuilder.php: -------------------------------------------------------------------------------- 1 | Responses\FetchMany::class, 34 | Actions\FetchOne::class => Responses\FetchOne::class, 35 | Actions\Store::class => Responses\FetchOne::class, 36 | Actions\Update::class => Responses\FetchOne::class, 37 | Actions\Destroy::class => Responses\Destroy::class, 38 | Actions\FetchRelated::class => Responses\FetchRelated::class, 39 | Actions\AttachRelationship::class => Responses\AttachRelationship::class, 40 | Actions\DetachRelationship::class => Responses\DetachRelationship::class, 41 | Actions\FetchRelationship::class => Responses\FetchRelation::class, 42 | Actions\UpdateRelationship::class => Responses\UpdateRelationship::class, 43 | ]; 44 | 45 | public function __construct( 46 | Generator $generator, 47 | ComponentsContainer $components, 48 | SchemaBuilder $schemaBuilder, 49 | ) { 50 | parent::__construct($generator); 51 | $this->components = $components; 52 | $this->schemaBuilder = $schemaBuilder; 53 | 54 | $this->addDefaults(); 55 | } 56 | 57 | /** 58 | * @return \GoldSpecDigital\ObjectOrientedOAS\Objects\Response[] 59 | */ 60 | public function build(Route $route): array 61 | { 62 | return $this->getDescriptor($route)->response(); 63 | } 64 | 65 | /** 66 | * @param Schema $data 67 | * 68 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 69 | */ 70 | public static function buildResponse( 71 | SchemaContract $data, 72 | ?Schema $meta = null, 73 | ?Schema $links = null, 74 | ): Schema { 75 | $jsonapi = Schema::object('jsonapi') 76 | ->properties(Schema::string('version') 77 | ->title('version') 78 | ->example('1.0') 79 | ); 80 | 81 | $schemas = collect([$jsonapi, $data, $meta, $links]) 82 | ->whereNotNull()->toArray(); 83 | 84 | return Schema::object() 85 | ->properties(...$schemas) 86 | ->required('jsonapi', 'data'); 87 | } 88 | 89 | /** 90 | * @return \LaravelJsonApi\OpenApiSpec\Descriptors\Actions\ActionDescriptor|null 91 | */ 92 | protected function getDescriptor(Route $route): ?Responses\ResponseDescriptor 93 | { 94 | $class = $this->descriptorClass($route); 95 | if (isset($this->descriptors[$class])) { 96 | return new $this->descriptors[$class]( 97 | $this->generator, 98 | $route, 99 | $this->schemaBuilder, 100 | $this->defaults, 101 | ); 102 | } 103 | 104 | return null; 105 | } 106 | 107 | /** 108 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 109 | */ 110 | protected function addDefaults(): void 111 | { 112 | $this->jsonapi = $this->components->addSchema( 113 | Schema::object('helper.jsonapi') 114 | ->title('Helper/JSONAPI') 115 | ->properties(Schema::string('version') 116 | ->title('version') 117 | ->example('1.0') 118 | ) 119 | ->required('version') 120 | ); 121 | 122 | $errors = $this->components->addSchema( 123 | Schema::array('helper.errors') 124 | ->title('Helper/Errors') 125 | ->items(Schema::object('error') 126 | ->title('Error') 127 | ->properties( 128 | Schema::string('detail'), 129 | Schema::string('status'), 130 | Schema::string('title'), 131 | Schema::object('source') 132 | ->properties(Schema::string('pointer')) 133 | ) 134 | ->required('status', 'title') 135 | ) 136 | ); 137 | $errorBody = Schema::object()->properties($this->jsonapi, $errors); 138 | $this->defaults = collect([ 139 | Response::badRequest('400') 140 | ->description('Bad request') 141 | ->content( 142 | MediaType::create() 143 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 144 | ->schema($errorBody) 145 | ->examples(Example::create('-')->value([ 146 | 'jsonapi' => [ 147 | 'version' => '1.0', 148 | ], 149 | 'errors' => [ 150 | [ 151 | 'detail' => 'The member id is required.', 152 | 'source' => ['pointer' => '/data'], 153 | 'status' => '400', 154 | 'title' => 'Non-Compliant JSON:API Document', 155 | ], 156 | ], 157 | ]) 158 | ) 159 | ), 160 | Response::unauthorized('401') 161 | ->description('Unauthorized Action') 162 | ->content( 163 | MediaType::create() 164 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 165 | ->schema($errorBody) 166 | ->examples(Example::create('-')->value([ 167 | 'jsonapi' => [ 168 | 'version' => '1.0', 169 | ], 170 | 'errors' => [ 171 | [ 172 | 'title' => 'Unauthorized.', 173 | 'status' => '401', 174 | 'detail' => 'Unauthenticated.', 175 | ], 176 | ], 177 | ]) 178 | ) 179 | ), 180 | Response::notFound('404') 181 | ->description('Content Not Found') 182 | ->content( 183 | MediaType::create() 184 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 185 | ->schema($errorBody) 186 | ->examples(Example::create('-')->value([ 187 | 'jsonapi' => [ 188 | 'version' => '1.0', 189 | ], 190 | 'errors' => [ 191 | [ 192 | 'title' => 'Not Found', 193 | 'status' => '404', 194 | ], 195 | ], 196 | ]) 197 | ) 198 | ), 199 | Response::unprocessableEntity('422') 200 | ->statusCode(422) 201 | ->description('Unprocessable Entity') 202 | ->content( 203 | MediaType::create() 204 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 205 | ->schema($errorBody) 206 | ->examples(Example::create('-')->value([ 207 | 'jsonapi' => [ 208 | 'version' => '1.0', 209 | ], 210 | 'errors' => [ 211 | [ 212 | 'detail' => 'Lorem Ipsum', 213 | 'source' => ['pointer' => '/data/attributes/lorem'], 214 | 'title' => 'Unprocessable Entity', 215 | 'status' => '422', 216 | ], 217 | ], 218 | ]) 219 | ) 220 | ), 221 | ]) 222 | ->mapWithKeys(function (Response $response) { 223 | $ref = $this->components->addResponse($response); 224 | 225 | return [$response->objectId => $ref]; 226 | }); 227 | } 228 | } 229 | -------------------------------------------------------------------------------- /src/Builders/Paths/Operation/SchemaBuilder.php: -------------------------------------------------------------------------------- 1 | components = $components; 26 | } 27 | 28 | /** 29 | * @return \GoldSpecDigital\ObjectOrientedOAS\Objects\Schema 30 | * 31 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 32 | * 33 | * @todo Use a schema descriptor container (Container should allow customs 34 | * via attribute) 35 | */ 36 | public function build(Route $route, bool $isRequest = false): SchemaContract 37 | { 38 | $objectId = self::objectId($route, $isRequest); 39 | 40 | if ($data = $this->components->getSchema($objectId)) { 41 | return $data; 42 | } 43 | 44 | $descriptor = new SchemaDescriptor($this->generator); 45 | 46 | if ($isRequest) { 47 | $schema = $this->buildRequestSchema($route, $descriptor, $objectId); 48 | } else { 49 | $schema = $this->buildResponseSchema($route, $descriptor, $objectId); 50 | } 51 | 52 | return $this->components->addSchema($schema); 53 | } 54 | 55 | /** 56 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 57 | */ 58 | protected function buildResponseSchema( 59 | Route $route, 60 | SchemaDescriptorContract $descriptor, 61 | string $objectId, 62 | ): SchemaContract { 63 | $method = $route->action(); 64 | 65 | if ($data = $this->components->getSchema($objectId)) { 66 | return $data; 67 | } 68 | 69 | if ($method === 'showRelated' && $route->isPolymorphic()) { 70 | $schemas = collect($route->inversSchemas()) 71 | ->map(function (JASchema $schema, string $name) use ($descriptor) { 72 | $objectId = "resources.$name.resource.fetch"; 73 | if ($data = $this->components->getSchema($objectId)) { 74 | return $data; 75 | } 76 | 77 | return $this->components->addSchema( 78 | $descriptor->fetch( 79 | $schema, 80 | $objectId, 81 | $name, 82 | ucfirst(Str::singular($name)) 83 | ) 84 | ); 85 | }); 86 | 87 | return OneOf::create($objectId) 88 | ->schemas(...array_values($schemas->toArray())); 89 | } 90 | 91 | if ($method !== 'showRelated' && $route->isRelation()) { 92 | $schema = $descriptor->fetchRelationship($route); 93 | } else { 94 | switch ($method) { 95 | case 'index': 96 | case 'show': 97 | case 'store': 98 | case 'update': 99 | $schema = $descriptor->fetch( 100 | $route->schema(), 101 | $objectId, 102 | $route->resource(), 103 | $route->name(true) 104 | ); 105 | break; 106 | case 'showRelated': 107 | $schema = $descriptor->fetch( 108 | $route->inversSchema(), 109 | $objectId, 110 | $route->relation() !== null ? $route->relation()->inverse() : null, 111 | $route->inverseName(true) 112 | ); 113 | break; 114 | default: 115 | exit($method); // @todo Add proper Exception 116 | } 117 | } 118 | 119 | return $schema->objectId($objectId); 120 | } 121 | 122 | /** 123 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 124 | */ 125 | protected function buildRequestSchema( 126 | Route $route, 127 | SchemaDescriptorContract $descriptor, 128 | string $objectId, 129 | ): SchemaContract { 130 | $method = $route->action(); 131 | if ($route->isRelation()) { 132 | switch ($method) { 133 | case 'update': 134 | $schema = $descriptor->updateRelationship($route); 135 | break; 136 | case 'attach': 137 | $schema = $descriptor->attachRelationship($route); 138 | break; 139 | case 'detach': 140 | $schema = $descriptor->detachRelationship($route); 141 | break; 142 | default: 143 | exit('Request '.$method); // @todo Add proper Exception 144 | } 145 | } else { 146 | switch ($method) { 147 | case 'store': 148 | $schema = $descriptor->store($route); 149 | break; 150 | case 'update': 151 | $schema = $descriptor->update($route); 152 | break; 153 | default: 154 | exit('Request '.$method); // @todo Add proper Exception 155 | } 156 | } 157 | 158 | return $schema->objectId($objectId); 159 | } 160 | 161 | public static function objectId( 162 | Route $route, 163 | bool $isRequest = false, 164 | ): string { 165 | if ($isRequest) { 166 | $method = $route->action(); 167 | 168 | $resource = $route->resource(); 169 | } else { 170 | switch ($route->action()) { 171 | case 'index': 172 | case 'show': 173 | case 'showRelated': 174 | case 'store': 175 | case 'update': 176 | case 'attach': 177 | case 'detach': 178 | $method = 'fetch'; 179 | break; 180 | default: 181 | $method = $route->action(); 182 | } 183 | 184 | $resource = $route->action() === 'showRelated' ? $route->relation()->inverse() : $route->resource(); 185 | } 186 | 187 | if ($route->isPolymorphic() && $route->action() === 'showRelated') { 188 | $resource = $route->resource(); 189 | $type = "related.{$route->relationName()}"; 190 | } else { 191 | $type = $route->isRelation() && $route->action() !== 'showRelated' ? 192 | "relationship.{$route->relationName()}" : 'resource'; 193 | } 194 | 195 | return "resources.$resource.$type.$method"; 196 | } 197 | } 198 | -------------------------------------------------------------------------------- /src/Builders/Paths/OperationBuilder.php: -------------------------------------------------------------------------------- 1 | Descriptors\Actions\FetchMany::class, 35 | Controllers\Actions\FetchOne::class => Descriptors\Actions\FetchOne::class, 36 | Controllers\Actions\Store::class => Descriptors\Actions\Store::class, 37 | Controllers\Actions\Update::class => Descriptors\Actions\Update::class, 38 | Controllers\Actions\Destroy::class => Descriptors\Actions\Destroy::class, 39 | Controllers\Actions\FetchRelated::class => Descriptors\Actions\Relationship\FetchRelated::class, 40 | Controllers\Actions\AttachRelationship::class => Descriptors\Actions\Relationship\Attach::class, 41 | Controllers\Actions\DetachRelationship::class => Descriptors\Actions\Relationship\Detach::class, 42 | Controllers\Actions\FetchRelationship::class => Descriptors\Actions\Relationship\Fetch::class, 43 | Controllers\Actions\UpdateRelationship::class => Descriptors\Actions\Relationship\Update::class, 44 | ]; 45 | 46 | public function __construct( 47 | Generator $generator, 48 | ComponentsContainer $components, 49 | ) { 50 | parent::__construct($generator); 51 | $this->components = $components; 52 | 53 | $this->schemaBuilder = new SchemaBuilder($generator, $components); 54 | $this->parameterBuilder = new ParameterBuilder($generator); 55 | $this->requestBodyBuilder = new RequestBodyBuilder($generator, 56 | $this->schemaBuilder); 57 | $this->responseBuilder = new ResponseBuilder($generator, $components, 58 | $this->schemaBuilder); 59 | } 60 | 61 | public function build(SpecRoute $route): ?Operation 62 | { 63 | return $this->getDescriptor($route) !== null ? $this->getDescriptor($route)->action() : null; 64 | } 65 | 66 | protected function getDescriptor(Route $route): ?Descriptors\Actions\ActionDescriptor 67 | { 68 | $class = $this->descriptorClass($route); 69 | if (isset($this->descriptors[$class])) { 70 | return new $this->descriptors[$class]( 71 | $this->parameterBuilder, 72 | $this->requestBodyBuilder, 73 | $this->responseBuilder, 74 | $this->generator, 75 | $route 76 | ); 77 | } 78 | 79 | return null; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Builders/PathsBuilder.php: -------------------------------------------------------------------------------- 1 | components = $components; 26 | $this->operation = new OperationBuilder($generator, $components); 27 | } 28 | 29 | /** 30 | * @return \GoldSpecDigital\ObjectOrientedOAS\Objects\PathItem[] 31 | */ 32 | public function build(): array 33 | { 34 | return collect(Route::getRoutes()->getRoutes()) 35 | ->filter( 36 | fn (IlluminateRoute $route) => SpecRoute::belongsTo($route, 37 | $this->generator->server()) 38 | ) 39 | ->map(fn (IlluminateRoute $route) => new SpecRoute($this->generator->server(), $route)) 40 | ->mapToGroups(function (SpecRoute $route) { 41 | return [$route->uri() => $route]; 42 | }) 43 | ->map(function (Collection $routes, string $uri) { 44 | $operations = $routes 45 | ->map(function (SpecRoute $route) { 46 | return $this->operation->build($route); 47 | }) 48 | ->filter(fn ($val) => $val !== null); 49 | 50 | if ($operations->isEmpty()) { 51 | return null; 52 | } 53 | 54 | return PathItem::create() 55 | ->route($uri) 56 | ->operations(...$operations->toArray()); 57 | }) 58 | ->filter(fn ($val) => $val !== null) 59 | ->toArray(); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Builders/ServerBuilder.php: -------------------------------------------------------------------------------- 1 | generator))->servers(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Commands/GenerateCommand.php: -------------------------------------------------------------------------------- 1 | argument('serverKey'); 34 | $format = $this->argument('format'); 35 | 36 | $this->info('Generating Open API spec...'); 37 | try { 38 | GeneratorFacade::generate($serverKey, $format); 39 | } catch (ValidationException $exception) { 40 | $this->error('Validation failed'); 41 | $this->line('Errors:'); 42 | collect($exception->getErrors()) 43 | ->map(function ($val) { 44 | return collect($val)->map(function ($val, $key) { 45 | return sprintf('%s: %s', ucfirst($key), $val); 46 | })->join("\n"); 47 | })->each(function ($string) { 48 | $this->line($string); 49 | $this->line("\n"); 50 | }); 51 | 52 | return 1; 53 | } 54 | 55 | /** @var \Illuminate\Filesystem\FilesystemAdapter $storageDisk */ 56 | $storageDisk = Storage::disk(config('openapi.filesystem_disk')); 57 | 58 | $fileName = $serverKey.'_openapi.'.$format; 59 | $filePath = str_replace(base_path().'/', '', $storageDisk->path($fileName)); 60 | 61 | $this->line('Complete! '.$filePath); 62 | $this->newLine(); 63 | $this->line('Run the following to see your API docs'); 64 | $this->info('speccy serve '.$filePath); 65 | $this->newLine(); 66 | 67 | return 0; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/ComponentsContainer.php: -------------------------------------------------------------------------------- 1 | schemas[$schema->objectId] = $schema; 31 | 32 | return $this->ref($schema); 33 | } 34 | 35 | public function getSchema(string $objectId): ?Schema 36 | { 37 | return isset($this->schemas[$objectId]) ? $this->ref($this->schemas[$objectId]) : null; 38 | } 39 | 40 | public function addRequestBody(RequestBody $requestBody): BaseObject 41 | { 42 | $this->requestBodies[$requestBody->objectId] = $requestBody; 43 | 44 | return $this->ref($requestBody); 45 | } 46 | 47 | public function getRequestBody(string $objectId): ?BaseObject 48 | { 49 | return $this->requestBodies[$objectId] ?? null; 50 | } 51 | 52 | public function addParameter(Parameter $parameter): BaseObject 53 | { 54 | $this->parameters[$parameter->objectId] = $parameter; 55 | 56 | return $this->ref($parameter); 57 | } 58 | 59 | public function getParameter(string $objectId): ?BaseObject 60 | { 61 | return $this->parameters[$objectId] ?? null; 62 | } 63 | 64 | public function addResponse(Response $response): BaseObject 65 | { 66 | $this->responses[$response->objectId] = $response; 67 | 68 | return Response::ref('#/components/responses/'.$response->objectId, 69 | $response->objectId)->statusCode($response->statusCode); 70 | } 71 | 72 | public function getResponse(string $objectId): ?BaseObject 73 | { 74 | return $this->responses[$objectId] ?? null; 75 | } 76 | 77 | public function components(): Components 78 | { 79 | $schemas = collect($this->schemas) 80 | ->sortBy(fn (BaseObject $schema) => $schema->objectId) 81 | ->toArray(); 82 | 83 | return Components::create() 84 | ->responses(...$this->responses) 85 | ->parameters(...$this->parameters) 86 | ->requestBodies(...$this->requestBodies) 87 | ->schemas(...array_values($schemas)); 88 | } 89 | 90 | /** 91 | * @return mixed 92 | */ 93 | protected function ref(BaseObject $object): BaseObject 94 | { 95 | switch (true) { 96 | case $object instanceof Parameter: 97 | $baseRef = '#/components/parameters/'; 98 | break; 99 | case $object instanceof RequestBody: 100 | $baseRef = '#/components/requestBodies/'; 101 | break; 102 | case $object instanceof Response: 103 | $baseRef = '#/components/responses/'; 104 | break; 105 | case $object instanceof SchemaContract: 106 | $baseRef = '#/components/schemas/'; 107 | break; 108 | case $object instanceof SecurityScheme: 109 | $baseRef = '#/components/securitySchemes/'; 110 | break; 111 | default: 112 | exit(get_class($object)); 113 | } 114 | 115 | return $object::ref($baseRef.$object->objectId, 116 | $object->objectId); 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /src/Concerns/ResolvesActionTraitToDescriptor.php: -------------------------------------------------------------------------------- 1 | controllerCallable(); 15 | try { 16 | $reflection = new \ReflectionClass($class); 17 | $methodReflection = $reflection->getMethod($method); 18 | 19 | if ($methodReflection->getDeclaringClass()->name !== $reflection->name) { 20 | $reflection = $methodReflection->getDeclaringClass(); 21 | } 22 | $traitMethod = collect($reflection->getTraits()) 23 | ->map(function (\ReflectionClass $trait) { 24 | return $trait->getMethods(); 25 | }) 26 | ->flatten() 27 | ->mapWithKeys( 28 | fn (\ReflectionMethod $method) => [$method->name => $method]) 29 | ->get($method); 30 | } catch (\ReflectionException $exception) { 31 | return null; 32 | } 33 | 34 | return $traitMethod !== null ? $traitMethod->getDeclaringClass()->name : null; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Contracts/DescribesEndpoints.php: -------------------------------------------------------------------------------- 1 | parameterBuilder = $parameterBuilder; 38 | $this->requestBodyBuilder = $requestBodyBuilder; 39 | $this->responseBuilder = $responseBuilder; 40 | 41 | $this->server = $generator->server(); 42 | $this->route = $route; 43 | } 44 | 45 | /** 46 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 47 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 48 | */ 49 | public function action(): Operation 50 | { 51 | switch ($this->route->method()) { 52 | case 'POST': 53 | $operation = Operation::post(); 54 | break; 55 | case 'PATCH': 56 | $operation = Operation::patch(); 57 | break; 58 | case 'DELETE': 59 | $operation = Operation::delete(); 60 | break; 61 | case 'GET': 62 | default: 63 | $operation = Operation::get(); 64 | break; 65 | } 66 | 67 | return $operation 68 | ->operationId($this->route->id()) 69 | ->responses(...$this->responses()) 70 | ->parameters(...$this->parameters()) 71 | ->requestBody($this->requestBody()) 72 | ->description($this->description()) 73 | ->summary($this->summary()) 74 | ->tags(...$this->tags()); 75 | } 76 | 77 | protected function description(): string 78 | { 79 | /** @var DescribesEndpoints $schema */ 80 | $schema = $this->route->schema(); 81 | 82 | if (! $schema instanceof DescribesEndpoints) { 83 | return ''; 84 | } 85 | 86 | return $schema->describeEndpoint($this->route->route()->getName()); 87 | } 88 | 89 | protected function summary(): string 90 | { 91 | return ''; 92 | } 93 | 94 | /** 95 | * @return string[] 96 | */ 97 | protected function tags(): array 98 | { 99 | return [ucfirst($this->route->name())]; 100 | } 101 | 102 | /** 103 | * @return Parameter[] 104 | */ 105 | protected function parameters(): array 106 | { 107 | return $this->parameterBuilder->build($this->route); 108 | } 109 | 110 | /** 111 | * @return Response[] 112 | */ 113 | protected function responses(): array 114 | { 115 | return $this->responseBuilder->build($this->route); 116 | } 117 | 118 | protected function requestBody(): ?RequestBody 119 | { 120 | return $this->requestBodyBuilder->build($this->route); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Destroy.php: -------------------------------------------------------------------------------- 1 | route->name(true)}"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/FetchMany.php: -------------------------------------------------------------------------------- 1 | route->name()}"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/FetchOne.php: -------------------------------------------------------------------------------- 1 | route->name(true)}"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Relationship/Attach.php: -------------------------------------------------------------------------------- 1 | route->relationName()} relation"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Relationship/Detach.php: -------------------------------------------------------------------------------- 1 | route->relationName()} relation"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Relationship/Fetch.php: -------------------------------------------------------------------------------- 1 | route->relationName()} relation"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Relationship/FetchRelated.php: -------------------------------------------------------------------------------- 1 | route->relationName()}"; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Relationship/Update.php: -------------------------------------------------------------------------------- 1 | route->relationName()} relation"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Store.php: -------------------------------------------------------------------------------- 1 | route->name(true)}"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Actions/Update.php: -------------------------------------------------------------------------------- 1 | route->name(true)}"; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Descriptor.php: -------------------------------------------------------------------------------- 1 | generator = $generator; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/Descriptors/Requests/AttachRelationship.php: -------------------------------------------------------------------------------- 1 | route = $route; 35 | $this->components = $this->generator->components(); 36 | $this->schemaBuilder = $schemaBuilder; 37 | } 38 | 39 | public function request(): RequestBody 40 | { 41 | return RequestBody::create() 42 | ->content( 43 | MediaType::create() 44 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 45 | ->schema( 46 | Schema::object()->properties( 47 | $this->schemaBuilder->build($this->route, true) 48 | ->objectId('data') 49 | ) 50 | ->required('data') 51 | ) 52 | ); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/Descriptors/Requests/Store.php: -------------------------------------------------------------------------------- 1 | content( 21 | MediaType::create() 22 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 23 | ->schema( 24 | Schema::object()->properties( 25 | $this->schemaBuilder->build($this->route, true)->objectId('data') 26 | ) 27 | ->required('data') 28 | ) 29 | ); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Descriptors/Requests/Update.php: -------------------------------------------------------------------------------- 1 | content( 23 | MediaType::create() 24 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 25 | ->schema( 26 | Schema::object()->properties( 27 | $this->schemaBuilder->build($this->route, true) 28 | ->objectId('data') 29 | ) 30 | ->required('data') 31 | ) 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Descriptors/Requests/UpdateRelationship.php: -------------------------------------------------------------------------------- 1 | ok(), 23 | ...$this->defaults(), 24 | ]; 25 | } 26 | 27 | /** 28 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 29 | */ 30 | protected function data(): Schema 31 | { 32 | if ($this->route->relation() instanceof ToMany) { 33 | return Schema::array('data') 34 | ->items($this->schemaBuilder->build($this->route)); 35 | } 36 | 37 | return $this->schemaBuilder->build($this->route)->objectId('data'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/Destroy.php: -------------------------------------------------------------------------------- 1 | noContent(), 18 | ...$this->defaults(), 19 | ]; 20 | } 21 | 22 | /** 23 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 24 | */ 25 | protected function data(): Schema 26 | { 27 | return $this->schemaBuilder->build($this->route); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/DetachRelationship.php: -------------------------------------------------------------------------------- 1 | ok(), 21 | ...$this->defaults(), 22 | ]; 23 | } 24 | 25 | /** 26 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 27 | */ 28 | protected function data(): Schema 29 | { 30 | if ($this->route->relation() instanceof ToMany) { 31 | return Schema::array('data') 32 | ->items($this->schemaBuilder->build($this->route)); 33 | } 34 | 35 | return $this->schemaBuilder->build($this->route)->objectId('data'); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/FetchMany.php: -------------------------------------------------------------------------------- 1 | ok(), 18 | ...$this->defaults(), 19 | ]; 20 | } 21 | 22 | /** 23 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 24 | */ 25 | protected function data(): Schema 26 | { 27 | return Schema::array('data') 28 | ->items($this->schemaBuilder->build($this->route)); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/FetchOne.php: -------------------------------------------------------------------------------- 1 | ok(), 20 | ...$this->defaults(), 21 | ]; 22 | } 23 | 24 | /** 25 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 26 | */ 27 | protected function data(): Schema 28 | { 29 | return $this->schemaBuilder->build($this->route)->objectId('data'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/FetchRelated.php: -------------------------------------------------------------------------------- 1 | ok(), 22 | ...$this->defaults(), 23 | ]; 24 | } 25 | 26 | /** 27 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 28 | */ 29 | protected function data(): SchemaContract 30 | { 31 | if ($this->route->relation() instanceof ToMany) { 32 | return Schema::array('data') 33 | ->items($this->schemaBuilder->build($this->route)); 34 | } 35 | 36 | return $this->schemaBuilder->build($this->route)->objectId('data'); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/FetchRelation.php: -------------------------------------------------------------------------------- 1 | ok(), 24 | ...$this->defaults(), 25 | ]; 26 | } 27 | 28 | /** 29 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 30 | */ 31 | protected function data(): Schema 32 | { 33 | if ($this->route->relation() instanceof ToMany) { 34 | return Schema::array('data') 35 | ->items($this->schemaBuilder->build($this->route)); 36 | } 37 | 38 | return $this->schemaBuilder->build($this->route)->objectId('data'); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/ResponseDescriptor.php: -------------------------------------------------------------------------------- 1 | route = $route; 41 | $this->components = $this->generator->components(); 42 | $this->schemaBuilder = $schemaBuilder; 43 | $this->defaults = $defaults; 44 | } 45 | 46 | /** 47 | * @return \GoldSpecDigital\ObjectOrientedOAS\Objects\Response[] 48 | */ 49 | abstract public function response(): array; 50 | 51 | /** 52 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 53 | */ 54 | protected function ok(): Response 55 | { 56 | return Response::ok() 57 | ->description($this->description()) 58 | ->content( 59 | MediaType::create() 60 | ->mediaType(MediaTypeInterface::JSON_API_MEDIA_TYPE) 61 | ->schema(ResponseBuilder::buildResponse($this->data(), 62 | $this->meta(), $this->links())) 63 | ); 64 | } 65 | 66 | protected function noContent(): Response 67 | { 68 | return Response::create()->statusCode(204)->description('No Content'); 69 | } 70 | 71 | protected function defaults(): array 72 | { 73 | $except = []; 74 | if (! $this->hasId) { 75 | $except[] = '404'; 76 | } 77 | if (! $this->validates) { 78 | $except[] = '422'; 79 | } 80 | 81 | return $this->defaults->except($except)->toArray(); 82 | } 83 | 84 | protected function description(): string 85 | { 86 | return ucfirst($this->route->action()).' '.$this->route->name(); 87 | } 88 | 89 | abstract protected function data(): SchemaContract; 90 | 91 | protected function meta(): ?Schema 92 | { 93 | return null; 94 | } 95 | 96 | protected function links(): ?Schema 97 | { 98 | return null; 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/Descriptors/Responses/UpdateRelationship.php: -------------------------------------------------------------------------------- 1 | ok(), 23 | ...$this->defaults(), 24 | ]; 25 | } 26 | 27 | /** 28 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 29 | */ 30 | protected function data(): Schema 31 | { 32 | if ($this->route->relation() instanceof ToMany) { 33 | return Schema::array('data') 34 | ->items($this->schemaBuilder->build($this->route)); 35 | } 36 | 37 | return $this->schemaBuilder->build($this->route)->objectId('data'); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/BooleanFilter.php: -------------------------------------------------------------------------------- 1 | name("filter[{$this->filter->key()}]") 18 | ->description($this->description()) 19 | ->required(false) 20 | ->allowEmptyValue(false) 21 | ->schema(OASchema::boolean()), 22 | ]; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/DefaultDescriptor.php: -------------------------------------------------------------------------------- 1 | name("filter[{$this->filter->key()}]") 18 | ->description($this->description()) 19 | ->required(false) 20 | ->allowEmptyValue(false) 21 | ->schema(OASchema::string()), 22 | ]; 23 | } 24 | 25 | protected function description(): string 26 | { 27 | return 'Filters the records'; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/FilterDescriptor.php: -------------------------------------------------------------------------------- 1 | route = $route; 22 | $this->filter = $filter; 23 | } 24 | 25 | abstract protected function description(): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/Has.php: -------------------------------------------------------------------------------- 1 | filter->key()}."; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/Scope.php: -------------------------------------------------------------------------------- 1 | filter->key()} scope."; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/Where.php: -------------------------------------------------------------------------------- 1 | generator->resources() 23 | ->resources($this->route->schema()::model())) 24 | ->pluck($this->filter->column()) 25 | ->filter() 26 | ->map(function ($f) { 27 | if (function_exists('enum_exists') && $f instanceof \UnitEnum) { 28 | $f = $f instanceof \BackedEnum ? $f->value : $f->name; 29 | } 30 | 31 | // @todo Watch out for ids? 32 | return Example::create($f)->value($f); 33 | }) 34 | ->toArray(); 35 | 36 | return [ 37 | Parameter::query() 38 | ->name("filter[{$this->filter->key()}]") 39 | ->description($this->description()) 40 | ->required(false) 41 | ->allowEmptyValue(false) 42 | ->schema(OASchema::string()->default('')) 43 | ->examples(...$examples), 44 | ]; 45 | } 46 | 47 | protected function description(): string 48 | { 49 | return 'Filters the records'; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/WhereIdIn.php: -------------------------------------------------------------------------------- 1 | filter->key(); 19 | $examples = collect($this->generator->resources() 20 | ->resources($this->route->schema()::model())) 21 | ->map(function (JsonApiResource $resource) { 22 | $id = $resource->id(); 23 | 24 | return Example::create($id)->value([$id]); 25 | }) 26 | ->toArray(); 27 | 28 | return [ 29 | Parameter::query() 30 | ->name("filter[{$key}]") 31 | ->description($this->description()) 32 | ->required(false) 33 | ->allowEmptyValue(false) 34 | ->schema(Schema::array()->items(Schema::string())->default([])) 35 | ->examples(Example::create('empty')->value([]), ...$examples) 36 | ->style('form') 37 | ->explode(false), 38 | ]; 39 | } 40 | 41 | /** 42 | * {@inheritDoc} 43 | */ 44 | protected function description(): string 45 | { 46 | return $this->filter instanceof WhereIdNotIn ? 47 | 'A list of ids to exclude by.' : 48 | 'A list of ids to filter by.'; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/WhereIn.php: -------------------------------------------------------------------------------- 1 | generator->resources() 25 | ->resources($this->route->schema()::model())) 26 | ->pluck($this->filter->column()) 27 | ->filter() 28 | ->map(function ($f) { 29 | if (function_exists('enum_exists') && $f instanceof \UnitEnum) { 30 | $f = $f instanceof \BackedEnum ? $f->value : $f->name; 31 | } 32 | 33 | // @todo Watch out for ids? 34 | return Example::create($f)->value($f); 35 | }) 36 | ->toArray(); 37 | 38 | return [ 39 | Parameter::query() 40 | ->name("filter[{$this->filter->key()}]") 41 | ->description($this->description()) 42 | ->required(false) 43 | ->allowEmptyValue(false) 44 | ->schema(Schema::array()->items(Schema::string())->default(Example::create('empty')->value([]))) 45 | ->examples(...$examples) 46 | ->style('form') 47 | ->explode(false), 48 | ]; 49 | } 50 | 51 | protected function description(): string 52 | { 53 | return $this->filter instanceof WhereNotIn || $this->filter instanceof WherePivotNotIn 54 | ? "A list of {$this->filter->key()}s to exclude by." 55 | : "A list of {$this->filter->key()}s to filter by."; 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/WhereNull.php: -------------------------------------------------------------------------------- 1 | filter instanceof WhereNotNull ? "Only includes records where {$this->filter->key()} is not null." : "Only includes records where {$this->filter->key()} is null."; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Filters/WithTrashed.php: -------------------------------------------------------------------------------- 1 | filter instanceof OnlyTrashed ? 'Show only trashed records.' : 'Include trashed records'; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Descriptors/Schema/Schema.php: -------------------------------------------------------------------------------- 1 | Filters\WhereIdIn::class, 40 | Eloquent\Filters\WhereIn::class => Filters\WhereIn::class, 41 | Eloquent\Filters\Scope::class => Filters\Scope::class, 42 | Eloquent\Filters\WithTrashed::class => Filters\WithTrashed::class, 43 | Eloquent\Filters\Where::class => Filters\Where::class, 44 | Eloquent\Filters\WhereNull::class => Filters\WhereNull::class, 45 | Eloquent\Filters\Has::class => Filters\Has::class, 46 | ]; 47 | 48 | /** 49 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 50 | */ 51 | public function fetch( 52 | JASchema $schema, 53 | string $objectId, 54 | string $type, 55 | string $name, 56 | ): OASchema { 57 | $resource = $this->generator 58 | ->resources() 59 | ->resource($schema::model()); 60 | 61 | $fields = $this->fields($schema->fields(), $resource); 62 | $properties = [ 63 | OASchema::string('type') 64 | ->title('type') 65 | ->default($type), 66 | OASchema::string('id') 67 | ->example($resource->id()), 68 | OASchema::object('attributes') 69 | ->properties(...$fields->get('attributes')), 70 | ]; 71 | 72 | if ($fields->has('relationships')) { 73 | $properties[] = OASchema::object('relationships') 74 | ->properties(...$fields->get('relationships')); 75 | } 76 | 77 | return OASchema::object($objectId) 78 | ->title('Resource/'.ucfirst($name).'/Fetch') 79 | ->required('type', 'id', 'attributes') 80 | ->properties(...$properties); 81 | } 82 | 83 | /** 84 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 85 | */ 86 | public function store(Route $route): OASchema 87 | { 88 | $objectId = SchemaBuilder::objectId($route); 89 | 90 | $resource = $this->generator->resources() 91 | ->resource($route->schema()::model()); 92 | 93 | $fields = $this->fields($route->schema()->fields(), $resource); 94 | 95 | return OASchema::object($objectId) 96 | ->title('Resource/'.ucfirst($route->name(true)).'/Store') 97 | ->required('type', 'attributes') 98 | ->properties( 99 | OASchema::string('type') 100 | ->title('type') 101 | ->default($route->name()), 102 | OASchema::object('attributes') 103 | ->properties(...$fields->get('attributes')), 104 | OASchema::object('relationships') 105 | ->properties(...$fields->get('relationships') ?: []) 106 | ); 107 | } 108 | 109 | /** 110 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 111 | */ 112 | public function update(Route $route): OASchema 113 | { 114 | $objectId = SchemaBuilder::objectId($route); 115 | $resource = $this->generator->resources() 116 | ->resource($route->schema()::model()); 117 | 118 | $fields = $this->fields($route->schema()->fields(), $resource); 119 | 120 | return OASchema::object($objectId) 121 | ->title('Resource/'.ucfirst($route->name(true)).'/Update') 122 | ->properties( 123 | OASchema::string('type') 124 | ->title('type') 125 | ->default($route->name()), 126 | OASchema::string('id') 127 | ->example($resource->id()), 128 | OASchema::object('attributes') 129 | ->properties(...$fields->get('attributes')), 130 | OASchema::object('relationships') 131 | ->properties(...$fields->get('relationships') ?: []) 132 | ) 133 | ->required('type', 'id', 'attributes'); 134 | } 135 | 136 | /** 137 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 138 | */ 139 | public function fetchRelationship(Route $route): OASchema 140 | { 141 | if (! $route->isPolymorphic()) { 142 | $resource = $this->generator->resources() 143 | ->resource($route->inversSchema()::model()); 144 | } else { 145 | $resource = $this->generator->resources() 146 | ->resource(Arr::first($route->inversSchemas())::model()); 147 | } 148 | 149 | $inverseRelation = $route->relation() !== null ? $route->relation()->inverse() : null; 150 | 151 | return $this->relationshipData($route->relation(), $resource, 152 | $inverseRelation) 153 | ->title('Resource/'.ucfirst($route->name(true)).'/Relationship/'.ucfirst($route->relationName()).'/Fetch'); 154 | } 155 | 156 | /** 157 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 158 | */ 159 | public function updateRelationship(Route $route): OASchema 160 | { 161 | if (! $route->isPolymorphic()) { 162 | $resource = $this->generator->resources() 163 | ->resource($route->inversSchema()::model()); 164 | } else { 165 | $resource = $this->generator->resources() 166 | ->resource(Arr::first($route->inversSchemas())::model()); 167 | } 168 | 169 | $dataSchema = $this->getDataSchema($route, $resource); 170 | 171 | return $dataSchema->title('Resource/'.ucfirst($route->name(true)).'/Relationship/'.ucfirst($route->relationName()).'/Update'); 172 | } 173 | 174 | /** 175 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 176 | */ 177 | public function attachRelationship(Route $route): OASchema 178 | { 179 | if (! $route->isPolymorphic()) { 180 | $resource = $this->generator->resources() 181 | ->resource($route->inversSchema()::model()); 182 | } else { 183 | $resource = $this->generator->resources() 184 | ->resource(Arr::first($route->inversSchemas())::model()); 185 | } 186 | 187 | $dataSchema = $this->getDataSchema($route, $resource); 188 | 189 | return $dataSchema->title('Resource/'.ucfirst($route->name(true)).'/Relationship/'.ucfirst($route->relationName()).'/Attach'); 190 | } 191 | 192 | /** 193 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 194 | */ 195 | public function detachRelationship(Route $route): OASchema 196 | { 197 | if (! $route->isPolymorphic()) { 198 | $resource = $this->generator->resources() 199 | ->resource($route->inversSchema()::model()); 200 | } else { 201 | $resource = $this->generator->resources() 202 | ->resource(Arr::first($route->inversSchemas())::model()); 203 | } 204 | $dataSchema = $this->getDataSchema($route, $resource); 205 | 206 | return $dataSchema->title('Resource/'.ucfirst($route->name(true)).'/Relationship/'.ucfirst($route->relationName()).'/Detach'); 207 | } 208 | 209 | /** 210 | * @param mixed $objectId 211 | * 212 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 213 | */ 214 | public function fetchPolymorphicRelationship( 215 | Route $route, 216 | $objectId, 217 | ): OASchema { 218 | $resource = $this->generator->resources() 219 | ->resource($route->schema()::model()); 220 | 221 | $inverseRelation = $route->relation() !== null ? $route->relation()->inverse() : null; 222 | 223 | return $this->relationshipData($route->relation(), $resource, 224 | $inverseRelation) 225 | ->objectId($objectId) 226 | ->title('Resource/'.ucfirst($route->name(true)).'/Relationship/'.ucfirst($route->relationName()).'/Fetch'); 227 | } 228 | 229 | /** 230 | * @param mixed $route 231 | * @return \GoldSpecDigital\ObjectOrientedOAS\Objects\Parameter[] 232 | */ 233 | public function sortables($route): array 234 | { 235 | $fields = collect($route->schema()->sortFields()) 236 | ->merge(collect($route->schema()->sortables()) 237 | ->map(function (Sortable $sortable) { 238 | return $sortable->sortField(); 239 | })->whereNotNull()) 240 | ->map(function (string $field) { 241 | return [$field, '-'.$field]; 242 | })->flatten()->toArray(); 243 | 244 | return [ 245 | Parameter::query('sort') 246 | ->name('sort') 247 | ->schema(OASchema::array() 248 | ->items(OASchema::string()->enum(...$fields)) 249 | ) 250 | ->allowEmptyValue(false) 251 | ->required(false), 252 | ]; 253 | } 254 | 255 | public function pagination(Route $route): array 256 | { 257 | $pagination = $route->schema()->pagination(); 258 | if ($pagination instanceof PagePagination) { 259 | return [ 260 | Parameter::query('pageSize') 261 | ->name('page[size]') 262 | ->description('The page size for paginated results') 263 | ->required(false) 264 | ->allowEmptyValue(false) 265 | ->schema(OASchema::integer()), 266 | Parameter::query('pageNumber') 267 | ->name('page[number]') 268 | ->description('The page number for paginated results') 269 | ->required(false) 270 | ->allowEmptyValue(false) 271 | ->schema(OASchema::integer()), 272 | ]; 273 | } 274 | 275 | if ($pagination instanceof CursorPagination) { 276 | return [ 277 | Parameter::query('pageLimit') 278 | ->name('page[limit]') 279 | ->description('The page limit for paginated results') 280 | ->required(false) 281 | ->allowEmptyValue(false) 282 | ->schema(OASchema::integer()), 283 | Parameter::query('pageAfter') 284 | ->name('page[after]') 285 | ->description('The page offset for paginated results') 286 | ->required(false) 287 | ->allowEmptyValue(false) 288 | ->schema(OASchema::string()), 289 | Parameter::query('pageBefore') 290 | ->name('page[before]') 291 | ->description('The page offset for paginated results') 292 | ->required(false) 293 | ->allowEmptyValue(false) 294 | ->schema(OASchema::string()), 295 | ]; 296 | } 297 | 298 | return []; 299 | } 300 | 301 | /** 302 | * @return \GoldSpecDigital\ObjectOrientedOAS\Objects\Parameter[] 303 | */ 304 | public function filters($route): array 305 | { 306 | return collect($route->schema()->filters()) 307 | ->map(function (Filter $filterInstance) use ($route 308 | ) { 309 | $descriptor = $this->getDescriptor($filterInstance); 310 | 311 | return (new $descriptor($this->generator, $route, $filterInstance))->filter(); 312 | }) 313 | ->flatten() 314 | ->toArray(); 315 | } 316 | 317 | /** 318 | * @param \LaravelJsonApi\Contracts\Schema\Field[] $fields 319 | */ 320 | protected function fields( 321 | array $fields, 322 | JsonApiResource $resource, 323 | ): Collection { 324 | return collect($fields) 325 | ->mapToGroups(function (Field $field) { 326 | switch (true) { 327 | case $field instanceof AttributeContract: 328 | $key = 'attributes'; 329 | break; 330 | case $field instanceof RelationContract: 331 | $key = 'relationships'; 332 | break; 333 | default: 334 | $key = 'unknown'; 335 | } 336 | 337 | return [$key => $field]; 338 | }) 339 | ->map(function ($fields, $type) use ($resource) { 340 | switch ($type) { 341 | case 'attributes': 342 | return $this->attributes($fields, $resource); 343 | case 'relationships': 344 | return $this->relationships($fields, $resource); 345 | default: 346 | return null; 347 | } 348 | }); 349 | } 350 | 351 | /** 352 | * @return Schema[] 353 | */ 354 | protected function attributes( 355 | Collection $fields, 356 | JsonApiResource $example, 357 | ): array { 358 | return $fields 359 | ->filter(fn ($field) => ! ($field instanceof ID)) 360 | ->map(function (Field $field) use ($example) { 361 | $fieldId = $field->name(); 362 | switch (true) { 363 | case $field instanceof Boolean: 364 | $fieldDataType = OASchema::boolean($fieldId); 365 | break; 366 | case $field instanceof Number: 367 | $fieldDataType = OASchema::number($fieldId); 368 | break; 369 | case $field instanceof ArrayList: 370 | $fieldDataType = OASchema::array($fieldId); 371 | break; 372 | case $field instanceof ArrayHash: 373 | case $field instanceof Map: 374 | $fieldDataType = OASchema::object($fieldId); 375 | break; 376 | default: 377 | $fieldDataType = OASchema::string($fieldId); 378 | } 379 | 380 | $schema = $fieldDataType->title($field->name()); 381 | 382 | $column = $field instanceof EloquentAttribute ? $field->column() : $field->name(); 383 | 384 | if ($field instanceof NonEloquentAttribute) { 385 | $attributes = $example->attributes(null); 386 | if (isset($attributes[$column])) { 387 | $schema = $schema->example($attributes[$column]); 388 | } 389 | } else { 390 | if (isset($example[$column])) { 391 | $schema = $schema->example($example[$column]); 392 | } 393 | if ($field instanceof EloquentAttribute && $field->isReadOnly(null)) { 394 | $schema = $schema->readOnly(true); 395 | } 396 | } 397 | 398 | return $schema; 399 | })->toArray(); 400 | } 401 | 402 | /** 403 | * @todo Fix relation field names 404 | */ 405 | protected function relationships( 406 | Collection $relationships, 407 | JsonApiResource $example, 408 | ): array { 409 | return $relationships 410 | ->map(function (RelationContract $relation) use ($example) { 411 | return $this->relationship($relation, $example); 412 | })->toArray(); 413 | } 414 | 415 | /** 416 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 417 | */ 418 | protected function relationship( 419 | RelationContract $relation, 420 | JsonApiResource $example, 421 | bool $includeData = false, 422 | ): OASchema { 423 | $fieldId = $relation->name(); 424 | 425 | $type = $relation->inverse(); 426 | 427 | $linkSchema = $this->relationshipLinks($relation, $example); 428 | 429 | $dataSchema = $this->relationshipData($relation, $example, $type); 430 | 431 | if ($relation instanceof Eloquent\Fields\Relations\ToMany) { 432 | $dataSchema = OASchema::array('data') 433 | ->items($dataSchema); 434 | } 435 | $schema = OASchema::object($fieldId) 436 | ->title($relation->name()); 437 | 438 | if ($includeData) { 439 | return $schema->properties($dataSchema); 440 | } else { 441 | return $schema->properties($linkSchema); 442 | } 443 | } 444 | 445 | /** 446 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 447 | */ 448 | protected function relationshipData( 449 | RelationContract $relation, 450 | JsonApiResource $example, 451 | string $type, 452 | ): OASchema { 453 | if ($relation instanceof PolymorphicRelation) { 454 | // @todo Add examples for each available type 455 | $dataSchema = OASchema::object('data') 456 | ->title($relation->name()) 457 | ->required('type', 'id') 458 | ->properties( 459 | OASchema::string('type') 460 | ->title('type') 461 | ->enum(...$relation->inverseTypes()), 462 | OASchema::string('id') 463 | ->title('id') 464 | ); 465 | } else { 466 | $dataSchema = OASchema::object('data') 467 | ->title($relation->name()) 468 | ->required('type', 'id') 469 | ->properties( 470 | OASchema::string('type') 471 | ->title('type') 472 | ->default($type), 473 | OASchema::string('id') 474 | ->title('id') 475 | ->example($example->id()) 476 | ); 477 | } 478 | 479 | return $dataSchema; 480 | } 481 | 482 | public function relationshipLinks( 483 | RelationContract $relation, 484 | JsonApiResource $example, 485 | ): OASchema { 486 | $name = Str::dasherize(Str::plural(Str::camel($relation->name()))); 487 | 488 | /* 489 | * @todo Create real links 490 | */ 491 | $relatedLink = $this->generator->server()->url([ 492 | $name, 493 | $example->id(), 494 | ]); 495 | 496 | /* 497 | * @todo Create real links 498 | */ 499 | $selfLink = $this->generator->server()->url([ 500 | $name, 501 | $example->id(), 502 | ]); 503 | 504 | return OASchema::object('links') 505 | ->readOnly(true) 506 | ->properties( 507 | OASchema::string('related') 508 | ->title('related') 509 | ->example($relatedLink), 510 | OASchema::string('self') 511 | ->title('self') 512 | ->example($selfLink), 513 | ); 514 | } 515 | 516 | protected function links(Route $route, JsonApiResource $resource): array 517 | { 518 | $url = $this->generator->server()->url([ 519 | $route->name(), 520 | $resource->id(), 521 | ]); 522 | 523 | return [ 524 | OASchema::string('self') 525 | ->title('self') 526 | ->example($url), 527 | ]; 528 | } 529 | 530 | /** 531 | * @todo Get descriptors from Attributes 532 | */ 533 | protected function getDescriptor(Filter $filter): string 534 | { 535 | foreach ($this->filterDescriptors as $filterClass => $descriptor) { 536 | if ($filter instanceof $filterClass) { 537 | return $descriptor; 538 | } 539 | } 540 | 541 | return Filters\DefaultDescriptor::class; 542 | } 543 | 544 | /** 545 | * @throws \GoldSpecDigital\ObjectOrientedOAS\Exceptions\InvalidArgumentException 546 | */ 547 | protected function getDataSchema(Route $route, JsonApiResource $resource): OASchema 548 | { 549 | $inverseRelation = $route->relation() !== null ? $route->relation()->inverse() : null; 550 | 551 | $relation = $route->relation(); 552 | 553 | $dataSchema = $this 554 | ->relationshipData( 555 | $relation, 556 | $resource, 557 | $inverseRelation 558 | ); 559 | 560 | if ($relation instanceof Eloquent\Fields\Relations\ToMany) { 561 | $dataSchema = OASchema::array('data') 562 | ->items($dataSchema); 563 | } 564 | 565 | return $dataSchema; 566 | } 567 | } 568 | -------------------------------------------------------------------------------- /src/Descriptors/Server.php: -------------------------------------------------------------------------------- 1 | title(config("openapi.servers.{$this->generator->key()}.info.title")) 19 | ->description(config("openapi.servers.{$this->generator->key()}.info.description")) 20 | ->version(config("openapi.servers.{$this->generator->key()}.info.version")); 21 | } 22 | 23 | /** 24 | * @return \LaravelJsonApi\Core\Server\Server[] 25 | * 26 | * @todo Allow Configuration 27 | * @todo Use for enums? 28 | * @todo Extract only URI Server Prefix and let domain be set separately 29 | */ 30 | public function servers(): array 31 | { 32 | return [ 33 | Objects\Server::create() 34 | ->url('{serverUrl}') 35 | ->variables(Objects\ServerVariable::create('serverUrl') 36 | ->default($this->generator->server()->url()) 37 | ), 38 | ]; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/Facades/GeneratorFacade.php: -------------------------------------------------------------------------------- 1 | key = $key; 34 | 35 | $apiServer = config("jsonapi.servers.$key"); 36 | $appResolver = app(AppResolver::class); 37 | 38 | $this->server = new $apiServer($appResolver, $this->key); 39 | 40 | $this->infoBuilder = new InfoBuilder($this); 41 | $this->serverBuilder = new ServerBuilder($this); 42 | $this->components = new ComponentsContainer; 43 | $this->resources = new ResourceContainer($this->server); 44 | $this->pathsBuilder = new PathsBuilder($this, $this->components); 45 | } 46 | 47 | public function generate(): OpenApi 48 | { 49 | return OpenApi::create() 50 | ->openapi(OpenApi::OPENAPI_3_0_2) 51 | ->info($this->infoBuilder->build()) 52 | ->servers(...$this->serverBuilder->build()) 53 | ->paths(...array_values($this->pathsBuilder->build())) 54 | ->components($this->components()->components()); 55 | } 56 | 57 | public function key(): string 58 | { 59 | return $this->key; 60 | } 61 | 62 | public function server(): Server 63 | { 64 | return $this->server; 65 | } 66 | 67 | public function components(): ComponentsContainer 68 | { 69 | return $this->components; 70 | } 71 | 72 | public function resources(): ResourceContainer 73 | { 74 | return $this->resources; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/OpenApiGenerator.php: -------------------------------------------------------------------------------- 1 | generate(); 17 | 18 | $openapi->validate(); 19 | 20 | $storageDisk = Storage::disk(config('openapi.filesystem_disk')); 21 | 22 | $fileName = $serverKey.'_openapi.'.$format; 23 | 24 | if ($format === 'yaml') { 25 | $output = Yaml::dump($openapi->toArray()); 26 | } elseif ($format === 'json') { 27 | $output = json_encode($openapi->toArray(), JSON_PRETTY_PRINT); 28 | } 29 | 30 | $storageDisk->put($fileName, $output); 31 | 32 | return $output; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/OpenApiServiceProvider.php: -------------------------------------------------------------------------------- 1 | app->runningInConsole()) { 19 | $this->publishes([ 20 | __DIR__.'/../config/config.php' => config_path('openapi.php'), 21 | ], 'config'); 22 | 23 | // Publishing the translation files. 24 | /*$this->publishes([ 25 | __DIR__.'/../resources/lang' => resource_path('lang/vendor/openapi'), 26 | ], 'lang');*/ 27 | 28 | // Registering package commands. 29 | $this->commands([ 30 | Commands\GenerateCommand::class, 31 | ]); 32 | } 33 | } 34 | 35 | /** 36 | * Register the application services. 37 | */ 38 | public function register() 39 | { 40 | // Automatically apply the package configuration 41 | $this->mergeConfigFrom(__DIR__.'/../config/config.php', 'openapi'); 42 | 43 | // Register the main class to use with the facade 44 | $this->app->singleton('openapi-generator', function () { 45 | return new OpenApiGenerator; 46 | }); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/ResourceContainer.php: -------------------------------------------------------------------------------- 1 | server = $server; 20 | } 21 | 22 | /** 23 | * @param mixed $model Model class as FQN, model instance or an Schema instance 24 | */ 25 | public function resource($model): JsonApiResource 26 | { 27 | $fqn = $this->getFQN($model); 28 | if (! isset($this->resources[$fqn])) { 29 | $this->loadResources($fqn); 30 | } 31 | 32 | $resource = $this->resources[$fqn]->first(); 33 | 34 | if (! $resource) { 35 | throw new \RuntimeException(sprintf('No resource found for model [%s], make sure your database is seeded!', $fqn)); 36 | } 37 | 38 | return $resource; 39 | } 40 | 41 | /** 42 | * @param mixed $model 43 | * @return JsonApiResource[] 44 | */ 45 | public function resources($model): array 46 | { 47 | $fqn = $this->getFQN($model); 48 | if (! isset($this->resource[$fqn])) { 49 | $this->loadResources($fqn); 50 | } 51 | 52 | $resources = $this->resources[$fqn]->toArray(); 53 | 54 | if (empty($resources)) { 55 | throw new \RuntimeException(sprintf('No resources found for model [%s], make sure your database is seeded!', $fqn)); 56 | } 57 | 58 | return $resources; 59 | } 60 | 61 | protected function getFQN($model): string 62 | { 63 | $fqn = $model; 64 | if ($model instanceof Schema) { 65 | $fqn = $model::model(); 66 | } elseif (is_object($model)) { 67 | $fqn = get_class($model); 68 | } 69 | 70 | return $fqn; 71 | } 72 | 73 | protected function loadResources(string $model) 74 | { 75 | $schema = $this->server->schemas()->schemaForModel($model); 76 | $repository = $schema->repository(); 77 | 78 | if ($repository instanceof QueriesAll) { 79 | $this->resources[$model] = collect($repository->queryAll()->get()) 80 | ->map(function ($model) { 81 | return $this->server->resources()->create($model); 82 | }) 83 | ->take(3); 84 | 85 | return; 86 | } 87 | 88 | if (method_exists($model, 'all')) { 89 | $resources = $model::all()->map(function ($model) { 90 | return $this->server->resources()->create($model); 91 | })->take(3); 92 | 93 | $this->resources[$model] = $resources; 94 | } 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/Route.php: -------------------------------------------------------------------------------- 1 | server = $server; 54 | $this->route = $route; 55 | 56 | $segments = explode('.', $this->route->getName()); 57 | $segments = array_slice( 58 | $segments, 59 | array_search($this->server->name(), $segments) + 1 60 | ); 61 | 62 | $this->operationId = collect($segments)->join('.'); 63 | $relation = null; 64 | 65 | if (count($segments) === 2) { 66 | [$resource, $action] = $segments; 67 | } elseif (count($segments) === 3) { 68 | [$resource, $relation, $action] = $segments; 69 | } else { 70 | throw new \LogicException('Unable to handle action structure '.$route->getName()); 71 | } 72 | 73 | $this->resource = $resource; 74 | $this->schema = $this->server->schemas()->schemaFor($resource); 75 | 76 | if ($action !== null && $relation === null && $this->schema->isRelationship($action)) { 77 | $this->relation = $action; 78 | $this->action = 'showRelated'; 79 | } else { 80 | $this->relation = $relation; 81 | $this->action = $action; 82 | } 83 | 84 | $this->setUriForRoute(); 85 | 86 | [$controller, $method] = explode('@', $this->route->getActionName(), 2); 87 | 88 | $this->controller = $controller; 89 | $this->method = $method; 90 | } 91 | 92 | /** 93 | * @return string The HTTP method 94 | */ 95 | public function method(): string 96 | { 97 | return collect($this->route->methods()) 98 | ->filter(fn ($method) => $method !== 'HEAD') 99 | ->first(); 100 | } 101 | 102 | public function schema(): Schema 103 | { 104 | return $this->schema; 105 | } 106 | 107 | public function route(): IlluminateRoute 108 | { 109 | return $this->route; 110 | } 111 | 112 | /** 113 | * @return string[] 114 | */ 115 | public function controllerCallable(): array 116 | { 117 | return [$this->controller, $this->method]; 118 | } 119 | 120 | public function id(): string 121 | { 122 | return $this->operationId; 123 | } 124 | 125 | public function uri(): string 126 | { 127 | return $this->uri; 128 | } 129 | 130 | public function relationName(): ?string 131 | { 132 | return $this->relation; 133 | } 134 | 135 | public function relation(): ?Relation 136 | { 137 | $relation = $this->relation ? $this->schema() 138 | ->relationship($this->relation) : null; 139 | 140 | if ($relation !== null && ! ($relation instanceof Relation)) { 141 | throw new \RuntimeException('Unexpected Type'); 142 | } 143 | 144 | return $relation; 145 | } 146 | 147 | public function isRelation(): bool 148 | { 149 | return $this->relation !== null; 150 | } 151 | 152 | public function isPolymorphic(): bool 153 | { 154 | return $this->relation() instanceof PolymorphicRelation; 155 | } 156 | 157 | public function invers(): ?string 158 | { 159 | return $this->relation() !== null ? $this->relation()->inverse() : null; 160 | } 161 | 162 | public function inversSchema(): ?Schema 163 | { 164 | if ($this->isRelation()) { 165 | if ($this->relation() instanceof PolymorphicRelation) { 166 | throw new \LogicException('Method is not allowed for Polymorphic relationships'); 167 | } 168 | 169 | return $this->server->schemas() 170 | ->schemaFor($this->relation() !== null ? $this->relation()->inverse() : null); 171 | } 172 | 173 | return null; 174 | } 175 | 176 | /** 177 | * @return \LaravelJsonApi\Contracts\Schema\Schema[] 178 | */ 179 | public function inversSchemas(): array 180 | { 181 | $schemas = []; 182 | if ($this->isRelation()) { 183 | $relation = $this->relation(); 184 | if ($relation instanceof PolymorphicRelation) { 185 | foreach ($relation->inverseTypes() as $type) { 186 | $schemas[$type] = $this->server->schemas() 187 | ->schemaFor($type); 188 | } 189 | } else { 190 | $schemas[$relation->inverse()] = $this->server->schemas() 191 | ->schemaFor($relation->inverse()); 192 | } 193 | } 194 | 195 | return $schemas; 196 | } 197 | 198 | public function inverseName(bool $singular = false): ?string 199 | { 200 | $relation = $this->relation() !== null ? $this->relation()->inverse() : null; 201 | if ($singular) { 202 | return Str::singular($relation); 203 | } 204 | 205 | return $relation; 206 | } 207 | 208 | /** 209 | * @param false $singular 210 | */ 211 | public function name(bool $singular = false): string 212 | { 213 | if ($singular) { 214 | return Str::singular($this->resource); 215 | } 216 | 217 | return $this->resource; 218 | } 219 | 220 | public function resource(): string 221 | { 222 | return $this->resource; 223 | } 224 | 225 | public function action(): string 226 | { 227 | return $this->action; 228 | } 229 | 230 | public static function belongsTo( 231 | IlluminateRoute $route, 232 | Server $server, 233 | ): bool { 234 | return Str::contains( 235 | $route->getName(), 236 | $server->name(), 237 | ); 238 | } 239 | 240 | protected function setUriForRoute(): void 241 | { 242 | $domain = URL::to('/'); 243 | $serverBasePath = str_replace( 244 | $domain, 245 | '', 246 | $this->server->url(), 247 | ); 248 | 249 | $this->uri = str_replace( 250 | $serverBasePath, 251 | '', 252 | '/'.$this->route->uri(), 253 | ); 254 | } 255 | } 256 | --------------------------------------------------------------------------------