├── .editorconfig ├── .github └── workflows │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── acceptance ├── .editorconfig ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── crate_segment_path │ ├── Cargo.toml │ └── crate_folder │ │ ├── lib.rs │ │ └── sub_folder │ │ ├── mod.rs │ │ ├── paths.rs │ │ └── test.rs ├── folder_in_src │ ├── Cargo.toml │ └── crate_folder │ │ ├── lib.rs │ │ └── new_sub_folder │ │ ├── mod.rs │ │ ├── paths.rs │ │ └── test.rs ├── generics │ ├── Cargo.toml │ └── src │ │ ├── main.rs │ │ ├── open_api.expected.json │ │ ├── routes.rs │ │ └── schemas.rs ├── responses │ ├── Cargo.toml │ └── src │ │ ├── main.rs │ │ ├── open_api.expected.json │ │ ├── response.rs │ │ └── routes.rs ├── rustfmt.toml └── utility │ ├── Cargo.toml │ └── src │ └── lib.rs ├── armory.toml ├── rustfmt.toml ├── utoipauto-core ├── Cargo.toml └── src │ ├── attribute_utils.rs │ ├── discover.rs │ ├── file_utils.rs │ ├── lib.rs │ ├── pair.rs │ ├── string_utils.rs │ └── token_utils.rs ├── utoipauto-macro ├── Cargo.toml └── src │ └── lib.rs └── utoipauto ├── Cargo.toml ├── src └── lib.rs └── tests ├── default_features ├── controllers │ ├── controller1.rs │ ├── controller2.rs │ ├── controller3.rs │ └── mod.rs ├── mod.rs ├── models.rs └── test.rs └── test.rs /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | indent_style = space 13 | indent_size = 4 14 | max_line_length = 120 15 | 16 | [*.md] 17 | # double whitespace at end of line 18 | # denotes a line break in Markdown 19 | trim_trailing_whitespace = false 20 | 21 | [{*.yml,*.yaml}] 22 | indent_size = 2 23 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | # push: 5 | # branches: ["main"] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | env: 10 | CARGO_TERM_COLOR: always 11 | 12 | jobs: 13 | test: 14 | name: Test 15 | runs-on: ubuntu-latest 16 | strategy: 17 | matrix: 18 | rust: [ "1.75.0", "stable", "nightly" ] 19 | steps: 20 | - uses: actions/checkout@v4 21 | - name: install toolchain 22 | uses: dtolnay/rust-toolchain@master # Needed to always have access to the latest releases. DO NOT REPLACE WITH STABLE! 23 | with: 24 | toolchain: ${{ matrix.rust }} 25 | - name: add cargo caching 26 | uses: Swatinem/rust-cache@v2 27 | - name: Build 28 | run: cargo build --all-features --verbose 29 | - name: Run tests 30 | run: cargo test --verbose 31 | - name: Run tests with all features 32 | run: cargo test --all-features --verbose 33 | 34 | acceptance-tests: 35 | name: Acceptance Tests 36 | runs-on: ubuntu-latest 37 | # Set default working directory 38 | defaults: 39 | run: 40 | working-directory: ./acceptance 41 | strategy: 42 | matrix: 43 | rust: [ "stable", "nightly" ] 44 | steps: 45 | - uses: actions/checkout@v3 46 | - name: install toolchain 47 | uses: dtolnay/rust-toolchain@master # Needed to always have access to the latest releases. DO NOT REPLACE WITH STABLE! 48 | with: 49 | toolchain: ${{ matrix.rust }} 50 | - name: add cargo caching 51 | uses: Swatinem/rust-cache@v2 52 | - name: Run acceptance tests (build) 53 | run: cargo build 54 | - name: Run acceptance tests (tests) 55 | run: cargo test --verbose 56 | 57 | format: 58 | name: Format 59 | runs-on: ubuntu-latest 60 | steps: 61 | - uses: actions/checkout@v4 62 | - name: install toolchain 63 | uses: dtolnay/rust-toolchain@stable 64 | with: 65 | components: rustfmt 66 | - name: add cargo caching 67 | uses: Swatinem/rust-cache@v2 68 | - name: run formater 69 | run: cargo fmt --check 70 | 71 | clippy: 72 | name: Lint 73 | runs-on: ubuntu-latest 74 | steps: 75 | - uses: actions/checkout@v4 76 | - name: install toolchain 77 | uses: dtolnay/rust-toolchain@stable 78 | with: 79 | components: clippy 80 | - name: add cargo caching 81 | uses: Swatinem/rust-cache@v2 82 | - name: run formater 83 | run: cargo clippy -- -D warnings 84 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: publish 2 | 3 | on: 4 | release: 5 | types: [ published ] 6 | workflow_dispatch: 7 | 8 | jobs: 9 | utoipauto-core: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: dtolnay/rust-toolchain@stable 13 | with: 14 | components: rustfmt, clippy 15 | 16 | - name: Checkout 17 | uses: actions/checkout@v4 18 | 19 | - name: Get releasing version 20 | working-directory: utoipauto-core 21 | run: echo NEXT_VERSION=$(sed -nE 's/^\s*version = "(.*?)"/\1/p' Cargo.toml) >> $GITHUB_ENV 22 | 23 | - name: Check published version 24 | run: echo PREV_VERSION=$(cargo search utoipauto-core --limit 1 | sed -nE 's/^[^"]*"//; s/".*//1p' -) >> $GITHUB_ENV 25 | 26 | - name: Cargo login 27 | if: env.NEXT_VERSION != env.PREV_VERSION 28 | run: cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} 29 | 30 | - name: Cargo package 31 | if: env.NEXT_VERSION != env.PREV_VERSION 32 | working-directory: utoipauto-core 33 | run: | 34 | echo "Releasing version: $NEXT_VERSION" 35 | echo "Published version: $PREV_VERSION" 36 | echo "Cargo Packaging..." 37 | cargo package 38 | 39 | - name: Publish utoipauto-core 40 | if: env.NEXT_VERSION != env.PREV_VERSION 41 | working-directory: utoipauto-core 42 | run: | 43 | echo "Cargo Publishing..." 44 | cargo publish --no-verify -p utoipauto-core 45 | echo "New version $NEXT_VERSION has been published" 46 | 47 | utoipauto-macro: 48 | needs: utoipauto-core 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: dtolnay/rust-toolchain@stable 52 | with: 53 | components: rustfmt, clippy 54 | 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | 58 | - name: Get releasing version 59 | working-directory: utoipauto-macro 60 | run: echo NEXT_VERSION=$(sed -nE 's/^\s*version = "(.*?)"/\1/p' Cargo.toml) >> $GITHUB_ENV 61 | 62 | - name: Check published version 63 | run: echo PREV_VERSION=$(cargo search utoipauto-macro --limit 1 | sed -nE 's/^[^"]*"//; s/".*//1p' -) >> $GITHUB_ENV 64 | 65 | - name: Cargo login 66 | if: env.NEXT_VERSION != env.PREV_VERSION 67 | run: cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} 68 | 69 | - name: Cargo package 70 | if: env.NEXT_VERSION != env.PREV_VERSION 71 | working-directory: utoipauto-macro 72 | run: | 73 | echo "Releasing version: $NEXT_VERSION" 74 | echo "Published version: $PREV_VERSION" 75 | echo "Cargo Packaging..." 76 | cargo package 77 | 78 | - name: Publish utoipauto-macro 79 | if: env.NEXT_VERSION != env.PREV_VERSION 80 | working-directory: utoipauto-macro 81 | run: | 82 | echo "Cargo Publishing..." 83 | cargo publish --no-verify -p utoipauto-macro 84 | echo "New version $NEXT_VERSION has been published" 85 | 86 | utoipauto: 87 | needs: utoipauto-macro 88 | runs-on: ubuntu-latest 89 | steps: 90 | - uses: dtolnay/rust-toolchain@stable 91 | with: 92 | components: rustfmt, clippy 93 | 94 | - name: Checkout 95 | uses: actions/checkout@v4 96 | 97 | - name: Get releasing version 98 | run: echo NEXT_VERSION=$(sed -nE 's/^\s*version = "(.*?)"/\1/p' Cargo.toml) >> $GITHUB_ENV 99 | 100 | - name: Check published version 101 | run: echo PREV_VERSION=$(cargo search utoipauto --limit 1 | sed -nE 's/^[^"]*"//; s/".*//1p' -) >> $GITHUB_ENV 102 | 103 | - name: Cargo login 104 | if: env.NEXT_VERSION != env.PREV_VERSION 105 | run: cargo login ${{ secrets.CARGO_REGISTRY_TOKEN }} 106 | 107 | - name: Cargo package 108 | if: env.NEXT_VERSION != env.PREV_VERSION 109 | run: | 110 | echo "Releasing version: $NEXT_VERSION" 111 | echo "Published version: $PREV_VERSION" 112 | echo "Cargo Packaging..." 113 | cargo package 114 | 115 | - name: Publish utoipauto 116 | if: env.NEXT_VERSION != env.PREV_VERSION 117 | run: | 118 | echo "Cargo Publishing..." 119 | cargo publish --no-verify -p utoipauto 120 | echo "New version $NEXT_VERSION has been published" 121 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # IDE 2 | /.idea 3 | 4 | *target 5 | /Cargo.lock 6 | git_cmd.md 7 | .vscode/settings.json 8 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["utoipauto", "utoipauto-core", "utoipauto-macro"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | authors = ["ProbablyClem", "RxDiscovery", "DenuxPlays"] 7 | version = "0.3.0-alpha.2" 8 | edition = "2021" 9 | keywords = ["utoipa", "openapi", "swagger", "path", "auto"] 10 | description = "Rust Macros to automate the addition of Paths/Schemas to Utoipa crate, simulating Reflection during the compilation phase" 11 | categories = [ 12 | "parsing", 13 | "development-tools::procedural-macro-helpers", 14 | "web-programming", 15 | ] 16 | license = "MIT OR Apache-2.0" 17 | readme = "README.md" 18 | repository = "https://github.com/ProbablyClem/utoipauto" 19 | homepage = "https://github.com/ProbablyClem/utoipauto" 20 | rust-version = "1.75.0" 21 | 22 | [workspace.dependencies] 23 | # Core dependencies 24 | utoipauto-core = { path = "utoipauto-core", version = "0.3.0-alpha.2" } 25 | utoipauto-macro = { path = "utoipauto-macro", version = "0.3.0-alpha.2" } 26 | 27 | # Utoipa 28 | utoipa = { version = "5.0.0", features = ["preserve_path_order"] } 29 | 30 | # Macro dependencies 31 | quote = "1.0.36" 32 | syn = { version = "2.0.74", features = ["full"] } 33 | proc-macro2 = "1.0.86" 34 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 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 [utoipa_paths_discovery] [RxDiscovery] 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 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017-NOW RxDiscovery Team 2 | 3 | Permission is hereby granted, free of charge, to any 4 | person obtaining a copy of this software and associated 5 | documentation files (the "Software"), to deal in the 6 | Software without restriction, including without 7 | limitation the rights to use, copy, modify, merge, 8 | publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software 10 | is furnished to do so, subject to the following 11 | conditions: 12 | 13 | The above copyright notice and this permission notice 14 | shall be included in all copies or substantial portions 15 | of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF 18 | ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED 19 | TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A 20 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT 21 | SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 22 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 23 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR 24 | IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER 25 | DEALINGS IN THE SOFTWARE. 26 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 |

Utoipauto

3 |

4 | Rust Macros to automate the addition of Paths/Schemas to Utoipa crate, simulating Reflection during the compilation phase 5 |

6 |
7 | 8 | # Crate presentation 9 | 10 | Utoipa is a great crate for generating documentation (openapi/swagger) via source code. 11 | 12 | But since Rust is a static programming language, we don't have the possibility of automatically discovering paths and 13 | dto in runtime and adding them to the documentation, 14 | 15 | For APIs with just a few endpoints, it's not that much trouble to add controller functions one by one, and DTOs one by 16 | one. 17 | 18 | But, if you have hundreds or even thousands of endpoints, the code becomes very verbose and difficult to maintain. 19 | 20 | Ex : 21 | 22 | ```rust 23 | 24 | #[derive(OpenApi)] 25 | #[openapi( 26 | paths( 27 | // <================================ All functions 1 to N 28 | test_controller::service::func_get_1, 29 | test_controller::service::func_get_2, 30 | test_controller::service::func_get_3, 31 | test_controller::service::func_get_4, 32 | .... 33 | .... 34 | .... 35 | test_controller::service::func_get_N, 36 | 37 | ), 38 | components( 39 | // <====================== All DTO one by one 40 | schemas(TestDTO_1, TestDTO_2, ........ , TestDTO_N) 41 | ), 42 | tags( 43 | (name = "todo", description = "Todo management endpoints.") 44 | ), 45 | modifiers(&SecurityAddon) 46 | )] 47 | pub struct ApiDoc; 48 | 49 | ``` 50 | 51 | The goal of this crate is to propose a macro that automates the detection of methods carrying Utoipa 52 | macros (`#[utoipa::path(...]`), and adds them automatically. (it also detects sub-modules.) 53 | 54 | It also detects struct that derive or implement `ToSchema` for the `components(schemas)` section, and the `ToResponse` 55 | for the `components(responses)` section. 56 | 57 | # Features 58 | 59 | - [x] Automatic recursive path detection 60 | - [x] Automatic import from module 61 | - [x] Automatic import from src folder 62 | - [x] Automatic model detection 63 | - [x] Automatic response detection 64 | - [x] Works with workspaces 65 | - [x] Exclude a method from automatic scanning 66 | - [x] Custom path detection 67 | 68 | # How to use it 69 | 70 | Simply add the crate `utoipauto` to the project 71 | 72 | ``` 73 | cargo add utoipauto 74 | ``` 75 | 76 | Import macro 77 | 78 | ```rust 79 | use utoipauto::utoipauto; 80 | ``` 81 | 82 | Then add the `#[utoipauto]` macro just before the #[derive(OpenApi)] and `#[openapi]` macros. 83 | 84 | ## Important !! 85 | 86 | Put `#[utoipauto]` before `#[derive(OpenApi)] `and `#[openapi]` macros. 87 | 88 | ```rust 89 | #[utoipauto(paths = "MODULE_SRC_FILE_PATH, MODULE_SRC_FILE_PATH, ...")] 90 | ``` 91 | 92 | The paths receives a String which must respect this structure : 93 | 94 | `"MODULE_SRC_FILE_PATH, MODULE_SRC_FILE_PATH, ..."` 95 | 96 | You can add several paths by separating them with a coma `","`. 97 | 98 | ## Usage with workspaces 99 | 100 | If you are using a workspace, you must specify the name of the crate in the path. 101 |
102 | This applies even if you are using `#[utoipauto]` in the same crate. 103 | 104 | ```rust 105 | #[utoipauto(paths = "./utoipauto/src")] 106 | ``` 107 | 108 | You can specify that the specified paths are from another crate by using the from key work. 109 | 110 | ```rust 111 | #[utoipauto(paths = "./utoipauto/src from utoipauto")] 112 | ``` 113 | 114 | ### Import from src folder 115 | 116 | If no path is specified, the macro will automatically scan the `src` folder and add all the methods carrying 117 | the `#[utoipa::path(...)]` macro, and all structs deriving `ToSchema` and `ToResponse`. 118 | Here's an example of how to add all the methods contained in the src code. 119 | 120 | ```rust 121 | ... 122 | 123 | use utoipauto::utoipauto; 124 | 125 | ... 126 | #[utoipauto] 127 | #[derive(OpenApi)] 128 | #[openapi( 129 | tags( 130 | (name = "todo", description = "Todo management endpoints.") 131 | ), 132 | modifiers(&SecurityAddon) 133 | )] 134 | 135 | pub struct ApiDoc; 136 | 137 | ... 138 | 139 | ``` 140 | 141 | ### Import from module 142 | 143 | Here's an example of how to add all the methods and structs contained in the rest module. 144 | 145 | ```rust 146 | 147 | use utoipauto::utoipauto; 148 | 149 | #[utoipauto( 150 | paths = "./src/rest" 151 | )] 152 | #[derive(OpenApi)] 153 | #[openapi( 154 | tags( 155 | (name = "todo", description = "Todo management endpoints.") 156 | ), 157 | modifiers(&SecurityAddon) 158 | )] 159 | 160 | pub struct ApiDoc; 161 | 162 | ``` 163 | 164 | Here's an example of how to add all methods and structs contained in the rest module if it is in a sub-folder: 165 | 166 | ```rust 167 | 168 | use utoipauto::utoipauto; 169 | 170 | #[utoipauto( 171 | paths = "(./src/lib/rest from crate::rest)" 172 | )] 173 | #[derive(OpenApi)] 174 | #[openapi( 175 | tags( 176 | (name = "todo", description = "Todo management endpoints.") 177 | ), 178 | modifiers(&SecurityAddon) 179 | )] 180 | 181 | pub struct ApiDoc; 182 | 183 | ``` 184 | 185 | ### Import from filename 186 | 187 | Here's an example of how to add all the methods contained in the test_controller and test2_controller modules. 188 | you can also combine automatic and manual addition, as here we've added a method manually to the documentation " 189 | other_controller::get_users", and a schema "TestDTO". 190 | 191 | ```rust 192 | 193 | use utoipauto::utoipauto; 194 | 195 | #[utoipauto( 196 | paths = "./src/rest/test_controller.rs,./src/rest/test2_controller.rs " 197 | )] 198 | #[derive(OpenApi)] 199 | #[openapi( 200 | paths( 201 | 202 | crate::rest::other_controller::get_users, 203 | ), 204 | components( 205 | schemas(TestDTO) 206 | ), 207 | tags( 208 | (name = "todo", description = "Todo management endpoints.") 209 | ), 210 | modifiers(&SecurityAddon) 211 | )] 212 | 213 | pub struct ApiDoc; 214 | 215 | 216 | 217 | ``` 218 | 219 | ## Exclude a method from automatic scanning 220 | 221 | you can exclude a function from the Doc Path list by adding the following macro `#[utoipa_ignore]` . 222 | 223 | ex: 224 | 225 | ```rust 226 | /// Get all pets from database 227 | /// 228 | #[utoipa_ignore] //<============== this Macro 229 | #[utoipa::path( 230 | responses( 231 | (status = 200, description = "List all Pets", body = [ListPetsDTO]) 232 | ) 233 | )] 234 | #[get("/pets")] 235 | async fn get_all_pets(req: HttpRequest, store: web::Data) -> impl Responder { 236 | // your CODE 237 | } 238 | 239 | ``` 240 | 241 | ## Exclude a struct from automatic scanning 242 | 243 | you can also exclude a struct from the models and reponses list by adding the following macro `#[utoipa_ignore]` . 244 | 245 | ex: 246 | 247 | ```rust 248 | #[utoipa_ignore] //<============== this Macro 249 | #[derive(ToSchema)] 250 | struct ModelToIgnore { 251 | // your CODE 252 | } 253 | 254 | ``` 255 | 256 | ### Custom path detection 257 | 258 | By default, this macro will look for function with the `#[utoipa::path(...)]` attribute, but you can also specify a 259 | custom attribute to look for. 260 | 261 | ```rust 262 | #[handler] 263 | pub fn custom_router() { 264 | // ... 265 | } 266 | 267 | #[utoipauto(function_attribute_name = "handler")] //Custom attribute 268 | #[derive(OpenApi)] 269 | #[openapi(tags()))] 270 | pub struct ApiDoc; 271 | 272 | ``` 273 | 274 | You can also specify custom attributes for the model and response detection. 275 | 276 | ```rust 277 | #[derive(Schema, Response)] 278 | pub struct CustomModel { 279 | // ... 280 | } 281 | 282 | #[utoipauto(schema_attribute_name = "Schema", response_attribute_name = "Response")] //Custom derive 283 | #[derive(OpenApi)] 284 | #[openapi(tags())] 285 | pub struct ApiDoc; 286 | 287 | ``` 288 | 289 | ## Note 290 | 291 | Sub-modules within a module containing methods tagged with utoipa::path are also automatically detected. 292 | 293 | ## Contributing 294 | 295 | Contributions are welcomed, feel free to submit a PR or an issue. 296 | 297 | ## Inspiration 298 | 299 | Inspired by [utoipa_auto_discovery](https://github.com/rxdiscovery/utoipa_auto_discovery) 300 | -------------------------------------------------------------------------------- /acceptance/.editorconfig: -------------------------------------------------------------------------------- 1 | ../.editorconfig -------------------------------------------------------------------------------- /acceptance/Cargo.lock: -------------------------------------------------------------------------------- 1 | # This file is automatically @generated by Cargo. 2 | # It is not intended for manual editing. 3 | version = 4 4 | 5 | [[package]] 6 | name = "equivalent" 7 | version = "1.0.1" 8 | source = "registry+https://github.com/rust-lang/crates.io-index" 9 | checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" 10 | 11 | [[package]] 12 | name = "folder-in-src" 13 | version = "0.1.0" 14 | dependencies = [ 15 | "utoipa", 16 | "utoipauto", 17 | ] 18 | 19 | [[package]] 20 | name = "generics" 21 | version = "0.1.0" 22 | dependencies = [ 23 | "utility", 24 | "utoipa", 25 | "utoipauto", 26 | ] 27 | 28 | [[package]] 29 | name = "hashbrown" 30 | version = "0.15.0" 31 | source = "registry+https://github.com/rust-lang/crates.io-index" 32 | checksum = "1e087f84d4f86bf4b218b927129862374b72199ae7d8657835f1e89000eea4fb" 33 | 34 | [[package]] 35 | name = "indexmap" 36 | version = "2.6.0" 37 | source = "registry+https://github.com/rust-lang/crates.io-index" 38 | checksum = "707907fe3c25f5424cce2cb7e1cbcafee6bdbe735ca90ef77c29e84591e5b9da" 39 | dependencies = [ 40 | "equivalent", 41 | "hashbrown", 42 | "serde", 43 | ] 44 | 45 | [[package]] 46 | name = "itoa" 47 | version = "1.0.11" 48 | source = "registry+https://github.com/rust-lang/crates.io-index" 49 | checksum = "49f1f14873335454500d59611f1cf4a4b0f786f9ac11f4312a78e4cf2566695b" 50 | 51 | [[package]] 52 | name = "memchr" 53 | version = "2.7.4" 54 | source = "registry+https://github.com/rust-lang/crates.io-index" 55 | checksum = "78ca9ab1a0babb1e7d5695e3530886289c18cf2f87ec19a575a0abdce112e3a3" 56 | 57 | [[package]] 58 | name = "proc-macro2" 59 | version = "1.0.87" 60 | source = "registry+https://github.com/rust-lang/crates.io-index" 61 | checksum = "b3e4daa0dcf6feba26f985457cdf104d4b4256fc5a09547140f3631bb076b19a" 62 | dependencies = [ 63 | "unicode-ident", 64 | ] 65 | 66 | [[package]] 67 | name = "quote" 68 | version = "1.0.37" 69 | source = "registry+https://github.com/rust-lang/crates.io-index" 70 | checksum = "b5b9d34b8991d19d98081b46eacdd8eb58c6f2b201139f7c5f643cc155a633af" 71 | dependencies = [ 72 | "proc-macro2", 73 | ] 74 | 75 | [[package]] 76 | name = "responses" 77 | version = "0.1.0" 78 | dependencies = [ 79 | "serde_json", 80 | "utility", 81 | "utoipa", 82 | "utoipauto", 83 | ] 84 | 85 | [[package]] 86 | name = "ryu" 87 | version = "1.0.18" 88 | source = "registry+https://github.com/rust-lang/crates.io-index" 89 | checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" 90 | 91 | [[package]] 92 | name = "serde" 93 | version = "1.0.210" 94 | source = "registry+https://github.com/rust-lang/crates.io-index" 95 | checksum = "c8e3592472072e6e22e0a54d5904d9febf8508f65fb8552499a1abc7d1078c3a" 96 | dependencies = [ 97 | "serde_derive", 98 | ] 99 | 100 | [[package]] 101 | name = "serde_derive" 102 | version = "1.0.210" 103 | source = "registry+https://github.com/rust-lang/crates.io-index" 104 | checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" 105 | dependencies = [ 106 | "proc-macro2", 107 | "quote", 108 | "syn", 109 | ] 110 | 111 | [[package]] 112 | name = "serde_json" 113 | version = "1.0.128" 114 | source = "registry+https://github.com/rust-lang/crates.io-index" 115 | checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" 116 | dependencies = [ 117 | "itoa", 118 | "memchr", 119 | "ryu", 120 | "serde", 121 | ] 122 | 123 | [[package]] 124 | name = "syn" 125 | version = "2.0.79" 126 | source = "registry+https://github.com/rust-lang/crates.io-index" 127 | checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" 128 | dependencies = [ 129 | "proc-macro2", 130 | "quote", 131 | "unicode-ident", 132 | ] 133 | 134 | [[package]] 135 | name = "unicode-ident" 136 | version = "1.0.13" 137 | source = "registry+https://github.com/rust-lang/crates.io-index" 138 | checksum = "e91b56cd4cadaeb79bbf1a5645f6b4f8dc5bde8834ad5894a8db35fda9efa1fe" 139 | 140 | [[package]] 141 | name = "utility" 142 | version = "0.1.0" 143 | dependencies = [ 144 | "serde_json", 145 | ] 146 | 147 | [[package]] 148 | name = "utoipa" 149 | version = "5.0.0" 150 | source = "registry+https://github.com/rust-lang/crates.io-index" 151 | checksum = "5e2b34fc58a72021914a5745832024b2baa638fe771df5a35f3d1b69266bd92c" 152 | dependencies = [ 153 | "indexmap", 154 | "serde", 155 | "serde_json", 156 | "utoipa-gen", 157 | ] 158 | 159 | [[package]] 160 | name = "utoipa-gen" 161 | version = "5.0.0" 162 | source = "registry+https://github.com/rust-lang/crates.io-index" 163 | checksum = "866f11b33be38a747542f435578a164674b8922d958cc065d7f19319c19d4784" 164 | dependencies = [ 165 | "proc-macro2", 166 | "quote", 167 | "syn", 168 | ] 169 | 170 | [[package]] 171 | name = "utoipauto" 172 | version = "0.3.0-alpha.2" 173 | dependencies = [ 174 | "utoipauto-macro", 175 | ] 176 | 177 | [[package]] 178 | name = "utoipauto-core" 179 | version = "0.3.0-alpha.2" 180 | dependencies = [ 181 | "proc-macro2", 182 | "quote", 183 | "syn", 184 | ] 185 | 186 | [[package]] 187 | name = "utoipauto-lib-test" 188 | version = "0.1.0" 189 | dependencies = [ 190 | "folder-in-src", 191 | "utoipa", 192 | "utoipauto", 193 | ] 194 | 195 | [[package]] 196 | name = "utoipauto-macro" 197 | version = "0.3.0-alpha.2" 198 | dependencies = [ 199 | "proc-macro2", 200 | "quote", 201 | "syn", 202 | "utoipauto-core", 203 | ] 204 | -------------------------------------------------------------------------------- /acceptance/Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crate_segment_path", "folder_in_src", "generics", "responses", "utility"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | authors = ["ProbablyClem", "DenuxPlays", "joaquin041"] 7 | version = "0.1.0" 8 | edition = "2021" 9 | publish = false 10 | description = "A collection of crates to test utoipauto." 11 | readme = "README.md" 12 | license = "MIT OR Apache-2.0" 13 | repository = "https://github.com/ProbablyClem/utoipauto" 14 | homepage = "https://github.com/ProbablyClem/utoipauto" 15 | 16 | [workspace.dependencies] 17 | # Utoipa 18 | utoipauto = { path = "../utoipauto", version = "0.3.0-alpha.2" } 19 | utoipa = { version = "5.0.0", features = ["preserve_path_order"] } 20 | 21 | # Serde 22 | serde_json = "1.0.128" 23 | 24 | # Utilities 25 | utility = { path = "utility" } 26 | -------------------------------------------------------------------------------- /acceptance/LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | ../LICENSE-APACHE -------------------------------------------------------------------------------- /acceptance/LICENSE-MIT: -------------------------------------------------------------------------------- 1 | ../LICENSE-MIT -------------------------------------------------------------------------------- /acceptance/README.md: -------------------------------------------------------------------------------- 1 | # acceptance tests for utoipauto 2 | 3 | This workspace contains multiple crates that tests different aspects about the utoipauto macro. 4 | These tests should represent how the user is expected to use the library. 5 | 6 | ## MSRV 7 | 8 | This workspace does not have a minimum supported rust version. 9 | It should work with the latest stable version of rust and can use all the features that are available in the latest 10 | stable version of rust. 11 | -------------------------------------------------------------------------------- /acceptance/crate_segment_path/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "utoipauto-lib-test" 3 | description = "Test library that tests folders in the src directory." 4 | authors.workspace = true 5 | version.workspace = true 6 | edition.workspace = true 7 | publish.workspace = true 8 | readme.workspace = true 9 | license.workspace = true 10 | repository.workspace = true 11 | homepage.workspace = true 12 | 13 | [lib] 14 | path = "crate_folder/lib.rs" 15 | 16 | [dependencies] 17 | utoipa.workspace = true 18 | utoipauto.workspace = true 19 | folder-in-src = { path = "../folder_in_src" } -------------------------------------------------------------------------------- /acceptance/crate_segment_path/crate_folder/lib.rs: -------------------------------------------------------------------------------- 1 | mod sub_folder; 2 | -------------------------------------------------------------------------------- /acceptance/crate_segment_path/crate_folder/sub_folder/mod.rs: -------------------------------------------------------------------------------- 1 | mod paths; 2 | mod test; 3 | -------------------------------------------------------------------------------- /acceptance/crate_segment_path/crate_folder/sub_folder/paths.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // This code is used in the tests 2 | 3 | #[utoipa::path(post, path = "/route1")] 4 | pub fn route1() {} 5 | 6 | #[utoipa::path(post, path = "/route2")] 7 | pub fn route2() {} 8 | -------------------------------------------------------------------------------- /acceptance/crate_segment_path/crate_folder/sub_folder/test.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | use utoipauto::utoipauto; 3 | 4 | #[utoipauto(paths = "( ./crate_segment_path/crate_folder/sub_folder/paths.rs from crate::sub_folder )")] 5 | #[derive(OpenApi)] 6 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 7 | #[allow(dead_code)] 8 | pub struct CrateInAnotherPath {} 9 | 10 | #[utoipauto(paths = "( ./folder_in_src/crate_folder/new_sub_folder/paths.rs from folder-in-src::new_sub_folder )")] 11 | #[derive(OpenApi)] 12 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 13 | #[allow(dead_code)] 14 | pub struct CrateInAnotherCrate {} 15 | 16 | #[test] 17 | fn test_crate_in_another_path() { 18 | assert_eq!(CrateInAnotherPath::openapi().paths.paths.len(), 2) 19 | } 20 | 21 | #[test] 22 | fn test_crate_in_another_crate() { 23 | assert_eq!(CrateInAnotherCrate::openapi().paths.paths.len(), 2) 24 | } 25 | -------------------------------------------------------------------------------- /acceptance/folder_in_src/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "folder-in-src" 3 | description = "Test library that tests folders in the src directory." 4 | authors.workspace = true 5 | version.workspace = true 6 | edition.workspace = true 7 | publish.workspace = true 8 | readme.workspace = true 9 | license.workspace = true 10 | repository.workspace = true 11 | homepage.workspace = true 12 | 13 | [lib] 14 | path = "crate_folder/lib.rs" 15 | 16 | [dependencies] 17 | utoipa.workspace = true 18 | utoipauto.workspace = true -------------------------------------------------------------------------------- /acceptance/folder_in_src/crate_folder/lib.rs: -------------------------------------------------------------------------------- 1 | pub mod new_sub_folder; 2 | -------------------------------------------------------------------------------- /acceptance/folder_in_src/crate_folder/new_sub_folder/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod paths; 2 | mod test; 3 | -------------------------------------------------------------------------------- /acceptance/folder_in_src/crate_folder/new_sub_folder/paths.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // This code is used in the tests 2 | 3 | #[utoipa::path(post, path = "/route1new")] 4 | pub fn route1new() {} 5 | 6 | #[utoipa::path(post, path = "/route2new")] 7 | pub fn route2new() {} 8 | -------------------------------------------------------------------------------- /acceptance/folder_in_src/crate_folder/new_sub_folder/test.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | use utoipauto::utoipauto; 3 | 4 | #[utoipauto(paths = "( ./folder_in_src/crate_folder/new_sub_folder/paths.rs from crate::new_sub_folder )")] 5 | #[derive(OpenApi)] 6 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 7 | #[allow(dead_code)] 8 | pub struct CrateInAnotherPath {} 9 | 10 | #[test] 11 | fn test_crate_in_another_path() { 12 | assert_eq!(CrateInAnotherPath::openapi().paths.paths.len(), 2) 13 | } 14 | -------------------------------------------------------------------------------- /acceptance/generics/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "generics" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | publish.workspace = true 7 | description.workspace = true 8 | readme.workspace = true 9 | license.workspace = true 10 | repository.workspace = true 11 | homepage.workspace = true 12 | 13 | [lints.rust] 14 | unused = "allow" 15 | 16 | [dependencies] 17 | utoipa.workspace = true 18 | utoipauto = { workspace = true } 19 | 20 | [dev-dependencies] 21 | utility.workspace = true 22 | -------------------------------------------------------------------------------- /acceptance/generics/src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod routes; 2 | pub mod schemas; 3 | 4 | use routes::*; 5 | 6 | use utoipa::OpenApi; 7 | use utoipauto::utoipauto; 8 | 9 | #[utoipauto(paths = "./generics/src")] 10 | #[derive(Debug, OpenApi)] 11 | #[openapi(info(title = "Generic Test Api"))] 12 | pub(crate) struct ApiDoc; 13 | 14 | fn main() { 15 | println!( 16 | "Our OpenApi documentation {}", 17 | ApiDoc::openapi().to_pretty_json().unwrap() 18 | ); 19 | } 20 | 21 | #[cfg(test)] 22 | mod tests { 23 | use super::*; 24 | use utility::assert_json_eq; 25 | 26 | pub(crate) const EXPECTED_OPEN_API: &str = include_str!("open_api.expected.json"); 27 | 28 | #[test] 29 | fn test_openapi() { 30 | let open_api = ApiDoc::openapi().to_json().unwrap(); 31 | 32 | assert_json_eq(&open_api, EXPECTED_OPEN_API); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /acceptance/generics/src/open_api.expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.1.0", 3 | "info": { 4 | "title": "Generic Test Api", 5 | "description": "A collection of crates to test utoipauto.", 6 | "contact": { 7 | "name": "ProbablyClem" 8 | }, 9 | "license": { 10 | "name": "MIT OR Apache-2.0" 11 | }, 12 | "version": "0.1.0" 13 | }, 14 | "paths": { 15 | "/persons": { 16 | "get": { 17 | "tags": [ 18 | "crate::routes" 19 | ], 20 | "operationId": "get_persons", 21 | "responses": { 22 | "200": { 23 | "description": "A Response", 24 | "content": { 25 | "application/json": { 26 | "schema": { 27 | "$ref": "#/components/schemas/Response_Person" 28 | } 29 | } 30 | } 31 | } 32 | } 33 | } 34 | }, 35 | "/nested_persons": { 36 | "get": { 37 | "tags": [ 38 | "crate::routes" 39 | ], 40 | "operationId": "get_nested_persons", 41 | "responses": { 42 | "200": { 43 | "description": "A NestedResponse", 44 | "content": { 45 | "application/json": { 46 | "schema": { 47 | "$ref": "#/components/schemas/NestedResponse_Person" 48 | } 49 | } 50 | } 51 | } 52 | } 53 | } 54 | }, 55 | "/borrowed_persons": { 56 | "get": { 57 | "tags": [ 58 | "crate::routes" 59 | ], 60 | "operationId": "get_borrowed_persons", 61 | "responses": { 62 | "200": { 63 | "description": "A BorrowedResponse<'static>", 64 | "content": { 65 | "application/json": { 66 | "schema": { 67 | "$ref": "#/components/schemas/BorrowedResponse" 68 | } 69 | } 70 | } 71 | } 72 | } 73 | } 74 | }, 75 | "/nested_borrowed_persons": { 76 | "get": { 77 | "tags": [ 78 | "crate::routes" 79 | ], 80 | "operationId": "get_nested_borrowed_persons", 81 | "responses": { 82 | "200": { 83 | "description": "A NestedBorrowedResponse<'static, Person>", 84 | "content": { 85 | "application/json": { 86 | "schema": { 87 | "$ref": "#/components/schemas/NestedBorrowedResponse_Person" 88 | } 89 | } 90 | } 91 | } 92 | } 93 | } 94 | }, 95 | "/combined_persons": { 96 | "get": { 97 | "tags": [ 98 | "crate::routes" 99 | ], 100 | "operationId": "get_combined_persons", 101 | "responses": { 102 | "200": { 103 | "description": "A CombinedResponse<'static, Person>", 104 | "content": { 105 | "application/json": { 106 | "schema": { 107 | "$ref": "#/components/schemas/CombinedResponse_Person" 108 | } 109 | } 110 | } 111 | } 112 | } 113 | } 114 | } 115 | }, 116 | "components": { 117 | "schemas": { 118 | "BorrowedResponse": { 119 | "type": "object", 120 | "required": [ 121 | "data", 122 | "additional" 123 | ], 124 | "properties": { 125 | "additional": { 126 | "type": "object", 127 | "additionalProperties": { 128 | "type": "integer", 129 | "format": "int32" 130 | }, 131 | "propertyNames": { 132 | "type": "string" 133 | } 134 | }, 135 | "data": { 136 | "type": "string" 137 | } 138 | } 139 | }, 140 | "CombinedResponse_Person": { 141 | "type": "object", 142 | "required": [ 143 | "nested_response", 144 | "borrowed_response" 145 | ], 146 | "properties": { 147 | "borrowed_response": { 148 | "$ref": "#/components/schemas/NestedBorrowedResponse_Person" 149 | }, 150 | "nested_response": { 151 | "$ref": "#/components/schemas/NestedResponse_Person" 152 | } 153 | } 154 | }, 155 | "NestedBorrowedResponse_Person": { 156 | "type": "object", 157 | "required": [ 158 | "status", 159 | "data" 160 | ], 161 | "properties": { 162 | "data": { 163 | "type": "object", 164 | "required": [ 165 | "name", 166 | "age" 167 | ], 168 | "properties": { 169 | "age": { 170 | "type": "integer", 171 | "format": "int32", 172 | "minimum": 0 173 | }, 174 | "name": { 175 | "type": "string" 176 | } 177 | } 178 | }, 179 | "status": { 180 | "type": "integer", 181 | "format": "int32", 182 | "minimum": 0 183 | } 184 | } 185 | }, 186 | "NestedResponse_Person": { 187 | "type": "object", 188 | "required": [ 189 | "response" 190 | ], 191 | "properties": { 192 | "response": { 193 | "$ref": "#/components/schemas/Response_Person" 194 | } 195 | } 196 | }, 197 | "Person": { 198 | "type": "object", 199 | "required": [ 200 | "name", 201 | "age" 202 | ], 203 | "properties": { 204 | "age": { 205 | "type": "integer", 206 | "format": "int32", 207 | "minimum": 0 208 | }, 209 | "name": { 210 | "type": "string" 211 | } 212 | } 213 | }, 214 | "Response_Person": { 215 | "type": "object", 216 | "required": [ 217 | "status", 218 | "data" 219 | ], 220 | "properties": { 221 | "data": { 222 | "type": "object", 223 | "required": [ 224 | "name", 225 | "age" 226 | ], 227 | "properties": { 228 | "age": { 229 | "type": "integer", 230 | "format": "int32", 231 | "minimum": 0 232 | }, 233 | "name": { 234 | "type": "string" 235 | } 236 | } 237 | }, 238 | "status": { 239 | "type": "integer", 240 | "format": "int32", 241 | "minimum": 0 242 | } 243 | } 244 | } 245 | }, 246 | "responses": { 247 | "BorrowedResponse": { 248 | "description": "", 249 | "content": { 250 | "application/json": { 251 | "schema": { 252 | "type": "object", 253 | "required": [ 254 | "data", 255 | "additional" 256 | ], 257 | "properties": { 258 | "additional": { 259 | "type": "object", 260 | "additionalProperties": { 261 | "type": "integer", 262 | "format": "int32" 263 | }, 264 | "propertyNames": { 265 | "type": "string" 266 | } 267 | }, 268 | "data": { 269 | "type": "string" 270 | } 271 | } 272 | } 273 | } 274 | } 275 | }, 276 | "Person": { 277 | "description": "", 278 | "content": { 279 | "application/json": { 280 | "schema": { 281 | "type": "object", 282 | "required": [ 283 | "name", 284 | "age" 285 | ], 286 | "properties": { 287 | "age": { 288 | "type": "integer", 289 | "format": "int32", 290 | "minimum": 0 291 | }, 292 | "name": { 293 | "type": "string" 294 | } 295 | } 296 | } 297 | } 298 | } 299 | } 300 | } 301 | } 302 | } -------------------------------------------------------------------------------- /acceptance/generics/src/routes.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use crate::schemas::{BorrowedResponse, CombinedResponse, NestedBorrowedResponse, NestedResponse, Person, Response}; 4 | 5 | #[utoipa::path(get, 6 | path = "/persons", 7 | responses( 8 | (status = 200, description = "A Response", content_type = "application/json", body = Response), 9 | ) 10 | )] 11 | pub fn get_persons() -> Response { 12 | Response { 13 | status: 200, 14 | data: Person { 15 | name: "John Doe".to_string(), 16 | age: 30, 17 | }, 18 | } 19 | } 20 | 21 | #[utoipa::path(get, 22 | path = "/nested_persons", 23 | responses( 24 | (status = 200, description = "A NestedResponse", content_type = "application/json", body = NestedResponse), 25 | ) 26 | )] 27 | pub fn get_nested_persons() -> NestedResponse { 28 | NestedResponse { 29 | response: Response { 30 | status: 200, 31 | data: Person { 32 | name: "John Doe".to_string(), 33 | age: 30, 34 | }, 35 | }, 36 | } 37 | } 38 | 39 | #[utoipa::path(get, 40 | path = "/borrowed_persons", 41 | responses( 42 | (status = 200, description = "A BorrowedResponse<'static>", content_type = "application/json", body = BorrowedResponse<'static>), 43 | ) 44 | )] 45 | pub fn get_borrowed_persons() -> BorrowedResponse<'static> { 46 | let additional = HashMap::from([("first", &42), ("second", &-3)]); 47 | BorrowedResponse { 48 | data: "Test", 49 | additional, 50 | } 51 | } 52 | 53 | #[utoipa::path(get, 54 | path = "/nested_borrowed_persons", 55 | responses( 56 | (status = 200, description = "A NestedBorrowedResponse<'static, Person>", content_type = "application/json", body = NestedBorrowedResponse<'static, Person>), 57 | ) 58 | )] 59 | pub fn get_nested_borrowed_persons() -> NestedBorrowedResponse<'static, Person> { 60 | let person = Box::new(Person { 61 | name: "John Doe".to_string(), 62 | age: 30, 63 | }); 64 | NestedBorrowedResponse { 65 | status: 200, 66 | data: Box::leak(person), 67 | } 68 | } 69 | 70 | #[utoipa::path(get, 71 | path = "/combined_persons", 72 | responses( 73 | (status = 200, description = "A CombinedResponse<'static, Person>", content_type = "application/json", body = CombinedResponse<'static, Person>), 74 | ) 75 | )] 76 | pub fn get_combined_persons() -> CombinedResponse<'static, Person> { 77 | let person = Box::new(Person { 78 | name: "John Doe".to_string(), 79 | age: 30, 80 | }); 81 | let person_ref = Box::leak(person); 82 | CombinedResponse { 83 | nested_response: NestedResponse { 84 | response: Response { 85 | status: 200, 86 | data: person_ref.clone(), 87 | }, 88 | }, 89 | borrowed_response: NestedBorrowedResponse { 90 | status: 200, 91 | data: person_ref, 92 | }, 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /acceptance/generics/src/schemas.rs: -------------------------------------------------------------------------------- 1 | use std::collections::HashMap; 2 | 3 | use utoipa::{ToResponse, ToSchema}; 4 | 5 | #[derive(Debug, ToSchema, ToResponse)] 6 | pub struct Response { 7 | pub status: u16, 8 | pub data: T, 9 | } 10 | 11 | #[derive(Debug, Clone, ToSchema, ToResponse)] 12 | pub struct Person { 13 | pub name: String, 14 | pub age: u8, 15 | } 16 | 17 | // Nested Generics 18 | #[derive(Debug, ToSchema, ToResponse)] 19 | pub struct NestedResponse { 20 | pub response: Response, 21 | } 22 | 23 | // Lifetime Generics 24 | #[derive(Debug, ToSchema, ToResponse)] 25 | pub struct BorrowedResponse<'a> { 26 | pub data: &'a str, 27 | pub additional: HashMap<&'a str, &'a i32>, 28 | } 29 | 30 | // Lifetime + nested Generics 31 | #[derive(Debug, ToSchema, ToResponse)] 32 | pub struct NestedBorrowedResponse<'a, T: ToSchema> { 33 | pub status: u16, 34 | pub data: &'a T, 35 | } 36 | 37 | // Combined Generics 38 | #[derive(Debug, ToSchema, ToResponse)] 39 | pub struct CombinedResponse<'a, T: ToSchema> { 40 | pub nested_response: NestedResponse, 41 | pub borrowed_response: NestedBorrowedResponse<'a, T>, 42 | } 43 | -------------------------------------------------------------------------------- /acceptance/responses/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "responses" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | publish.workspace = true 7 | description.workspace = true 8 | readme.workspace = true 9 | license.workspace = true 10 | repository.workspace = true 11 | homepage.workspace = true 12 | 13 | [lints.rust] 14 | unused = "allow" 15 | 16 | [dependencies] 17 | # Utoipa 18 | utoipa.workspace = true 19 | utoipauto.workspace = true 20 | 21 | # Serde 22 | serde_json.workspace = true 23 | 24 | # Utility 25 | utility.workspace = true 26 | -------------------------------------------------------------------------------- /acceptance/responses/src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod response; 2 | mod routes; 3 | 4 | use utoipa::OpenApi; 5 | use utoipauto::utoipauto; 6 | 7 | #[utoipauto(paths = "./responses/src")] 8 | #[derive(Debug, OpenApi)] 9 | #[openapi(info(title = "Responses Test Api"))] 10 | pub(crate) struct ApiDoc; 11 | 12 | fn main() { 13 | println!( 14 | "Our OpenApi documentation {}", 15 | ApiDoc::openapi().to_pretty_json().unwrap() 16 | ); 17 | } 18 | 19 | #[cfg(test)] 20 | mod tests { 21 | use crate::ApiDoc; 22 | use utoipa::OpenApi; 23 | use utility::assert_json_eq; 24 | 25 | pub(crate) const EXPECTED_OPEN_API: &str = include_str!("open_api.expected.json"); 26 | #[test] 27 | fn test_open_api() { 28 | let open_api = ApiDoc::openapi().to_json().unwrap(); 29 | let expected_value = EXPECTED_OPEN_API; 30 | 31 | assert_json_eq(&open_api, expected_value); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /acceptance/responses/src/open_api.expected.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.1.0", 3 | "info": { 4 | "title": "Responses Test Api", 5 | "description": "A collection of crates to test utoipauto.", 6 | "contact": { 7 | "name": "ProbablyClem" 8 | }, 9 | "license": { 10 | "name": "MIT OR Apache-2.0" 11 | }, 12 | "version": "0.1.0" 13 | }, 14 | "paths": { 15 | "/api/user": { 16 | "get": { 17 | "tags": [ 18 | "crate::routes" 19 | ], 20 | "operationId": "get_user", 21 | "responses": { 22 | "200": { 23 | "description": "Success response", 24 | "content": { 25 | "application/json": { 26 | "schema": { 27 | "type": "object", 28 | "description": "Success response", 29 | "required": [ 30 | "value" 31 | ], 32 | "properties": { 33 | "value": { 34 | "type": "string" 35 | } 36 | } 37 | } 38 | } 39 | } 40 | }, 41 | "400": { 42 | "description": "", 43 | "content": { 44 | "application/json": { 45 | "schema": { 46 | "$ref": "#/components/schemas/BadRequest" 47 | } 48 | } 49 | } 50 | }, 51 | "404": { 52 | "description": "" 53 | } 54 | } 55 | } 56 | }, 57 | "/api/person": { 58 | "get": { 59 | "tags": [ 60 | "crate::routes" 61 | ], 62 | "operationId": "get_person", 63 | "responses": { 64 | "200": { 65 | "$ref": "#/components/responses/Person" 66 | } 67 | } 68 | } 69 | } 70 | }, 71 | "components": { 72 | "schemas": { 73 | "Admin": { 74 | "type": "object", 75 | "required": [ 76 | "name" 77 | ], 78 | "properties": { 79 | "name": { 80 | "type": "string" 81 | } 82 | } 83 | }, 84 | "Admin2": { 85 | "type": "object", 86 | "required": [ 87 | "name", 88 | "id" 89 | ], 90 | "properties": { 91 | "id": { 92 | "type": "integer", 93 | "format": "int32" 94 | }, 95 | "name": { 96 | "type": "string" 97 | } 98 | } 99 | }, 100 | "BadRequest": { 101 | "type": "object", 102 | "required": [ 103 | "message" 104 | ], 105 | "properties": { 106 | "message": { 107 | "type": "string" 108 | } 109 | } 110 | } 111 | }, 112 | "responses": { 113 | "Person": { 114 | "description": "", 115 | "content": { 116 | "application/vnd-custom-v1+json": { 117 | "schema": { 118 | "$ref": "#/components/schemas/Admin" 119 | }, 120 | "examples": { 121 | "Person1": { 122 | "value": { 123 | "name": "name1" 124 | } 125 | }, 126 | "Person2": { 127 | "value": { 128 | "name": "name2" 129 | } 130 | } 131 | } 132 | }, 133 | "application/vnd-custom-v2+json": { 134 | "schema": { 135 | "type": "object", 136 | "required": [ 137 | "name", 138 | "id" 139 | ], 140 | "properties": { 141 | "id": { 142 | "type": "integer", 143 | "format": "int32" 144 | }, 145 | "name": { 146 | "type": "string" 147 | } 148 | } 149 | }, 150 | "example": { 151 | "id": 1, 152 | "name": "name3" 153 | } 154 | } 155 | } 156 | } 157 | } 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /acceptance/responses/src/response.rs: -------------------------------------------------------------------------------- 1 | #[derive(utoipa::ToSchema)] 2 | pub struct Admin { 3 | pub name: String, 4 | } 5 | #[derive(utoipa::ToSchema)] 6 | pub struct Admin2 { 7 | pub name: String, 8 | pub id: i32, 9 | } 10 | 11 | #[derive(utoipa::ToResponse)] 12 | pub enum Person { 13 | #[response(examples( 14 | ("Person1" = (value = json!({"name": "name1"}))), 15 | ("Person2" = (value = json!({"name": "name2"}))) 16 | ))] 17 | Admin(#[content("application/vnd-custom-v1+json")] Admin), 18 | 19 | #[response(example = json!({"name": "name3", "id": 1}))] 20 | Admin2( 21 | #[content("application/vnd-custom-v2+json")] 22 | #[to_schema] 23 | Admin2, 24 | ), 25 | } 26 | 27 | #[derive(utoipa::ToSchema)] 28 | pub struct BadRequest { 29 | message: String, 30 | } 31 | 32 | #[derive(utoipa::IntoResponses)] 33 | pub enum UserResponses { 34 | /// Success response 35 | #[response(status = 200)] 36 | Success { value: String }, 37 | 38 | #[response(status = 404)] 39 | NotFound, 40 | 41 | #[response(status = 400)] 42 | BadRequest(BadRequest), 43 | } 44 | -------------------------------------------------------------------------------- /acceptance/responses/src/routes.rs: -------------------------------------------------------------------------------- 1 | use crate::response::{Admin, Person, UserResponses}; 2 | 3 | #[utoipa::path(get, path = "/api/user", responses(UserResponses))] 4 | fn get_user() -> UserResponses { 5 | UserResponses::NotFound 6 | } 7 | 8 | #[utoipa::path( 9 | get, 10 | path = "/api/person", 11 | responses( 12 | (status = 200, response = Person) 13 | ) 14 | )] 15 | fn get_person() -> Person { 16 | Person::Admin(Admin { 17 | name: "name1".to_string(), 18 | }) 19 | } 20 | -------------------------------------------------------------------------------- /acceptance/rustfmt.toml: -------------------------------------------------------------------------------- 1 | ../rustfmt.toml -------------------------------------------------------------------------------- /acceptance/utility/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "utility" 3 | description = "Utility functions for the acceptance tests" 4 | authors.workspace = true 5 | version.workspace = true 6 | edition.workspace = true 7 | publish.workspace = true 8 | readme.workspace = true 9 | license.workspace = true 10 | repository.workspace = true 11 | homepage.workspace = true 12 | 13 | [dependencies] 14 | serde_json.workspace = true 15 | -------------------------------------------------------------------------------- /acceptance/utility/src/lib.rs: -------------------------------------------------------------------------------- 1 | use serde_json::Value; 2 | 3 | pub fn assert_json_eq(actual: &str, expected: &str) { 4 | let actual_value: Value = serde_json::from_str(actual).expect("Invalid JSON in actual"); 5 | let expected_value: Value = serde_json::from_str(expected).expect("Invalid JSON in expected"); 6 | 7 | assert_eq!(actual_value, expected_value, "JSON objects are not equal"); 8 | } 9 | -------------------------------------------------------------------------------- /armory.toml: -------------------------------------------------------------------------------- 1 | version = "0.1.0" 2 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | max_width = 120 3 | newline_style = "Unix" 4 | hard_tabs = false 5 | tab_spaces = 4 6 | #group_imports = "StdExternalCrate" # currently a nightly feature -------------------------------------------------------------------------------- /utoipauto-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "utoipauto-core" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | keywords.workspace = true 7 | description.workspace = true 8 | categories.workspace = true 9 | license.workspace = true 10 | readme.workspace = true 11 | repository.workspace = true 12 | homepage.workspace = true 13 | rust-version.workspace = true 14 | 15 | [dependencies] 16 | quote.workspace = true 17 | syn.workspace = true 18 | proc-macro2.workspace = true 19 | 20 | [dev-dependencies] 21 | utoipa.workspace = true 22 | -------------------------------------------------------------------------------- /utoipauto-core/src/attribute_utils.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::TokenStream; 2 | use syn::{punctuated::Punctuated, Attribute, Meta, Token}; 3 | 4 | pub fn update_openapi_macro_attributes( 5 | macro_attibutes: &mut Vec, 6 | uto_paths: &TokenStream, 7 | uto_models: &TokenStream, 8 | uto_responses: &TokenStream, 9 | ) { 10 | let mut is_ok = false; 11 | for attr in macro_attibutes { 12 | if !attr.path().is_ident("openapi") { 13 | continue; 14 | } 15 | is_ok = true; 16 | match &attr.meta { 17 | // #[openapi] 18 | Meta::Path(_path) => { 19 | *attr = build_new_openapi_attributes(Punctuated::new(), uto_paths, uto_models, uto_responses); 20 | } 21 | // #[openapi()] or #[openapi(attribute(...))] 22 | Meta::List(meta_list) => { 23 | let nested = meta_list 24 | .parse_args_with(Punctuated::::parse_terminated) 25 | .expect("Expected a list of attributes inside #[openapi(...)]!"); 26 | *attr = build_new_openapi_attributes(nested, uto_paths, uto_models, uto_responses); 27 | } 28 | // This would be #[openapi = "foo"], which is not valid 29 | Meta::NameValue(_) => panic!("Expected #[openapi(...)], but found #[openapi = value]!"), 30 | } 31 | } 32 | if !is_ok { 33 | panic!("No utoipa::openapi Macro found !"); 34 | } 35 | } 36 | 37 | /// Build the new openapi macro attribute with the newly discovered paths 38 | pub fn build_new_openapi_attributes( 39 | nested_attributes: Punctuated, 40 | uto_paths: &TokenStream, 41 | uto_models: &TokenStream, 42 | uto_responses: &TokenStream, 43 | ) -> Attribute { 44 | let paths = extract_paths(&nested_attributes); 45 | let schemas = extract_components(&nested_attributes, "schemas"); 46 | let responses = extract_components(&nested_attributes, "responses"); 47 | let remaining_nested_attributes = remove_paths_and_components(nested_attributes); 48 | 49 | let uto_paths = match uto_paths.is_empty() { 50 | true => TokenStream::new(), 51 | false => quote::quote!(#uto_paths,), 52 | }; 53 | let uto_models = match uto_models.is_empty() { 54 | true => TokenStream::new(), 55 | false => quote::quote!(#uto_models,), 56 | }; 57 | let uto_responses = match uto_responses.is_empty() { 58 | true => TokenStream::new(), 59 | false => quote::quote!(#uto_responses,), 60 | }; 61 | let uto_macro = quote::quote!( 62 | paths(#uto_paths #paths),components(schemas(#uto_models #schemas),responses(#uto_responses #responses)), 63 | #remaining_nested_attributes 64 | ); 65 | 66 | syn::parse_quote! { #[openapi( #uto_macro )] } 67 | } 68 | 69 | fn remove_paths_and_components(nested_attributes: Punctuated) -> TokenStream { 70 | let mut remaining = Vec::new(); 71 | for meta in nested_attributes { 72 | match meta { 73 | Meta::List(list) if list.path.is_ident("paths") => (), 74 | Meta::List(list) if list.path.is_ident("components") => (), 75 | // These should be handled by removing `components`, this is just in case they occur outside of `components` for some reason. 76 | Meta::List(list) if list.path.is_ident("schemas") => (), 77 | Meta::List(list) if list.path.is_ident("responses") => (), 78 | _ => remaining.push(meta), 79 | } 80 | } 81 | quote::quote!( #(#remaining),* ) 82 | } 83 | 84 | fn extract_paths(nested_attributes: &Punctuated) -> TokenStream { 85 | nested_attributes 86 | .iter() 87 | .find_map(|meta| { 88 | let Meta::List(list) = meta else { return None }; 89 | list.path.is_ident("paths").then(|| list.tokens.clone()) 90 | }) 91 | .unwrap_or_else(TokenStream::new) 92 | } 93 | 94 | fn extract_components(nested_attributes: &Punctuated, component_kind: &str) -> TokenStream { 95 | nested_attributes 96 | .iter() 97 | .find_map(|meta| { 98 | let Meta::List(list) = meta else { return None }; 99 | if !list.path.is_ident("components") { 100 | return None; 101 | } 102 | 103 | let nested = list 104 | .parse_args_with(Punctuated::::parse_terminated) 105 | .expect("Expected a list of attributes inside components(...)!"); 106 | 107 | nested.iter().find_map(|meta| { 108 | let Meta::List(list) = meta else { return None }; 109 | list.path.is_ident(component_kind).then(|| list.tokens.clone()) 110 | }) 111 | }) 112 | .unwrap_or_else(TokenStream::new) 113 | } 114 | 115 | #[cfg(test)] 116 | mod test { 117 | use proc_macro2::TokenStream; 118 | use quote::ToTokens; 119 | use syn::punctuated::Punctuated; 120 | 121 | #[test] 122 | fn test_extract_paths() { 123 | assert_eq!( 124 | super::extract_paths(&syn::parse_quote!(paths(p1))).to_string(), 125 | "p1".to_string() 126 | ); 127 | } 128 | 129 | #[test] 130 | fn test_extract_paths_empty() { 131 | assert_eq!(super::extract_paths(&Punctuated::new()).to_string(), "".to_string()); 132 | } 133 | 134 | #[test] 135 | fn test_build_new_openapi_attributes() { 136 | assert_eq!( 137 | super::build_new_openapi_attributes( 138 | Punctuated::new(), 139 | "e::quote!(crate::api::test), 140 | &TokenStream::new(), 141 | &TokenStream::new(), 142 | ) 143 | .to_token_stream() 144 | .to_string() 145 | .replace(' ', ""), 146 | "#[openapi(paths(crate::api::test,),components(schemas(),responses()),)]".to_string() 147 | ); 148 | } 149 | 150 | #[test] 151 | fn test_build_new_openapi_attributes_path_replace() { 152 | assert_eq!( 153 | super::build_new_openapi_attributes( 154 | syn::parse_quote!(paths(p1)), 155 | "e::quote!(crate::api::test), 156 | &TokenStream::new(), 157 | &TokenStream::new(), 158 | ) 159 | .to_token_stream() 160 | .to_string() 161 | .replace(' ', ""), 162 | "#[openapi(paths(crate::api::test,p1),components(schemas(),responses()),)]".to_string() 163 | ); 164 | } 165 | 166 | #[test] 167 | fn test_build_new_openapi_attributes_components() { 168 | assert_eq!( 169 | super::build_new_openapi_attributes( 170 | syn::parse_quote!(paths(p1)), 171 | "e::quote!(crate::api::test), 172 | "e::quote!(model), 173 | &TokenStream::new(), 174 | ) 175 | .to_token_stream() 176 | .to_string() 177 | .replace(' ', ""), 178 | "#[openapi(paths(crate::api::test,p1),components(schemas(model,),responses()),)]".to_string() 179 | ); 180 | } 181 | 182 | #[test] 183 | fn test_build_new_openapi_attributes_components_schemas_replace() { 184 | assert_eq!( 185 | super::build_new_openapi_attributes( 186 | syn::parse_quote!(paths(p1), components(schemas(m1))), 187 | "e::quote!(crate::api::test), 188 | "e::quote!(model), 189 | &TokenStream::new(), 190 | ) 191 | .to_token_stream() 192 | .to_string() 193 | .replace(' ', ""), 194 | "#[openapi(paths(crate::api::test,p1),components(schemas(model,m1),responses()),)]".to_string() 195 | ); 196 | } 197 | 198 | #[test] 199 | fn test_build_new_openapi_attributes_components_responses_replace() { 200 | assert_eq!( 201 | super::build_new_openapi_attributes( 202 | syn::parse_quote!(paths(p1), components(responses(r1))), 203 | "e::quote!(crate::api::test), 204 | &TokenStream::new(), 205 | "e::quote!(response), 206 | ) 207 | .to_token_stream() 208 | .to_string() 209 | .replace(' ', ""), 210 | "#[openapi(paths(crate::api::test,p1),components(schemas(),responses(response,r1)),)]".to_string() 211 | ); 212 | } 213 | 214 | #[test] 215 | fn test_build_new_openapi_attributes_components_responses_schemas_replace() { 216 | assert_eq!( 217 | super::build_new_openapi_attributes( 218 | syn::parse_quote!(paths(p1), components(responses(r1), schemas(m1))), 219 | "e::quote!(crate::api::test), 220 | "e::quote!(model), 221 | "e::quote!(response), 222 | ) 223 | .to_token_stream() 224 | .to_string() 225 | .replace(' ', ""), 226 | "#[openapi(paths(crate::api::test,p1),components(schemas(model,m1),responses(response,r1)),)]".to_string() 227 | ); 228 | } 229 | 230 | #[test] 231 | fn test_build_new_openapi_attributes_components_responses_schemas() { 232 | assert_eq!( 233 | super::build_new_openapi_attributes( 234 | syn::parse_quote!(paths(p1), components(responses(r1), schemas(m1))), 235 | "e::quote!(crate::api::test), 236 | &TokenStream::new(), 237 | "e::quote!(response), 238 | ) 239 | .to_token_stream() 240 | .to_string() 241 | .replace(' ', ""), 242 | "#[openapi(paths(crate::api::test,p1),components(schemas(m1),responses(response,r1)),)]".to_string() 243 | ); 244 | } 245 | 246 | #[test] 247 | fn test_build_new_openapi_attributes_components_schemas_reponses() { 248 | assert_eq!( 249 | super::build_new_openapi_attributes( 250 | syn::parse_quote!(paths(p1), components(schemas(m1), responses(r1))), 251 | "e::quote!(crate::api::test), 252 | "e::quote!(model), 253 | &TokenStream::new(), 254 | ) 255 | .to_token_stream() 256 | .to_string() 257 | .replace(' ', ""), 258 | "#[openapi(paths(crate::api::test,p1),components(schemas(model,m1),responses(r1)),)]".to_string() 259 | ); 260 | } 261 | 262 | #[test] 263 | fn test_update_openapi_attributes_empty() { 264 | let mut attrs = vec![syn::parse_quote!(#[openapi])]; 265 | super::update_openapi_macro_attributes( 266 | &mut attrs, 267 | "e::quote!(crate::api::test), 268 | "e::quote!(model), 269 | &TokenStream::new(), 270 | ); 271 | assert_eq!( 272 | attrs[0].to_token_stream().to_string().replace(' ', ""), 273 | "#[openapi(paths(crate::api::test,),components(schemas(model,),responses()),)]".to_string() 274 | ); 275 | } 276 | 277 | #[test] 278 | fn test_update_openapi_attributes_components_schemas_reponses() { 279 | let mut attrs = vec![syn::parse_quote!(#[openapi(paths(p1), components(schemas(m1), responses(r1)))])]; 280 | super::update_openapi_macro_attributes( 281 | &mut attrs, 282 | "e::quote!(crate::api::test), 283 | "e::quote!(model), 284 | &TokenStream::new(), 285 | ); 286 | assert_eq!( 287 | attrs[0].to_token_stream().to_string().replace(' ', ""), 288 | "#[openapi(paths(crate::api::test,p1),components(schemas(model,m1),responses(r1)),)]".to_string() 289 | ); 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /utoipauto-core/src/discover.rs: -------------------------------------------------------------------------------- 1 | use std::vec; 2 | 3 | use crate::file_utils::{extract_module_name_from_path, parse_files}; 4 | use crate::token_utils::Parameters; 5 | use quote::ToTokens; 6 | use syn::token::Comma; 7 | use syn::Ident; 8 | use syn::{punctuated::Punctuated, Attribute, GenericParam, Item, ItemFn, ItemImpl, Meta, Token}; 9 | 10 | /// Discover everything from a file, will explore folder recursively 11 | pub fn discover_from_file( 12 | src_path: String, 13 | crate_name: String, 14 | params: &Parameters, 15 | ) -> (Vec, Vec, Vec) { 16 | let files = parse_files(&src_path).unwrap_or_else(|_| panic!("Failed to parse file {}", src_path)); 17 | 18 | files 19 | .into_iter() 20 | .map(|e| parse_module_items(extract_module_name_from_path(&e.0, &crate_name), e.1.items, params)) 21 | .fold(Vec::::new(), |mut acc, mut v| { 22 | acc.append(&mut v); 23 | acc 24 | }) 25 | .into_iter() 26 | .fold( 27 | ( 28 | Vec::::new(), 29 | Vec::::new(), 30 | Vec::::new(), 31 | ), 32 | |mut acc, v| { 33 | match v { 34 | DiscoverType::Fn(n) => acc.0.push(n), 35 | DiscoverType::Model(n) => acc.1.push(n), 36 | DiscoverType::Response(n) => acc.2.push(n), 37 | DiscoverType::CustomModelImpl(n) => acc.1.push(n), 38 | DiscoverType::CustomResponseImpl(n) => acc.2.push(n), 39 | }; 40 | 41 | acc 42 | }, 43 | ) 44 | } 45 | 46 | #[allow(unused)] 47 | enum DiscoverType { 48 | Fn(syn::Path), 49 | Model(syn::Path), 50 | Response(syn::Path), 51 | CustomModelImpl(syn::Path), 52 | CustomResponseImpl(syn::Path), 53 | } 54 | 55 | fn parse_module_items(module_path: syn::Path, items: Vec, params: &Parameters) -> Vec { 56 | items 57 | .into_iter() 58 | .filter(|e| { 59 | matches!( 60 | e, 61 | Item::Mod(_) | Item::Fn(_) | Item::Struct(_) | Item::Enum(_) | Item::Impl(_) 62 | ) 63 | }) 64 | .map(|v| match v { 65 | Item::Mod(m) => m.content.map_or(Vec::::new(), |cs| { 66 | parse_module_items(build_path(&module_path, &m.ident), cs.1, params) 67 | }), 68 | Item::Fn(f) => parse_function(&f, ¶ms.fn_attribute_name) 69 | .into_iter() 70 | .map(|item| DiscoverType::Fn(build_path(&module_path, &item))) 71 | .collect(), 72 | Item::Struct(s) => parse_from_attr(&s.attrs, build_path(&module_path, &s.ident), s.generics.params, params), 73 | Item::Enum(e) => parse_from_attr(&e.attrs, build_path(&module_path, &e.ident), e.generics.params, params), 74 | Item::Impl(im) => parse_from_impl(&im, &module_path, params), 75 | _ => vec![], 76 | }) 77 | .fold(Vec::::new(), |mut acc, mut v| { 78 | acc.append(&mut v); 79 | acc 80 | }) 81 | } 82 | 83 | /// Search for ToSchema and ToResponse implementations in attr 84 | fn parse_from_attr( 85 | a: &Vec, 86 | name: syn::Path, 87 | generic_params: Punctuated, 88 | params: &Parameters, 89 | ) -> Vec { 90 | let mut out: Vec = vec![]; 91 | if !generic_params.iter().all(|p| matches!(p, GenericParam::Lifetime(_))) { 92 | return out; 93 | } 94 | 95 | for attr in a { 96 | let meta = &attr.meta; 97 | if meta.path().is_ident("utoipa_ignore") { 98 | return vec![]; 99 | } 100 | if meta.path().is_ident("derive") { 101 | let nested = attr 102 | .parse_args_with(Punctuated::::parse_terminated) 103 | .expect("Failed to parse derive attribute"); 104 | for nested_meta in nested { 105 | if nested_meta.path().segments.len() == 2 && nested_meta.path().segments[0].ident == "utoipa" { 106 | match nested_meta.path().segments[1].ident.to_string().as_str() { 107 | "ToSchema" => out.push(DiscoverType::Model(name.clone())), 108 | "ToResponse" => out.push(DiscoverType::Response(name.clone())), 109 | _ => {} 110 | } 111 | } else { 112 | if nested_meta.path().is_ident(¶ms.schema_attribute_name) { 113 | out.push(DiscoverType::Model(name.clone())); 114 | } 115 | if nested_meta.path().is_ident(¶ms.response_attribute_name) { 116 | out.push(DiscoverType::Response(name.clone())); 117 | } 118 | } 119 | } 120 | } 121 | } 122 | 123 | out 124 | } 125 | 126 | fn parse_from_impl(im: &ItemImpl, module_base_path: &syn::Path, params: &Parameters) -> Vec { 127 | im.trait_ 128 | .as_ref() 129 | .and_then(|trt| trt.1.segments.last().map(|p| p.ident.to_string())) 130 | .and_then(|impl_name| { 131 | if impl_name.eq(params.schema_attribute_name.as_str()) { 132 | Some(vec![DiscoverType::CustomModelImpl(build_path( 133 | module_base_path, 134 | &im.self_ty, 135 | ))]) 136 | } else if impl_name.eq(params.response_attribute_name.as_str()) { 137 | Some(vec![DiscoverType::CustomResponseImpl(build_path( 138 | module_base_path, 139 | &im.self_ty, 140 | ))]) 141 | } else { 142 | None 143 | } 144 | }) 145 | .unwrap_or_default() 146 | } 147 | 148 | fn parse_function(f: &ItemFn, fn_attributes_name: &str) -> Vec { 149 | let mut fns_name: Vec = vec![]; 150 | if should_parse_fn(f) { 151 | for i in 0..f.attrs.len() { 152 | if f.attrs[i] 153 | .meta 154 | .path() 155 | .segments 156 | .iter() 157 | .any(|item| item.ident.eq(fn_attributes_name)) 158 | { 159 | fns_name.push(f.sig.ident.clone()); 160 | } 161 | } 162 | } 163 | fns_name 164 | } 165 | 166 | fn should_parse_fn(f: &ItemFn) -> bool { 167 | !f.attrs.is_empty() && !is_ignored(f) 168 | } 169 | 170 | fn is_ignored(f: &ItemFn) -> bool { 171 | f.attrs.iter().any(|attr| { 172 | if let Some(name) = attr.path().get_ident() { 173 | name.eq("utoipa_ignore") 174 | } else { 175 | false 176 | } 177 | }) 178 | } 179 | 180 | fn build_path(file_path: &syn::Path, fn_name: impl ToTokens) -> syn::Path { 181 | syn::parse_quote!(#file_path::#fn_name) 182 | } 183 | 184 | #[cfg(test)] 185 | mod test { 186 | use quote::quote; 187 | use syn::ItemFn; 188 | 189 | #[test] 190 | fn test_parse_function() { 191 | let quoted = quote! { 192 | #[utoipa] 193 | pub fn route_custom() {} 194 | }; 195 | 196 | let item_fn: ItemFn = syn::parse2(quoted).unwrap(); 197 | let fn_name = super::parse_function(&item_fn, "utoipa"); 198 | assert_eq!(fn_name, vec!["route_custom"]); 199 | 200 | let quoted = quote! { 201 | #[handler] 202 | pub fn route_custom() {} 203 | }; 204 | 205 | let item_fn: ItemFn = syn::parse2(quoted).unwrap(); 206 | let fn_name = super::parse_function(&item_fn, "handler"); 207 | assert_eq!(fn_name, vec!["route_custom"]); 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /utoipauto-core/src/file_utils.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | fs::{self, File}, 3 | io::{self, Read}, 4 | iter, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use proc_macro2::Span; 9 | 10 | pub fn parse_file>(filepath: T) -> Result { 11 | let pb: PathBuf = filepath.into(); 12 | 13 | if !pb.is_file() { 14 | panic!("File not found: {:?}", pb); 15 | } 16 | 17 | let mut file = File::open(&pb)?; 18 | let mut content = String::new(); 19 | file.read_to_string(&mut content)?; 20 | 21 | Ok(syn::parse_file(&content).unwrap_or_else(move |_| panic!("Failed to parse file {:?}", pb))) 22 | } 23 | 24 | /// Parse all the files in the given path 25 | pub fn parse_files>(path: T) -> Result, io::Error> { 26 | let mut files: Vec<(String, syn::File)> = vec![]; 27 | 28 | let pb: PathBuf = path.into(); 29 | if pb.is_file() { 30 | // we only parse rust files 31 | if is_rust_file(&pb) { 32 | files.push((pb.to_str().unwrap().to_string(), parse_file(pb)?)); 33 | } 34 | } else { 35 | for entry in fs::read_dir(pb)? { 36 | let entry = entry?; 37 | let path = entry.path(); 38 | if path.is_file() && is_rust_file(&path) { 39 | files.push((path.to_str().unwrap().to_string(), parse_file(path)?)); 40 | } else { 41 | files.append(&mut parse_files(path)?); 42 | } 43 | } 44 | } 45 | Ok(files) 46 | } 47 | 48 | fn is_rust_file(path: &Path) -> bool { 49 | path.is_file() 50 | && match path.extension() { 51 | Some(ext) => match ext.to_str() { 52 | Some(ext) => ext.eq("rs"), 53 | None => false, 54 | }, 55 | None => false, 56 | } 57 | } 58 | 59 | /// Extract the module name from the file path 60 | /// # Example 61 | /// ``` 62 | /// # use quote::ToTokens as _; 63 | /// use utoipauto_core::file_utils::extract_module_name_from_path; 64 | /// let module_name = extract_module_name_from_path( 65 | /// &"./utoipa-auto-macro/tests/controllers/controller1.rs".to_string(), 66 | /// "crate" 67 | /// ); 68 | /// assert_eq!( 69 | /// module_name.to_token_stream().to_string().replace(' ', ""), 70 | /// "crate::controllers::controller1".to_string() 71 | /// ); 72 | /// ``` 73 | pub fn extract_module_name_from_path(path: &str, crate_name: &str) -> syn::Path { 74 | let path = path.replace('\\', "/"); 75 | let path = path 76 | .trim_end_matches(".rs") 77 | .trim_end_matches("/mod") 78 | .trim_end_matches("/lib") 79 | .trim_end_matches("/main") 80 | .trim_start_matches("./"); 81 | let segments: Vec<_> = path.split('/').collect(); 82 | 83 | // In general, paths will look like `./src/my/module`, which should turn into `crate::my::module`. 84 | // When using cargo workspaces, paths may look like `./subcrate/src/my/module`, 85 | // `./crates/subcrate/src/my/module`, etc., so we need to remove anything up to `src` 86 | // (or `tests`) to still produce `crate::my::module`. 87 | // So we split the segments by the last occurrence of `src` or `tests` and take the last part. 88 | let segments_inside_crate = find_segment_and_skip(&segments, &["src", "tests"], 1); 89 | 90 | // Also skip fragments that are already out of the crate name. For example, 91 | // `./src/lib/my/module/name from crate::my::module` should turn into `crate::my::module:name`, 92 | // and not into `crate::lib::my::module::name`. 93 | let crate_name = crate_name.replace("-", "_"); 94 | let mut crate_segments = crate_name.split("::"); 95 | let first_crate_fragment = crate_segments.next().expect("Crate should not be empty"); 96 | let segments_inside_crate = match crate_segments.next() { 97 | Some(crate_fragment) => find_segment_and_skip(segments_inside_crate, &[crate_fragment], 0), 98 | None => segments_inside_crate, 99 | }; 100 | 101 | let full_crate_path = iter::once(first_crate_fragment) 102 | .chain(segments_inside_crate.iter().copied()) 103 | .map(|segment| syn::PathSegment::from(syn::Ident::new(&segment.replace('-', "_"), Span::mixed_site()))); 104 | syn::Path { 105 | leading_colon: None, 106 | segments: full_crate_path.collect(), 107 | } 108 | } 109 | 110 | fn find_segment_and_skip<'a>(segments: &'a [&str], to_find: &[&str], to_skip: usize) -> &'a [&'a str] { 111 | match segments.iter().rposition(|segment| to_find.contains(segment)) { 112 | Some(idx) => &segments[(idx + to_skip)..], 113 | None => segments, 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use quote::ToTokens; 120 | 121 | use super::*; 122 | 123 | #[test] 124 | fn test_extract_module_name_from_path() { 125 | assert_eq!( 126 | extract_module_name_from_path("./utoipa-auto-macro/tests/controllers/controller1.rs", "crate") 127 | .to_token_stream() 128 | .to_string() 129 | .replace(" ", ""), 130 | "crate::controllers::controller1" 131 | ); 132 | } 133 | 134 | #[test] 135 | fn test_extract_module_name_from_path_windows() { 136 | assert_eq!( 137 | extract_module_name_from_path(".\\utoipa-auto-macro\\tests\\controllers\\controller1.rs", "crate") 138 | .to_token_stream() 139 | .to_string() 140 | .replace(" ", ""), 141 | "crate::controllers::controller1" 142 | ); 143 | } 144 | 145 | #[test] 146 | fn test_extract_module_name_from_mod() { 147 | assert_eq!( 148 | extract_module_name_from_path("./utoipa-auto-macro/tests/controllers/mod.rs", "crate") 149 | .to_token_stream() 150 | .to_string() 151 | .replace(" ", ""), 152 | "crate::controllers" 153 | ); 154 | } 155 | 156 | #[test] 157 | fn test_extract_module_name_from_lib() { 158 | assert_eq!( 159 | extract_module_name_from_path("./src/lib.rs", "crate") 160 | .to_token_stream() 161 | .to_string() 162 | .replace(" ", ""), 163 | "crate" 164 | ); 165 | } 166 | 167 | #[test] 168 | fn test_extract_module_name_from_main() { 169 | assert_eq!( 170 | extract_module_name_from_path("./src/main.rs", "crate") 171 | .to_token_stream() 172 | .to_string() 173 | .replace(" ", ""), 174 | "crate" 175 | ); 176 | } 177 | 178 | #[test] 179 | fn test_extract_module_name_from_workspace() { 180 | assert_eq!( 181 | extract_module_name_from_path("./server/src/routes/asset.rs", "crate") 182 | .to_token_stream() 183 | .to_string() 184 | .replace(" ", ""), 185 | "crate::routes::asset" 186 | ); 187 | } 188 | 189 | #[test] 190 | fn test_extract_module_name_from_workspace_nested() { 191 | assert_eq!( 192 | extract_module_name_from_path("./crates/server/src/routes/asset.rs", "crate") 193 | .to_token_stream() 194 | .to_string() 195 | .replace(" ", ""), 196 | "crate::routes::asset" 197 | ); 198 | } 199 | 200 | #[test] 201 | fn test_extract_module_name_from_folders() { 202 | assert_eq!( 203 | extract_module_name_from_path("./src/routing/api/audio.rs", "crate") 204 | .to_token_stream() 205 | .to_string() 206 | .replace(" ", ""), 207 | "crate::routing::api::audio" 208 | ); 209 | } 210 | 211 | #[test] 212 | fn test_extract_module_name_from_folders_nested() { 213 | assert_eq!( 214 | extract_module_name_from_path("./src/applications/src/retail_api/controllers/mod.rs", "crate") 215 | .to_token_stream() 216 | .to_string() 217 | .replace(" ", ""), 218 | "crate::retail_api::controllers" 219 | ); 220 | } 221 | 222 | #[test] 223 | fn test_extract_module_name_from_folders_nested_external_crate() { 224 | assert_eq!( 225 | extract_module_name_from_path("./src/applications/src/retail_api/controllers/mod.rs", "other_crate") 226 | .to_token_stream() 227 | .to_string() 228 | .replace(" ", ""), 229 | "other_crate::retail_api::controllers" 230 | ); 231 | } 232 | 233 | #[test] 234 | fn test_extract_module_name_from_workspace_with_prefix_path() { 235 | assert_eq!( 236 | extract_module_name_from_path("./crates/server/src/routes_lib/routes/asset.rs", "crate::routes") 237 | .to_token_stream() 238 | .to_string() 239 | .replace(" ", ""), 240 | "crate::routes::asset" 241 | ); 242 | } 243 | 244 | #[test] 245 | fn test_extract_module_name_from_workspace_with_external_crate_and_underscore() { 246 | assert_eq!( 247 | extract_module_name_from_path("./src/applications/src/retail-api/controllers/mod.rs", "other-crate") 248 | .to_token_stream() 249 | .to_string() 250 | .replace(" ", ""), 251 | "other_crate::retail_api::controllers" 252 | ); 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /utoipauto-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | extern crate proc_macro; 2 | extern crate proc_macro2; 3 | extern crate quote; 4 | extern crate syn; 5 | pub mod attribute_utils; 6 | pub mod discover; 7 | pub mod file_utils; 8 | pub mod string_utils; 9 | pub mod token_utils; 10 | -------------------------------------------------------------------------------- /utoipauto-core/src/pair.rs: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /utoipauto-core/src/string_utils.rs: -------------------------------------------------------------------------------- 1 | use proc_macro2::TokenStream; 2 | 3 | use crate::{discover::discover_from_file, token_utils::Parameters}; 4 | 5 | pub fn rem_first_and_last(value: &str) -> &str { 6 | let mut chars = value.chars(); 7 | chars.next(); 8 | chars.next_back(); 9 | chars.as_str() 10 | } 11 | 12 | pub fn trim_whites(str: &str) -> String { 13 | let s = str.trim(); 14 | 15 | let s: String = s.replace('\n', ""); 16 | 17 | s 18 | } 19 | 20 | pub fn trim_parentheses(str: &str) -> String { 21 | let s = str.trim(); 22 | 23 | let s: String = s.replace(['(', ')'], ""); 24 | 25 | s 26 | } 27 | 28 | /// Extract the file paths from the attributes 29 | /// Support the old syntax (MODULE_TREE_PATH => MODULE_SRC_PATH) ; (MODULE_TREE_PATH => MODULE_SRC_PATH) ; 30 | /// and the new syntax MODULE_SRC_PATH, MODULE_SRC_PATH 31 | /// 32 | /// # Example 33 | /// ``` 34 | /// use utoipauto_core::string_utils::extract_paths; 35 | /// let paths = extract_paths( 36 | /// "(utoipa_auto_macro::tests::controllers::controller1 => ./utoipa-auto-macro/tests/controllers/controller1.rs) ; (utoipa_auto_macro::tests::controllers::controller2 => ./utoipa-auto-macro/tests/controllers/controller2.rs)" 37 | /// ); 38 | /// assert_eq!( 39 | /// paths, 40 | /// vec![ 41 | /// "./utoipa-auto-macro/tests/controllers/controller1.rs".to_string(), 42 | /// "./utoipa-auto-macro/tests/controllers/controller2.rs".to_string(), 43 | /// ] 44 | /// ); 45 | /// ``` 46 | pub fn extract_paths(attributes: &str) -> Vec { 47 | let attributes = trim_parentheses(attributes); 48 | 49 | if attributes.contains('|') { 50 | panic!("Please use the new syntax ! paths=\"(MODULE_TREE_PATH => MODULE_SRC_PATH) ;\"") 51 | } 52 | let paths = if attributes.contains("=>") { 53 | extract_paths_arrow(attributes) 54 | } else { 55 | extract_paths_coma(attributes) 56 | }; 57 | if paths.is_empty() { 58 | panic!("utoipauto: No paths specified !") 59 | } 60 | paths 61 | } 62 | 63 | // (MODULE_TREE_PATH => MODULE_SRC_PATH) ; (MODULE_TREE_PATH => MODULE_SRC_PATH) ; 64 | // extract the paths from the string 65 | // Here for legacy support 66 | fn extract_paths_arrow(attributes: String) -> Vec { 67 | let mut paths: Vec = vec![]; 68 | let attributes = attributes.split(';'); 69 | 70 | for p in attributes { 71 | let pair = p.split_once("=>"); 72 | 73 | if let Some(pair) = pair { 74 | paths.push(trim_whites(pair.1)); 75 | } 76 | } 77 | paths 78 | } 79 | 80 | // MODULE_SRC_PATH, MODULE_SRC_PATH 81 | fn extract_paths_coma(attributes: String) -> Vec { 82 | let mut paths: Vec = vec![]; 83 | let attributes = attributes.split(','); 84 | 85 | for p in attributes { 86 | paths.push(trim_whites(p)); 87 | } 88 | paths 89 | } 90 | 91 | /// Return the list of all the functions with the #[utoipa] attribute 92 | /// and the list of all the structs with the #[derive(ToSchema)] attribute 93 | /// and the list of all the structs with the #[derive(ToResponse)] attribute 94 | pub fn discover(paths: Vec, params: &Parameters) -> (TokenStream, TokenStream, TokenStream) { 95 | let mut uto_paths = Vec::new(); 96 | let mut uto_models = Vec::new(); 97 | let mut uto_responses = Vec::new(); 98 | for p in paths { 99 | let path = extract_crate_name(p); 100 | let (list_fn, list_model, list_reponse) = discover_from_file(path.paths, path.crate_name, params); 101 | uto_paths.extend(list_fn); 102 | uto_models.extend(list_model); 103 | uto_responses.extend(list_reponse); 104 | } 105 | // We need to add a coma after each path 106 | ( 107 | quote::quote!(#(#uto_paths),*), 108 | quote::quote!(#(#uto_models),*), 109 | quote::quote!(#(#uto_responses),*), 110 | ) 111 | } 112 | 113 | #[derive(Debug, PartialEq)] 114 | struct Path { 115 | paths: String, 116 | crate_name: String, 117 | } 118 | 119 | fn extract_crate_name(path: String) -> Path { 120 | let mut path = path.split(" from "); 121 | let paths = path.next().unwrap(); 122 | let crate_name = path.next().unwrap_or("crate").to_string(); 123 | Path { 124 | paths: paths.to_string(), 125 | crate_name, 126 | } 127 | } 128 | 129 | #[cfg(test)] 130 | mod test { 131 | use crate::string_utils::extract_paths; 132 | 133 | #[test] 134 | fn test_extract_path() { 135 | let paths = "./src"; 136 | let extracted = extract_paths(paths); 137 | assert_eq!(extracted, vec!["./src".to_string()]); 138 | } 139 | 140 | #[test] 141 | fn test_extract_crate_name() { 142 | assert_eq!( 143 | super::extract_crate_name( 144 | "utoipa_auto_macro::from::controllers::controller1 from utoipa_auto_macro".to_string(), 145 | ), 146 | super::Path { 147 | paths: "utoipa_auto_macro::from::controllers::controller1".to_string(), 148 | crate_name: "utoipa_auto_macro".to_string() 149 | } 150 | ); 151 | } 152 | 153 | #[test] 154 | fn test_extract_crate_name_default() { 155 | assert_eq!( 156 | super::extract_crate_name("utoipa_auto_macro::from::controllers::controller1".to_string()), 157 | super::Path { 158 | paths: "utoipa_auto_macro::from::controllers::controller1".to_string(), 159 | crate_name: "crate".to_string() 160 | } 161 | ); 162 | } 163 | 164 | #[test] 165 | fn test_extract_paths_arrow() { 166 | assert_eq!( 167 | super::extract_paths( 168 | "(utoipa_auto_macro::tests::controllers::controller1 => ./utoipa-auto-macro/tests/controllers/controller1.rs) ; (utoipa_auto_macro::tests::controllers::controller2 => ./utoipa-auto-macro/tests/controllers/controller2.rs)" 169 | ), 170 | vec![ 171 | "./utoipa-auto-macro/tests/controllers/controller1.rs".to_string(), 172 | "./utoipa-auto-macro/tests/controllers/controller2.rs".to_string() 173 | ] 174 | ); 175 | } 176 | 177 | #[test] 178 | fn test_extract_paths_coma() { 179 | assert_eq!( 180 | super::extract_paths( 181 | "./utoipa-auto-macro/tests/controllers/controller1.rs, ./utoipa-auto-macro/tests/controllers/controller2.rs" 182 | ), 183 | vec![ 184 | "./utoipa-auto-macro/tests/controllers/controller1.rs".to_string(), 185 | "./utoipa-auto-macro/tests/controllers/controller2.rs".to_string() 186 | ] 187 | ); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /utoipauto-core/src/token_utils.rs: -------------------------------------------------------------------------------- 1 | use proc_macro::TokenStream; 2 | use proc_macro2::Literal; 3 | use quote::quote; 4 | use syn::Attribute; 5 | 6 | pub struct Parameters { 7 | pub paths: String, 8 | pub fn_attribute_name: String, 9 | pub schema_attribute_name: String, 10 | pub response_attribute_name: String, 11 | } 12 | 13 | /// Extract the paths string attribute from the proc_macro::TokenStream 14 | /// 15 | /// If none is specified, we use the default path "./src" 16 | pub fn extract_attributes(stream: proc_macro2::TokenStream) -> Parameters { 17 | let paths = extract_attribute("paths", stream.clone()); 18 | let fn_attribute_name = extract_attribute("function_attribute_name", stream.clone()); 19 | let schema_attribute_name = extract_attribute("schema_attribute_name", stream.clone()); 20 | let response_attribute_name = extract_attribute("response_attribute_name", stream); 21 | // if no paths specified, we use the default path "./src" 22 | Parameters { 23 | paths: paths.unwrap_or("./src".to_string()), 24 | fn_attribute_name: fn_attribute_name.unwrap_or("utoipa".to_string()), 25 | schema_attribute_name: schema_attribute_name.unwrap_or("ToSchema".to_string()), 26 | response_attribute_name: response_attribute_name.unwrap_or("ToResponse".to_string()), 27 | } 28 | } 29 | 30 | // extract the name = "" attributes from the proc_macro::TokenStream 31 | fn extract_attribute(name: &str, stream: proc_macro2::TokenStream) -> Option { 32 | let mut has_value = false; 33 | 34 | for token in stream { 35 | if has_value { 36 | if let proc_macro2::TokenTree::Literal(lit) = token { 37 | return Some(get_content(lit)); 38 | } 39 | } 40 | if let proc_macro2::TokenTree::Ident(ident) = token { 41 | if ident.to_string().eq(name) { 42 | has_value = true; 43 | } 44 | } 45 | } 46 | None 47 | } 48 | 49 | fn get_content(lit: Literal) -> String { 50 | let content = lit.to_string(); 51 | content[1..content.len() - 1].to_string() 52 | } 53 | 54 | /// Check if the macro is placed before the #[derive] and #[openapi] attributes 55 | /// Otherwise, panic! 56 | pub fn check_macro_placement(attrs: Vec) { 57 | if !attrs.iter().any(|elm| elm.path().is_ident("derive")) { 58 | panic!("Please put utoipauto before #[derive] and #[openapi]"); 59 | } 60 | 61 | if !attrs.iter().any(|elm| elm.path().is_ident("openapi")) { 62 | panic!("Please put utoipauto before #[derive] and #[openapi]"); 63 | } 64 | } 65 | 66 | // Output the macro back to the compiler 67 | pub fn output_macro(openapi_macro: syn::ItemStruct) -> proc_macro::TokenStream { 68 | let code = quote!( 69 | #openapi_macro 70 | ); 71 | 72 | TokenStream::from(code) 73 | } 74 | 75 | #[cfg(test)] 76 | mod tests { 77 | use super::*; 78 | 79 | #[test] 80 | fn test_get_content() { 81 | let lit = Literal::string("p1"); 82 | let content = get_content(lit); 83 | assert_eq!(content, "p1"); 84 | } 85 | 86 | #[test] 87 | fn test_extract_attributes() { 88 | let tokens = quote! { 89 | paths = "p1" 90 | }; 91 | 92 | let attributes = extract_attributes(tokens); 93 | assert_eq!(attributes.paths, "p1") 94 | } 95 | 96 | #[test] 97 | fn test_extract_attribute() { 98 | let quote = quote! { 99 | paths = "p1", thing = "thing", other = "other" 100 | }; 101 | 102 | let attributes = extract_attribute("thing", quote).unwrap(); 103 | assert_eq!(attributes, "thing"); 104 | } 105 | 106 | #[test] 107 | fn test_extract_attribute_none() { 108 | let quote = quote! { 109 | paths = "p1", thing = "thing", other = "other" 110 | }; 111 | 112 | let attributes = extract_attribute("not_found", quote); 113 | assert_eq!(attributes, None); 114 | } 115 | 116 | #[test] 117 | fn test_extract_attribute_empty() { 118 | let quote = quote! {}; 119 | 120 | let attributes = extract_attribute("thing", quote); 121 | assert_eq!(attributes, None); 122 | } 123 | 124 | #[test] 125 | fn test_extract_attributes_empty() { 126 | let tokens = quote! {}; 127 | 128 | let attributes = extract_attributes(tokens); 129 | assert_eq!(attributes.paths, "./src"); 130 | assert_eq!(attributes.fn_attribute_name, "utoipa"); 131 | assert_eq!(attributes.schema_attribute_name, "ToSchema"); 132 | assert_eq!(attributes.response_attribute_name, "ToResponse"); 133 | } 134 | 135 | #[test] 136 | fn test_extract_attributes_custom_name() { 137 | let tokens = quote! { 138 | paths = "p1", function_attribute_name = "handler", schema_attribute_name = "Schema", response_attribute_name = "Response" 139 | }; 140 | 141 | let attributes = extract_attributes(tokens); 142 | assert_eq!(attributes.paths, "p1"); 143 | assert_eq!(attributes.fn_attribute_name, "handler"); 144 | assert_eq!(attributes.schema_attribute_name, "Schema"); 145 | assert_eq!(attributes.response_attribute_name, "Response"); 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /utoipauto-macro/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "utoipauto-macro" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | keywords.workspace = true 7 | description.workspace = true 8 | categories.workspace = true 9 | license.workspace = true 10 | readme.workspace = true 11 | repository.workspace = true 12 | homepage.workspace = true 13 | rust-version.workspace = true 14 | 15 | [lib] 16 | proc-macro = true 17 | 18 | [dependencies] 19 | utoipauto-core.workspace = true 20 | 21 | quote.workspace = true 22 | syn.workspace = true 23 | proc-macro2.workspace = true 24 | -------------------------------------------------------------------------------- /utoipauto-macro/src/lib.rs: -------------------------------------------------------------------------------- 1 | use attribute_utils::update_openapi_macro_attributes; 2 | use proc_macro::TokenStream; 3 | 4 | use quote::quote; 5 | use string_utils::{discover, extract_paths}; 6 | use syn::parse_macro_input; 7 | use token_utils::{check_macro_placement, extract_attributes, output_macro}; 8 | use utoipauto_core::{attribute_utils, string_utils, token_utils}; 9 | 10 | /// Macro to automatically discover all the functions with the #[utoipa] attribute 11 | /// And the struct deriving ToSchema and ToResponse 12 | #[proc_macro_attribute] 13 | pub fn utoipauto( 14 | attributes: proc_macro::TokenStream, // #[utoipauto(paths = "(MODULE_TREE_PATH => MODULE_SRC_PATH) ;")] 15 | item: proc_macro::TokenStream, // #[openapi(paths = "")] 16 | ) -> proc_macro::TokenStream { 17 | // (MODULE_TREE_PATH => MODULE_SRC_PATH) ; (MODULE_TREE_PATH => MODULE_SRC_PATH) ; ... 18 | let params = extract_attributes(attributes.into()); 19 | // [(MODULE_TREE_PATH, MODULE_SRC_PATH)] 20 | let paths: Vec = extract_paths(¶ms.paths); 21 | 22 | // #[openapi(...)] 23 | let mut openapi_macro = parse_macro_input!(item as syn::ItemStruct); 24 | 25 | // Discover all the functions with the #[utoipa] attribute 26 | let (uto_paths, uto_models, uto_responses) = discover(paths, ¶ms); 27 | 28 | // extract the openapi macro attributes : #[openapi(openapi_macro_attibutes)] 29 | let openapi_macro_attibutes = &mut openapi_macro.attrs; 30 | 31 | // Check if the macro is placed before the #[derive] and #[openapi] attributes 32 | check_macro_placement(openapi_macro_attibutes.clone()); 33 | 34 | // Update the openapi macro attributes with the newly discovered paths 35 | update_openapi_macro_attributes(openapi_macro_attibutes, &uto_paths, &uto_models, &uto_responses); 36 | 37 | // Output the macro back to the compiler 38 | output_macro(openapi_macro) 39 | } 40 | 41 | /// Ignore the function from the auto discovery 42 | #[proc_macro_attribute] 43 | pub fn utoipa_ignore(_attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream { 44 | let input = parse_macro_input!(item as syn::Item); 45 | let code = quote!( 46 | #input 47 | ); 48 | 49 | TokenStream::from(code) 50 | } 51 | 52 | /// Useless macro to test custom function attributes 53 | #[proc_macro_attribute] 54 | pub fn test_handler(_attr: proc_macro::TokenStream, item: proc_macro::TokenStream) -> proc_macro::TokenStream { 55 | let input = parse_macro_input!(item as syn::ItemFn); 56 | 57 | let code = quote!( 58 | #[utoipa::path(get, path = "/")] 59 | #input 60 | ); 61 | 62 | TokenStream::from(code) 63 | } 64 | -------------------------------------------------------------------------------- /utoipauto/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "utoipauto" 3 | authors.workspace = true 4 | version.workspace = true 5 | edition.workspace = true 6 | keywords.workspace = true 7 | description.workspace = true 8 | categories.workspace = true 9 | license.workspace = true 10 | readme.workspace = true 11 | repository.workspace = true 12 | homepage.workspace = true 13 | rust-version.workspace = true 14 | 15 | [dependencies] 16 | utoipauto-macro.workspace = true 17 | 18 | [dev-dependencies] 19 | utoipa.workspace = true 20 | serde_json = "1.0.128" 21 | syn = { version = "2.0.74", features = ["extra-traits", "full"] } 22 | -------------------------------------------------------------------------------- /utoipauto/src/lib.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // This code is used in the tests 2 | 3 | pub use utoipauto_macro::*; 4 | 5 | #[cfg(test)] 6 | mod test { 7 | use utoipa::OpenApi; 8 | use utoipauto_macro::utoipauto; 9 | 10 | #[utoipa::path(post, path = "/route1")] 11 | pub fn route1() {} 12 | 13 | #[utoipa::path(post, path = "/route2")] 14 | pub fn route2() {} 15 | 16 | #[utoipa::path(post, path = "/route3")] 17 | pub fn route3() {} 18 | 19 | /// Discover from the crate root auto 20 | #[utoipauto(paths = "./utoipauto/src")] 21 | #[derive(OpenApi)] 22 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 23 | pub struct CrateAutoApiDocs {} 24 | 25 | #[test] 26 | fn test_crate_auto_import_path() { 27 | assert_eq!(CrateAutoApiDocs::openapi().paths.paths.len(), 3) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /utoipauto/tests/default_features/controllers/controller1.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // This code is used in the tests 2 | use utoipauto_macro::utoipa_ignore; 3 | 4 | #[utoipa::path(post, path = "/route1")] 5 | pub fn route1() {} 6 | 7 | #[utoipa_ignore] 8 | #[utoipa::path(post, path = "/route-ignored")] 9 | pub fn route_ignored() {} 10 | -------------------------------------------------------------------------------- /utoipauto/tests/default_features/controllers/controller2.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] // This code is used in the tests 2 | 3 | #[utoipa::path(post, path = "/route3")] 4 | pub fn route3() {} 5 | -------------------------------------------------------------------------------- /utoipauto/tests/default_features/controllers/controller3.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | use utoipauto_macro::test_handler; 3 | 4 | #[test_handler] 5 | pub fn route_custom() {} 6 | -------------------------------------------------------------------------------- /utoipauto/tests/default_features/controllers/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controller1; 2 | pub mod controller2; 3 | pub mod controller3; 4 | -------------------------------------------------------------------------------- /utoipauto/tests/default_features/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod controllers; 2 | pub mod models; 3 | pub mod test; 4 | -------------------------------------------------------------------------------- /utoipauto/tests/default_features/models.rs: -------------------------------------------------------------------------------- 1 | #![allow(dead_code)] 2 | 3 | use std::borrow::Cow; 4 | use utoipa::openapi::schema::SchemaType; 5 | use utoipa::openapi::{Response, ResponseBuilder, Type}; 6 | // This code is used in the tests 7 | use utoipa::{ 8 | openapi::{ObjectBuilder, RefOr, Schema}, 9 | PartialSchema, ToResponse, ToSchema, 10 | }; 11 | use utoipauto_macro::utoipa_ignore; 12 | 13 | #[derive(ToSchema)] 14 | pub struct ModelSchema; 15 | #[derive(ToResponse)] 16 | pub struct ModelResponse; 17 | 18 | #[utoipa_ignore] 19 | #[derive(ToSchema)] 20 | pub struct IgnoredModelSchema; 21 | 22 | // Manual implementation of ToSchema 23 | pub struct ModelSchemaImpl; 24 | 25 | impl PartialSchema for ModelSchemaImpl { 26 | fn schema() -> RefOr { 27 | ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)).into() 28 | } 29 | } 30 | 31 | impl ToSchema for ModelSchemaImpl { 32 | fn name() -> Cow<'static, str> { 33 | Cow::Borrowed("ModelSchemaImpl") 34 | } 35 | } 36 | 37 | // Manual implementation of utoipa::ToSchema 38 | pub struct ModelSchemaImplFullName; 39 | 40 | impl PartialSchema for ModelSchemaImplFullName { 41 | fn schema() -> RefOr { 42 | ObjectBuilder::new().schema_type(SchemaType::Type(Type::String)).into() 43 | } 44 | } 45 | 46 | impl utoipa::ToSchema for ModelSchemaImplFullName { 47 | fn name() -> Cow<'static, str> { 48 | Cow::Borrowed("ModelSchemaImplFullName") 49 | } 50 | } 51 | 52 | // Manual implementation of ToSchema 53 | pub struct ModelResponseImpl; 54 | 55 | impl<'s> ToResponse<'s> for ModelResponseImpl { 56 | fn response() -> (&'s str, RefOr) { 57 | ( 58 | "ModelResponseImpl", 59 | ResponseBuilder::new().description("A manual response").into(), 60 | ) 61 | } 62 | } 63 | 64 | // Manual implementation of utoipa::ToResponse 65 | pub struct ModelResponseImplFullName; 66 | 67 | impl<'s> utoipa::ToResponse<'s> for ModelResponseImplFullName { 68 | fn response() -> (&'s str, RefOr) { 69 | ( 70 | "ModelResponseImplFullName", 71 | ResponseBuilder::new().description("A manual response").into(), 72 | ) 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /utoipauto/tests/default_features/test.rs: -------------------------------------------------------------------------------- 1 | use utoipa::OpenApi; 2 | 3 | use utoipauto::utoipauto; 4 | 5 | use crate::default_features::controllers; 6 | 7 | // Discover from multiple controllers 8 | #[utoipauto( 9 | paths = "( crate::controllers::controller1 => ./utoipauto/tests/default_features/controllers/controller1.rs) ; ( crate::controllers::controller2 => ./utoipauto/tests/default_features/controllers/controller2.rs )" 10 | )] 11 | #[derive(OpenApi)] 12 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 13 | pub struct MultiControllerApiDocs {} 14 | 15 | #[test] 16 | fn test_path_import() { 17 | assert_eq!(MultiControllerApiDocs::openapi().paths.paths.len(), 2) 18 | } 19 | 20 | /// Discover from a single controller 21 | #[utoipauto( 22 | paths = "( crate::controllers::controller1 => ./utoipauto/tests/default_features/controllers/controller1.rs)" 23 | )] 24 | #[derive(OpenApi)] 25 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 26 | pub struct SingleControllerApiDocs {} 27 | 28 | #[test] 29 | fn test_ignored_path() { 30 | assert_eq!(SingleControllerApiDocs::openapi().paths.paths.len(), 1) 31 | } 32 | 33 | /// Discover with manual path 34 | #[utoipauto(paths = "./utoipauto/tests/default_features/controllers/controller1.rs")] 35 | #[derive(OpenApi)] 36 | #[openapi( 37 | info(title = "Percentage API", version = "1.0.0"), 38 | paths(controllers::controller2::route3) 39 | )] 40 | pub struct SingleControllerManualPathApiDocs {} 41 | 42 | #[test] 43 | fn test_manual_path() { 44 | assert_eq!(SingleControllerManualPathApiDocs::openapi().paths.paths.len(), 2) 45 | } 46 | 47 | /// Discover from a module root 48 | #[utoipauto(paths = "( crate::controllers => ./utoipauto/tests/default_features/controllers)")] 49 | #[derive(OpenApi)] 50 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 51 | pub struct ModuleApiDocs {} 52 | 53 | #[test] 54 | fn test_module_import_path() { 55 | assert_eq!(ModuleApiDocs::openapi().paths.paths.len(), 2) 56 | } 57 | 58 | /// Discover from the crate root 59 | #[utoipauto(paths = "./utoipauto/tests/default_features")] 60 | #[derive(OpenApi)] 61 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 62 | pub struct CrateApiDocs {} 63 | 64 | #[test] 65 | fn test_crate_import_path() { 66 | assert_eq!(CrateApiDocs::openapi().paths.paths.len(), 2) 67 | } 68 | 69 | // Discover from multiple controllers new syntax 70 | #[utoipauto( 71 | paths = "./utoipauto/tests/default_features/controllers/controller1.rs, ./utoipauto/tests/default_features/controllers/controller2.rs" 72 | )] 73 | #[derive(OpenApi)] 74 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 75 | pub struct MultiControllerNoModuleApiDocs {} 76 | 77 | #[test] 78 | fn test_path_import_no_module() { 79 | assert_eq!(MultiControllerNoModuleApiDocs::openapi().paths.paths.len(), 2) 80 | } 81 | 82 | // Discover from multiple controllers new syntax 83 | #[utoipauto(paths = "./utoipauto/tests/default_features/models.rs")] 84 | #[derive(OpenApi)] 85 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 86 | pub struct ModelsImportApiDocs {} 87 | 88 | #[test] 89 | fn test_path_import_schema() { 90 | assert_eq!( 91 | ModelsImportApiDocs::openapi() 92 | .components 93 | .expect("no components") 94 | .schemas 95 | .len(), 96 | 3, // 1 derive, 1 manual, 1 manual with utoipa::ToSchema 97 | ) 98 | } 99 | 100 | // Discover from multiple controllers new syntax 101 | #[utoipauto(paths = "./utoipauto/tests/default_features/models.rs")] 102 | #[derive(OpenApi)] 103 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 104 | pub struct ResponsesImportApiDocs {} 105 | 106 | #[test] 107 | fn test_path_import_responses() { 108 | assert_eq!( 109 | ResponsesImportApiDocs::openapi() 110 | .components 111 | .expect("no components") 112 | .responses 113 | .len(), 114 | 3, // 1 derive, 1 manual, 1 manual with utoipa::ToResponse 115 | ) 116 | } 117 | 118 | /// Discover custom handler 119 | #[utoipauto( 120 | paths = "./utoipauto/tests/default_features/controllers/controller3.rs", 121 | function_attribute_name = "test_handler" 122 | )] 123 | #[derive(OpenApi)] 124 | #[openapi(info(title = "Percentage API", version = "1.0.0"))] 125 | pub struct CustomHandlerApiDocs {} 126 | 127 | #[test] 128 | fn test_custom_handler() { 129 | assert_eq!(CustomHandlerApiDocs::openapi().paths.paths.len(), 1) 130 | } 131 | -------------------------------------------------------------------------------- /utoipauto/tests/test.rs: -------------------------------------------------------------------------------- 1 | mod default_features; 2 | --------------------------------------------------------------------------------