diff --git a/.github/actions/gitlog/action.yaml b/.github/actions/gitlog/action.yaml new file mode 100644 index 0000000..b4a4e9b --- /dev/null +++ b/.github/actions/gitlog/action.yaml @@ -0,0 +1,20 @@ +name: Git log +description: Mangles git log for change log +inputs: + output-file: + description: File path where to place the content of the changed commits + required: true + crate: + description: Name of the crate to get git log for + required: true +outputs: + last_release: + description: Last release commit or first commit of history + value: ${{ steps.gitlog.outputs.last_release }} +runs: + using: composite + steps: + - shell: bash + id: gitlog + run: | + ${{ github.action_path }}/gitlog.sh --output-file ${{ inputs.output-file }} --crate ${{ inputs.crate }} diff --git a/.github/actions/gitlog/gitlog.sh b/.github/actions/gitlog/gitlog.sh new file mode 100755 index 0000000..f00c4ac --- /dev/null +++ b/.github/actions/gitlog/gitlog.sh @@ -0,0 +1,126 @@ +#!/bin/bash + +# This mangles git log entries for change lop purposes + +output_file="" +crate="" +range="" +auth="" +while true; do + case $1 in + "--output-file") + shift + output_file="$1" + shift + ;; + "--crate") + shift + crate="$1" + shift + ;; + "--range") + shift + range="$1" + shift + ;; + "--auth") + shift + auth="$1" + shift + ;; + *) + break + ;; + esac +done + +if [[ "$output_file" == "" ]]; then + echo "Missing --output-file option argument, define path to file or - for stdout" && exit 1 +fi +if [[ "$crate" == "" ]]; then + echo "Missing --crate option argument, need an explisit crate to get git log for" && exit 1 +fi + +commit_range="" +if [ -z "$range" ]; then + from_commit=HEAD + last_release=$(git tag --sort=-committerdate | grep -E "$crate-[0-9]*\.[0-9]*\.[0-9]*" | head -1) + echo "Found tag: $last_release" + if [[ "$last_release" == "" ]]; then + last_release=$(git tag --sort=-committerdate | head -1) # get last tag + echo "Using latest tag: $last_release" + fi + + if [[ $last_release != "" ]]; then + commit_range="$from_commit...$last_release" + else + commit_range="$from_commit" + fi +else + commit_range="$range" +fi + +ancestry_path="" +if [[ "$last_release" != "" ]]; then + ancestry_path="--ancestry-path" +fi + +mapfile -t log_lines < <(git log --pretty=format:'(%h) %s' $ancestry_path "$commit_range") + +function is_crate_related { + commit="$1" + changes="$(git diff --name-only "$commit"~ "$commit" | awk -F / '{print $1}' | xargs)" + IFS=" " read -r -a change_dirs <<<"$changes" + + is_related=false + for change in "${change_dirs[@]}"; do + if [[ "$change" == "$crate" ]]; then + is_related=true + break + fi + done + + echo $is_related +} + +get_username() { + commit=$1 + + args=() + if [ -n "$auth" ]; then + args=("${args[@]}" "-H" "Authorization: Bearer $auth") + fi + + curl -sSL \ + -H "Accept: application/vnd.github+json" \ + -H "X-GitHub-Api-Version: 2022-11-28" \ + "${args[@]}" \ + https://api.github.com/repos/nxpkg/fastapi/commits/"$commit" | jq -r .author.login +} + +log="" +for line in "${log_lines[@]}"; do + commit=$(echo "$line" | awk -F ' ' '{print $1}') + commit=${commit//[\(\)]/} + + if [[ $(is_crate_related "$commit") == true ]]; then + user=$(get_username "$commit") + log=$log"* $line @$user\n" + fi +done + +if [[ "$output_file" != "" ]]; then + if [[ "$output_file" == "-" ]]; then + echo -e "$log" + else + echo -e "$log" >"$output_file" + fi +fi + +if [[ "$last_release" == "" ]]; then + last_release=$(git rev-list --reverse HEAD | head -1) +fi + +if [ -n "$GITHUB_OUTPUT" ]; then + echo "last_release=$last_release" >>"$GITHUB_OUTPUT" +fi diff --git a/.github/actions/publish/action.yaml b/.github/actions/publish/action.yaml new file mode 100644 index 0000000..35bde60 --- /dev/null +++ b/.github/actions/publish/action.yaml @@ -0,0 +1,16 @@ +name: Publish crate +description: Publishes crate to crates.io +inputs: + token: + description: Cargo login token to use the publish the crate + required: true + ref: + description: "Github release tag ref" + required: true +runs: + using: composite + steps: + - shell: bash + id: publish_crate + run: | + ${{ github.action_path }}/publish.sh --token ${{ inputs.token }} --ref ${{ inputs.ref }} diff --git a/.github/actions/publish/publish.sh b/.github/actions/publish/publish.sh new file mode 100755 index 0000000..ab9d7b4 --- /dev/null +++ b/.github/actions/publish/publish.sh @@ -0,0 +1,70 @@ +#!/bin/bash + +# Publishes crate to crates.io + +token="" +ref="" +while true; do + case $1 in + "--token") + shift + token="$1" + shift + ;; + "--ref") + shift + ref=${1/refs\/tags\//} + shift + ;; + *) + break + ;; + esac +done + +if [[ "$token" == "" ]]; then + echo "Missing --token option argument, cannot publish crates without it!" && exit 1 +fi +if [[ "$ref" == "" ]]; then + echo "Missing --ref option argument, need an explisit ref to release!" && exit 1 +fi + +function publish { + module="$1" + # echo "publish: $module" + cargo publish -p "$module" +} + +if [ ! -f "Cargo.toml" ]; then + echo "Missing Cargo.toml file, not in a Rust project root?" && exit 1 +fi + +echo "$token" | cargo login +while read -r module; do + # crate=$(echo "$ref" | sed 's|-[0-9]*\.[0-9]*\.[0-9].*||') + crate=${ref/-[0-9]\.[0-9]\.[0-9]*/} + if [[ "$crate" != "$module" ]]; then + echo "Module: $module does not match to release crate: $crate, skipping release for module" + continue + fi + + current_version=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | select(.name | test("'"$module"'$")) | .version') + last_version=$(curl -sS https://crates.io/api/v1/crates/"$module"/versions | jq -r '.versions[0].num') + if [[ "$last_version" == "$current_version" ]]; then + echo "Module: $module, is already at it's latest release ($last_version), nothing to release" + continue + fi + + echo "Publishing module $module..." + max_retries=10 + retry=0 + while ! publish "$module" && [[ $retry -lt $max_retries ]]; do + await_time=$((retry*2)) + echo "Failed to publish, Retrying $retry... after $await_time sec." + sleep $await_time + retry=$((retry+1)) + done + if [[ $retry -eq $max_retries ]]; then + echo "Failed to publish crate $module, try to increase await time? Or retries?" && exit 1 + fi +done < <(cargo metadata --format-version=1 --no-deps | jq -r '.metadata.publish.order[]') diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..d3a9882 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,143 @@ +name: Fastapi build + +on: + push: + paths: + - "**.rs" + - "**Cargo.toml" + pull_request: + branches: [master] + paths: + - "**.rs" + - "**Cargo.toml" +env: + CARGO_TERM_COLOR: always + +jobs: + test: + strategy: + matrix: + crate: + - fastapi + - fastapi-gen + - fastapi-swagger-ui-vendored + - fastapi-swagger-ui + - fastapi-redoc + - fastapi-rapidoc + - fastapi-scalar + - fastapi-axum + - fastapi-config + - fastapi-actix-web + fail-fast: true + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }} + + - name: Resolve changed paths + id: changes + run: | + if [[ $GITHUB_EVENT_NAME != "pull_request" ]]; then + echo "changes=true" >> $GITHUB_OUTPUT + exit 0 + fi + changes=false + while read -r change; do + if [[ "$change" == "fastapi-gen" && "${{ matrix.crate }}" == "fastapi-gen" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-swagger-ui-vendored" && "${{ matrix.crate }}" == "fastapi-swagger-ui-vendored" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-swagger-ui" && "${{ matrix.crate }}" == "fastapi-swagger-ui" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi" && "${{ matrix.crate }}" == "fastapi" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-redoc" && "${{ matrix.crate }}" == "fastapi-redoc" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-rapidoc" && "${{ matrix.crate }}" == "fastapi-rapidoc" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-scalar" && "${{ matrix.crate }}" == "fastapi-scalar" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-axum" && "${{ matrix.crate }}" == "fastapi-axum" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-config" && "${{ matrix.crate }}" == "fastapi-config" && $changes == false ]]; then + changes=true + elif [[ "$change" == "fastapi-actix-web" && "${{ matrix.crate }}" == "fastapi-actix-web" && $changes == false ]]; then + changes=true + fi + done < <(git diff --name-only ${{ github.sha }}~ ${{ github.sha }} | grep .rs | awk -F \/ '{print $1}') + echo "${{ matrix.crate }} changes: $changes" + echo "changes=$changes" >> $GITHUB_OUTPUT + + - name: Check format + run: | + if [[ ${{ steps.changes.outputs.changes }} == true ]]; then + cargo fmt --check --package ${{ matrix.crate }} + fi + + - name: Check clippy + run: | + if [[ ${{ steps.changes.outputs.changes }} == true ]]; then + cargo clippy --quiet --package ${{ matrix.crate }} + fi + + - name: Run tests + run: | + if [[ ${{ steps.changes.outputs.changes }} == true ]]; then + ./scripts/test.sh ${{ matrix.crate }} + fi + + check-typos: + name: typos + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: taiki-e/install-action@v2 + with: + tool: typos + - run: typos + + test-examples-compile: + name: "test (examples)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - name: Install stable Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: stable + components: clippy, rustfmt + + - name: Install nightly Rust + uses: dtolnay/rust-toolchain@master + with: + toolchain: nightly + components: clippy, rustfmt + + - uses: actions/cache@v3 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + examples/**/target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}_examples + + - name: Test that examples compile + run: | + ./scripts/validate-examples.sh diff --git a/.github/workflows/draft.yaml b/.github/workflows/draft.yaml new file mode 100644 index 0000000..6879946 --- /dev/null +++ b/.github/workflows/draft.yaml @@ -0,0 +1,87 @@ +name: Draft release + +on: + push: + branches: + - master + +env: + CARGO_TERM_COLOR: always + +jobs: + draft: + strategy: + matrix: + crate: + - fastapi + - fastapi-gen + - fastapi-swagger-ui-vendored + - fastapi-swagger-ui + - fastapi-redoc + - fastapi-rapidoc + - fastapi-scalar + - fastapi-axum + - fastapi-config + - fastapi-actix-web + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 0 + + - uses: ./.github/actions/gitlog + name: Get changed commits + id: gitlog + with: + output-file: ./draft-gitlog.md + crate: ${{ matrix.crate }} + + - name: Prepare changes + run: | + echo "## What's New :gem: :new: :tada:" > ./draft-changes.md + cat < ./draft-gitlog.md >> ./draft-changes.md + + - name: Get release info + id: release_info + run: | + module="${{ matrix.crate }}" + version=$(cargo metadata --format-version=1 --no-deps | jq -r '.packages[] | select(.name | test("'"$module"'$")) | .version') + prerelease=false + if [[ "$version" =~ .*-.* ]]; then + prerelease=true + fi + + echo "is_prerelease=$prerelease" >> $GITHUB_OUTPUT + echo "version=$version" >> $GITHUB_OUTPUT + + - name: Add full change log link + run: | + echo -e "#### Full [change log](${{ github.server_url }}/${{ github.repository }}/compare/${{ steps.gitlog.outputs.last_release }}...${{ matrix.crate }}-${{ steps.release_info.outputs.version }})" >> ./draft-changes.md + + - name: Check existing release + id: existing_release + run: | + if git tag | grep -e ^${{ matrix.crate }}-${{ steps.release_info.outputs.version }}$ > /dev/null; then + echo "Tag tag with ${{ matrix.crate }}-${{ steps.release_info.outputs.version }} already exists, cannot draft a release for already existing tag!, Consider upgrading versions to Cargo.toml file" + echo "is_new=false" >> $GITHUB_OUTPUT + else + echo "is_new=true" >> $GITHUB_OUTPUT + fi + + - name: Remove previous release + if: ${{ steps.existing_release.outputs.is_new == 'true' }} + run: | + echo ${{ secrets.GITHUB_TOKEN }} | gh auth login --with-token + gh release delete ${{ matrix.crate }}-${{ steps.release_info.outputs.version }} -y || true + + - name: Create release + id: create_release + if: ${{ steps.existing_release.outputs.is_new == 'true' }} + uses: softprops/action-gh-release@v2.0.4 + with: + tag_name: ${{ matrix.crate }}-${{ steps.release_info.outputs.version }} + name: ${{ matrix.crate }}-${{ steps.release_info.outputs.version }} + body_path: ./draft-changes.md + draft: true + prerelease: ${{ steps.release_info.outputs.is_prerelease }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..e88c897 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,23 @@ +name: Publish release + +on: + release: + types: [published] + +env: + CARGO_TERM_COLOR: always + +jobs: + publish: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + with: + fetch-depth: 2 + + - uses: ./.github/actions/publish + name: Cargo publish + with: + token: ${{ secrets.CARGO_LOGIN }} + ref: ${{ github.ref }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..25c680a --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +/target +Cargo.lock +*.iml +.idea +.vscode +target +.nvim + diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..ff45d03 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,31 @@ +[workspace.package] +rust-version = "1.75" + +[workspace] +resolver = "2" +members = [ + "fastapi", + "fastapi-gen", + "fastapi-swagger-ui-vendored", + "fastapi-swagger-ui", + "fastapi-redoc", + "fastapi-rapidoc", + "fastapi-scalar", + "fastapi-axum", + "fastapi-config", + "fastapi-actix-web", +] + +[workspace.metadata.publish] +order = [ + "fastapi-config", + "fastapi-gen", + "fastapi", + "fastapi-swagger-ui-vendored", + "fastapi-swagger-ui", + "fastapi-redoc", + "fastapi-rapidoc", + "fastapi-scalar", + "fastapi-axum", + "fastapi-actix-web", +] diff --git a/LICENSE-APACHE b/LICENSE-APACHE new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENSE-APACHE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/LICENSE-MIT b/LICENSE-MIT new file mode 100644 index 0000000..63e5114 --- /dev/null +++ b/LICENSE-MIT @@ -0,0 +1,22 @@ +The MIT License (MIT) + +Copyright © 2024 + + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. diff --git a/README.md b/README.md index 349c26a..1c11df8 100644 --- a/README.md +++ b/README.md @@ -1 +1,318 @@ -# fastapi \ No newline at end of file +# fastapi - Auto-generated OpenAPI documentation + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi) + +Want to have your API documented with OpenAPI? But don't want to be bothered +with manual YAML or JSON tweaking? Would like it to be so easy that it would almost +be utopic? Don't worry: fastapi is here to fill this gap. It aims to do, if not all, then +most of the heavy lifting for you, enabling you to focus on writing the actual API logic instead of +documentation. It aims to be _minimal_, _simple_ and _fast_. It uses simple `proc` macros which +you can use to annotate your code to have items documented. + +The `fastapi` crate provides auto-generated OpenAPI documentation for Rust REST APIs. It treats +code-first approach as a first class citizen and simplifies API documentation by providing +simple macros for generating the documentation from your code. + +It also contains Rust types of the OpenAPI spec, allowing you to write the OpenAPI spec only using +Rust if auto generation is not your flavor or does not fit your purpose. + +Long term goal of the library is to be the place to go when OpenAPI documentation is needed in any Rust +codebase. + +Fastapi is framework-agnostic, and could be used together with any web framework, or even without one. While +being portable and standalone, one of its key aspects is simple integration with web frameworks. + +## Choose your flavor and document your API with ice-cold IPA + +|Flavor|Support| +|--|--| +|[actix-web](https://github.com/actix/actix-web)|Parse path, path parameters and query parameters, recognize request body and response body, [`fastapi-actix-web` bindings](./fastapi-actix-web/README.md). See more at [docs](https://docs.rs/fastapi/latest/fastapi/attr.path.html#actix_extras-feature-support-for-actix-web)| +|[axum](https://github.com/tokio-rs/axum)|Parse path and query parameters, recognize request body and response body, [`fastapi-axum` bindings](./fastapi-axum/README.md). See more at [docs](https://docs.rs/fastapi/latest/fastapi/attr.path.html#axum_extras-feature-support-for-axum)| +|[rocket](https://github.com/SergioBenitez/Rocket)| Parse path, path parameters and query parameters, recognize request body and response body. See more at [docs](https://docs.rs/fastapi/latest/fastapi/attr.path.html#rocket_extras-feature-support-for-rocket)| +|Others*| Plain `fastapi` without extra flavor. This gives you all the basic benefits listed below in **[Features](#features)** section but with little less automation.| + +> Others* = For example [warp](https://github.com/seanmonstar/warp) but could be anything. + +Refer to the existing [examples](./examples) to find out more. + +## Features + +* OpenAPI 3.1 +* Pluggable, easy setup and integration with frameworks. +* No bloat, enable what you need. +* Support for generic types + * **Note!**
+ Tuples, arrays and slices cannot be used as generic arguments on types. Types implementing `ToSchema` manually should not have generic arguments, as + they are not composeable and will result compile error. +* Automatic schema collection from usages recursively. + * Request body from either handler function arguments (if supported by framework) or from `request_body` attribute. + * Response body from response `body` attribute or response `content` attribute. +* Various OpenAPI visualization tools supported out of the box. +* Rust type aliases via [`fastapi-config`](./fastapi-config/README.md). + +## What's up with the word play? + +The name comes from the words `utopic` and `api` where `uto` are the first three letters of _utopic_ +and the `ipa` is _api_ reversed. Aaand... `ipa` is also an awesome type of beer :beer:. + +## Crate Features + +- **`macros`** Enable `fastapi-gen` macros. **This is enabled by default.** +- **`yaml`**: Enables **serde_yaml** serialization of OpenAPI objects. +- **`actix_extras`**: Enhances [actix-web](https://github.com/actix/actix-web/) integration with being able to + parse `path`, `path` and `query` parameters from actix web path attribute macros. See + [docs](https://docs.rs/fastapi/latest/fastapi/attr.path.html#actix_extras-feature-support-for-actix-web) or [examples](./examples) for more details. +- **`rocket_extras`**: Enhances [rocket](https://github.com/SergioBenitez/Rocket) framework integration with being + able to parse `path`, `path` and `query` parameters from rocket path attribute macros. See [docs](https://docs.rs/fastapi/latest/fastapi/attr.path.html#rocket_extras-feature-support-for-rocket) + or [examples](./examples) for more details. +- **`axum_extras`**: Enhances [axum](https://github.com/tokio-rs/axum) framework integration allowing users to use `IntoParams` without + defining the `parameter_in` attribute. See [docs](https://docs.rs/fastapi/latest/fastapi/attr.path.html#axum_extras-feature-support-for-axum) + or [examples](./examples) for more details. +- **`debug`**: Add extra traits such as debug traits to openapi definitions and elsewhere. +- **`chrono`**: Add support for [chrono](https://crates.io/crates/chrono) `DateTime`, `Date`, `NaiveDate`, `NaiveDateTime`, `NaiveTime` and `Duration` + types. By default these types are parsed to `string` types with additional `format` information. + `format: date-time` for `DateTime` and `NaiveDateTime` and `format: date` for `Date` and `NaiveDate` according + [RFC3339](https://www.rfc-editor.org/rfc/rfc3339#section-5.6) as `ISO-8601`. To + override default `string` representation users have to use `value_type` attribute to override the type. + See [docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html) for more details. +- **`time`**: Add support for [time](https://crates.io/crates/time) `OffsetDateTime`, `PrimitiveDateTime`, `Date`, and `Duration` types. + By default these types are parsed as `string`. `OffsetDateTime` and `PrimitiveDateTime` will use `date-time` format. `Date` will use + `date` format and `Duration` will not have any format. To override default `string` representation users have to use `value_type` attribute + to override the type. See [docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html) for more details. +- **`decimal`**: Add support for [rust_decimal](https://crates.io/crates/rust_decimal) `Decimal` type. **By default** + it is interpreted as `String`. If you wish to change the format you need to override the type. + See the `value_type` in [component derive docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html). +- **`decimal_float`**: Add support for [rust_decimal](https://crates.io/crates/rust_decimal) `Decimal` type. **By default** + it is interpreted as `Number`. This feature is mutually exclusive with **decimal** and allow to change the default type used in your + documentation for `Decimal` much like `serde_with_float` feature exposed by rust_decimal. +- **`uuid`**: Add support for [uuid](https://github.com/uuid-rs/uuid). `Uuid` type will be presented as `String` with + format `uuid` in OpenAPI spec. +- **`ulid`**: Add support for [ulid](https://github.com/dylanhart/ulid-rs). `Ulid` type will be presented as `String` with + format `ulid` in OpenAPI spec. +- **`url`**: Add support for [url](https://github.com/servo/rust-url). `Url` type will be presented as `String` with + format `uri` in OpenAPI spec. +- **`smallvec`**: Add support for [smallvec](https://crates.io/crates/smallvec). `SmallVec` will be treated as `Vec`. +- **`openapi_extensions`**: Adds traits and functions that provide extra convenience functions. + See the [`request_body` docs](https://docs.rs/fastapi/latest/fastapi/openapi/request_body) for an example. +- **`repr`**: Add support for [repr_serde](https://github.com/dtolnay/serde-repr)'s `repr(u*)` and `repr(i*)` attributes to unit type enums for + C-like enum representation. See [docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html) for more details. +- **`preserve_order`**: Preserve order of properties when serializing the schema for a component. + When enabled, the properties are listed in order of fields in the corresponding struct definition. + When disabled, the properties are listed in alphabetical order. +- **`preserve_path_order`**: Preserve order of OpenAPI Paths according to order they have been + introduced to the `#[openapi(paths(...))]` macro attribute. If disabled the paths will be + ordered in alphabetical order. **However** the operations order under the path **will** be always constant according to [specification](https://spec.openapis.org/oas/latest.html#fixed-fields-6) +- **`indexmap`**: Add support for [indexmap](https://crates.io/crates/indexmap). When enabled `IndexMap` will be rendered as a map similar to + `BTreeMap` and `HashMap`. +- **`non_strict_integers`**: Add support for non-standard integer formats `int8`, `int16`, `uint8`, `uint16`, `uint32`, and `uint64`. +- **`rc_schema`**: Add `ToSchema` support for `Arc` and `Rc` types. **Note!** serde `rc` feature flag must be enabled separately to allow + serialization and deserialization of `Arc` and `Rc` types. See more about [serde feature flags](https://serde.rs/feature-flags.html). +- **`config`** Enables [`fastapi-config`](./fastapi-config/README.md) for the project which allows defining global configuration options for `fastapi`. + +### Default Library Support + +* Implicit partial support for `serde` attributes. See [docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html#partial-serde-attributes-support) for more details. +* Support for [http](https://crates.io/crates/http) `StatusCode` in responses. + +## Install + +Add dependency declaration to `Cargo.toml`. + +```toml +[dependencies] +fastapi = "5" +``` + +## Examples + +_Create type with `ToSchema` and use it in `#[fastapi::path(...)]` that is registered to the `OpenApi`._ + +```rust +use fastapi::{OpenApi, ToSchema}; + +#[derive(ToSchema)] +struct Pet { + id: u64, + name: String, + age: Option, +} + +mod pet_api { + /// Get pet by id + /// + /// Get pet from database by pet id + #[fastapi::path( + get, + path = "/pets/{id}", + responses( + (status = 200, description = "Pet found successfully", body = Pet), + (status = NOT_FOUND, description = "Pet was not found") + ), + params( + ("id" = u64, Path, description = "Pet database id to get Pet for"), + ) + )] + async fn get_pet_by_id(pet_id: u64) -> Result { + Ok(Pet { + id: pet_id, + age: None, + name: "lightning".to_string(), + }) + } +} + +#[derive(OpenApi)] +#[openapi(paths(pet_api::get_pet_by_id))] +struct ApiDoc; + +println!("{}", ApiDoc::openapi().to_pretty_json().unwrap()); +``` + +
+ Above example will produce an OpenAPI doc like this: + +```json +{ + "openapi": "3.1.0", + "info": { + "title": "application name from Cargo.toml", + "description": "description from Cargo.toml", + "contact": { + "name": "author name from Cargo.toml", + "email": "author email from Cargo.toml" + }, + "license": { + "name": "license from Cargo.toml" + }, + "version": "version from Cargo.toml" + }, + "paths": { + "/pets/{id}": { + "get": { + "tags": [ + "pet_api" + ], + "summary": "Get pet by id", + "description": "Get pet from database by pet id", + "operationId": "get_pet_by_id", + "parameters": [ + { + "name": "id", + "in": "path", + "description": "Pet database id to get Pet for", + "required": true, + "schema": { + "type": "integer", + "format": "int64", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "Pet found successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Pet" + } + } + } + }, + "404": { + "description": "Pet was not found" + } + } + } + } + }, + "components": { + "schemas": { + "Pet": { + "type": "object", + "required": [ + "id", + "name" + ], + "properties": { + "age": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "id": { + "type": "integer", + "format": "int64", + "minimum": 0 + }, + "name": { + "type": "string" + } + } + } + } + } +} +``` + +
+ +## Modify OpenAPI at runtime + +You can modify generated OpenAPI at runtime either via generated types directly or using +[Modify](https://docs.rs/fastapi/latest/fastapi/trait.Modify.html) trait. + +_Modify generated OpenAPI via types directly._ + +```rust +#[derive(OpenApi)] +#[openapi( + info(description = "My Api description"), +)] +struct ApiDoc; + +let mut doc = ApiDoc::openapi(); +doc.info.title = String::from("My Api"); +``` + +_You can even convert the generated [OpenApi](https://docs.rs/fastapi/latest/fastapi/openapi/struct.OpenApi.html) to [OpenApiBuilder](https://docs.rs/fastapi/latest/fastapi/openapi/struct.OpenApiBuilder.html)._ + +```rust +let builder: OpenApiBuilder = ApiDoc::openapi().into(); +``` + +See [Modify](https://docs.rs/fastapi/latest/fastapi/trait.Modify.html) trait for examples on how to modify generated OpenAPI via it. + +## Go beyond the surface + +- See how to serve OpenAPI doc via Swagger UI check [fastapi-swagger-ui](https://docs.rs/fastapi-swagger-ui/) crate for more details. +- Browse to [examples](https://github.com/nxpkg/fastapi/tree/master/examples) for more comprehensive examples. +- Check [IntoResponses](https://docs.rs/fastapi/latest/fastapi/derive.IntoResponses.html) and [ToResponse](https://docs.rs/fastapi/latest/fastapi/derive.ToResponse.html) for examples on deriving responses. +- More about OpenAPI security in [security documentation](https://docs.rs/fastapi/latest/fastapi/openapi/security/index.html). +- Dump generated API doc to file at build time. See [issue 214 comment](https://github.com/nxpkg/fastapi/issues/214#issuecomment-1179589373). + +## FAQ + +### Swagger UI returns 404 NotFound from built binary + +This is highly probably due to `RustEmbed` not embedding the Swagger UI to the executable. This is natural since the `RustEmbed` +library **does not** by default embed files on debug builds. To get around this you can do one of the following. + +1. Build your executable in `--release` mode + +Find `fastapi-swagger-ui` [feature flags here](https://github.com/nxpkg/fastapi/tree/master/fastapi-swagger-ui#crate-features). + +### Auto discover for OpenAPI schemas and paths? + +Currently there is no build in solution to automatically discover the OpenAPI types but for your luck there is a pretty neat crate that +just does this for you called [fastapiuto](https://github.com/ProbablyClem/fastapiuto). + +## License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..a676187 --- /dev/null +++ b/examples/README.md @@ -0,0 +1,23 @@ +# fastapi examples + +This is folder contain a set of examples of fastapi library which should help people to get started +with the library. + +All examples have their own `README.md`, and can be seen using two steps: + +1. Run `cargo run` +2. Browse to `http://localhost:8080/swagger-ui/` or `http://localhost:8080/redoc` or `http://localhost:8080/rapidoc`. + +`todo-actix`, `todo-axum` and `rocket-todo` have Swagger UI, Redoc, RapiDoc, and Scalar setup, others have Swagger UI +if not explicitly stated otherwise. + +Even if there is no example for your favourite framework, `fastapi` can be used with any +web framework which supports decorating functions with macros similarly to the **warp** and **tide** examples. + +## Community examples + +- **[graphul](https://github.com/graphul-rs/graphul/tree/main/examples/fastapi-swagger-ui)** +- **[salvo](https://github.com/salvo-rs/salvo/tree/main/examples/todos-fastapi)** +- **[viz](https://github.com/viz-rs/viz/tree/main/examples/routing/openapi)** +- **[ntex](https://github.com/leon3s/ntex-rest-api-example)** + diff --git a/examples/actix-web-multiple-api-docs-with-scopes/Cargo.toml b/examples/actix-web-multiple-api-docs-with-scopes/Cargo.toml new file mode 100644 index 0000000..0bd6448 --- /dev/null +++ b/examples/actix-web-multiple-api-docs-with-scopes/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "actix-web-multiple-api-docs-with-scopes" +description = "Simple actix-web with multiple scoped api docs" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Example " +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11.0" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi", features = ["actix_extras"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = ["actix-web"] } + +[workspace] diff --git a/examples/actix-web-multiple-api-docs-with-scopes/README.md b/examples/actix-web-multiple-api-docs-with-scopes/README.md new file mode 100644 index 0000000..62a30e6 --- /dev/null +++ b/examples/actix-web-multiple-api-docs-with-scopes/README.md @@ -0,0 +1,22 @@ +# actix-web-multiple-api-docs-with-scopes ~ fastapi with fastapi-swagger-ui example + +This is a demo `actix-web` application with multiple API docs with scope and context path. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +```bash +cargo run +``` + +On the Swagger-UI will be a drop-down labelled "Select a definition", containing "api1" and "api2". + +Alternatively, they can be loaded directly using + +- api1: http://localhost:8080/swagger-ui/?urls.primaryName=api1 +- api2: http://localhost:8080/swagger-ui/?urls.primaryName=api1 + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/actix-web-multiple-api-docs-with-scopes/src/main.rs b/examples/actix-web-multiple-api-docs-with-scopes/src/main.rs new file mode 100644 index 0000000..0b39487 --- /dev/null +++ b/examples/actix-web-multiple-api-docs-with-scopes/src/main.rs @@ -0,0 +1,71 @@ +use std::{error::Error, net::Ipv4Addr}; + +use actix_web::{middleware::Logger, web, App, HttpServer}; +use fastapi::OpenApi; +use fastapi_swagger_ui::{SwaggerUi, Url}; + +#[actix_web::main] +async fn main() -> Result<(), impl Error> { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi(paths(api1::hello1))] + struct ApiDoc1; + + #[derive(OpenApi)] + #[openapi(paths(api2::hello2))] + struct ApiDoc2; + + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .service( + web::scope("/api") + .service(api1::hello1) + .service(api2::hello2), + ) + .service(SwaggerUi::new("/swagger-ui/{_:.*}").urls(vec![ + ( + Url::new("api1", "/api-docs/openapi1.json"), + ApiDoc1::openapi(), + ), + ( + Url::with_primary("api2", "/api-docs/openapi2.json", true), + ApiDoc2::openapi(), + ), + ])) + }) + .bind((Ipv4Addr::UNSPECIFIED, 8080))? + .run() + .await +} + +mod api1 { + use actix_web::get; + + #[fastapi::path( + context_path = "/api", + responses( + (status = 200, description = "Hello from api 1", body = String) + ) + )] + #[get("/api1/hello")] + pub(super) async fn hello1() -> String { + "hello from api 1".to_string() + } +} + +mod api2 { + use actix_web::get; + + #[fastapi::path( + context_path = "/api", + responses( + (status = 200, description = "Hello from api 2", body = String) + ) + )] + #[get("/api2/hello")] + pub(super) async fn hello2() -> String { + "hello from api 2".to_string() + } +} diff --git a/examples/actix-web-scopes-binding/Cargo.toml b/examples/actix-web-scopes-binding/Cargo.toml new file mode 100644 index 0000000..d70d376 --- /dev/null +++ b/examples/actix-web-scopes-binding/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "actix-web-scopes-binding" +description = "Simple actix-web demo to demonstrate fastapi-actix-web bidings with scopes" +version = "0.1.0" +edition = "2021" + +[dependencies] +actix-web = "4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11.0" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi", features = ["actix_extras"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = [ + "actix-web", +] } +fastapi-actix-web = { path = "../../fastapi-actix-web" } + +[workspace] diff --git a/examples/actix-web-scopes-binding/README.md b/examples/actix-web-scopes-binding/README.md new file mode 100644 index 0000000..db8d430 --- /dev/null +++ b/examples/actix-web-scopes-binding/README.md @@ -0,0 +1,15 @@ +# actix-web-scopes-binding + + +This is a demo `actix-web` application with [`fastapi-actix-web`][../../fastapi-actix-web] bindings demonstrating scopes support. + +Run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +```bash +cargo run +``` + +Or with more logs +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/actix-web-scopes-binding/src/main.rs b/examples/actix-web-scopes-binding/src/main.rs new file mode 100644 index 0000000..1380346 --- /dev/null +++ b/examples/actix-web-scopes-binding/src/main.rs @@ -0,0 +1,55 @@ +use std::{error::Error, net::Ipv4Addr}; + +use actix_web::{middleware::Logger, App, HttpServer}; +use fastapi_actix_web::{scope, AppExt}; +use fastapi_swagger_ui::SwaggerUi; + +#[actix_web::main] +async fn main() -> Result<(), impl Error> { + env_logger::init(); + + HttpServer::new(move || { + let (app, api) = App::new() + .into_fastapi_app() + .map(|app| app.wrap(Logger::default())) + .service( + scope::scope("/api") + .service(scope::scope("/v1").service(api1::hello1)) + .service(scope::scope("/v2").service(api2::hello2)), + ) + .split_for_parts(); + + app.service(SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", api)) + }) + .bind((Ipv4Addr::UNSPECIFIED, 8080))? + .run() + .await +} + +mod api1 { + use actix_web::get; + + #[fastapi::path( + responses( + (status = 200, description = "Hello from api 1", body = str) + ) + )] + #[get("/hello")] + pub(super) async fn hello1() -> &'static str { + "hello from api 1" + } +} + +mod api2 { + use actix_web::get; + + #[fastapi::path( + responses( + (status = 200, description = "Hello from api 2", body = str) + ) + )] + #[get("/hello")] + pub(super) async fn hello2() -> &'static str { + "hello from api 2" + } +} diff --git a/examples/axum-fastapi-bindings/Cargo.toml b/examples/axum-fastapi-bindings/Cargo.toml new file mode 100644 index 0000000..89fa0c0 --- /dev/null +++ b/examples/axum-fastapi-bindings/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "axum-fastapi-bindings" +version = "0.1.0" +edition = "2021" + +[dependencies] +axum = "0.7" +tokio = { version = "1", features = ["full"] } +tower = "0.5" +fastapi = { path = "../../fastapi", features = ["axum_extras", "debug"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = ["axum"] } +fastapi-axum = { path = "../../fastapi-axum" ,features = ["debug"] } +serde = "1" +serde_json = "1" + +[workspace] diff --git a/examples/axum-fastapi-bindings/README.md b/examples/axum-fastapi-bindings/README.md new file mode 100644 index 0000000..42208c6 --- /dev/null +++ b/examples/axum-fastapi-bindings/README.md @@ -0,0 +1,14 @@ +# fastapi with axum bindings + +This demo `axum` application demonstrates `fastapi` and `axum` seamless integration with `fastapi-axum` crate. +API doc is served via Swagger UI. + +Run the app +```bash +cargo run +``` + +Browse the API docs. +``` +http://localhost:8080/swagger-ui +``` diff --git a/examples/axum-fastapi-bindings/src/main.rs b/examples/axum-fastapi-bindings/src/main.rs new file mode 100644 index 0000000..47f89de --- /dev/null +++ b/examples/axum-fastapi-bindings/src/main.rs @@ -0,0 +1,141 @@ +use std::io; +use std::net::Ipv4Addr; + +use tokio::net::TcpListener; +use fastapi::OpenApi; +use fastapi_axum::router::OpenApiRouter; +use fastapi_axum::routes; +use fastapi_swagger_ui::SwaggerUi; + +const CUSTOMER_TAG: &str = "customer"; +const ORDER_TAG: &str = "order"; + +#[derive(OpenApi)] +#[openapi( + tags( + (name = CUSTOMER_TAG, description = "Customer API endpoints"), + (name = ORDER_TAG, description = "Order API endpoints") + ) +)] +struct ApiDoc; + +/// Get health of the API. +#[fastapi::path( + method(get, head), + path = "/api/health", + responses( + (status = OK, description = "Success", body = str, content_type = "text/plain") + ) +)] +async fn health() -> &'static str { + "ok" +} + +#[tokio::main] +async fn main() -> Result<(), io::Error> { + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .routes(routes!(health)) + .nest("/api/customer", customer::router()) + .nest("/api/order", order::router()) + .routes(routes!( + inner::secret_handlers::get_secret, + inner::secret_handlers::post_secret + )) + .split_for_parts(); + + let router = router.merge(SwaggerUi::new("/swagger-ui").url("/apidoc/openapi.json", api)); + + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 8080)).await?; + axum::serve(listener, router).await +} + +mod customer { + use axum::Json; + use serde::Serialize; + use fastapi::ToSchema; + use fastapi_axum::router::OpenApiRouter; + use fastapi_axum::routes; + + /// This is the customer + #[derive(ToSchema, Serialize)] + struct Customer { + name: String, + } + + /// expose the Customer OpenAPI to parent module + pub fn router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(get_customer)) + } + + /// Get customer + /// + /// Just return a static Customer object + #[fastapi::path(get, path = "", responses((status = OK, body = Customer)), tag = super::CUSTOMER_TAG)] + async fn get_customer() -> Json { + Json(Customer { + name: String::from("Bill Book"), + }) + } +} + +mod order { + use axum::Json; + use serde::{Deserialize, Serialize}; + use fastapi::ToSchema; + use fastapi_axum::router::OpenApiRouter; + use fastapi_axum::routes; + + /// This is the order + #[derive(ToSchema, Serialize)] + struct Order { + id: i32, + name: String, + } + + #[derive(ToSchema, Deserialize, Serialize)] + struct OrderRequest { + name: String, + } + + /// expose the Order OpenAPI to parent module + pub fn router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(get_order, create_order)) + } + + /// Get static order object + #[fastapi::path(get, path = "", responses((status = OK, body = Order)), tag = super::ORDER_TAG)] + async fn get_order() -> Json { + Json(Order { + id: 100, + name: String::from("Bill Book"), + }) + } + + /// Create an order. + /// + /// Create an order by basically passing through the name of the request with static id. + #[fastapi::path(post, path = "", responses((status = OK, body = Order)), tag = super::ORDER_TAG)] + async fn create_order(Json(order): Json) -> Json { + Json(Order { + id: 120, + name: order.name, + }) + } +} + +mod inner { + pub mod secret_handlers { + + /// This is some secret inner handler + #[fastapi::path(get, path = "/api/inner/secret", responses((status = OK, body = str)))] + pub async fn get_secret() -> &'static str { + "secret" + } + + /// Post some secret inner handler + #[fastapi::path(post, path = "/api/inner/secret", responses((status = OK)))] + pub async fn post_secret() { + println!("You posted a secret") + } + } +} diff --git a/examples/axum-fastapi-docker b/examples/axum-fastapi-docker new file mode 120000 index 0000000..2dd810f --- /dev/null +++ b/examples/axum-fastapi-docker @@ -0,0 +1 @@ +./axum-utoipa-nesting-vendored \ No newline at end of file diff --git a/examples/axum-fastapi-nesting-vendored/Cargo.toml b/examples/axum-fastapi-nesting-vendored/Cargo.toml new file mode 100644 index 0000000..6db5ced --- /dev/null +++ b/examples/axum-fastapi-nesting-vendored/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "axum-fastapi-nesting" +description = "Axum nesting example with Swagger UI" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Elli Example "] + + +[dependencies] +axum = "0.7" +hyper = { version = "1.0.1", features = ["full"] } +tokio = { version = "1.17", features = ["full"] } +tower = "0.5" +fastapi = { path = "../../fastapi", features = ["axum_extras"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = ["axum", "vendored"], default-features = false } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11" +log = "0.4" + +[workspace] diff --git a/examples/axum-fastapi-nesting-vendored/Dockerfile b/examples/axum-fastapi-nesting-vendored/Dockerfile new file mode 100644 index 0000000..689a1f0 --- /dev/null +++ b/examples/axum-fastapi-nesting-vendored/Dockerfile @@ -0,0 +1,5 @@ +FROM alpine:3.14 + +COPY target/x86_64-unknown-linux-musl/release/axum-fastapi-nesting /axum-fastapi-nesting + +ENTRYPOINT [ "/axum-fastapi-nesting" ] diff --git a/examples/axum-fastapi-nesting-vendored/README.md b/examples/axum-fastapi-nesting-vendored/README.md new file mode 100644 index 0000000..e2b90a0 --- /dev/null +++ b/examples/axum-fastapi-nesting-vendored/README.md @@ -0,0 +1,21 @@ +# axum-nesting-vendored ~ fastapi with fastapi-swagger-ui example + +This example demonstrates `axum` with programmatic and macro based nesting of OpenApis +using `fastapi-swagger-ui` for visualization. + +Example uses `fastapi-swagger-ui-vendored` to demonstrate vendored version of Swagger UI. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +```bash +cargo run +``` + +## Run with Docker + +You have to build the crate with `--release` or set `debug-embed` in order to embed Swagger UI. +```bash +cargo build --release --target x86_64-unknown-linux-musl +docker build -t axum-fastapi-nesting:latest . +docker run -p 8080:8080 -t axum-fastapi-nesting:latest +``` diff --git a/examples/axum-fastapi-nesting-vendored/src/main.rs b/examples/axum-fastapi-nesting-vendored/src/main.rs new file mode 100644 index 0000000..a668c0f --- /dev/null +++ b/examples/axum-fastapi-nesting-vendored/src/main.rs @@ -0,0 +1,67 @@ +use std::net::{Ipv4Addr, SocketAddr}; + +use axum::{routing, Router}; +use std::io::Error; +use tokio::net::TcpListener; +use fastapi::openapi::path::Operation; +use fastapi::openapi::{OpenApiBuilder, PathItem, PathsBuilder}; +use fastapi::OpenApi; +use fastapi_swagger_ui::SwaggerUi; + +#[tokio::main] +async fn main() -> Result<(), Error> { + #[derive(OpenApi)] + #[openapi( + nest( + // you can nest sub apis here + (path = "/api/v1/ones", api = one::OneApi) + ) + )] + struct ApiDoc; + + #[derive(OpenApi)] + #[openapi()] + struct HelloApi; + + let hello_api = + Into::::into(HelloApi::openapi()).paths(PathsBuilder::new().path( + "", + PathItem::new(fastapi::openapi::HttpMethod::Get, Operation::new()), + )); + + let mut doc = ApiDoc::openapi(); + doc = doc.nest("/hello", hello_api); // you can even nest programmatically apis + + let app = Router::new() + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", doc)) + .route("/hello", routing::get(|| async { "hello" })) + .nest("/api/v1/ones", one::router()); + + let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 8080)); + let listener = TcpListener::bind(&address).await?; + axum::serve(listener, app.into_make_service()).await +} + +mod one { + use axum::{routing, Router}; + use fastapi::OpenApi; + + #[derive(OpenApi)] + #[openapi(paths(get_one))] + pub(super) struct OneApi; + + pub(super) fn router() -> Router { + Router::new().route("/one", routing::get(get_one)) + } + + #[fastapi::path( + get, + path = "/one", + responses( + (status = OK, description = "One result ok", body = str) + ) + )] + async fn get_one() -> &'static str { + "one" + } +} diff --git a/examples/fastapi-config-test/README.md b/examples/fastapi-config-test/README.md new file mode 100644 index 0000000..33f594c --- /dev/null +++ b/examples/fastapi-config-test/README.md @@ -0,0 +1,6 @@ +# fastapi-config-test + +This example demonstrates global Rust type aliases in fastapi project. +Check out `main.rs` and `build.rs` and then run `cargo run`. + +Browse the [project files here](../../fastapi-config/config-test-crate/). diff --git a/examples/generics-actix/Cargo.toml b/examples/generics-actix/Cargo.toml new file mode 100644 index 0000000..96f8a28 --- /dev/null +++ b/examples/generics-actix/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "generics-actix" +description = "Simple actix-web using non-supported types and generics with fastapi and Swagger" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Example " +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "4" +env_logger = "0.10.0" +geo-types = { version = "0.7", features = ["serde"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +fastapi = { path = "../../fastapi", features = ["actix_extras", "non_strict_integers"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = ["actix-web"] } + +[workspace] diff --git a/examples/generics-actix/README.md b/examples/generics-actix/README.md new file mode 100644 index 0000000..89a8c52 --- /dev/null +++ b/examples/generics-actix/README.md @@ -0,0 +1,21 @@ +# generics-actix + +This is demo `actix-web` application showing using external `geo-types`, which uses generics, in endpoints. +The API demonstrates `fastapi` with `fastapi-swagger-ui` functionalities. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +```bash +cargo run +``` + +In the swagger UI: + +1. Send `x=1`, `y=2` to endpoint `coord_u64` to see an integer `x`,`y` coord object returned. +2. Send `x=1.1`, `y=2.2` to endpoint `coord_f64` to see a float `x`,`y` coord object returned. + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/generics-actix/src/main.rs b/examples/generics-actix/src/main.rs new file mode 100644 index 0000000..dcda656 --- /dev/null +++ b/examples/generics-actix/src/main.rs @@ -0,0 +1,119 @@ +use std::{error::Error, net::Ipv4Addr}; + +use actix_web::{ + get, + middleware::Logger, + web::{Json, Query}, + App, HttpServer, Responder, Result, +}; +use serde::{Deserialize, Serialize}; +use fastapi::{ + openapi::schema::{Object, ObjectBuilder}, + IntoParams, OpenApi, PartialSchema, ToSchema, +}; +use fastapi_swagger_ui::SwaggerUi; + +fn get_coord_schema() -> Object { + ObjectBuilder::new() + .property("x", T::schema()) + .required("x") + .property("y", T::schema()) + .required("y") + .description(Some("this is the coord description")) + .build() +} + +#[derive(Serialize, ToSchema)] +pub struct MyObject { + #[schema(schema_with=get_coord_schema::)] + at: geo_types::Coord, +} + +// FloatParams and IntegerParams cant be merged using generics because +// IntoParams does not support it, and does not support `schema_with` either. +#[derive(Deserialize, Debug, IntoParams)] +pub struct FloatParams { + /// x value + x: f64, + /// y value + y: f64, +} + +#[fastapi::path( + params( + FloatParams + ), + responses( + (status = 200, description = "OK", body = MyObject), + ), + security( + ("api_key" = []) + ), +)] +#[get("/coord_f64")] +pub async fn coord_f64(params: Query) -> Result { + let params: FloatParams = params.into_inner(); + let coord = geo_types::Coord:: { + x: params.x, + y: params.y, + }; + eprintln!("response = {:?}", coord); + Ok(Json(coord)) +} + +#[derive(Deserialize, Debug, IntoParams)] +pub struct IntegerParams { + /// x value + x: u64, + /// y value + y: u64, +} + +#[fastapi::path( + params( + IntegerParams, + ), + responses( + (status = 200, description = "OK", body = MyObject), + ), + security( + ("api_key" = []) + ), +)] +#[get("/coord_u64")] +pub async fn coord_u64(params: Query) -> Result { + let params: IntegerParams = params.into_inner(); + let coord = geo_types::Coord:: { + x: params.x, + y: params.y, + }; + eprintln!("response = {:?}", coord); + Ok(Json(coord)) +} + +#[actix_web::main] +async fn main() -> Result<(), impl Error> { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi( + paths(coord_f64, coord_u64), + components(schemas(MyObject, MyObject)) + )] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .service(coord_f64) + .service(coord_u64) + .service( + SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", openapi.clone()), + ) + }) + .bind((Ipv4Addr::UNSPECIFIED, 8080))? + .run() + .await +} diff --git a/examples/raw-json-actix/Cargo.toml b/examples/raw-json-actix/Cargo.toml new file mode 100644 index 0000000..437c58e --- /dev/null +++ b/examples/raw-json-actix/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "raw-json-actix" +description = "Simple actix-web using raw JSON with fastapi and Swagger" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Example " +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "4" +env_logger = "0.10.0" +serde_json = "1.0" +fastapi = { path = "../../fastapi", features = ["actix_extras"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = ["actix-web"] } + +[workspace] diff --git a/examples/raw-json-actix/README.md b/examples/raw-json-actix/README.md new file mode 100644 index 0000000..f696dc3 --- /dev/null +++ b/examples/raw-json-actix/README.md @@ -0,0 +1,22 @@ +# raw-json-actix + +This is a demo `actix-web` application showing using raw JSON in endpoints. +The API demonstrates `fastapi` with `fastapi-swagger-ui` functionalities. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +```bash +cargo run +``` + +In the swagger UI: + +1. Send body `"string"` and the console will show the body was a `serde_json::String`. +2. Send body `1` and the console will show the body was a `serde_json::Number`. +3. Send body `[1, 2]` and the console will show the body was a `serde_json::Array`. + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/raw-json-actix/src/main.rs b/examples/raw-json-actix/src/main.rs new file mode 100644 index 0000000..0fabc8d --- /dev/null +++ b/examples/raw-json-actix/src/main.rs @@ -0,0 +1,48 @@ +use std::{error::Error, net::Ipv4Addr}; + +use actix_web::{ + middleware::Logger, patch, web::Json, App, HttpResponse, HttpServer, Responder, Result, +}; +use serde_json::Value; +use fastapi::OpenApi; +use fastapi_swagger_ui::SwaggerUi; + +#[fastapi::path( + request_body = Value, + responses( + (status = 200, description = "Patch completed"), + (status = 406, description = "Not accepted"), + ), + security( + ("api_key" = []) + ), +)] +#[patch("/patch_raw")] +pub async fn patch_raw(body: Json) -> Result { + let value: Value = body.into_inner(); + eprintln!("body = {:?}", value); + Ok(HttpResponse::Ok()) +} + +#[actix_web::main] +async fn main() -> Result<(), impl Error> { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi(paths(patch_raw))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + + HttpServer::new(move || { + App::new() + .wrap(Logger::default()) + .service(patch_raw) + .service( + SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", openapi.clone()), + ) + }) + .bind((Ipv4Addr::UNSPECIFIED, 8080))? + .run() + .await +} diff --git a/examples/rocket-todo/Cargo.toml b/examples/rocket-todo/Cargo.toml new file mode 100644 index 0000000..13e4813 --- /dev/null +++ b/examples/rocket-todo/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "rocket-todo" +description = "Simple rocket todo example api with fastapi and Swagger UI, Rapidoc, Redoc, and Scalar" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Elli Example "] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +rocket = { version = "0.5", features = ["json"] } +fastapi = { path = "../../fastapi", features = ["rocket_extras"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = ["rocket"] } +fastapi-redoc = { path = "../../fastapi-redoc", features = ["rocket"] } +fastapi-rapidoc = { path = "../../fastapi-rapidoc", features = ["rocket"] } +fastapi-scalar = { path = "../../fastapi-scalar", features = ["rocket"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +log = "0.4" + +[workspace] diff --git a/examples/rocket-todo/README.md b/examples/rocket-todo/README.md new file mode 100644 index 0000000..18f224b --- /dev/null +++ b/examples/rocket-todo/README.md @@ -0,0 +1,24 @@ +# todo-rocket ~ fastapi with fastapi-swagger-ui, fastapi-redoc and fastapi-rapidoc example + +This is a demo `rocket` application with in-memory storage to manage Todo items. The API +demonstrates `fastapi` with `fastapi-swagger-ui` functionalities. + +For security restricted endpoints the super secret API key is: `fastapi-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8000/swagger-ui/`. + +If you prefer Redoc just head to `http://localhost:8000/redoc` and view the Open API. + +RapiDoc can be found from `http://localhost:8000/redoc`. + +Scalar can be reached on `http://localhost:8000/scalar`. + +```bash +cargo run +``` + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/rocket-todo/src/main.rs b/examples/rocket-todo/src/main.rs new file mode 100644 index 0000000..e82b76d --- /dev/null +++ b/examples/rocket-todo/src/main.rs @@ -0,0 +1,314 @@ +use rocket::{catch, catchers, routes, Build, Request, Rocket}; +use serde_json::json; +use todo::RequireApiKey; +use fastapi::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use fastapi_rapidoc::RapiDoc; +use fastapi_redoc::{Redoc, Servable}; +use fastapi_scalar::{Scalar, Servable as ScalarServable}; +use fastapi_swagger_ui::SwaggerUi; + +use crate::todo::TodoStore; + +#[rocket::launch] +fn rocket() -> Rocket { + #[derive(OpenApi)] + #[openapi( + nest( + (path = "/api/todo", api = todo::TodoApi) + ), + tags( + (name = "todo", description = "Todo management endpoints.") + ), + modifiers(&SecurityAddon) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + rocket::build() + .manage(TodoStore::default()) + .register("/api/todo", catchers![unauthorized]) + .mount( + "/", + SwaggerUi::new("/swagger-ui/<_..>").url("/api-docs/openapi.json", ApiDoc::openapi()), + ) + // There is no need to create RapiDoc::with_openapi because the OpenApi is served + // via SwaggerUi instead we only make rapidoc to point to the existing doc. + .mount("/", RapiDoc::new("/api-docs/openapi.json").path("/rapidoc")) + // Alternative to above + // .mount( + // "/", + // RapiDoc::with_openapi("/api-docs/openapi2.json", ApiDoc::openapi()).path("/rapidoc") + // ) + .mount("/", Redoc::with_url("/redoc", ApiDoc::openapi())) + .mount("/", Scalar::with_url("/scalar", ApiDoc::openapi())) + .mount( + "/api/todo", + routes![ + todo::get_tasks, + todo::create_todo, + todo::mark_done, + todo::delete_todo, + todo::search_todos + ], + ) +} + +#[catch(401)] +async fn unauthorized(req: &Request<'_>) -> serde_json::Value { + let (_, todo_error) = req.guard::().await.failed().unwrap(); + + json!(todo_error) +} + +mod todo { + use std::sync::{Arc, Mutex}; + + use rocket::{ + delete, get, + http::Status, + outcome::Outcome, + post, put, + request::{self, FromRequest}, + response::{status::Custom, Responder}, + serde::json::Json, + FromForm, Request, State, + }; + use serde::{Deserialize, Serialize}; + use fastapi::{IntoParams, OpenApi, ToSchema}; + + #[derive(OpenApi)] + #[openapi(paths(get_tasks, create_todo, mark_done, delete_todo, search_todos,))] + pub struct TodoApi; + + pub(super) type TodoStore = Arc>>; + + /// Todo operation error. + #[derive(Serialize, ToSchema, Responder, Debug)] + pub(super) enum TodoError { + /// When there is conflict creating a new todo. + #[response(status = 409)] + Conflict(String), + + /// When todo item is not found from storage. + #[response(status = 404)] + NotFound(String), + + /// When unauthorized to complete operation + #[response(status = 401)] + Unauthorized(String), + } + + pub(super) struct RequireApiKey; + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for RequireApiKey { + type Error = TodoError; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + match request.headers().get("todo_apikey").next() { + Some("fastapi-rocks") => Outcome::Success(RequireApiKey), + None => Outcome::Error(( + Status::Unauthorized, + TodoError::Unauthorized(String::from("missing api key")), + )), + _ => Outcome::Error(( + Status::Unauthorized, + TodoError::Unauthorized(String::from("invalid api key")), + )), + } + } + } + + pub(super) struct LogApiKey; + + #[rocket::async_trait] + impl<'r> FromRequest<'r> for LogApiKey { + type Error = TodoError; + + async fn from_request(request: &'r Request<'_>) -> request::Outcome { + match request.headers().get("todo_apikey").next() { + Some("fastapi-rocks") => { + log::info!("authenticated"); + Outcome::Success(LogApiKey) + } + _ => { + log::info!("no api key"); + Outcome::Forward(Status::Unauthorized) + } + } + } + } + + /// Task to do. + #[derive(Serialize, Deserialize, ToSchema, Clone)] + pub(super) struct Todo { + /// Unique todo id. + #[schema(example = 1)] + id: i32, + /// Description of a tasks. + #[schema(example = "Buy groceries")] + value: String, + /// Indication whether task is done or not. + done: bool, + } + + /// List all available todo items. + #[fastapi::path( + responses( + (status = 200, description = "Get all todos", body = [Todo]) + ) + )] + #[get("/")] + pub(super) async fn get_tasks(store: &State) -> Json> { + Json(store.lock().unwrap().clone()) + } + + /// Create new todo item. + /// + /// Create new todo item and add it to the storage. + #[fastapi::path( + responses( + (status = 201, description = "Todo item created successfully", body = Todo), + (status = 409, description = "Todo already exists", body = TodoError, example = json!(TodoError::Conflict(String::from("id = 1")))) + ) + )] + #[post("/", data = "")] + pub(super) async fn create_todo( + todo: Json, + store: &State, + ) -> Result>, TodoError> { + let mut todos = store.lock().unwrap(); + todos + .iter() + .find(|existing| existing.id == todo.id) + .map(|todo| Err(TodoError::Conflict(format!("id = {}", todo.id)))) + .unwrap_or_else(|| { + todos.push(todo.0.clone()); + + Ok(Custom(Status::Created, Json(todo.0))) + }) + } + + /// Mark Todo item done by given id + /// + /// Tries to find todo item by given id and mark it done if found. Will return not found in case todo + /// item does not exists. + #[fastapi::path( + responses( + (status = 200, description = "Todo item marked done successfully"), + (status = 404, description = "Todo item not found from storage", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Todo item unique id") + ), + security( + (), + ("api_key" = []) + ) + )] + #[put("/")] + pub(super) async fn mark_done( + id: i32, + _api_key: LogApiKey, + store: &State, + ) -> Result { + store + .lock() + .unwrap() + .iter_mut() + .find(|todo| todo.id == id) + .map(|todo| { + todo.done = true; + + Ok(Status::Ok) + }) + .unwrap_or_else(|| Err(TodoError::NotFound(format!("id = {id}")))) + } + + /// Delete Todo by given id. + /// + /// Delete Todo from storage by Todo id if found. + #[fastapi::path( + responses( + (status = 200, description = "Todo deleted successfully"), + (status = 401, description = "Unauthorized to delete Todos", body = TodoError, example = json!(TodoError::Unauthorized(String::from("id = 1")))), + (status = 404, description = "Todo not found", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Todo item id") + ), + security( + ("api_key" = []) + ) + )] + #[delete("/")] + pub(super) async fn delete_todo( + id: i32, + _api_key: RequireApiKey, + store: &State, + ) -> Result { + let mut todos = store.lock().unwrap(); + let len = todos.len(); + todos.retain(|todo| todo.id != id); + + if len == todos.len() { + Err(TodoError::NotFound(format!("id = {id}"))) + } else { + Ok(Status::Ok) + } + } + + #[derive(Deserialize, FromForm, IntoParams)] + pub(super) struct SearchParams { + /// Value to be search form `Todo`s + value: String, + /// Search whether todo is done + done: Option, + } + + /// Search Todo items by their value. + /// + /// Search is performed in case sensitive manner from value of Todo. + #[fastapi::path( + params( + SearchParams + ), + responses( + (status = 200, description = "Found Todo items", body = [Todo]) + ) + )] + #[get("/search?")] + pub(super) async fn search_todos( + search: SearchParams, + store: &State, + ) -> Json> { + let SearchParams { value, done } = search; + + Json( + store + .lock() + .unwrap() + .iter() + .filter(|todo| { + todo.value.to_lowercase().contains(&value.to_lowercase()) + && done.map(|done| done == todo.done).unwrap_or(true) + }) + .cloned() + .collect(), + ) + } +} diff --git a/examples/simple-axum/Cargo.toml b/examples/simple-axum/Cargo.toml new file mode 100644 index 0000000..1f76bbf --- /dev/null +++ b/examples/simple-axum/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "simple-axum" +description = "Very short Axum example that exposes OpenAPI json file" +version = "0.1.0" +edition = "2021" +license = "MIT" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +axum = "0.7" +tokio = { version = "1.17", features = ["full"] } +fastapi = { path = "../../fastapi", features = ["axum_extras"] } + +[workspace] diff --git a/examples/simple-axum/src/main.rs b/examples/simple-axum/src/main.rs new file mode 100644 index 0000000..a199aa9 --- /dev/null +++ b/examples/simple-axum/src/main.rs @@ -0,0 +1,32 @@ +use std::net::SocketAddr; + +use axum::{routing::get, Json}; +use fastapi::OpenApi; + +#[derive(OpenApi)] +#[openapi(paths(openapi))] +struct ApiDoc; + +/// Return JSON version of an OpenAPI schema +#[fastapi::path( + get, + path = "/api-docs/openapi.json", + responses( + (status = 200, description = "JSON file", body = ()) + ) +)] +async fn openapi() -> Json { + Json(ApiDoc::openapi()) +} + +#[tokio::main] +async fn main() { + let socket_address: SocketAddr = "127.0.0.1:8080".parse().unwrap(); + let listener = tokio::net::TcpListener::bind(socket_address).await.unwrap(); + + let app = axum::Router::new().route("/api-docs/openapi.json", get(openapi)); + + axum::serve(listener, app.into_make_service()) + .await + .unwrap() +} diff --git a/examples/todo-actix/Cargo.toml b/examples/todo-actix/Cargo.toml new file mode 100644 index 0000000..c4a0a5e --- /dev/null +++ b/examples/todo-actix/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "todo-actix" +description = "Simple actix-web todo example api with fastapi and Swagger UI, Rapidoc, Redoc, and Scalar" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Example "] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +actix-web = "4" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi", features = ["actix_extras"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = [ + "actix-web", +] } +fastapi-redoc = { path = "../../fastapi-redoc", features = ["actix-web"] } +fastapi-rapidoc = { path = "../../fastapi-rapidoc", features = ["actix-web"] } +fastapi-scalar = { path = "../../fastapi-scalar", features = ["actix-web"] } +fastapi-actix-web = { path = "../../fastapi-actix-web" } + +[workspace] diff --git a/examples/todo-actix/README.md b/examples/todo-actix/README.md new file mode 100644 index 0000000..d22b85a --- /dev/null +++ b/examples/todo-actix/README.md @@ -0,0 +1,24 @@ +# todo-actix ~ fastapi with fastapi-swagger-ui, fastapi-redoc and fastapi-rapidoc example + +This is a demo `actix-web` application with in-memory storage to manage Todo items. The API +demonstrates `fastapi` with `fastapi-swagger-ui` functionalities. + +For security restricted endpoints the super secret API key is: `fastapi-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +If you prefer Redoc just head to `http://localhost:8080/redoc` and view the Open API. + +RapiDoc can be found from `http://localhost:8080/rapidoc`. + +Scalar can be reached on `http://localhost:8080/scalar`. + +```bash +cargo run +``` + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/todo-actix/src/main.rs b/examples/todo-actix/src/main.rs new file mode 100644 index 0000000..6559328 --- /dev/null +++ b/examples/todo-actix/src/main.rs @@ -0,0 +1,206 @@ +use std::{ + error::Error, + future::{self, Ready}, + net::Ipv4Addr, +}; + +use actix_web::{ + dev::{Service, ServiceRequest, ServiceResponse, Transform}, + middleware::Logger, + web::Data, + App, HttpResponse, HttpServer, +}; +use futures::future::LocalBoxFuture; +use fastapi::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use fastapi_actix_web::AppExt; +use fastapi_rapidoc::RapiDoc; +use fastapi_redoc::{Redoc, Servable}; +use fastapi_scalar::{Scalar, Servable as ScalarServable}; +use fastapi_swagger_ui::SwaggerUi; + +use crate::todo::TodoStore; + +use self::todo::ErrorResponse; + +mod todo; + +const API_KEY_NAME: &str = "todo_apikey"; +const API_KEY: &str = "fastapi-rocks"; + +#[actix_web::main] +async fn main() -> Result<(), impl Error> { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi( + tags( + (name = "todo", description = "Todo management endpoints.") + ), + modifiers(&SecurityAddon) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + let store = Data::new(TodoStore::default()); + + HttpServer::new(move || { + // This factory closure is called on each worker thread independently. + App::new() + .into_fastapi_app() + .openapi(ApiDoc::openapi()) + .map(|app| app.wrap(Logger::default())) + .service(fastapi_actix_web::scope("/api/todo").configure(todo::configure(store.clone()))) + .openapi_service(|api| Redoc::with_url("/redoc", api)) + .openapi_service(|api| { + SwaggerUi::new("/swagger-ui/{_:.*}").url("/api-docs/openapi.json", api) + }) + // There is no need to create RapiDoc::with_openapi because the OpenApi is served + // via SwaggerUi. Instead we only make rapidoc to point to the existing doc. + // + // If we wanted to serve the schema, the following would work: + // .openapi_service(|api| RapiDoc::with_openapi("/api-docs/openapi2.json", api).path("/rapidoc")) + .map(|app| app.service(RapiDoc::new("/api-docs/openapi.json").path("/rapidoc"))) + .openapi_service(|api| Scalar::with_url("/scalar", api)) + .into_app() + }) + .bind((Ipv4Addr::UNSPECIFIED, 8080))? + .run() + .await +} + +/// Require api key middleware will actually require valid api key +struct RequireApiKey; + +impl Transform for RequireApiKey +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + >, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = ApiKeyMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + future::ready(Ok(ApiKeyMiddleware { + service, + log_only: false, + })) + } +} + +/// Log api key middleware only logs about missing or invalid api keys +struct LogApiKey; + +impl Transform for LogApiKey +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + >, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Transform = ApiKeyMiddleware; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + future::ready(Ok(ApiKeyMiddleware { + service, + log_only: true, + })) + } +} + +struct ApiKeyMiddleware { + service: S, + log_only: bool, +} + +impl Service for ApiKeyMiddleware +where + S: Service< + ServiceRequest, + Response = ServiceResponse, + Error = actix_web::Error, + >, + S::Future: 'static, +{ + type Response = ServiceResponse; + type Error = actix_web::Error; + type Future = LocalBoxFuture<'static, Result>; + + fn poll_ready( + &self, + ctx: &mut core::task::Context<'_>, + ) -> std::task::Poll> { + self.service.poll_ready(ctx) + } + + fn call(&self, req: ServiceRequest) -> Self::Future { + let response = |req: ServiceRequest, response: HttpResponse| -> Self::Future { + Box::pin(async { Ok(req.into_response(response)) }) + }; + + match req.headers().get(API_KEY_NAME) { + Some(key) if key != API_KEY => { + if self.log_only { + log::debug!("Incorrect api api provided!!!") + } else { + return response( + req, + HttpResponse::Unauthorized().json(ErrorResponse::Unauthorized( + String::from("incorrect api key"), + )), + ); + } + } + None => { + if self.log_only { + log::debug!("Missing api key!!!") + } else { + return response( + req, + HttpResponse::Unauthorized() + .json(ErrorResponse::Unauthorized(String::from("missing api key"))), + ); + } + } + _ => (), // just passthrough + } + + if self.log_only { + log::debug!("Performing operation") + } + + let future = self.service.call(req); + + Box::pin(async move { + let response = future.await?; + + Ok(response) + }) + } +} diff --git a/examples/todo-actix/src/todo.rs b/examples/todo-actix/src/todo.rs new file mode 100644 index 0000000..3001d5a --- /dev/null +++ b/examples/todo-actix/src/todo.rs @@ -0,0 +1,273 @@ +use std::sync::Mutex; + +use actix_web::{ + delete, get, post, put, + web::{Data, Json, Path, Query}, + HttpResponse, Responder, +}; +use serde::{Deserialize, Serialize}; +use fastapi::{IntoParams, ToSchema}; +use fastapi_actix_web::service_config::ServiceConfig; + +use crate::{LogApiKey, RequireApiKey}; + +#[derive(Default)] +pub(super) struct TodoStore { + todos: Mutex>, +} + +const TODO: &str = "todo"; + +pub(super) fn configure(store: Data) -> impl FnOnce(&mut ServiceConfig) { + |config: &mut ServiceConfig| { + config + .app_data(store) + .service(search_todos) + .service(get_todos) + .service(create_todo) + .service(delete_todo) + .service(get_todo_by_id) + .service(update_todo); + } +} + +/// Task to do. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +struct Todo { + /// Unique id for the todo item. + #[schema(example = 1)] + id: i32, + /// Description of the tasks to do. + #[schema(example = "Remember to buy groceries")] + value: String, + /// Mark is the task done or not + checked: bool, +} + +/// Request to update existing `Todo` item. +#[derive(Serialize, Deserialize, ToSchema, Clone, Debug)] +struct TodoUpdateRequest { + /// Optional new value for the `Todo` task. + #[schema(example = "Dentist at 14.00")] + value: Option, + /// Optional check status to mark is the task done or not. + checked: Option, +} + +/// Todo endpoint error responses +#[derive(Serialize, Deserialize, Clone, ToSchema)] +pub(super) enum ErrorResponse { + /// When Todo is not found by search term. + NotFound(String), + /// When there is a conflict storing a new todo. + Conflict(String), + /// When todo endpoint was called without correct credentials + Unauthorized(String), +} + +/// Get list of todos. +/// +/// List todos from in-memory todo store. +/// +/// One could call the api endpoint with following curl. +/// ```text +/// curl localhost:8080/todo +/// ``` +#[fastapi::path( + tag = TODO, + responses( + (status = 200, description = "List current todo items", body = [Todo]) + ) +)] +#[get("")] +async fn get_todos(todo_store: Data) -> impl Responder { + let todos = todo_store.todos.lock().unwrap(); + + HttpResponse::Ok().json(todos.clone()) +} + +/// Create new Todo to shared in-memory storage. +/// +/// Post a new `Todo` in request body as json to store it. Api will return +/// created `Todo` on success or `ErrorResponse::Conflict` if todo with same id already exists. +/// +/// One could call the api with. +/// ```text +/// curl localhost:8080/todo -d '{"id": 1, "value": "Buy movie ticket", "checked": false}' +/// ``` +#[fastapi::path( + tag = TODO, + responses( + (status = 201, description = "Todo created successfully", body = Todo), + (status = 409, description = "Todo with id already exists", body = ErrorResponse, example = json!(ErrorResponse::Conflict(String::from("id = 1")))) + ) +)] +#[post("")] +async fn create_todo(todo: Json, todo_store: Data) -> impl Responder { + let mut todos = todo_store.todos.lock().unwrap(); + let todo = &todo.into_inner(); + + todos + .iter() + .find(|existing| existing.id == todo.id) + .map(|existing| { + HttpResponse::Conflict().json(ErrorResponse::Conflict(format!("id = {}", existing.id))) + }) + .unwrap_or_else(|| { + todos.push(todo.clone()); + + HttpResponse::Ok().json(todo) + }) +} + +/// Delete Todo by given path variable id. +/// +/// This endpoint needs `api_key` authentication in order to call. Api key can be found from README.md. +/// +/// Api will delete todo from shared in-memory storage by the provided id and return success 200. +/// If storage does not contain `Todo` with given id 404 not found will be returned. +#[fastapi::path( + tag = TODO, + responses( + (status = 200, description = "Todo deleted successfully"), + (status = 401, description = "Unauthorized to delete Todo", body = ErrorResponse, example = json!(ErrorResponse::Unauthorized(String::from("missing api key")))), + (status = 404, description = "Todo not found by id", body = ErrorResponse, example = json!(ErrorResponse::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Unique storage id of Todo") + ), + security( + ("api_key" = []) + ) +)] +#[delete("/{id}", wrap = "RequireApiKey")] +async fn delete_todo(id: Path, todo_store: Data) -> impl Responder { + let mut todos = todo_store.todos.lock().unwrap(); + let id = id.into_inner(); + + let new_todos = todos + .iter() + .filter(|todo| todo.id != id) + .cloned() + .collect::>(); + + if new_todos.len() == todos.len() { + HttpResponse::NotFound().json(ErrorResponse::NotFound(format!("id = {id}"))) + } else { + *todos = new_todos; + HttpResponse::Ok().finish() + } +} + +/// Get Todo by given todo id. +/// +/// Return found `Todo` with status 200 or 404 not found if `Todo` is not found from shared in-memory storage. +#[fastapi::path( + tag = TODO, + responses( + (status = 200, description = "Todo found from storage", body = Todo), + (status = 404, description = "Todo not found by id", body = ErrorResponse, example = json!(ErrorResponse::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Unique storage id of Todo") + ) +)] +#[get("/{id}")] +async fn get_todo_by_id(id: Path, todo_store: Data) -> impl Responder { + let todos = todo_store.todos.lock().unwrap(); + let id = id.into_inner(); + + todos + .iter() + .find(|todo| todo.id == id) + .map(|todo| HttpResponse::Ok().json(todo)) + .unwrap_or_else(|| { + HttpResponse::NotFound().json(ErrorResponse::NotFound(format!("id = {id}"))) + }) +} + +/// Update Todo with given id. +/// +/// This endpoint supports optional authentication. +/// +/// Tries to update `Todo` by given id as path variable. If todo is found by id values are +/// updated according `TodoUpdateRequest` and updated `Todo` is returned with status 200. +/// If todo is not found then 404 not found is returned. +#[fastapi::path( + tag = TODO, + responses( + (status = 200, description = "Todo updated successfully", body = Todo), + (status = 404, description = "Todo not found by id", body = ErrorResponse, example = json!(ErrorResponse::NotFound(String::from("id = 1")))) + ), + params( + ("id", description = "Unique storage id of Todo") + ), + security( + (), + ("api_key" = []) + ) +)] +#[put("/{id}", wrap = "LogApiKey")] +async fn update_todo( + id: Path, + todo: Json, + todo_store: Data, +) -> impl Responder { + let mut todos = todo_store.todos.lock().unwrap(); + let id = id.into_inner(); + let todo = todo.into_inner(); + + todos + .iter_mut() + .find_map(|todo| if todo.id == id { Some(todo) } else { None }) + .map(|existing_todo| { + if let Some(checked) = todo.checked { + existing_todo.checked = checked; + } + if let Some(value) = todo.value { + existing_todo.value = value; + } + + HttpResponse::Ok().json(existing_todo) + }) + .unwrap_or_else(|| { + HttpResponse::NotFound().json(ErrorResponse::NotFound(format!("id = {id}"))) + }) +} + +/// Search todos Query +#[derive(Deserialize, Debug, IntoParams)] +struct SearchTodos { + /// Content that should be found from Todo's value field + value: String, +} + +/// Search Todos with by value +/// +/// Perform search from `Todo`s present in in-memory storage by matching Todo's value to +/// value provided as query parameter. Returns 200 and matching `Todo` items. +#[fastapi::path( + tag = TODO, + params( + SearchTodos + ), + responses( + (status = 200, description = "Search Todos did not result error", body = [Todo]), + ) +)] +#[get("/search")] +async fn search_todos(query: Query, todo_store: Data) -> impl Responder { + let todos = todo_store.todos.lock().unwrap(); + + HttpResponse::Ok().json( + todos + .iter() + .filter(|todo| { + todo.value + .to_lowercase() + .contains(&query.value.to_lowercase()) + }) + .cloned() + .collect::>(), + ) +} diff --git a/examples/todo-axum/Cargo.toml b/examples/todo-axum/Cargo.toml new file mode 100644 index 0000000..93ac92e --- /dev/null +++ b/examples/todo-axum/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "todo-axum" +description = "Simple axum todo example api with fastapi and Swagger UI, Rapidoc, Redoc, and Scalar" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Elli Example "] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +axum = "0.7" +hyper = { version = "1.0.1", features = ["full"] } +tokio = { version = "1.17", features = ["full"] } +tower = "0.5" +fastapi = { path = "../../fastapi", features = ["axum_extras"] } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui", features = ["axum"] } +fastapi-axum = { path = "../../fastapi-axum" } +fastapi-redoc = { path = "../../fastapi-redoc", features = ["axum"] } +fastapi-rapidoc = { path = "../../fastapi-rapidoc", features = ["axum"] } +fastapi-scalar = { path = "../../fastapi-scalar", features = ["axum"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +[workspace] diff --git a/examples/todo-axum/README.md b/examples/todo-axum/README.md new file mode 100644 index 0000000..0cc46bd --- /dev/null +++ b/examples/todo-axum/README.md @@ -0,0 +1,18 @@ +# todo-axum ~ fastapi with fastapi-swagger-ui, fastapi-redoc and fastapi-rapidoc example + +This is a demo `axum` application with in-memory storage to manage Todo items. The API +demonstrates `fastapi` with `fastapi-swagger-ui` functionalities. + +For security restricted endpoints the super secret API key is: `fastapi-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +If you prefer Redoc just head to `http://localhost:8080/redoc` and view the Open API. + +RapiDoc can be found from `http://localhost:8080/rapidoc`. + +Scalar can be reached on `http://localhost:8080/scalar`. + +```bash +cargo run +``` diff --git a/examples/todo-axum/src/main.rs b/examples/todo-axum/src/main.rs new file mode 100644 index 0000000..ab3a730 --- /dev/null +++ b/examples/todo-axum/src/main.rs @@ -0,0 +1,311 @@ +use std::net::{Ipv4Addr, SocketAddr}; + +use std::io::Error; +use tokio::net::TcpListener; +use fastapi::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use fastapi_axum::router::OpenApiRouter; +use fastapi_rapidoc::RapiDoc; +use fastapi_redoc::{Redoc, Servable}; +use fastapi_scalar::{Scalar, Servable as ScalarServable}; +use fastapi_swagger_ui::SwaggerUi; + +const TODO_TAG: &str = "todo"; + +#[tokio::main] +async fn main() -> Result<(), Error> { + #[derive(OpenApi)] + #[openapi( + modifiers(&SecurityAddon), + tags( + (name = TODO_TAG, description = "Todo items management API") + ) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { + if let Some(components) = openapi.components.as_mut() { + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + } + + let (router, api) = OpenApiRouter::with_openapi(ApiDoc::openapi()) + .nest("/api/v1/todos", todo::router()) + .split_for_parts(); + + let router = router + .merge(SwaggerUi::new("/swagger-ui").url("/api-docs/openapi.json", api.clone())) + .merge(Redoc::with_url("/redoc", api.clone())) + // There is no need to create `RapiDoc::with_openapi` because the OpenApi is served + // via SwaggerUi instead we only make rapidoc to point to the existing doc. + .merge(RapiDoc::new("/api-docs/openapi.json").path("/rapidoc")) + // Alternative to above + // .merge(RapiDoc::with_openapi("/api-docs/openapi2.json", api).path("/rapidoc")) + .merge(Scalar::with_url("/scalar", api)); + + let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 8080)); + let listener = TcpListener::bind(&address).await?; + axum::serve(listener, router.into_make_service()).await +} + +mod todo { + use std::sync::Arc; + + use axum::{ + extract::{Path, Query, State}, + response::IntoResponse, + Json, + }; + use hyper::{HeaderMap, StatusCode}; + use serde::{Deserialize, Serialize}; + use tokio::sync::Mutex; + use fastapi::{IntoParams, ToSchema}; + use fastapi_axum::{router::OpenApiRouter, routes}; + + use crate::TODO_TAG; + + /// In-memory todo store + type Store = Mutex>; + + /// Item to do. + #[derive(Serialize, Deserialize, ToSchema, Clone)] + struct Todo { + id: i32, + #[schema(example = "Buy groceries")] + value: String, + done: bool, + } + + /// Todo operation errors + #[derive(Serialize, Deserialize, ToSchema)] + enum TodoError { + /// Todo already exists conflict. + #[schema(example = "Todo already exists")] + Conflict(String), + /// Todo not found by id. + #[schema(example = "id = 1")] + NotFound(String), + /// Todo operation unauthorized + #[schema(example = "missing api key")] + Unauthorized(String), + } + + pub(super) fn router() -> OpenApiRouter { + let store = Arc::new(Store::default()); + OpenApiRouter::new() + .routes(routes!(list_todos, create_todo)) + .routes(routes!(search_todos)) + .routes(routes!(mark_done, delete_todo)) + .with_state(store) + } + + /// List all Todo items + /// + /// List all Todo items from in-memory storage. + #[fastapi::path( + get, + path = "", + tag = TODO_TAG, + responses( + (status = 200, description = "List all todos successfully", body = [Todo]) + ) + )] + async fn list_todos(State(store): State>) -> Json> { + let todos = store.lock().await.clone(); + + Json(todos) + } + + /// Todo search query + #[derive(Deserialize, IntoParams)] + struct TodoSearchQuery { + /// Search by value. Search is incase sensitive. + value: String, + /// Search by `done` status. + done: bool, + } + + /// Search Todos by query params. + /// + /// Search `Todo`s by query params and return matching `Todo`s. + #[fastapi::path( + get, + path = "/search", + tag = TODO_TAG, + params( + TodoSearchQuery + ), + responses( + (status = 200, description = "List matching todos by query", body = [Todo]) + ) + )] + async fn search_todos( + State(store): State>, + query: Query, + ) -> Json> { + Json( + store + .lock() + .await + .iter() + .filter(|todo| { + todo.value.to_lowercase() == query.value.to_lowercase() + && todo.done == query.done + }) + .cloned() + .collect(), + ) + } + + /// Create new Todo + /// + /// Tries to create a new Todo item to in-memory storage or fails with 409 conflict if already exists. + #[fastapi::path( + post, + path = "", + tag = TODO_TAG, + responses( + (status = 201, description = "Todo item created successfully", body = Todo), + (status = 409, description = "Todo already exists", body = TodoError) + ) + )] + async fn create_todo( + State(store): State>, + Json(todo): Json, + ) -> impl IntoResponse { + let mut todos = store.lock().await; + + todos + .iter_mut() + .find(|existing_todo| existing_todo.id == todo.id) + .map(|found| { + ( + StatusCode::CONFLICT, + Json(TodoError::Conflict(format!( + "todo already exists: {}", + found.id + ))), + ) + .into_response() + }) + .unwrap_or_else(|| { + todos.push(todo.clone()); + + (StatusCode::CREATED, Json(todo)).into_response() + }) + } + + /// Mark Todo item done by id + /// + /// Mark Todo item done by given id. Return only status 200 on success or 404 if Todo is not found. + #[fastapi::path( + put, + path = "/{id}", + tag = TODO_TAG, + responses( + (status = 200, description = "Todo marked done successfully"), + (status = 404, description = "Todo not found") + ), + params( + ("id" = i32, Path, description = "Todo database id") + ), + security( + (), // <-- make optional authentication + ("api_key" = []) + ) + )] + async fn mark_done( + Path(id): Path, + State(store): State>, + headers: HeaderMap, + ) -> StatusCode { + match check_api_key(false, headers) { + Ok(_) => (), + Err(_) => return StatusCode::UNAUTHORIZED, + } + + let mut todos = store.lock().await; + + todos + .iter_mut() + .find(|todo| todo.id == id) + .map(|todo| { + todo.done = true; + StatusCode::OK + }) + .unwrap_or(StatusCode::NOT_FOUND) + } + + /// Delete Todo item by id + /// + /// Delete Todo item from in-memory storage by id. Returns either 200 success of 404 with TodoError if Todo is not found. + #[fastapi::path( + delete, + path = "/{id}", + tag = TODO_TAG, + responses( + (status = 200, description = "Todo marked done successfully"), + (status = 401, description = "Unauthorized to delete Todo", body = TodoError, example = json!(TodoError::Unauthorized(String::from("missing api key")))), + (status = 404, description = "Todo not found", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id" = i32, Path, description = "Todo database id") + ), + security( + ("api_key" = []) + ) + )] + async fn delete_todo( + Path(id): Path, + State(store): State>, + headers: HeaderMap, + ) -> impl IntoResponse { + match check_api_key(true, headers) { + Ok(_) => (), + Err(error) => return error.into_response(), + } + + let mut todos = store.lock().await; + + let len = todos.len(); + + todos.retain(|todo| todo.id != id); + + if todos.len() != len { + StatusCode::OK.into_response() + } else { + ( + StatusCode::NOT_FOUND, + Json(TodoError::NotFound(format!("id = {id}"))), + ) + .into_response() + } + } + + // normally you should create a middleware for this but this is sufficient for sake of example. + fn check_api_key( + require_api_key: bool, + headers: HeaderMap, + ) -> Result<(), (StatusCode, Json)> { + match headers.get("todo_apikey") { + Some(header) if header != "fastapi-rocks" => Err(( + StatusCode::UNAUTHORIZED, + Json(TodoError::Unauthorized(String::from("incorrect api key"))), + )), + None if require_api_key => Err(( + StatusCode::UNAUTHORIZED, + Json(TodoError::Unauthorized(String::from("missing api key"))), + )), + _ => Ok(()), + } + } +} diff --git a/examples/todo-tide/Cargo.toml b/examples/todo-tide/Cargo.toml new file mode 100644 index 0000000..c9fe0fe --- /dev/null +++ b/examples/todo-tide/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "todo-tide" +description = "Simple tide todo example api with fastapi and Swagger UI" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Elli Example " +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tide = "0.16.0" +async-std = { version = "1.8.0", features = ["attributes"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11.0" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi" } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui" } + +[workspace] diff --git a/examples/todo-tide/README.md b/examples/todo-tide/README.md new file mode 100644 index 0000000..7e338f8 --- /dev/null +++ b/examples/todo-tide/README.md @@ -0,0 +1,18 @@ +# todo-tide ~ fastapi with fastapi-swagger-ui example + +This is a demo `tide` application with in-memory storage to manage Todo items. The API +demonstrates `fastapi` with `fastapi-swagger-ui` functionalities. + +For security restricted endpoints the super secret API key is: `fastapi-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/index.html`. + +```bash +cargo run +``` + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/todo-tide/src/main.rs b/examples/todo-tide/src/main.rs new file mode 100644 index 0000000..ff78416 --- /dev/null +++ b/examples/todo-tide/src/main.rs @@ -0,0 +1,250 @@ +use std::sync::Arc; + +use serde_json::json; +use tide::{http::Mime, Redirect, Response}; +use fastapi::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use fastapi_swagger_ui::Config; + +use crate::todo::Store; + +#[async_std::main] +async fn main() -> std::io::Result<()> { + env_logger::init(); + let config = Arc::new(Config::from("/api-docs/openapi.json")); + let mut app = tide::with_state(config); + + #[derive(OpenApi)] + #[openapi( + nest( + (path = "/api/todo", api = todo::TodoApi) + ), + modifiers(&SecurityAddon), + tags( + (name = "todo", description = "Todo items management endpoints.") + ) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + // serve OpenApi json + app.at("/api-docs/openapi.json") + .get(|_| async move { Ok(Response::builder(200).body(json!(ApiDoc::openapi()))) }); + + // serve Swagger UI + app.at("/swagger-ui") + .get(|_| async move { Ok(Redirect::new("/swagger-ui/index.html")) }); + app.at("/swagger-ui/*").get(serve_swagger); + + app.at("/api").nest({ + let mut todos = tide::with_state(Store::default()); + + todos.at("/todo").get(todo::list_todos); + todos.at("/todo").post(todo::create_todo); + todos.at("/todo/:id").delete(todo::delete_todo); + todos.at("/todo/:id").put(todo::mark_done); + + todos + }); + + app.listen("0.0.0.0:8080").await +} + +async fn serve_swagger(request: tide::Request>>) -> tide::Result { + let config = request.state().clone(); + let path = request.url().path().to_string(); + let tail = path.strip_prefix("/swagger-ui/").unwrap(); + + match fastapi_swagger_ui::serve(tail, config) { + Ok(swagger_file) => swagger_file + .map(|file| { + Ok(Response::builder(200) + .body(file.bytes.to_vec()) + .content_type(file.content_type.parse::()?) + .build()) + }) + .unwrap_or_else(|| Ok(Response::builder(404).build())), + Err(error) => Ok(Response::builder(500).body(error.to_string()).build()), + } +} + +mod todo { + use std::sync::{Arc, Mutex}; + + use serde::{Deserialize, Serialize}; + use serde_json::json; + use tide::{Request, Response}; + use fastapi::{OpenApi, ToSchema}; + + #[derive(OpenApi)] + #[openapi( + paths( + list_todos, + create_todo, + delete_todo, + mark_done + ), + tags( + (name = "todo", description = "Todo items management endpoints.") + ) + )] + pub struct TodoApi; + + /// Item to complete + #[derive(Serialize, Deserialize, ToSchema, Clone)] + pub(super) struct Todo { + /// Unique database id for `Todo` + #[schema(example = 1)] + id: i32, + /// Description of task to complete + #[schema(example = "Buy coffee")] + value: String, + /// Indicates whether task is done or not + done: bool, + } + + /// Error that might occur when managing `Todo` items + #[derive(Serialize, Deserialize, ToSchema)] + pub(super) enum TodoError { + /// Happens when Todo item already exists + Config(String), + /// Todo not found from storage + NotFound(String), + } + + pub(super) type Store = Arc>>; + + /// List todos from in-memory storage. + /// + /// List all todos from in memory storage. + #[fastapi::path( + get, + path = "", + responses( + (status = 200, description = "List all todos successfully", body = [Todo]) + ) + )] + pub(super) async fn list_todos(req: Request) -> tide::Result { + let todos = req.state().lock().unwrap().clone(); + + Ok(Response::builder(200).body(json!(todos)).build()) + } + + /// Create new todo + /// + /// Create new todo to in-memory storage if not exists. + #[fastapi::path( + post, + path = "", + request_body = Todo, + responses( + (status = 201, description = "Todo created successfully", body = Todo), + (status = 409, description = "Todo already exists", body = TodoError, example = json!(TodoError::Config(String::from("id = 1")))) + ) + )] + pub(super) async fn create_todo(mut req: Request) -> tide::Result { + let new_todo = req.body_json::().await?; + let mut todos = req.state().lock().unwrap(); + + todos + .iter() + .find(|existing| existing.id == new_todo.id) + .map(|existing| { + Ok(Response::builder(409) + .body(json!(TodoError::Config(format!("id = {}", existing.id)))) + .build()) + }) + .unwrap_or_else(|| { + todos.push(new_todo.clone()); + + Ok(Response::builder(200).body(json!(new_todo)).build()) + }) + } + + /// Delete todo by id. + /// + /// Delete todo from in-memory storage. + #[fastapi::path( + delete, + path = "/{id}", + responses( + (status = 200, description = "Todo deleted successfully"), + (status = 401, description = "Unauthorized to delete Todo"), + (status = 404, description = "Todo not found", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id" = i32, Path, description = "Id of todo item to delete") + ), + security( + ("api_key" = []) + ) + )] + pub(super) async fn delete_todo(req: Request) -> tide::Result { + let id = req.param("id")?.parse::()?; + let api_key = req + .header("todo_apikey") + .map(|header| header.as_str().to_string()) + .unwrap_or_default(); + + if api_key != "fastapi-rocks" { + return Ok(Response::new(401)); + } + + let mut todos = req.state().lock().unwrap(); + + let old_size = todos.len(); + + todos.retain(|todo| todo.id != id); + + if old_size == todos.len() { + Ok(Response::builder(404) + .body(json!(TodoError::NotFound(format!("id = {id}")))) + .build()) + } else { + Ok(Response::new(200)) + } + } + + /// Mark todo done by id + #[fastapi::path( + put, + path = "/{id}", + responses( + (status = 200, description = "Todo marked done successfully"), + (status = 404, description = "Todo not found", body = TodoError, example = json!(TodoError::NotFound(String::from("id = 1")))) + ), + params( + ("id" = i32, Path, description = "Id of todo item to mark done") + ) + )] + pub(super) async fn mark_done(req: Request) -> tide::Result { + let id = req.param("id")?.parse::()?; + let mut todos = req.state().lock().unwrap(); + + todos + .iter_mut() + .find(|todo| todo.id == id) + .map(|todo| { + todo.done = true; + Ok(Response::new(200)) + }) + .unwrap_or_else(|| { + Ok(Response::builder(404) + .body(json!(TodoError::NotFound(format!("id = {id}")))) + .build()) + }) + } +} diff --git a/examples/todo-warp-rapidoc/Cargo.toml b/examples/todo-warp-rapidoc/Cargo.toml new file mode 100644 index 0000000..92c1504 --- /dev/null +++ b/examples/todo-warp-rapidoc/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "todo-warp-rapidoc" +description = "Simple warp todo example api with fastapi and fastapi-rapidoc" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Elli Example " +] + +[dependencies] +tokio = { version = "1", features = ["full"] } +warp = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11.0" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi" } +fastapi-rapidoc = { path = "../../fastapi-rapidoc" } + +[workspace] + diff --git a/examples/todo-warp-rapidoc/README.md b/examples/todo-warp-rapidoc/README.md new file mode 100644 index 0000000..5c819b9 --- /dev/null +++ b/examples/todo-warp-rapidoc/README.md @@ -0,0 +1,12 @@ +# warp with fastapi-rapidoc + +This is simple Todo app example with warp and fastapi-rapidoc OpenAPI viewer. + +For security restricted endpoints the super secret API key is: `fastapi-rocks`. + +Head to `http://localhost:8080/rapidoc` for the demo. + +run +```rust +RUST_LOG=debug cargo run +``` diff --git a/examples/todo-warp-rapidoc/src/main.rs b/examples/todo-warp-rapidoc/src/main.rs new file mode 100644 index 0000000..3b2c888 --- /dev/null +++ b/examples/todo-warp-rapidoc/src/main.rs @@ -0,0 +1,243 @@ +use std::net::Ipv4Addr; + +use fastapi::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use fastapi_rapidoc::RapiDoc; +use warp::Filter; + +#[tokio::main] +async fn main() { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi( + nest( + (path = "/api", api = todo::TodoApi) + ), + modifiers(&SecurityAddon), + tags( + (name = "todo", description = "Todo items management API") + ) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + let api_doc = warp::path("api-doc.json") + .and(warp::get()) + .map(|| warp::reply::json(&ApiDoc::openapi())); + + let rapidoc_handler = warp::path("rapidoc") + .and(warp::get()) + .map(|| warp::reply::html(RapiDoc::new("/api-doc.json").to_html())); + + warp::serve( + api_doc + .or(rapidoc_handler) + .or(warp::path("api").and(todo::handlers())), + ) + .run((Ipv4Addr::UNSPECIFIED, 8080)) + .await +} + +mod todo { + use std::{ + convert::Infallible, + sync::{Arc, Mutex}, + }; + + use serde::{Deserialize, Serialize}; + use fastapi::{IntoParams, OpenApi, ToSchema}; + use warp::{hyper::StatusCode, Filter, Rejection, Reply}; + + #[derive(OpenApi)] + #[openapi(paths(list_todos, create_todo, delete_todo))] + pub struct TodoApi; + + pub type Store = Arc>>; + + /// Item to complete. + #[derive(Serialize, Deserialize, ToSchema, Clone)] + pub struct Todo { + /// Unique database id. + #[schema(example = 1)] + id: i64, + /// Description of what need to be done. + #[schema(example = "Buy movie tickets")] + value: String, + } + + #[derive(Debug, Deserialize, ToSchema)] + #[serde(rename_all = "snake_case")] + pub enum Order { + AscendingId, + DescendingId, + } + + #[derive(Debug, Deserialize, IntoParams)] + #[into_params(parameter_in = Query)] + pub struct ListQueryParams { + /// Filters the returned `Todo` items according to whether they contain the specified string. + #[param(style = Form, example = json!("task"))] + contains: Option, + /// Order the returned `Todo` items. + #[param(inline)] + order: Option, + } + + pub fn handlers() -> impl Filter + Clone { + let store = Store::default(); + + let list = warp::path("todo") + .and(warp::get()) + .and(warp::path::end()) + .and(with_store(store.clone())) + .and(warp::query::()) + .and_then(list_todos); + + let create = warp::path("todo") + .and(warp::post()) + .and(warp::path::end()) + .and(warp::body::json()) + .and(with_store(store.clone())) + .and_then(create_todo); + + let delete = warp::path!("todo" / i64) + .and(warp::delete()) + .and(warp::path::end()) + .and(with_store(store)) + .and(warp::header::header("todo_apikey")) + .and_then(delete_todo); + + list.or(create).or(delete) + } + + fn with_store(store: Store) -> impl Filter + Clone { + warp::any().map(move || store.clone()) + } + + /// List todos from in-memory storage. + /// + /// List all todos from in-memory storage. + #[fastapi::path( + get, + path = "/todo", + params(ListQueryParams), + responses( + (status = 200, description = "List todos successfully", body = [Todo]) + ) + )] + pub async fn list_todos( + store: Store, + query: ListQueryParams, + ) -> Result { + let todos = store.lock().unwrap(); + + let mut todos: Vec = if let Some(contains) = query.contains { + todos + .iter() + .filter(|todo| todo.value.contains(&contains)) + .cloned() + .collect() + } else { + todos.clone() + }; + + if let Some(order) = query.order { + match order { + Order::AscendingId => { + todos.sort_by_key(|todo| todo.id); + } + Order::DescendingId => { + todos.sort_by_key(|todo| todo.id); + todos.reverse(); + } + } + } + + Ok(warp::reply::json(&todos)) + } + + /// Create new todo item. + /// + /// Creates new todo item to in-memory storage if it is unique by id. + #[fastapi::path( + post, + path = "/todo", + request_body = Todo, + responses( + (status = 200, description = "Todo created successfully", body = Todo), + (status = 409, description = "Todo already exists") + ) + )] + pub async fn create_todo(todo: Todo, store: Store) -> Result, Infallible> { + let mut todos = store.lock().unwrap(); + + if todos + .iter() + .any(|existing_todo| existing_todo.id == todo.id) + { + Ok(Box::new(StatusCode::CONFLICT)) + } else { + todos.push(todo.clone()); + + Ok(Box::new(warp::reply::with_status( + warp::reply::json(&todo), + StatusCode::CREATED, + ))) + } + } + + /// Delete todo item by id. + /// + /// Delete todo item by id from in-memory storage. + #[fastapi::path( + delete, + path = "/todo/{id}", + responses( + (status = 200, description = "Delete successful"), + (status = 400, description = "Missing todo_apikey request header"), + (status = 401, description = "Unauthorized to delete todo"), + (status = 404, description = "Todo not found to delete"), + ), + params( + ("id" = i64, Path, description = "Todo's unique id") + ), + security( + ("api_key" = []) + ) + )] + pub async fn delete_todo( + id: i64, + store: Store, + api_key: String, + ) -> Result { + if api_key != "fastapi-rocks" { + return Ok(StatusCode::UNAUTHORIZED); + } + + let mut todos = store.lock().unwrap(); + + let size = todos.len(); + + todos.retain(|existing| existing.id != id); + + if size == todos.len() { + Ok(StatusCode::NOT_FOUND) + } else { + Ok(StatusCode::OK) + } + } +} diff --git a/examples/todo-warp-redoc-with-file-config/Cargo.toml b/examples/todo-warp-redoc-with-file-config/Cargo.toml new file mode 100644 index 0000000..0127f6e --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "todo-warp-redoc-with-file-config" +description = "Simple warp todo example api with fastapi and fastapi-redoc" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = ["Elli Example "] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1", features = ["full"] } +warp = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11.0" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi" } +fastapi-redoc = { path = "../../fastapi-redoc" } + +[workspace] diff --git a/examples/todo-warp-redoc-with-file-config/README.md b/examples/todo-warp-redoc-with-file-config/README.md new file mode 100644 index 0000000..dd859ee --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/README.md @@ -0,0 +1,27 @@ +# todo-warp-redoc-with-file-config ~ fastapi with fastapi-redoc example + +This is a demo `warp` application with in-memory storage to manage Todo items. + +This example is more bare minimum compared to `todo-actix`, since similarly same macro syntax is +supported, no matter the framework. + + +This how `fastapi-redoc` can be used as standalone without pre-existing framework integration with additional +file configuration for the Redoc UI. The configuration is applicable in any other `fastapi-redoc` setup as well. + +See the `build.rs` file that defines the Redoc config file and `redoc.json` where the [configuration options](https://redocly.com/docs/api-reference-docs/configuration/functionality/#configuration-options-for-api-docs) +are defined. + +For security restricted endpoints the super secret API key is: `fastapi-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8080/redoc`. + +```bash +cargo run +``` + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/todo-warp-redoc-with-file-config/build.rs b/examples/todo-warp-redoc-with-file-config/build.rs new file mode 100644 index 0000000..3b2699f --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rustc-env=FASTAPI_REDOC_CONFIG_FILE=redoc.json") +} diff --git a/examples/todo-warp-redoc-with-file-config/redoc.json b/examples/todo-warp-redoc-with-file-config/redoc.json new file mode 100644 index 0000000..318f0e1 --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/redoc.json @@ -0,0 +1,3 @@ +{ + "disableSearch": true +} diff --git a/examples/todo-warp-redoc-with-file-config/src/main.rs b/examples/todo-warp-redoc-with-file-config/src/main.rs new file mode 100644 index 0000000..4dc3167 --- /dev/null +++ b/examples/todo-warp-redoc-with-file-config/src/main.rs @@ -0,0 +1,239 @@ +use std::net::Ipv4Addr; + +use fastapi::{ + openapi::{ + security::{ApiKey, ApiKeyValue, SecurityScheme}, + Components, + }, + Modify, OpenApi, +}; +use fastapi_redoc::{FileConfig, Redoc}; +use warp::Filter; + +#[tokio::main] +async fn main() { + env_logger::init(); + + #[derive(OpenApi)] + #[openapi( + nest( + (path = "/api", api = todo::TodoApi) + ), + modifiers(&SecurityAddon), + tags( + (name = "todo", description = "Todo items management API") + ) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { + let components = openapi.components.get_or_insert(Components::new()); + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + let redoc_ui = Redoc::with_config(ApiDoc::openapi(), FileConfig); + let redoc = warp::path("redoc") + .and(warp::get()) + .map(move || warp::reply::html(redoc_ui.to_html())); + + warp::serve(redoc.or(warp::path("api").and(todo::handlers()))) + .run((Ipv4Addr::UNSPECIFIED, 8080)) + .await +} + +mod todo { + use std::{ + convert::Infallible, + sync::{Arc, Mutex}, + }; + + use serde::{Deserialize, Serialize}; + use fastapi::{IntoParams, OpenApi, ToSchema}; + use warp::{hyper::StatusCode, Filter, Rejection, Reply}; + + #[derive(OpenApi)] + #[openapi(paths(list_todos, create_todo, delete_todo))] + pub struct TodoApi; + + pub type Store = Arc>>; + + /// Item to complete. + #[derive(Serialize, Deserialize, ToSchema, Clone)] + pub struct Todo { + /// Unique database id. + #[schema(example = 1)] + id: i64, + /// Description of what need to be done. + #[schema(example = "Buy movie tickets")] + value: String, + } + + #[derive(Debug, Deserialize, ToSchema)] + #[serde(rename_all = "snake_case")] + pub enum Order { + AscendingId, + DescendingId, + } + + #[derive(Debug, Deserialize, IntoParams)] + #[into_params(parameter_in = Query)] + pub struct ListQueryParams { + /// Filters the returned `Todo` items according to whether they contain the specified string. + #[param(style = Form, example = json!("task"))] + contains: Option, + /// Order the returned `Todo` items. + #[param(inline)] + order: Option, + } + + pub fn handlers() -> impl Filter + Clone { + let store = Store::default(); + + let list = warp::path("todo") + .and(warp::get()) + .and(warp::path::end()) + .and(with_store(store.clone())) + .and(warp::query::()) + .and_then(list_todos); + + let create = warp::path("todo") + .and(warp::post()) + .and(warp::path::end()) + .and(warp::body::json()) + .and(with_store(store.clone())) + .and_then(create_todo); + + let delete = warp::path!("todo" / i64) + .and(warp::delete()) + .and(warp::path::end()) + .and(with_store(store)) + .and(warp::header::header("todo_apikey")) + .and_then(delete_todo); + + list.or(create).or(delete) + } + + fn with_store(store: Store) -> impl Filter + Clone { + warp::any().map(move || store.clone()) + } + + /// List todos from in-memory storage. + /// + /// List all todos from in-memory storage. + #[fastapi::path( + get, + path = "/todo", + params(ListQueryParams), + responses( + (status = 200, description = "List todos successfully", body = [Todo]) + ) + )] + pub async fn list_todos( + store: Store, + query: ListQueryParams, + ) -> Result { + let todos = store.lock().unwrap(); + + let mut todos: Vec = if let Some(contains) = query.contains { + todos + .iter() + .filter(|todo| todo.value.contains(&contains)) + .cloned() + .collect() + } else { + todos.clone() + }; + + if let Some(order) = query.order { + match order { + Order::AscendingId => { + todos.sort_by_key(|todo| todo.id); + } + Order::DescendingId => { + todos.sort_by_key(|todo| todo.id); + todos.reverse(); + } + } + } + + Ok(warp::reply::json(&todos)) + } + + /// Create new todo item. + /// + /// Creates new todo item to in-memory storage if it is unique by id. + #[fastapi::path( + post, + path = "/todo", + request_body = Todo, + responses( + (status = 200, description = "Todo created successfully", body = Todo), + (status = 409, description = "Todo already exists") + ) + )] + pub async fn create_todo(todo: Todo, store: Store) -> Result, Infallible> { + let mut todos = store.lock().unwrap(); + + if todos + .iter() + .any(|existing_todo| existing_todo.id == todo.id) + { + Ok(Box::new(StatusCode::CONFLICT)) + } else { + todos.push(todo.clone()); + + Ok(Box::new(warp::reply::with_status( + warp::reply::json(&todo), + StatusCode::CREATED, + ))) + } + } + + /// Delete todo item by id. + /// + /// Delete todo item by id from in-memory storage. + #[fastapi::path( + delete, + path = "/todo/{id}", + responses( + (status = 200, description = "Delete successful"), + (status = 400, description = "Missing todo_apikey request header"), + (status = 401, description = "Unauthorized to delete todo"), + (status = 404, description = "Todo not found to delete"), + ), + params( + ("id" = i64, Path, description = "Todo's unique id") + ), + security( + ("api_key" = []) + ) + )] + pub async fn delete_todo( + id: i64, + store: Store, + api_key: String, + ) -> Result { + if api_key != "fastapi-rocks" { + return Ok(StatusCode::UNAUTHORIZED); + } + + let mut todos = store.lock().unwrap(); + + let size = todos.len(); + + todos.retain(|existing| existing.id != id); + + if size == todos.len() { + Ok(StatusCode::NOT_FOUND) + } else { + Ok(StatusCode::OK) + } + } +} diff --git a/examples/todo-warp/Cargo.toml b/examples/todo-warp/Cargo.toml new file mode 100644 index 0000000..9697ed5 --- /dev/null +++ b/examples/todo-warp/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "todo-warp" +description = "Simple warp todo example api with fastapi and fastapi-swagger-ui" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Elli Example " +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1", features = ["full"] } +warp = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.11.0" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi" } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui" } + +[workspace] diff --git a/examples/todo-warp/README.md b/examples/todo-warp/README.md new file mode 100644 index 0000000..428239d --- /dev/null +++ b/examples/todo-warp/README.md @@ -0,0 +1,24 @@ +# todo-warp ~ fastapi with fastapi-swagger-ui example + +This is a demo `warp` application with in-memory storage to manage Todo items. +The API demonstrates `fastapi` with `fastapi-swagger-ui` functionalities. + +This example is more bare minimum compared to `todo-actix`, since similarly same macro syntax is +supported, no matter the framework. + +Purpose of this `warp` demo is to mainly demonstrate how `fastapi` and `fastapi-swagger-ui` can be integrated +with other frameworks as well. + +For security restricted endpoints the super secret API key is: `fastapi-rocks`. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +```bash +cargo run +``` + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/todo-warp/src/main.rs b/examples/todo-warp/src/main.rs new file mode 100644 index 0000000..227fa64 --- /dev/null +++ b/examples/todo-warp/src/main.rs @@ -0,0 +1,285 @@ +use std::{net::Ipv4Addr, sync::Arc}; + +use fastapi::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + Modify, OpenApi, +}; +use fastapi_swagger_ui::Config; +use warp::{ + http::Uri, + hyper::{Response, StatusCode}, + path::{FullPath, Tail}, + Filter, Rejection, Reply, +}; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let config = Arc::new(Config::from("/api-doc.json")); + + #[derive(OpenApi)] + #[openapi( + nest( + (path = "/api", api = todo::TodoApi) + ), + modifiers(&SecurityAddon), + tags( + (name = "todo", description = "Todo items management API") + ) + )] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { + let components = openapi.components.as_mut().unwrap(); // we can unwrap safely since there already is components registered. + components.add_security_scheme( + "api_key", + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("todo_apikey"))), + ) + } + } + + let api_doc = warp::path("api-doc.json") + .and(warp::get()) + .map(|| warp::reply::json(&ApiDoc::openapi())); + + let swagger_ui = warp::path("swagger-ui") + .and(warp::get()) + .and(warp::path::full()) + .and(warp::path::tail()) + .and(warp::any().map(move || config.clone())) + .and_then(serve_swagger); + + warp::serve( + api_doc + .or(swagger_ui) + .or(warp::path("api").and(todo::handlers())), + ) + .run((Ipv4Addr::UNSPECIFIED, 8080)) + .await +} + +async fn serve_swagger( + full_path: FullPath, + tail: Tail, + config: Arc>, +) -> Result, Rejection> { + if full_path.as_str() == "/swagger-ui" { + return Ok(Box::new(warp::redirect::found(Uri::from_static( + "/swagger-ui/", + )))); + } + + let path = tail.as_str(); + match fastapi_swagger_ui::serve(path, config) { + Ok(file) => { + if let Some(file) = file { + Ok(Box::new( + Response::builder() + .header("Content-Type", file.content_type) + .body(file.bytes), + )) + } else { + Ok(Box::new(StatusCode::NOT_FOUND)) + } + } + Err(error) => Ok(Box::new( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(error.to_string()), + )), + } +} + +mod todo { + use std::{ + convert::Infallible, + sync::{Arc, Mutex}, + }; + + use serde::{Deserialize, Serialize}; + use fastapi::{IntoParams, OpenApi, ToSchema}; + use warp::{hyper::StatusCode, Filter, Rejection, Reply}; + + #[derive(OpenApi)] + #[openapi(paths(list_todos, create_todo, delete_todo))] + pub struct TodoApi; + + pub type Store = Arc>>; + + /// Item to complete. + #[derive(Serialize, Deserialize, ToSchema, Clone)] + pub struct Todo { + /// Unique database id. + #[schema(example = 1)] + id: i64, + /// Description of what need to be done. + #[schema(example = "Buy movie tickets")] + value: String, + } + + #[derive(Debug, Deserialize, ToSchema)] + #[serde(rename_all = "snake_case")] + pub enum Order { + AscendingId, + DescendingId, + } + + #[derive(Debug, Deserialize, IntoParams)] + #[into_params(parameter_in = Query)] + pub struct ListQueryParams { + /// Filters the returned `Todo` items according to whether they contain the specified string. + #[param(style = Form, example = json!("task"))] + contains: Option, + /// Order the returned `Todo` items. + #[param(inline)] + order: Option, + } + + pub fn handlers() -> impl Filter + Clone { + let store = Store::default(); + + let list = warp::path("todo") + .and(warp::get()) + .and(warp::path::end()) + .and(with_store(store.clone())) + .and(warp::query::()) + .and_then(list_todos); + + let create = warp::path("todo") + .and(warp::post()) + .and(warp::path::end()) + .and(warp::body::json()) + .and(with_store(store.clone())) + .and_then(create_todo); + + let delete = warp::path!("todo" / i64) + .and(warp::delete()) + .and(warp::path::end()) + .and(with_store(store)) + .and(warp::header::header("todo_apikey")) + .and_then(delete_todo); + + list.or(create).or(delete) + } + + fn with_store(store: Store) -> impl Filter + Clone { + warp::any().map(move || store.clone()) + } + + /// List todos from in-memory storage. + /// + /// List all todos from in-memory storage. + #[fastapi::path( + get, + path = "/todo", + params(ListQueryParams), + responses( + (status = 200, description = "List todos successfully", body = [Todo]) + ) + )] + pub async fn list_todos( + store: Store, + query: ListQueryParams, + ) -> Result { + let todos = store.lock().unwrap(); + + let mut todos: Vec = if let Some(contains) = query.contains { + todos + .iter() + .filter(|todo| todo.value.contains(&contains)) + .cloned() + .collect() + } else { + todos.clone() + }; + + if let Some(order) = query.order { + match order { + Order::AscendingId => { + todos.sort_by_key(|todo| todo.id); + } + Order::DescendingId => { + todos.sort_by_key(|todo| todo.id); + todos.reverse(); + } + } + } + + Ok(warp::reply::json(&todos)) + } + + /// Create new todo item. + /// + /// Creates new todo item to in-memory storage if it is unique by id. + #[fastapi::path( + post, + path = "/todo", + request_body = Todo, + responses( + (status = 200, description = "Todo created successfully", body = Todo), + (status = 409, description = "Todo already exists") + ) + )] + pub async fn create_todo(todo: Todo, store: Store) -> Result, Infallible> { + let mut todos = store.lock().unwrap(); + + if todos + .iter() + .any(|existing_todo| existing_todo.id == todo.id) + { + Ok(Box::new(StatusCode::CONFLICT)) + } else { + todos.push(todo.clone()); + + Ok(Box::new(warp::reply::with_status( + warp::reply::json(&todo), + StatusCode::CREATED, + ))) + } + } + + /// Delete todo item by id. + /// + /// Delete todo item by id from in-memory storage. + #[fastapi::path( + delete, + path = "/todo/{id}", + responses( + (status = 200, description = "Delete successful"), + (status = 400, description = "Missing todo_apikey request header"), + (status = 401, description = "Unauthorized to delete todo"), + (status = 404, description = "Todo not found to delete"), + ), + params( + ("id" = i64, Path, description = "Todo's unique id") + ), + security( + ("api_key" = []) + ) + )] + pub async fn delete_todo( + id: i64, + store: Store, + api_key: String, + ) -> Result { + if api_key != "fastapi-rocks" { + return Ok(StatusCode::UNAUTHORIZED); + } + + let mut todos = store.lock().unwrap(); + + let size = todos.len(); + + todos.retain(|existing| existing.id != id); + + if size == todos.len() { + Ok(StatusCode::NOT_FOUND) + } else { + Ok(StatusCode::OK) + } + } +} diff --git a/examples/warp-multiple-api-docs/Cargo.toml b/examples/warp-multiple-api-docs/Cargo.toml new file mode 100644 index 0000000..92e8f15 --- /dev/null +++ b/examples/warp-multiple-api-docs/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "warp-multiple-api-docs" +description = "Simple warp example api with multiple api docs" +version = "0.1.0" +edition = "2021" +license = "MIT" +authors = [ + "Elli Example " +] + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +tokio = { version = "1", features = ["full"] } +warp = "0.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +env_logger = "0.10.0" +log = "0.4" +futures = "0.3" +fastapi = { path = "../../fastapi" } +fastapi-swagger-ui = { path = "../../fastapi-swagger-ui" } + +[workspace] diff --git a/examples/warp-multiple-api-docs/README.md b/examples/warp-multiple-api-docs/README.md new file mode 100644 index 0000000..77bbbc5 --- /dev/null +++ b/examples/warp-multiple-api-docs/README.md @@ -0,0 +1,22 @@ +# warp-multiple-api-docs ~ fastapi with fastapi-swagger-ui example + +This is a demo `warp` application with multiple API docs to demonstrate splitting APIs with `fastapi` and `fastapi-swagger-ui`. + +Just run command below to run the demo application and browse to `http://localhost:8080/swagger-ui/`. + +```bash +cargo run +``` + +On the Swagger-UI will be a drop-down labelled "Select a definition", containing `/api-doc1.json` and `/api-doc2.json`. + +Alternatively, they can be loaded directly using + +- api1: http://localhost:8080/swagger-ui/?urls.primaryName=%2Fapi-doc1.json +- api2: http://localhost:8080/swagger-ui/?urls.primaryName=%2Fapi-doc2.json + +If you want to see some logging, you may prepend the command with `RUST_LOG=debug` as shown below. + +```bash +RUST_LOG=debug cargo run +``` diff --git a/examples/warp-multiple-api-docs/src/main.rs b/examples/warp-multiple-api-docs/src/main.rs new file mode 100644 index 0000000..5e4c3bf --- /dev/null +++ b/examples/warp-multiple-api-docs/src/main.rs @@ -0,0 +1,124 @@ +use std::{net::Ipv4Addr, sync::Arc}; + +use fastapi::OpenApi; +use fastapi_swagger_ui::Config; +use warp::{ + http::Uri, + hyper::{Response, StatusCode}, + path::{FullPath, Tail}, + Filter, Rejection, Reply, +}; + +#[tokio::main] +async fn main() { + env_logger::init(); + + let config = Arc::new(Config::new(["/api-doc1.json", "/api-doc2.json"])); + + #[derive(OpenApi)] + #[openapi(paths(api1::hello1))] + struct ApiDoc1; + + #[derive(OpenApi)] + #[openapi(paths(api2::hello2))] + struct ApiDoc2; + + let api_doc1 = warp::path("api-doc1.json") + .and(warp::get()) + .map(|| warp::reply::json(&ApiDoc1::openapi())); + + let api_doc2 = warp::path("api-doc2.json") + .and(warp::get()) + .map(|| warp::reply::json(&ApiDoc2::openapi())); + + let swagger_ui = warp::path("swagger-ui") + .and(warp::get()) + .and(warp::path::full()) + .and(warp::path::tail()) + .and(warp::any().map(move || config.clone())) + .and_then(serve_swagger); + + let hello1 = warp::path("hello1") + .and(warp::get()) + .and(warp::path::end()) + .and_then(api1::hello1); + + let hello2 = warp::path("hello2") + .and(warp::get()) + .and(warp::path::end()) + .and_then(api2::hello2); + + warp::serve(api_doc1.or(api_doc2).or(swagger_ui).or(hello1).or(hello2)) + .run((Ipv4Addr::UNSPECIFIED, 8080)) + .await +} + +async fn serve_swagger( + full_path: FullPath, + tail: Tail, + config: Arc>, +) -> Result, Rejection> { + if full_path.as_str() == "/swagger-ui" { + return Ok(Box::new(warp::redirect::found(Uri::from_static( + "/swagger-ui/", + )))); + } + + let path = tail.as_str(); + match fastapi_swagger_ui::serve(path, config) { + Ok(file) => { + if let Some(file) = file { + Ok(Box::new( + Response::builder() + .header("Content-Type", file.content_type) + .body(file.bytes), + )) + } else { + Ok(Box::new(StatusCode::NOT_FOUND)) + } + } + Err(error) => Ok(Box::new( + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(error.to_string()), + )), + } +} + +mod api1 { + use std::convert::Infallible; + + use warp::{hyper::Response, Reply}; + + #[fastapi::path( + get, + path = "/hello1", + responses( + (status = 200, body = String) + ) + )] + pub async fn hello1() -> Result { + Ok(Response::builder() + .header("content-type", "text/plain") + .body("hello 1")) + } +} + +mod api2 { + use std::convert::Infallible; + + use warp::{hyper::Response, Reply}; + + #[fastapi::path( + get, + path = "/hello2", + responses( + (status = 200, body = String) + ) + )] + pub async fn hello2() -> Result { + Ok(Response::builder() + .header("content-type", "text/plain") + .body("hello 2")) + } +} diff --git a/fastapi-actix-web/Cargo.toml b/fastapi-actix-web/Cargo.toml new file mode 100644 index 0000000..f02b767 --- /dev/null +++ b/fastapi-actix-web/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "fastapi-actix-web" +description = "Fastapi's actix-web bindings for seamless integration of the two" +version = "0.1.2" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["fastapi", "actix-web", "bindings"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[dependencies] +fastapi = { path = "../fastapi", version = "0" } +actix-web = { version = "4", default-features = false } +actix-service = "2" + +[dev-dependencies] +fastapi = { path = "../fastapi", version = "0", features = [ + "actix_extras", + "macros", + "debug", +] } +actix-web = { version = "4", default-features = false, features = ["macros"] } +serde = "1" + +[package.metadata.docs.rs] +features = [] +rustdoc-args = ["--cfg", "doc_cfg"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi-actix-web/LICENSE-APACHE b/fastapi-actix-web/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-actix-web/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-actix-web/LICENSE-MIT b/fastapi-actix-web/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-actix-web/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-actix-web/README.md b/fastapi-actix-web/README.md new file mode 100644 index 0000000..4be5735 --- /dev/null +++ b/fastapi-actix-web/README.md @@ -0,0 +1,54 @@ +# fastapi-actix-web - Bindings for Actix Web and fastapi + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-actix-web.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-actix-web) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-actix-web&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-actix-web/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +This crate implements necessary bindings for automatically collecting `paths` and `schemas` recursively from Actix Web +`App`, `Scope` and `ServiceConfig`. It provides natural API reducing duplication and support for scopes while generating +OpenAPI specification without the need to declare `paths` and `schemas` to `#[openapi(...)]` attribute of `OpenApi` derive. + +Currently only `service(...)` calls supports automatic collection of schemas and paths. Manual routes via `route(...)` or +`Route::new().to(...)` is not supported. + +## Install + +Add dependency declaration to `Cargo.toml`. + +```toml +[dependencies] +fastapi-actix-web = "0.1" +``` + +## Examples + +Collect handlers annotated with `#[fastapi::path]` recursively from `service(...)` calls to compose OpenAPI spec. + +```rust +use actix_web::{get, App}; +use fastapi_actix_web::{scope, AppExt}; + +#[derive(fastapi::ToSchema)] +struct User { + id: i32, +} + +#[fastapi::path(responses((status = OK, body = User)))] +#[get("/user")] +async fn get_user() -> Json { + Json(User { id: 1 }) +} + +let (_, mut api) = App::new() + .into_fastapi_app() + .service(scope::scope("/api/v1").service(get_user)) + .split_for_parts(); +``` + +## License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. diff --git a/fastapi-actix-web/src/lib.rs b/fastapi-actix-web/src/lib.rs new file mode 100644 index 0000000..4369d17 --- /dev/null +++ b/fastapi-actix-web/src/lib.rs @@ -0,0 +1,486 @@ +//! This crate implements necessary bindings for automatically collecting `paths` and `schemas` recursively from Actix Web +//! `App`, `Scope` and `ServiceConfig`. It provides natural API reducing duplication and support for scopes while generating +//! OpenAPI specification without the need to declare `paths` and `schemas` to `#[openapi(...)]` attribute of `OpenApi` derive. +//! +//! Currently only `service(...)` calls supports automatic collection of schemas and paths. Manual routes via `route(...)` or +//! `Route::new().to(...)` is not supported. +//! +//! ## Install +//! +//! Add dependency declaration to `Cargo.toml`. +//! +//! ```toml +//! [dependencies] +//! fastapi-actix-web = "0.1" +//! ``` +//! +//! ## Examples +//! +//! _**Collect handlers annotated with `#[fastapi::path]` recursively from `service(...)` calls to compose OpenAPI spec.**_ +//! +//! ```rust +//! use actix_web::web::Json; +//! use actix_web::{get, App}; +//! use fastapi_actix_web::{scope, AppExt}; +//! +//! #[derive(fastapi::ToSchema, serde::Serialize)] +//! struct User { +//! id: i32, +//! } +//! +//! #[fastapi::path(responses((status = OK, body = User)))] +//! #[get("/user")] +//! async fn get_user() -> Json { +//! Json(User { id: 1 }) +//! } +//! +//! let (_, mut api) = App::new() +//! .into_fastapi_app() +//! .service(scope::scope("/api/v1").service(get_user)) +//! .split_for_parts(); +//! ``` + +#![cfg_attr(doc_cfg, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] + +use core::fmt; +use std::future::Future; + +use actix_service::{IntoServiceFactory, ServiceFactory}; +use actix_web::dev::{HttpServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::Error; +use fastapi::openapi::PathItem; +use fastapi::OpenApi; + +use self::service_config::ServiceConfig; + +pub mod scope; +pub mod service_config; + +pub use scope::scope; + +/// This trait is used to unify OpenAPI items collection from types implementing this trait. +pub trait OpenApiFactory { + /// Get OpenAPI paths. + fn paths(&self) -> fastapi::openapi::path::Paths; + /// Collect schema reference and append them to the _`schemas`_. + fn schemas( + &self, + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ); +} + +impl<'t, T: fastapi::Path + fastapi::__dev::SchemaReferences + fastapi::__dev::Tags<'t>> + OpenApiFactory for T +{ + fn paths(&self) -> fastapi::openapi::path::Paths { + let methods = T::methods(); + + methods + .into_iter() + .fold( + fastapi::openapi::path::Paths::builder(), + |mut builder, method| { + let mut operation = T::operation(); + let other_tags = T::tags(); + if !other_tags.is_empty() { + let tags = operation.tags.get_or_insert(Vec::new()); + tags.extend(other_tags.into_iter().map(ToString::to_string)); + }; + + let path_item = PathItem::new(method, operation); + builder = builder.path(T::path(), path_item); + + builder + }, + ) + .build() + } + + fn schemas( + &self, + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + ::schemas(schemas); + } +} + +/// Extends [`actix_web::App`] with `fastapi` related functionality. +pub trait AppExt { + /// Convert's this [`actix_web::App`] to [`FastapiApp`]. + /// + /// See usage from [`FastapiApp`][struct@FastapiApp] + fn into_fastapi_app(self) -> FastapiApp; +} + +impl AppExt for actix_web::App { + fn into_fastapi_app(self) -> FastapiApp { + FastapiApp::from(self) + } +} + +/// Wrapper type for [`actix_web::App`] and [`fastapi::openapi::OpenApi`]. +/// +/// [`FastapiApp`] behaves exactly same way as [`actix_web::App`] but allows automatic _`schema`_ and +/// _`path`_ collection from `service(...)` calls directly or via [`ServiceConfig::service`]. +/// +/// It exposes typical methods from [`actix_web::App`] and provides custom [`FastapiApp::map`] +/// method to add additional configuration options to wrapper [`actix_web::App`]. +/// +/// This struct need be instantiated from [`actix_web::App`] by calling `.into_fastapi_app()` +/// because we do not have access to _`actix_web::App`_ generic argument and the _`App`_ does +/// not provide any default implementation. +/// +/// # Examples +/// +/// _**Create new [`FastapiApp`] instance.**_ +/// ```rust +/// # use fastapi_actix_web::{AppExt, FastapiApp}; +/// # use actix_web::App; +/// let fastapi_app = App::new().into_fastapi_app(); +/// ``` +/// +/// _**Convert `actix_web::App` to `FastapiApp`.**_ +/// ```rust +/// # use fastapi_actix_web::{AppExt, FastapiApp}; +/// # use actix_web::App; +/// let a: FastapiApp<_> = actix_web::App::new().into(); +/// ``` +pub struct FastapiApp(actix_web::App, fastapi::openapi::OpenApi); + +impl From> for FastapiApp { + fn from(value: actix_web::App) -> Self { + #[derive(OpenApi)] + struct Api; + FastapiApp(value, Api::openapi()) + } +} + +impl FastapiApp +where + T: ServiceFactory, +{ + /// Replace the wrapped [`fastapi::openapi::OpenApi`] with given _`openapi`_. + /// + /// This is useful to prepend OpenAPI doc generated with [`FastapiApp`] + /// with content that cannot be provided directly via [`FastapiApp`]. + /// + /// # Examples + /// + /// _**Replace wrapped [`fastapi::openapi::OpenApi`] with custom one.**_ + /// ```rust + /// # use fastapi_actix_web::{AppExt, FastapiApp}; + /// # use actix_web::App; + /// # use fastapi::OpenApi; + /// #[derive(OpenApi)] + /// #[openapi(info(title = "Api title"))] + /// struct Api; + /// + /// let _ = actix_web::App::new().into_fastapi_app().openapi(Api::openapi()); + /// ``` + pub fn openapi(mut self, openapi: fastapi::openapi::OpenApi) -> Self { + self.1 = openapi; + + self + } + + /// Passthrough implementation for [`actix_web::App::app_data`]. + pub fn app_data(self, data: U) -> Self { + let app = self.0.app_data(data); + Self(app, self.1) + } + + /// Passthrough implementation for [`actix_web::App::data_factory`]. + pub fn data_factory(self, data: F) -> Self + where + F: Fn() -> Out + 'static, + Out: Future> + 'static, + D: 'static, + E: std::fmt::Debug, + { + let app = self.0.data_factory(data); + + Self(app, self.1) + } + + /// Extended version of [`actix_web::App::configure`] which handles _`schema`_ and _`path`_ + /// collection from [`ServiceConfig`] into the wrapped [`fastapi::openapi::OpenApi`] instance. + pub fn configure(self, f: F) -> Self + where + F: FnOnce(&mut ServiceConfig), + { + let mut openapi = self.1; + + let app = self.0.configure(|config| { + let mut service_config = ServiceConfig::new(config); + + f(&mut service_config); + + let paths = service_config.1.take(); + openapi.paths.merge(paths); + let schemas = service_config.2.take(); + let components = openapi + .components + .get_or_insert(fastapi::openapi::Components::new()); + components.schemas.extend(schemas); + }); + + Self(app, openapi) + } + + /// Passthrough implementation for [`actix_web::App::route`]. + pub fn route(self, path: &str, route: actix_web::Route) -> Self { + let app = self.0.route(path, route); + + Self(app, self.1) + } + + /// Extended version of [`actix_web::App::service`] method which handles _`schema`_ and _`path`_ + /// collection from [`HttpServiceFactory`]. + pub fn service(self, factory: F) -> Self + where + F: HttpServiceFactory + OpenApiFactory + 'static, + { + let mut schemas = Vec::<( + String, + fastapi::openapi::RefOr, + )>::new(); + + factory.schemas(&mut schemas); + let paths = factory.paths(); + + let mut openapi = self.1; + + openapi.paths.merge(paths); + let components = openapi + .components + .get_or_insert(fastapi::openapi::Components::new()); + components.schemas.extend(schemas); + + let app = self.0.service(factory); + + Self(app, openapi) + } + + /// Helper method to serve wrapped [`fastapi::openapi::OpenApi`] via [`HttpServiceFactory`]. + /// + /// This method functions as a convenience to serve the wrapped OpenAPI spec alternatively to + /// first call [`FastapiApp::split_for_parts`] and then calling [`actix_web::App::service`]. + pub fn openapi_service(self, factory: F) -> Self + where + F: FnOnce(fastapi::openapi::OpenApi) -> O, + O: HttpServiceFactory + 'static, + { + let service = factory(self.1.clone()); + let app = self.0.service(service); + Self(app, self.1) + } + + /// Passthrough implementation for [`actix_web::App::default_service`]. + pub fn default_service(self, svc: F) -> Self + where + F: IntoServiceFactory, + U: ServiceFactory + + 'static, + U::InitError: fmt::Debug, + { + Self(self.0.default_service(svc), self.1) + } + + /// Passthrough implementation for [`actix_web::App::external_resource`]. + pub fn external_resource(self, name: N, url: U) -> Self + where + N: AsRef, + U: AsRef, + { + Self(self.0.external_resource(name, url), self.1) + } + + /// Convenience method to add custom configuration to [`actix_web::App`] that is not directly + /// exposed via [`FastapiApp`]. This could for example be adding middlewares. + /// + /// # Examples + /// + /// _**Add middleware via `map` method.**_ + /// + /// ```rust + /// # use fastapi_actix_web::{AppExt, FastapiApp}; + /// # use actix_web::App; + /// # use actix_service::Service; + /// # use actix_web::http::header::{HeaderValue, CONTENT_TYPE}; + /// let _ = App::new() + /// .into_fastapi_app() + /// .map(|app| { + /// app.wrap_fn(|req, srv| { + /// let fut = srv.call(req); + /// async { + /// let mut res = fut.await?; + /// res.headers_mut() + /// .insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + /// Ok(res) + /// } + /// }) + /// }); + /// ``` + pub fn map< + F: FnOnce(actix_web::App) -> actix_web::App, + NF: ServiceFactory, + >( + self, + op: F, + ) -> FastapiApp { + let app = op(self.0); + FastapiApp(app, self.1) + } + + /// Split this [`FastapiApp`] into parts returning tuple of [`actix_web::App`] and + /// [`fastapi::openapi::OpenApi`] of this instance. + pub fn split_for_parts(self) -> (actix_web::App, fastapi::openapi::OpenApi) { + (self.0, self.1) + } + + /// Converts this [`FastapiApp`] into the wrapped [`actix_web::App`]. + pub fn into_app(self) -> actix_web::App { + self.0 + } +} + +impl From> for actix_web::App { + fn from(value: FastapiApp) -> Self { + value.0 + } +} + +#[cfg(test)] +mod tests { + #![allow(unused)] + + use actix_service::Service; + use actix_web::guard::{Get, Guard}; + use actix_web::http::header::{HeaderValue, CONTENT_TYPE}; + use actix_web::web::{self, Data}; + use actix_web::{get, App, HttpRequest, HttpResponse}; + use fastapi::ToSchema; + + use super::*; + + #[derive(ToSchema)] + struct Value12 { + v: String, + } + + #[derive(ToSchema)] + struct Value2(i32); + + #[derive(ToSchema)] + struct Value1 { + bar: Value2, + } + + #[derive(ToSchema)] + struct ValueValue { + value: i32, + } + + #[fastapi::path(responses( + (status = 200, body = ValueValue) + ))] + #[get("/handler2")] + async fn handler2() -> &'static str { + "this is message 2" + } + + #[fastapi::path(responses( + (status = 200, body = Value12) + ))] + #[get("/handler")] + async fn handler() -> &'static str { + "this is message" + } + + #[fastapi::path(responses( + (status = 200, body = Value1) + ))] + #[get("/handler3")] + async fn handler3() -> &'static str { + "this is message 3" + } + + mod inner { + use actix_web::get; + use actix_web::web::Data; + use fastapi::ToSchema; + + #[derive(ToSchema)] + struct Bar(i32); + + #[derive(ToSchema)] + struct Foobar { + bar: Bar, + } + + #[fastapi::path(responses( + (status = 200, body = Foobar) + ))] + #[get("/inner_handler")] + pub async fn inner_handler(_: Data) -> &'static str { + "this is message" + } + + #[fastapi::path()] + #[get("/inner_handler3")] + pub async fn inner_handler3(_: Data) -> &'static str { + "this is message 3" + } + } + + #[get("/normal_service")] + async fn normal_service() -> &'static str { + "str" + } + + #[test] + fn test_app_generate_correct_openapi() { + fn config(cfg: &mut service_config::ServiceConfig) { + cfg.service(handler3) + .map(|config| config.service(normal_service)); + } + + let (_, mut api) = App::new() + .into_fastapi_app() + .service(handler) + .configure(config) + .service(scope::scope("/path-prefix").service(handler2).map(|scope| { + let s = scope.wrap_fn(|req, srv| { + let fut = srv.call(req); + async { + let mut res = fut.await?; + res.headers_mut() + .insert(CONTENT_TYPE, HeaderValue::from_static("text/plain")); + Ok(res) + } + }); + + s + })) + .service(scope::scope("/api/v1/inner").configure(|cfg| { + cfg.service(inner::inner_handler) + .service(inner::inner_handler3) + .app_data(Data::new(String::new())); + })) + .split_for_parts(); + api.info = fastapi::openapi::info::Info::new("title", "version"); + let json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + println!("{json}"); + + let expected = include_str!("../testdata/app_generated_openapi"); + assert_eq!(json.trim(), expected.trim()); + } +} diff --git a/fastapi-actix-web/src/scope.rs b/fastapi-actix-web/src/scope.rs new file mode 100644 index 0000000..8263dff --- /dev/null +++ b/fastapi-actix-web/src/scope.rs @@ -0,0 +1,265 @@ +//! Implement `fastapi` extended [`Scope`] for [`actix_web::Scope`]. +//! +//! See usage from [`scope`][fn@scope]. + +use core::fmt; +use std::cell::{Cell, RefCell}; + +use actix_service::{IntoServiceFactory, ServiceFactory, Transform}; +use actix_web::body::MessageBody; +use actix_web::dev::{AppService, HttpServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::guard::Guard; +use actix_web::{Error, Route}; + +use crate::service_config::ServiceConfig; +use crate::OpenApiFactory; + +/// Wrapper type for [`actix_web::Scope`] and [`fastapi::openapi::OpenApi`] with additional path +/// prefix created with `scope::scope("path-prefix")` call. +/// +/// See usage from [`scope`][fn@scope]. +pub struct Scope( + actix_web::Scope, + RefCell, + Cell, +); + +impl From> for Scope +where + T: ServiceFactory, +{ + fn from(value: actix_web::Scope) -> Self { + Self( + value, + RefCell::new(fastapi::openapi::OpenApiBuilder::new().build()), + Cell::new(String::new()), + ) + } +} + +impl<'s, T: ServiceFactory> + From<&'s str> for Scope +where + Scope: std::convert::From, +{ + fn from(value: &'s str) -> Self { + let scope = actix_web::Scope::new(value); + let s: Scope = scope.into(); + Scope(s.0, s.1, Cell::new(String::from(value))) + } +} + +/// Create a new [`Scope`] with given _`scope`_ e.g. `scope("/api/v1")`. +/// +/// This behaves exactly same way as [`actix_web::Scope`] but allows automatic _`schema`_ and +/// _`path`_ collection from `service(...)` calls directly or via [`ServiceConfig::service`]. +/// +/// # Examples +/// +/// _**Create new scoped service.**_ +/// +/// ```rust +/// # use actix_web::{get, App}; +/// # use fastapi_actix_web::{AppExt, scope}; +/// # +/// #[fastapi::path()] +/// #[get("/handler")] +/// pub async fn handler() -> &'static str { +/// "OK" +/// } +/// let _ = App::new() +/// .into_fastapi_app() +/// .service(scope::scope("/api/v1/inner").configure(|cfg| { +/// cfg.service(handler); +/// })); +/// ``` +pub fn scope< + I: Into>, + T: ServiceFactory, +>( + scope: I, +) -> Scope { + scope.into() +} + +impl Scope +where + T: ServiceFactory, +{ + /// Passthrough implementation for [`actix_web::Scope::guard`]. + pub fn guard(self, guard: G) -> Self { + let scope = self.0.guard(guard); + Self(scope, self.1, self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::app_data`]. + pub fn app_data(self, data: U) -> Self { + Self(self.0.app_data(data), self.1, self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::wrap`]. + pub fn wrap( + self, + middleware: M, + ) -> Scope< + impl ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + >, + > + where + M: Transform< + T::Service, + ServiceRequest, + Response = ServiceResponse, + Error = Error, + InitError = (), + > + 'static, + B: MessageBody, + { + let scope = self.0.wrap(middleware); + Scope(scope, self.1, self.2) + } + + /// Synonymous for [`FastapiApp::configure`][fastapi_app_configure] + /// + /// [fastapi_app_configure]: ../struct.FastapiApp.html#method.configure + pub fn configure(self, cfg_fn: F) -> Self + where + F: FnOnce(&mut ServiceConfig), + { + let mut openapi = self.1.borrow_mut(); + + let scope = self.0.configure(|config| { + let mut service_config = ServiceConfig::new(config); + + cfg_fn(&mut service_config); + + let other_paths = service_config.1.take(); + openapi.paths.merge(other_paths); + let schemas = service_config.2.take(); + let components = openapi + .components + .get_or_insert(fastapi::openapi::Components::new()); + components.schemas.extend(schemas); + }); + drop(openapi); + + Self(scope, self.1, self.2) + } + + /// Synonymous for [`FastapiApp::service`][fastapi_app_service] + /// + /// [fastapi_app_service]: ../struct.FastapiApp.html#method.service + pub fn service(self, factory: F) -> Self + where + F: HttpServiceFactory + OpenApiFactory + 'static, + { + let mut schemas = Vec::<( + String, + fastapi::openapi::RefOr, + )>::new(); + { + let mut openapi = self.1.borrow_mut(); + let other_paths = factory.paths(); + factory.schemas(&mut schemas); + openapi.paths.merge(other_paths); + let components = openapi + .components + .get_or_insert(fastapi::openapi::Components::new()); + components.schemas.extend(schemas); + } + + let app = self.0.service(factory); + + Self(app, self.1, self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::route`]. + pub fn route(self, path: &str, route: Route) -> Self { + Self(self.0.route(path, route), self.1, self.2) + } + + /// Passthrough implementation for [`actix_web::Scope::default_service`]. + pub fn default_service(self, f: F) -> Self + where + F: IntoServiceFactory, + U: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = actix_web::Error, + > + 'static, + U::InitError: fmt::Debug, + { + Self(self.0.default_service(f), self.1, self.2) + } + + /// Synonymous for [`FastapiApp::map`][fastapi_app_map] + /// + /// [fastapi_app_map]: ../struct.FastapiApp.html#method.map + pub fn map< + F: FnOnce(actix_web::Scope) -> actix_web::Scope, + NF: ServiceFactory, + >( + self, + op: F, + ) -> Scope { + let scope = op(self.0); + Scope(scope, self.1, self.2) + } +} + +impl HttpServiceFactory for Scope +where + T: ServiceFactory< + ServiceRequest, + Config = (), + Response = ServiceResponse, + Error = Error, + InitError = (), + > + 'static, + B: MessageBody + 'static, +{ + fn register(self, config: &mut AppService) { + let Scope(scope, ..) = self; + scope.register(config); + } +} + +impl OpenApiFactory for Scope { + fn paths(&self) -> fastapi::openapi::path::Paths { + let prefix = self.2.take(); + let mut openapi = self.1.borrow_mut(); + let mut paths = std::mem::take(&mut openapi.paths); + + let prefixed_paths = paths + .paths + .into_iter() + .map(|(path, item)| { + let path = format!("{prefix}{path}"); + + (path, item) + }) + .collect::>(); + paths.paths = prefixed_paths; + + paths + } + + fn schemas( + &self, + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + let mut api = self.1.borrow_mut(); + if let Some(components) = &mut api.components { + schemas.extend(std::mem::take(&mut components.schemas)); + } + } +} diff --git a/fastapi-actix-web/src/service_config.rs b/fastapi-actix-web/src/service_config.rs new file mode 100644 index 0000000..ed5f783 --- /dev/null +++ b/fastapi-actix-web/src/service_config.rs @@ -0,0 +1,111 @@ +//! Implement `fastapi` extended [`ServiceConfig`] for [`actix_web::web::ServiceConfig`]. + +use std::cell::Cell; + +use actix_service::{IntoServiceFactory, ServiceFactory}; +use actix_web::dev::{HttpServiceFactory, ServiceRequest, ServiceResponse}; +use actix_web::{Error, Route}; + +use crate::OpenApiFactory; + +/// Wrapper type for [`actix_web::web::ServiceConfig`], [`fastapi::openapi::path::Paths`] and +/// vec of [`fastapi::openapi::schema::Schema`] references. +pub struct ServiceConfig<'s>( + pub(super) &'s mut actix_web::web::ServiceConfig, + pub(super) Cell, + pub(super) Cell< + Vec<( + String, + fastapi::openapi::RefOr, + )>, + >, +); + +impl<'s> ServiceConfig<'s> { + /// Construct a new [`ServiceConfig`] from given [`actix_web::web::ServiceConfig`]. + pub fn new(conf: &'s mut actix_web::web::ServiceConfig) -> ServiceConfig<'s> { + ServiceConfig( + conf, + Cell::new(fastapi::openapi::path::Paths::new()), + Cell::new(Vec::new()), + ) + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::app_data`]. + pub fn app_data(&mut self, ext: U) -> &mut Self { + self.0.app_data(ext); + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::default_service`]. + pub fn default_service(&mut self, f: F) -> &mut Self + where + F: IntoServiceFactory, + U: ServiceFactory + + 'static, + U::InitError: std::fmt::Debug, + { + self.0.default_service(f); + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::configure`]. + pub fn configure(&mut self, f: F) -> &mut Self + where + F: FnOnce(&mut ServiceConfig), + { + f(self); + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::route`]. + pub fn route(&mut self, path: &str, route: Route) -> &mut Self { + self.0.route(path, route); + self + } + + /// Counterpart for [`FastapiApp::service`][fastapi_app_service]. + /// + /// [fastapi_app_service]: ../struct.FastapiApp.html#method.service + pub fn service(&mut self, factory: F) -> &mut Self + where + F: HttpServiceFactory + OpenApiFactory + 'static, + { + let mut paths = self.1.take(); + let other_paths = factory.paths(); + paths.merge(other_paths); + + let mut schemas = self.2.take(); + factory.schemas(&mut schemas); + self.2.set(schemas); + + self.0.service(factory); + self.1.set(paths); + + self + } + + /// Passthrough implementation for [`actix_web::web::ServiceConfig::external_resource`]. + pub fn external_resource(&mut self, name: N, url: U) -> &mut Self + where + N: AsRef, + U: AsRef, + { + self.0.external_resource(name, url); + self + } + + /// Synonymous for [`FastapiApp::map`][fastapi_app_map] + /// + /// [fastapi_app_map]: ../struct.FastapiApp.html#method.map + pub fn map< + F: FnOnce(&mut actix_web::web::ServiceConfig) -> &mut actix_web::web::ServiceConfig, + >( + &mut self, + op: F, + ) -> &mut Self { + op(self.0); + + self + } +} diff --git a/fastapi-actix-web/testdata/app_generated_openapi b/fastapi-actix-web/testdata/app_generated_openapi new file mode 100644 index 0000000..16373c6 --- /dev/null +++ b/fastapi-actix-web/testdata/app_generated_openapi @@ -0,0 +1,140 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": { + "/api/v1/inner/inner_handler": { + "get": { + "operationId": "inner_handler", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foobar" + } + } + } + } + } + } + }, + "/api/v1/inner/inner_handler3": { + "get": { + "operationId": "inner_handler3", + "responses": {} + } + }, + "/handler": { + "get": { + "operationId": "handler", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Value12" + } + } + } + } + } + } + }, + "/handler3": { + "get": { + "operationId": "handler3", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Value1" + } + } + } + } + } + } + }, + "/path-prefix/handler2": { + "get": { + "operationId": "handler2", + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValueValue" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Bar": { + "type": "integer", + "format": "int32" + }, + "Foobar": { + "type": "object", + "required": [ + "bar" + ], + "properties": { + "bar": { + "$ref": "#/components/schemas/Bar" + } + } + }, + "Value1": { + "type": "object", + "required": [ + "bar" + ], + "properties": { + "bar": { + "$ref": "#/components/schemas/Value2" + } + } + }, + "Value12": { + "type": "object", + "required": [ + "v" + ], + "properties": { + "v": { + "type": "string" + } + } + }, + "Value2": { + "type": "integer", + "format": "int32" + }, + "ValueValue": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "format": "int32" + } + } + } + } + } +} diff --git a/fastapi-axum/Cargo.toml b/fastapi-axum/Cargo.toml new file mode 100644 index 0000000..d39d48b --- /dev/null +++ b/fastapi-axum/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "fastapi-axum" +description = "Fastapi's axum bindings for seamless integration for the two" +version = "0.1.2" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["fastapi", "axum", "bindings"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[features] +debug = [] + +[dependencies] +axum = { version = "0.7", default-features = false } +fastapi = { version = "0.1.1", path = "../fastapi", default-features = false, features = [ + "macros", +] } +tower-service = "0.3" +tower-layer = "0.3.2" +paste = "1.0" + +[dev-dependencies] +fastapi = { path = "../fastapi", features = ["debug"] } +axum = { version = "0.7", default-features = false, features = ["json"] } +serde = "1" + +[package.metadata.docs.rs] +features = [] +rustdoc-args = ["--cfg", "doc_cfg"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi-axum/LICENSE-APACHE b/fastapi-axum/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-axum/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-axum/LICENSE-MIT b/fastapi-axum/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-axum/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-axum/README.md b/fastapi-axum/README.md new file mode 100644 index 0000000..5a1e3a0 --- /dev/null +++ b/fastapi-axum/README.md @@ -0,0 +1,52 @@ +# fastapi-axum - Bindings for Axum and fastapi + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-axum.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-axum) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-axum&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-axum/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +Fastapi axum brings `fastapi` and `axum` closer together by the way of providing an ergonomic API that is extending on +the `axum` API. It gives a natural way to register handlers known to `axum` and also simultaneously generates OpenAPI +specification from the handlers. + +## Crate features + +- **`debug`**: Implement debug traits for types. + +## Install + +Add dependency declaration to `Cargo.toml`. + +```toml +[dependencies] +fastapi-axum = "0.1" +``` + +## Examples + +Use `OpenApiRouter` to collect handlers with `#[fastapi::path]` macro to compose service and form OpenAPI spec. + +```rust +use fastapi_axum::{routes, PathItemExt, router::OpenApiRouter}; + +#[derive(fastapi::ToSchema)] +struct User { + id: i32, +} + +#[fastapi::path(get, path = "/user", responses((status = OK, body = User)))] +async fn get_user() -> Json { + Json(User { id: 1 }) +} + +let (router, api) = OpenApiRouter::new() + .routes(routes!(get_user)) + .split_for_parts(); +``` + +## License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. diff --git a/fastapi-axum/src/lib.rs b/fastapi-axum/src/lib.rs new file mode 100644 index 0000000..255b8f3 --- /dev/null +++ b/fastapi-axum/src/lib.rs @@ -0,0 +1,421 @@ +#![cfg_attr(doc_cfg, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] + +//! Fastapi axum brings `fastapi` and `axum` closer together by the way of providing an ergonomic API that is extending on +//! the `axum` API. It gives a natural way to register handlers known to `axum` and also simultaneously generates OpenAPI +//! specification from the handlers. +//! +//! ## Crate features +//! +//! - **`debug`**: Implement debug traits for types. +//! +//! ## Install +//! +//! Add dependency declaration to `Cargo.toml`. +//! +//! ```toml +//! [dependencies] +//! fastapi-axum = "0.1" +//! ``` +//! +//! ## Examples +//! +//! _**Use [`OpenApiRouter`][router] to collect handlers with _`#[fastapi::path]`_ macro to compose service and form OpenAPI spec.**_ +//! +//! ```rust +//! # use axum::Json; +//! # use fastapi::openapi::OpenApi; +//! # use fastapi_axum::{routes, PathItemExt, router::OpenApiRouter}; +//! #[derive(fastapi::ToSchema, serde::Serialize)] +//! struct User { +//! id: i32, +//! } +//! +//! #[fastapi::path(get, path = "/user", responses((status = OK, body = User)))] +//! async fn get_user() -> Json { +//! Json(User { id: 1 }) +//! } +//! +//! let (router, api): (axum::Router, OpenApi) = OpenApiRouter::new() +//! .routes(routes!(get_user)) +//! .split_for_parts(); +//! ``` +//! +//! [router]: router/struct.OpenApiRouter.html + +pub mod router; + +use axum::routing::MethodFilter; +use fastapi::openapi::HttpMethod; + +/// Extends [`fastapi::openapi::path::PathItem`] by providing conversion methods to convert this +/// path item type to a [`axum::routing::MethodFilter`]. +pub trait PathItemExt { + /// Convert this path item type to a [`axum::routing::MethodFilter`]. + /// + /// Method filter is used with handler registration on [`axum::routing::MethodRouter`]. + fn to_method_filter(&self) -> MethodFilter; +} + +impl PathItemExt for HttpMethod { + fn to_method_filter(&self) -> MethodFilter { + match self { + HttpMethod::Get => MethodFilter::GET, + HttpMethod::Put => MethodFilter::PUT, + HttpMethod::Post => MethodFilter::POST, + HttpMethod::Head => MethodFilter::HEAD, + HttpMethod::Patch => MethodFilter::PATCH, + HttpMethod::Trace => MethodFilter::TRACE, + HttpMethod::Delete => MethodFilter::DELETE, + HttpMethod::Options => MethodFilter::OPTIONS, + } + } +} + +/// re-export paste so users do not need to add the dependency. +#[doc(hidden)] +pub use paste::paste; + +/// Collect axum handlers annotated with [`fastapi::path`] to [`router::FastapiMethodRouter`]. +/// +/// [`routes`] macro will return [`router::FastapiMethodRouter`] which contains an +/// [`axum::routing::MethodRouter`] and currently registered paths. The output of this macro is +/// meant to be used together with [`router::OpenApiRouter`] which combines the paths and axum +/// routers to a single entity. +/// +/// Only handlers collected with [`routes`] macro will get registered to the OpenApi. +/// +/// # Panics +/// +/// Routes registered via [`routes`] macro or via `axum::routing::*` operations are bound to same +/// rules where only one one HTTP method can can be registered once per call. This means that the +/// following will produce runtime panic from axum code. +/// +/// ```rust,no_run +/// # use fastapi_axum::{routes, router::FastapiMethodRouter}; +/// # use fastapi::path; +/// #[fastapi::path(get, path = "/search")] +/// async fn search_user() {} +/// +/// #[fastapi::path(get, path = "")] +/// async fn get_user() {} +/// +/// let _: FastapiMethodRouter = routes!(get_user, search_user); +/// ``` +/// Since the _`axum`_ does not support method filter for `CONNECT` requests, using this macro with +/// handler having request method type `CONNECT` `#[fastapi::path(connect, path = "")]` will panic at +/// runtime. +/// +/// # Examples +/// +/// _**Create new `OpenApiRouter` with `get_user` and `post_user` paths.**_ +/// ```rust +/// # use fastapi_axum::{routes, router::{OpenApiRouter, FastapiMethodRouter}}; +/// # use fastapi::path; +/// #[fastapi::path(get, path = "")] +/// async fn get_user() {} +/// +/// #[fastapi::path(post, path = "")] +/// async fn post_user() {} +/// +/// let _: OpenApiRouter = OpenApiRouter::new().routes(routes!(get_user, post_user)); +/// ``` +#[macro_export] +macro_rules! routes { + ( $handler:path $(, $tail:path)* ) => { + { + use $crate::PathItemExt; + let mut paths = fastapi::openapi::path::Paths::new(); + let mut schemas = Vec::<(String, fastapi::openapi::RefOr)>::new(); + let (path, item, types) = $crate::routes!(@resolve_types $handler : schemas); + #[allow(unused_mut)] + let mut method_router = types.iter().by_ref().fold(axum::routing::MethodRouter::new(), |router, path_type| { + router.on(path_type.to_method_filter(), $handler) + }); + paths.add_path_operation(&path, types, item); + $( method_router = $crate::routes!( schemas: method_router: paths: $tail ); )* + (schemas, paths, method_router) + } + }; + ( $schemas:tt: $router:ident: $paths:ident: $handler:path $(, $tail:tt)* ) => { + { + let (path, item, types) = $crate::routes!(@resolve_types $handler : $schemas); + let router = types.iter().by_ref().fold($router, |router, path_type| { + router.on(path_type.to_method_filter(), $handler) + }); + $paths.add_path_operation(&path, types, item); + router + } + }; + ( @resolve_types $handler:path : $schemas:tt ) => { + { + $crate::paste! { + let path = $crate::routes!( @path [path()] of $handler ); + let mut operation = $crate::routes!( @path [operation()] of $handler ); + let types = $crate::routes!( @path [methods()] of $handler ); + let tags = $crate::routes!( @path [tags()] of $handler ); + $crate::routes!( @path [schemas(&mut $schemas)] of $handler ); + if !tags.is_empty() { + let operation_tags = operation.tags.get_or_insert(Vec::new()); + operation_tags.extend(tags.iter().map(ToString::to_string)); + } + (path, operation, types) + } + } + }; + ( @path $op:tt of $part:ident $( :: $tt:tt )* ) => { + $crate::routes!( $op : [ $part $( $tt )*] ) + }; + ( $op:tt : [ $first:tt $( $rest:tt )* ] $( $rev:tt )* ) => { + $crate::routes!( $op : [ $( $rest )* ] $first $( $rev)* ) + }; + ( $op:tt : [] $first:tt $( $rest:tt )* ) => { + $crate::routes!( @inverse $op : $first $( $rest )* ) + }; + ( @inverse $op:tt : $tt:tt $( $rest:tt )* ) => { + $crate::routes!( @rev $op : $tt [$($rest)*] ) + }; + ( @rev $op:tt : $tt:tt [ $first:tt $( $rest:tt)* ] $( $reversed:tt )* ) => { + $crate::routes!( @rev $op : $tt [ $( $rest )* ] $first $( $reversed )* ) + }; + ( @rev [$op:ident $( $args:tt )* ] : $handler:tt [] $($tt:tt)* ) => { + { + #[allow(unused_imports)] + use fastapi::{Path, __dev::{Tags, SchemaReferences}}; + $crate::paste! { + $( $tt :: )* [<__path_ $handler>]::$op $( $args )* + } + } + }; + ( ) => {}; +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + use axum::extract::State; + use fastapi::openapi::{Content, Ref, ResponseBuilder}; + use fastapi::PartialSchema; + use router::*; + + #[fastapi::path(get, path = "/")] + async fn root() {} + + // --- user + + #[fastapi::path(get, path = "/")] + async fn get_user() {} + + #[fastapi::path(post, path = "/")] + async fn post_user() {} + + #[fastapi::path(delete, path = "/")] + async fn delete_user() {} + + #[fastapi::path(get, path = "/search")] + async fn search_user() {} + + // --- customer + + #[fastapi::path(get, path = "/")] + async fn get_customer() {} + + #[fastapi::path(post, path = "/")] + async fn post_customer() {} + + #[fastapi::path(delete, path = "/")] + async fn delete_customer() {} + + // test that with state handler compiles + #[fastapi::path(get, path = "/search")] + async fn search_customer(State(_s): State) {} + + #[test] + fn axum_router_nest_openapi_routes_compile() { + let user_router: OpenApiRouter = OpenApiRouter::new() + .routes(routes!(search_user)) + .routes(routes!(get_user, post_user, delete_user)); + + let customer_router: OpenApiRouter = OpenApiRouter::new() + .routes(routes!(get_customer, post_customer, delete_customer)) + .routes(routes!(search_customer)) + .with_state(String::new()); + + let router = OpenApiRouter::new() + .nest("/api/user", user_router) + .nest("/api/customer", customer_router) + .route("/", axum::routing::get(root)); + + let _ = router.get_openapi(); + } + + #[test] + fn openapi_router_with_openapi() { + use fastapi::OpenApi; + + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Todo { + id: i32, + } + #[derive(fastapi::OpenApi)] + #[openapi(components(schemas(Todo)))] + struct Api; + + let mut router: OpenApiRouter = OpenApiRouter::with_openapi(Api::openapi()) + .routes(routes!(search_user)) + .routes(routes!(get_user)); + + let paths = router.to_openapi().paths; + let expected_paths = fastapi::openapi::path::PathsBuilder::new() + .path( + "/", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Get, + fastapi::openapi::path::OperationBuilder::new().operation_id(Some("get_user")), + ), + ) + .path( + "/search", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Get, + fastapi::openapi::path::OperationBuilder::new() + .operation_id(Some("search_user")), + ), + ); + assert_eq!(expected_paths.build(), paths); + } + + #[test] + fn openapi_router_nest_openapi() { + use fastapi::OpenApi; + + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Todo { + id: i32, + } + #[derive(fastapi::OpenApi)] + #[openapi(components(schemas(Todo)))] + struct Api; + + let router: router::OpenApiRouter = + router::OpenApiRouter::with_openapi(Api::openapi()).routes(routes!(search_user)); + + let customer_router: router::OpenApiRouter = router::OpenApiRouter::new() + .routes(routes!(get_customer)) + .with_state(String::new()); + + let mut router = router.nest("/api/customer", customer_router); + let paths = router.to_openapi().paths; + let expected_paths = fastapi::openapi::path::PathsBuilder::new() + .path( + "/api/customer", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Get, + fastapi::openapi::path::OperationBuilder::new() + .operation_id(Some("get_customer")), + ), + ) + .path( + "/search", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Get, + fastapi::openapi::path::OperationBuilder::new() + .operation_id(Some("search_user")), + ), + ); + assert_eq!(expected_paths.build(), paths); + } + + #[test] + fn openapi_with_auto_collected_schemas() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Todo { + id: i32, + } + + #[fastapi::path(get, path = "/todo", responses((status = 200, body = Todo)))] + async fn get_todo() {} + + let mut router: router::OpenApiRouter = + router::OpenApiRouter::new().routes(routes!(get_todo)); + + let openapi = router.to_openapi(); + let paths = openapi.paths; + let schemas = openapi + .components + .expect("Router must have auto collected schemas") + .schemas; + + let expected_paths = fastapi::openapi::path::PathsBuilder::new().path( + "/todo", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Get, + fastapi::openapi::path::OperationBuilder::new() + .operation_id(Some("get_todo")) + .response( + "200", + ResponseBuilder::new().content( + "application/json", + Content::builder() + .schema(Some(Ref::from_schema_name("Todo"))) + .build(), + ), + ), + ), + ); + let expected_schemas = + BTreeMap::from_iter(std::iter::once(("Todo".to_string(), Todo::schema()))); + assert_eq!(expected_paths.build(), paths); + assert_eq!(expected_schemas, schemas); + } + + mod pets { + + #[fastapi::path(get, path = "/")] + pub async fn get_pet() {} + + #[fastapi::path(post, path = "/")] + pub async fn post_pet() {} + + #[fastapi::path(delete, path = "/")] + pub async fn delete_pet() {} + } + + #[test] + fn openapi_routes_from_another_path() { + let mut router: OpenApiRouter = + OpenApiRouter::new().routes(routes!(pets::get_pet, pets::post_pet, pets::delete_pet)); + let paths = router.to_openapi().paths; + + let expected_paths = fastapi::openapi::path::PathsBuilder::new() + .path( + "/", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Get, + fastapi::openapi::path::OperationBuilder::new().operation_id(Some("get_pet")), + ), + ) + .path( + "/", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Post, + fastapi::openapi::path::OperationBuilder::new().operation_id(Some("post_pet")), + ), + ) + .path( + "/", + fastapi::openapi::PathItem::new( + fastapi::openapi::path::HttpMethod::Delete, + fastapi::openapi::path::OperationBuilder::new() + .operation_id(Some("delete_pet")), + ), + ); + assert_eq!(expected_paths.build(), paths); + } +} diff --git a/fastapi-axum/src/router.rs b/fastapi-axum/src/router.rs new file mode 100644 index 0000000..c67fc61 --- /dev/null +++ b/fastapi-axum/src/router.rs @@ -0,0 +1,432 @@ +//! Implements Router for composing handlers and collecting OpenAPI information. +use std::borrow::Cow; +use std::convert::Infallible; + +use axum::extract::Request; +use axum::handler::Handler; +use axum::response::IntoResponse; +use axum::routing::{MethodRouter, Route, RouterAsService}; +use axum::Router; +use tower_layer::Layer; +use tower_service::Service; + +#[inline] +fn colonized_params>(path: S) -> String +where + String: From, +{ + String::from(path).replace('}', "").replace('{', ":") +} + +#[inline] +fn path_template>(path: S) -> String { + path.as_ref() + .split('/') + .map(|segment| { + if !segment.is_empty() && segment[0..1] == *":" { + Cow::Owned(format!("{{{}}}", &segment[1..])) + } else { + Cow::Borrowed(segment) + } + }) + .collect::>() + .join("/") +} + +/// Wrapper type for [`fastapi::openapi::path::Paths`] and [`axum::routing::MethodRouter`]. +/// +/// This is used with [`OpenApiRouter::routes`] method to register current _`paths`_ to the +/// [`fastapi::openapi::OpenApi`] of [`OpenApiRouter`] instance. +/// +/// See [`routes`][routes] for usage. +/// +/// [routes]: ../macro.routes.html +pub type FastapiMethodRouter = ( + Vec<( + String, + fastapi::openapi::RefOr, + )>, + fastapi::openapi::path::Paths, + axum::routing::MethodRouter, +); + +/// Extension trait for [`FastapiMethodRouter`] to expose typically used methods of +/// [`axum::routing::MethodRouter`] and to extend [`FastapiMethodRouter`] with useful convenience +/// methods. +pub trait FastapiMethodRouterExt +where + S: Send + Sync + Clone + 'static, +{ + /// Pass through method for [`axum::routing::MethodRouter::layer`]. + /// + /// This method is provided as convenience for defining layers to [`axum::routing::MethodRouter`] + /// routes. + fn layer(self, layer: L) -> FastapiMethodRouter + where + L: Layer> + Clone + Send + 'static, + L::Service: Service + Clone + Send + 'static, + >::Response: IntoResponse + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + E: 'static, + S: 'static, + NewError: 'static; + + /// Pass through method for [`axum::routing::MethodRouter::with_state`]. + /// + /// Allows quick state definition for underlying [`axum::routing::MethodRouter`]. + fn with_state(self, state: S) -> FastapiMethodRouter; + + /// Convenience method that allows custom mapping for [`axum::routing::MethodRouter`] via + /// methods that not exposed directly through [`FastapiMethodRouterExt`]. + /// + /// This method could be used to add layers, route layers or fallback handlers for the method + /// router. + /// ```rust + /// # use fastapi_axum::{routes, router::{FastapiMethodRouter, FastapiMethodRouterExt}}; + /// # #[fastapi::path(get, path = "")] + /// # async fn search_user() {} + /// let _: FastapiMethodRouter = routes!(search_user).map(|method_router| { + /// // .. implementation here + /// method_router + /// }); + /// ``` + fn map( + self, + op: impl FnOnce(MethodRouter) -> MethodRouter, + ) -> FastapiMethodRouter; +} + +impl FastapiMethodRouterExt for FastapiMethodRouter +where + S: Send + Sync + Clone + 'static, +{ + fn layer(self, layer: L) -> FastapiMethodRouter + where + L: Layer> + Clone + Send + 'static, + L::Service: Service + Clone + Send + 'static, + >::Response: IntoResponse + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + E: 'static, + S: 'static, + NewError: 'static, + { + (self.0, self.1, self.2.layer(layer)) + } + + fn with_state(self, state: S) -> FastapiMethodRouter { + (self.0, self.1, self.2.with_state(state)) + } + + fn map( + self, + op: impl FnOnce(MethodRouter) -> MethodRouter, + ) -> FastapiMethodRouter { + (self.0, self.1, op(self.2)) + } +} + +/// A wrapper struct for [`axum::Router`] and [`fastapi::openapi::OpenApi`] for composing handlers +/// and services with collecting OpenAPI information from the handlers. +/// +/// This struct provides pass through implementation for most of the [`axum::Router`] methods and +/// extends capabilities for few to collect the OpenAPI information. Methods that are not +/// implemented can be easily called after converting this router to [`axum::Router`] by +/// [`Into::into`]. +/// +/// # Examples +/// +/// _**Create new [`OpenApiRouter`] with default values populated from cargo environment variables.**_ +/// ```rust +/// # use fastapi_axum::router::OpenApiRouter; +/// let _: OpenApiRouter = OpenApiRouter::new(); +/// ``` +/// +/// _**Instantiate a new [`OpenApiRouter`] with new empty [`fastapi::openapi::OpenApi`].**_ +/// ```rust +/// # use fastapi_axum::router::OpenApiRouter; +/// let _: OpenApiRouter = OpenApiRouter::default(); +/// ``` +#[derive(Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct OpenApiRouter(Router, fastapi::openapi::OpenApi); + +impl OpenApiRouter +where + S: Send + Sync + Clone + 'static, +{ + /// Instantiate a new [`OpenApiRouter`] with default values populated from cargo environment + /// variables. This creates an `OpenApi` similar of creating a new `OpenApi` via + /// `#[derive(OpenApi)]` + /// + /// If you want to create [`OpenApiRouter`] with completely empty [`fastapi::openapi::OpenApi`] + /// instance, use [`OpenApiRouter::default()`]. + pub fn new() -> OpenApiRouter { + use fastapi::OpenApi; + #[derive(OpenApi)] + struct Api; + + Self::with_openapi(Api::openapi()) + } + + /// Instantiates a new [`OpenApiRouter`] with given _`openapi`_ instance. + /// + /// This function allows using existing [`fastapi::openapi::OpenApi`] as source for this router. + /// + /// # Examples + /// + /// _**Use derived [`fastapi::openapi::OpenApi`] as source for [`OpenApiRouter`].**_ + /// ```rust + /// # use fastapi::OpenApi; + /// # use fastapi_axum::router::OpenApiRouter; + /// #[derive(fastapi::ToSchema)] + /// struct Todo { + /// id: i32, + /// } + /// #[derive(fastapi::OpenApi)] + /// #[openapi(components(schemas(Todo)))] + /// struct Api; + /// + /// let mut router: OpenApiRouter = OpenApiRouter::with_openapi(Api::openapi()); + /// ``` + pub fn with_openapi(openapi: fastapi::openapi::OpenApi) -> Self { + Self(Router::new(), openapi) + } + + /// Pass through method for [`axum::Router::as_service`]. + pub fn as_service(&mut self) -> RouterAsService<'_, B, S> { + self.0.as_service() + } + + /// Pass through method for [`axum::Router::fallback`]. + pub fn fallback(self, handler: H) -> Self + where + H: Handler, + T: 'static, + { + Self(self.0.fallback(handler), self.1) + } + + /// Pass through method for [`axum::Router::fallback_service`]. + pub fn fallback_service(self, service: T) -> Self + where + T: Service + Clone + Send + 'static, + T::Response: IntoResponse, + T::Future: Send + 'static, + { + Self(self.0.fallback_service(service), self.1) + } + + /// Pass through method for [`axum::Router::layer`]. + pub fn layer(self, layer: L) -> Self + where + L: Layer + Clone + Send + 'static, + L::Service: Service + Clone + Send + 'static, + >::Response: IntoResponse + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + { + Self(self.0.layer(layer), self.1) + } + + /// Register [`FastapiMethodRouter`] content created with [`routes`][routes] macro to `self`. + /// + /// Paths of the [`FastapiMethodRouter`] will be extended to [`fastapi::openapi::OpenApi`] and + /// [`axum::routing::MethodRouter`] will be added to the [`axum::Router`]. + /// + /// [routes]: ../macro.routes.html + pub fn routes(mut self, (schemas, mut paths, method_router): FastapiMethodRouter) -> Self { + let router = if paths.paths.len() == 1 { + let first_entry = &paths.paths.first_entry(); + let path = first_entry.as_ref().map(|path| path.key()); + let Some(path) = path else { + unreachable!("Whoopsie, I thought there was one Path entry"); + }; + let path = if path.is_empty() { "/" } else { path }; + + self.0.route(&colonized_params(path), method_router) + } else { + paths.paths.iter().fold(self.0, |this, (path, _)| { + let path = if path.is_empty() { "/" } else { path }; + this.route(&colonized_params(path), method_router.clone()) + }) + }; + + // add or merge current paths to the OpenApi + for (path, item) in paths.paths { + if let Some(it) = self.1.paths.paths.get_mut(&path) { + it.merge_operations(item); + } else { + self.1.paths.paths.insert(path, item); + } + } + + let components = self + .1 + .components + .get_or_insert(fastapi::openapi::Components::new()); + components.schemas.extend(schemas); + + Self(router, self.1) + } + + /// Pass through method for [`axum::Router::route`]. + pub fn route(self, path: &str, method_router: MethodRouter) -> Self { + Self(self.0.route(&colonized_params(path), method_router), self.1) + } + + /// Pass through method for [`axum::Router::route_layer`]. + pub fn route_layer(self, layer: L) -> Self + where + L: Layer + Clone + Send + 'static, + L::Service: Service + Clone + Send + 'static, + >::Response: IntoResponse + 'static, + >::Error: Into + 'static, + >::Future: Send + 'static, + { + Self(self.0.route_layer(layer), self.1) + } + + /// Pass through method for [`axum::Router::route_service`]. + pub fn route_service(self, path: &str, service: T) -> Self + where + T: Service + Clone + Send + 'static, + T::Response: IntoResponse, + T::Future: Send + 'static, + { + Self(self.0.route_service(path, service), self.1) + } + + /// Nest `router` to `self` under given `path`. Router routes will be nested with + /// [`axum::Router::nest`]. + /// + /// This method expects [`OpenApiRouter`] instance in order to nest OpenApi paths and router + /// routes. If you wish to use [`axum::Router::nest`] you need to first convert this instance + /// to [`axum::Router`] _(`let _: Router = OpenApiRouter::new().into()`)_. + /// + /// # Examples + /// + /// _**Nest two routers.**_ + /// ```rust + /// # use fastapi_axum::{routes, PathItemExt, router::OpenApiRouter}; + /// #[fastapi::path(get, path = "/search")] + /// async fn search() {} + /// + /// let search_router = OpenApiRouter::new() + /// .routes(fastapi_axum::routes!(search)); + /// + /// let router: OpenApiRouter = OpenApiRouter::new() + /// .nest("/api", search_router); + /// ``` + pub fn nest(self, path: &str, router: OpenApiRouter) -> Self { + // from axum::routing::path_router::path_for_nested_route + // method is private, so we need to replicate it here + fn path_for_nested_route<'a>(prefix: &'a str, path: &'a str) -> String { + debug_assert!(prefix.starts_with('/')); + debug_assert!(path.starts_with('/')); + + if prefix.ends_with('/') { + format!("{prefix}{}", path.trim_start_matches('/')).into() + } else if path == "/" { + prefix.into() + } else { + format!("{prefix}{path}").into() + } + } + + let api = self.1.nest_with_path_composer( + path_for_nested_route(path, "/"), + router.1, + |a: &str, b: &str| path_for_nested_route(a, b), + ); + let router = self.0.nest(&colonized_params(path), router.0); + + Self(router, api) + } + + /// Pass through method for [`axum::Router::nest_service`]. _**This does nothing for OpenApi paths.**_ + pub fn nest_service(self, path: &str, service: T) -> Self + where + T: Service + Clone + Send + 'static, + T::Response: IntoResponse, + T::Future: Send + 'static, + { + Self(self.0.nest_service(path, service), self.1) + } + + /// Merge [`fastapi::openapi::path::Paths`] from `router` to `self` and merge [`Router`] routes + /// and fallback with [`axum::Router::merge`]. + /// + /// This method expects [`OpenApiRouter`] instance in order to merge OpenApi paths and router + /// routes. If you wish to use [`axum::Router::merge`] you need to first convert this instance + /// to [`axum::Router`] _(`let _: Router = OpenApiRouter::new().into()`)_. + /// + /// # Examples + /// + /// _**Merge two routers.**_ + /// ```rust + /// # use fastapi_axum::{routes, PathItemExt, router::OpenApiRouter}; + /// #[fastapi::path(get, path = "/search")] + /// async fn search() {} + /// + /// let search_router = OpenApiRouter::new() + /// .routes(fastapi_axum::routes!(search)); + /// + /// let router: OpenApiRouter = OpenApiRouter::new() + /// .merge(search_router); + /// ``` + pub fn merge(mut self, router: OpenApiRouter) -> Self { + self.1.merge(router.1); + + Self(self.0.merge(router.0), self.1) + } + + /// Pass through method for [`axum::Router::with_state`]. + pub fn with_state(self, state: S) -> OpenApiRouter { + OpenApiRouter(self.0.with_state(state), self.1) + } + + /// Consume `self` returning the [`fastapi::openapi::OpenApi`] instance of the + /// [`OpenApiRouter`]. + pub fn into_openapi(self) -> fastapi::openapi::OpenApi { + self.1 + } + + /// Take the [`fastapi::openapi::OpenApi`] instance without consuming the [`OpenApiRouter`]. + pub fn to_openapi(&mut self) -> fastapi::openapi::OpenApi { + std::mem::take(&mut self.1) + } + + /// Get reference to the [`fastapi::openapi::OpenApi`] instance of the router. + pub fn get_openapi(&self) -> &fastapi::openapi::OpenApi { + &self.1 + } + + /// Split the content of the [`OpenApiRouter`] to parts. Method will return a tuple of + /// inner [`axum::Router`] and [`fastapi::openapi::OpenApi`]. + pub fn split_for_parts(self) -> (axum::Router, fastapi::openapi::OpenApi) { + (self.0, self.1) + } +} + +impl Default for OpenApiRouter +where + S: Send + Sync + Clone + 'static, +{ + fn default() -> Self { + Self::with_openapi(fastapi::openapi::OpenApiBuilder::new().build()) + } +} + +impl From> for Router { + fn from(value: OpenApiRouter) -> Self { + value.0 + } +} + +impl From> for OpenApiRouter { + fn from(value: Router) -> Self { + OpenApiRouter(value, fastapi::openapi::OpenApiBuilder::new().build()) + } +} diff --git a/fastapi-config/Cargo.toml b/fastapi-config/Cargo.toml new file mode 100644 index 0000000..73b55b8 --- /dev/null +++ b/fastapi-config/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "fastapi-config" +description = "Config for controlling fastapi's various aspects" +version = "0.1.2" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["fastapi", "config", "fastapi-gen", "openapi", "auto-generate"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } + +[package.metadata.docs.rs] +features = [] +rustdoc-args = ["--cfg", "doc_cfg"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi-config/LICENSE-APACHE b/fastapi-config/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-config/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-config/LICENSE-MIT b/fastapi-config/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-config/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-config/README.md b/fastapi-config/README.md new file mode 100644 index 0000000..72576f3 --- /dev/null +++ b/fastapi-config/README.md @@ -0,0 +1,54 @@ +# fastapi-config + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-config.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-config) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-config&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-config/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +This crate provides global configuration capabilities for `fastapi`. + +## Config options + +* Define rust type aliases for `fastapi` with `.alias_for(...)` method. +* Define schema collect mode for `fastapi` with `.schema_collect(...)` method. + * `SchemaCollect:All` will collect all schemas from usages including inlined with `inline(T)` + * `SchemaCollect::NonInlined` will only collect non inlined schemas from usages. + +> [!WARNING] +> The build config will be stored to projects `OUTPUT` directory. It is then read from there via `OUTPUT` environment +> variable which will return **any instance** rust compiler might find at that time (Whatever the `OUTPUT` environment variable points to). +> **Be aware** that sometimes you might face a situation where the config is not aligned with your Rust aliases. +> This might need you to change something on your code before changed config might apply. + +## Install + +Add dependency declaration to `Cargo.toml`. + +```toml +[build-dependencies] +fastapi-config = "0.1" +``` + +## Examples + +Create `build.rs` file with following content, then in your code you can just use `MyType` as +alternative for `i32`. + +```rust +use fastapi_config::Config; + +fn main() { + Config::new() + .alias_for("MyType", "i32") + .write_to_file(); +} +``` + +See full [example for fastapi-config](../examples/fastapi-config-test/). + +## License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. diff --git a/fastapi-config/config-test-crate/Cargo.toml b/fastapi-config/config-test-crate/Cargo.toml new file mode 100644 index 0000000..b925572 --- /dev/null +++ b/fastapi-config/config-test-crate/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "fastapi-config-test" +version = "0.1.0" +edition = "2021" +rust-version = "1.75" + +[dependencies] +fastapi = { version = "0.1.1", path = "../../fastapi", features = [ + "debug", + "config", +] } +serde = "1" +serde_json = "1" + +[build-dependencies] +fastapi-config = { version = "0.1", path = "../../fastapi-config" } + +[dev-dependencies] +fastapi-config = { version = "0.1", path = "../../fastapi-config" } + +[workspace] diff --git a/fastapi-config/config-test-crate/README.md b/fastapi-config/config-test-crate/README.md new file mode 100644 index 0000000..b35341b --- /dev/null +++ b/fastapi-config/config-test-crate/README.md @@ -0,0 +1,4 @@ +# fastapi-config-test + +This example demonstrates global Rust type aliases in fastapi project. +Check out `main.rs` and `build.rs` and then run `cargo run`. diff --git a/fastapi-config/config-test-crate/build.rs b/fastapi-config/config-test-crate/build.rs new file mode 100644 index 0000000..614deb1 --- /dev/null +++ b/fastapi-config/config-test-crate/build.rs @@ -0,0 +1,10 @@ +fn main() { + fastapi_config::Config::new() + .alias_for("MyType", "bool") + .alias_for("MyInt", "Option") + .alias_for("MyValue", "str") + .alias_for("MyDateTime", "String") + .alias_for("EntryAlias", "Entry") + .alias_for("EntryString", "Entry") + .write_to_file() +} diff --git a/fastapi-config/config-test-crate/src/main.rs b/fastapi-config/config-test-crate/src/main.rs new file mode 100644 index 0000000..e6b4d28 --- /dev/null +++ b/fastapi-config/config-test-crate/src/main.rs @@ -0,0 +1,37 @@ +use fastapi::ToSchema; + +#[allow(unused)] +#[derive(ToSchema)] +struct AliasValues { + name: String, + + #[schema(value_type = MyType)] + my_type: String, + + #[schema(value_type = MyInt)] + my_int: String, + + #[schema(value_type = MyValue)] + my_value: bool, + + date: MyDateTime, + + optional_date: Option, +} + +#[allow(unused)] +struct MyDateTime { + millis: usize, +} + +fn main() { + let schema = fastapi::schema!( + #[inline] + AliasValues + ); + + println!( + "{}", + serde_json::to_string_pretty(&schema).expect("schema must be JSON serializable") + ); +} diff --git a/fastapi-config/config-test-crate/tests/config.rs b/fastapi-config/config-test-crate/tests/config.rs new file mode 100644 index 0000000..384aa16 --- /dev/null +++ b/fastapi-config/config-test-crate/tests/config.rs @@ -0,0 +1,180 @@ +use std::borrow::Cow; + +use fastapi::{OpenApi, ToSchema}; +use fastapi_config::{Config, SchemaCollect}; + +#[test] +fn test_create_config_with_aliases() { + let config: Config<'_> = Config::new().alias_for("i32", "Option"); + let json = serde_json::to_string(&config).expect("config is json serializable"); + + let config: Config = serde_json::from_str(&json).expect("config is json deserializable"); + + assert!(!config.aliases.is_empty()); + assert!(config.aliases.contains_key("i32")); + assert_eq!( + config.aliases.get("i32"), + Some(&Cow::Borrowed("Option")) + ); +} + +#[test] +fn test_config_with_collect_all() { + let config: Config<'_> = Config::new().schema_collect(fastapi_config::SchemaCollect::All); + let json = serde_json::to_string(&config).expect("config is json serializable"); + + let config: Config = serde_json::from_str(&json).expect("config is json deserializable"); + + assert!(matches!(config.schema_collect, SchemaCollect::All)); +} + +#[test] +fn test_to_schema_with_aliases() { + #[allow(unused)] + #[derive(ToSchema)] + struct AliasValues { + name: String, + + #[schema(value_type = MyType)] + my_type: String, + + #[schema(value_type = MyInt)] + my_int: String, + + #[schema(value_type = MyValue)] + my_value: bool, + + date: MyDateTime, + } + + #[allow(unused)] + struct MyDateTime { + millis: usize, + } + + let schema = fastapi::schema!( + #[inline] + AliasValues + ); + + let actual = serde_json::to_string_pretty(&schema).expect("schema must be JSON serializable"); + + let expected = r#"{ + "type": "object", + "required": [ + "name", + "my_type", + "my_value", + "date" + ], + "properties": { + "date": { + "type": "string" + }, + "my_int": { + "type": [ + "integer", + "null" + ], + "format": "int32" + }, + "my_type": { + "type": "boolean" + }, + "my_value": { + "type": "string" + }, + "name": { + "type": "string" + } + } +}"#; + + println!("{actual}"); + assert_eq!(expected.trim(), actual.trim()) +} + +#[test] +fn test_schema_with_enum_aliases() { + #![allow(unused)] + + #[derive(OpenApi)] + #[openapi(components(schemas(Transactions)))] + struct ApiDoc; + + #[derive(ToSchema)] + pub enum Transactions { + Transaction(EntryAlias), + TransactionEntryString(EntryString), + } + + pub type EntryAlias = Entry; + pub type EntryString = Entry; + + #[derive(ToSchema)] + pub struct Entry { + pub entry_id: I, + } + + let api = ApiDoc::openapi(); + let value = serde_json::to_value(api).expect("OpenApi must be JSON serializable"); + let schemas = value + .pointer("/components/schemas") + .expect("Must have schemas"); + + let expected = r###"{ + "Entry_String": { + "properties": { + "entry_id": { + "type": "string" + } + }, + "required": [ + "entry_id" + ], + "type": "object" + }, + "Entry_i32": { + "properties": { + "entry_id": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "entry_id" + ], + "type": "object" + }, + "Transactions": { + "oneOf": [ + { + "properties": { + "Transaction": { + "$ref": "#/components/schemas/Entry_i32" + } + }, + "required": [ + "Transaction" + ], + "type": "object" + }, + { + "properties": { + "TransactionEntryString": { + "$ref": "#/components/schemas/Entry_String" + } + }, + "required": [ + "TransactionEntryString" + ], + "type": "object" + } + ] + } +}"###; + assert_eq!( + serde_json::to_string_pretty(schemas).unwrap().trim(), + expected + ); +} diff --git a/fastapi-config/src/lib.rs b/fastapi-config/src/lib.rs new file mode 100644 index 0000000..0ef7572 --- /dev/null +++ b/fastapi-config/src/lib.rs @@ -0,0 +1,229 @@ +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![cfg_attr(doc_cfg, feature(doc_cfg))] +//! This crate provides global configuration capabilities for [`fastapi`](https://docs.rs/fastapi/latest/fastapi/). +//! +//! ## Config options +//! +//! * Define rust type aliases for `fastapi` with `.alias_for(...)` method. +//! * Define schema collect mode for `fastapi` with `.schema_collect(...)` method. +//! * [`SchemaCollect::All`] will collect all schemas from usages including inlined with `inline(T)` +//! * [`SchemaCollect::NonInlined`] will only collect non inlined schemas from usages. +//! +//!
+//! +//! Warning!
+//! The build config will be stored to projects `OUTPUT` directory. It is then read from there via `OUTPUT` environment +//! variable which will return **any instance** rust compiler might find at that time (Whatever the `OUTPUT` environment variable points to). +//! **Be aware** that sometimes you might face a situation where the config is not aligned with your Rust aliases. +//! This might need you to change something on your code before changed config might apply. +//! +//!
+//! +//! ## Install +//! +//! Add dependency declaration to `Cargo.toml`. +//! +//! ```toml +//! [build-dependencies] +//! fastapi-config = "0.1" +//! ``` +//! +//! ## Examples +//! +//! _**Create `build.rs` file with following content, then in your code you can just use `MyType` as +//! alternative for `i32`.**_ +//! +//! ```rust +//! # #![allow(clippy::needless_doctest_main)] +//! use fastapi_config::Config; +//! +//! fn main() { +//! Config::new() +//! .alias_for("MyType", "i32") +//! .write_to_file(); +//! } +//! ``` +//! +//! See full [example for fastapi-config](https://github.com/nxpkg/fastapi/tree/master/examples/fastapi-config-test/). + +use std::borrow::Cow; +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; + +use serde::de::Visitor; +use serde::{Deserialize, Serialize}; + +/// Global configuration initialized in `build.rs` of user project. +/// +/// This works similar fashion to what `hyperium/tonic` grpc library does with the project configuration. See +/// the quick usage from [module documentation][module] +/// +/// [module]: ./index.html +#[derive(Default, Serialize, Deserialize)] +#[non_exhaustive] +pub struct Config<'c> { + /// A map of global aliases `fastapi` will recognize as types. + #[doc(hidden)] + pub aliases: HashMap, Cow<'c, str>>, + /// Schema collect mode for `fastapi`. By default only non inlined schemas are collected. + pub schema_collect: SchemaCollect, +} + +/// Configures schema collect mode. By default only non explicitly inlined schemas are collected. +/// but this behavior can be changed to collect also inlined schemas by setting +/// [`SchemaCollect::All`]. +#[derive(Default)] +pub enum SchemaCollect { + /// Makes sure that all schemas from usages are collected including inlined. + All, + /// Collect only non explicitly inlined schemas to the OpenAPI. This will result smaller schema + /// foot print in the OpenAPI if schemas are typically inlined with `inline(T)` on usage. + #[default] + NonInlined, +} + +impl Serialize for SchemaCollect { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::All => serializer.serialize_str("all"), + Self::NonInlined => serializer.serialize_str("non_inlined"), + } + } +} + +impl<'de> Deserialize<'de> for SchemaCollect { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct SchemaCollectVisitor; + impl<'d> Visitor<'d> for SchemaCollectVisitor { + type Value = SchemaCollect; + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("expected str `all` or `non_inlined`") + } + + fn visit_str(self, v: &str) -> Result + where + E: serde::de::Error, + { + if v == "all" { + Ok(SchemaCollect::All) + } else { + Ok(SchemaCollect::NonInlined) + } + } + } + + deserializer.deserialize_str(SchemaCollectVisitor) + } +} + +impl<'c> Config<'c> { + const NAME: &'static str = "fastapi-config.json"; + + /// Construct a new [`Config`]. + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Add new global alias. + /// + /// This method accepts two arguments. First being identifier of the user's type alias. + /// Second is the type path definition to be used as alias value. The _`value`_ can be anything + /// that `fastapi` can parse as `TypeTree` and can be used as type for a value. + /// + /// Because of `TypeTree` the aliased value can also be a fairly complex type and not limited + /// to primitive types. This also allows users create custom types which can be treated as + /// primitive types. E.g. One could create custom date time type that is treated as chrono's + /// DateTime or a String. + /// + /// # Examples + /// + /// _**Create `MyType` alias for `i32`.**_ + /// ```rust + /// use fastapi_config::Config; + /// + /// let _ = Config::new() + /// .alias_for("MyType", "i32"); + /// ``` + /// + /// _**Create `Json` alias for `serde_json::Value`.**_ + /// ```rust + /// use fastapi_config::Config; + /// + /// let _ = Config::new() + /// .alias_for("Json", "Value"); + /// ``` + /// _**Create `NullableString` alias for `Option`.**_ + /// ```rust + /// use fastapi_config::Config; + /// + /// let _ = Config::new() + /// .alias_for("NullableString", "Option"); + /// ``` + pub fn alias_for(mut self, alias: &'c str, value: &'c str) -> Config<'c> { + self.aliases + .insert(Cow::Borrowed(alias), Cow::Borrowed(value)); + + self + } + + /// Define schema collect mode for `fastapi`. + /// + /// Method accepts one argument [`SchemaCollect`] which defines the collect mode to be used by + /// `utiopa`. If none is defined [`SchemaCollect::NonInlined`] schemas will be collected by + /// default. + /// + /// This can be changed to [`SchemaCollect::All`] if schemas called with `inline(T)` is wished + /// to be collected to the resulting OpenAPI. + pub fn schema_collect(mut self, schema_collect: SchemaCollect) -> Self { + self.schema_collect = schema_collect; + + self + } + + fn get_out_dir() -> Option { + match std::env::var("OUT_DIR") { + Ok(out_dir) => Some(out_dir), + Err(_) => None, + } + } + + /// Write the current [`Config`] to a file. This persists the [`Config`] for `fastapi` to read + /// and use later. + pub fn write_to_file(&self) { + let json = serde_json::to_string(self).expect("Config must be JSON serializable"); + + let Some(out_dir) = Config::get_out_dir() else { + return; + }; + + match fs::write([&*out_dir, Config::NAME].iter().collect::(), json) { + Ok(_) => (), + Err(error) => panic!("Failed to write config {}, error: {error}", Config::NAME), + }; + } + + /// Read a [`Config`] from a file. Used internally by `utiopa`. + #[doc(hidden)] + pub fn read_from_file() -> Config<'c> { + let Some(out_dir) = Config::get_out_dir() else { + return Config::default(); + }; + + let str = match fs::read_to_string([&*out_dir, Config::NAME].iter().collect::()) { + Ok(str) => str, + Err(error) => panic!("Failed to read config: {}, error: {error}", Config::NAME), + }; + + serde_json::from_str(&str).expect("Config muts be JSON deserializable") + } +} diff --git a/fastapi-gen/Cargo.toml b/fastapi-gen/Cargo.toml new file mode 100644 index 0000000..611fc35 --- /dev/null +++ b/fastapi-gen/Cargo.toml @@ -0,0 +1,74 @@ +[package] +name = "fastapi-gen" +description = "Code generation implementation for fastapi" +version = "0.1.1" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["openapi", "codegen", "proc-macro", "documentation", "compile-time"] +repository = "https://github.com/nxpkg/fastapi" +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[lib] +proc-macro = true + +[dependencies] +fastapi-config = { version = "0.1", path = "../fastapi-config", optional = true } +once_cell = { version = "1.19.0", optional = true } +proc-macro2 = "1.0" +syn = { version = "2.0", features = ["full", "extra-traits"] } +quote = "1.0" +regex = { version = "1.7", optional = true } +uuid = { version = "1", features = ["serde"], optional = true } +ulid = { version = "1", optional = true, default-features = false } +url = { version = "2", optional = true } + +[dev-dependencies] +fastapi = { path = "../fastapi", features = [ + "debug", + "uuid", + "macros", +], default-features = false } +serde_json = "1" +serde = "1" +actix-web = { version = "4", features = ["macros"], default-features = false } +axum = { version = "0.7", default-features = false, features = [ + "json", + "query", +] } +paste = "1" +rocket = { version = "0.5", features = ["json"] } +smallvec = { version = "1.10", features = ["serde"] } +rust_decimal = { version = "1", default-features = false } +chrono = { version = "0.4", features = ["serde"] } +assert-json-diff = "2" +time = { version = "0.3", features = ["serde-human-readable"] } +serde_with = "3.0" + +[features] +# See README.md for list and explanations of features +debug = ["syn/extra-traits"] +actix_extras = ["regex", "syn/extra-traits"] +chrono = [] +yaml = [] +decimal = [] +decimal_float = [] +rocket_extras = ["regex", "syn/extra-traits"] +non_strict_integers = [] +uuid = ["dep:uuid"] +ulid = ["dep:ulid"] +url = ["dep:url"] +axum_extras = ["regex", "syn/extra-traits"] +time = [] +smallvec = [] +repr = [] +indexmap = [] +rc_schema = [] +config = ["dep:fastapi-config", "dep:once_cell"] + +# EXPERIEMENTAL! use with cauntion +auto_into_responses = [] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } \ No newline at end of file diff --git a/fastapi-gen/LICENSE-APACHE b/fastapi-gen/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-gen/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-gen/LICENSE-MIT b/fastapi-gen/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-gen/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-gen/README.md b/fastapi-gen/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/fastapi-gen/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/fastapi-gen/src/component.rs b/fastapi-gen/src/component.rs new file mode 100644 index 0000000..e1d28cf --- /dev/null +++ b/fastapi-gen/src/component.rs @@ -0,0 +1,1616 @@ +use std::borrow::Cow; + +use proc_macro2::{Ident, Span, TokenStream}; +use quote::{quote, quote_spanned, ToTokens}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::token::Comma; +use syn::{ + AngleBracketedGenericArguments, Attribute, GenericArgument, GenericParam, Generics, Path, + PathArguments, PathSegment, Type, TypePath, +}; + +use crate::doc_comment::CommentAttributes; +use crate::schema_type::{KnownFormat, PrimitiveType, SchemaTypeInner}; +use crate::{ + as_tokens_or_diagnostics, Array, AttributesExt, Diagnostics, GenericsExt, OptionExt, + ToTokensDiagnostics, +}; +use crate::{schema_type::SchemaType, Deprecated}; + +use self::features::attributes::{Description, Nullable}; +use self::features::validation::Minimum; +use self::features::{ + pop_feature, Feature, FeaturesExt, IntoInner, IsInline, ToTokensExt, Validatable, +}; +use self::serde::{RenameRule, SerdeContainer, SerdeValue}; + +pub mod into_params; + +pub mod features; +pub mod schema; +pub mod serde; + +/// Check whether either serde `container_rule` or `field_rule` has _`default`_ attribute set. +#[inline] +fn is_default(container_rules: &SerdeContainer, field_rule: &SerdeValue) -> bool { + container_rules.default || field_rule.default +} + +/// Find `#[deprecated]` attribute from given attributes. Typically derive type attributes +/// or field attributes of struct. +fn get_deprecated(attributes: &[Attribute]) -> Option { + if attributes.has_deprecated() { + Some(Deprecated::True) + } else { + None + } +} + +/// Check whether field is required based on following rules. +/// +/// * If field has not serde's `skip_serializing_if` +/// * Field has not `serde_with` double option +/// * Field is not default +pub fn is_required(field_rule: &SerdeValue, container_rules: &SerdeContainer) -> bool { + !field_rule.skip_serializing_if + && !field_rule.double_option + && !is_default(container_rules, field_rule) +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum TypeTreeValue<'t> { + TypePath(&'t TypePath), + Path(&'t Path), + /// Slice and array types need to be manually defined, since they cannot be recognized from + /// generic arguments. + Array(Vec>, Span), + UnitType, + Tuple(Vec>, Span), +} + +impl PartialEq for TypeTreeValue<'_> { + fn eq(&self, other: &Self) -> bool { + match self { + Self::Path(_) => self == other, + Self::TypePath(_) => self == other, + Self::Array(array, _) => matches!(other, Self::Array(other, _) if other == array), + Self::Tuple(tuple, _) => matches!(other, Self::Tuple(other, _) if other == tuple), + Self::UnitType => self == other, + } + } +} + +enum TypeTreeValueIter<'a, T> { + Once(std::iter::Once), + Empty, + Iter(Box + 'a>), +} + +impl<'a, T> TypeTreeValueIter<'a, T> { + fn once(item: T) -> Self { + Self::Once(std::iter::once(item)) + } + + fn empty() -> Self { + Self::Empty + } +} + +impl<'a, T> Iterator for TypeTreeValueIter<'a, T> { + type Item = T; + + fn next(&mut self) -> Option { + match self { + Self::Once(iter) => iter.next(), + Self::Empty => None, + Self::Iter(iter) => iter.next(), + } + } + + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Once(once) => once.size_hint(), + Self::Empty => (0, None), + Self::Iter(iter) => iter.size_hint(), + } + } +} + +/// [`TypeTree`] of items which represents a single parsed `type` of a +/// `Schema`, `Parameter` or `FnArg` +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub struct TypeTree<'t> { + pub path: Option>, + #[allow(unused)] + pub span: Option, + pub value_type: ValueType, + pub generic_type: Option, + pub children: Option>>, +} + +pub trait SynPathExt { + /// Rewrite path will perform conditional substitution over the current path replacing + /// [`PathSegment`]s and [`syn::Ident`] with aliases if found via [`TypeTree::get_alias_type`] + /// or by [`PrimitiveType`] if type in question is known to be a primitive type. + fn rewrite_path(&self) -> Result; +} + +impl<'p> SynPathExt for &'p Path { + fn rewrite_path(&self) -> Result { + let last_segment = self + .segments + .last() + .expect("syn::Path must have at least one segment"); + + let mut segment = last_segment.clone(); + if let PathArguments::AngleBracketed(anglebracketed_args) = &last_segment.arguments { + let args = anglebracketed_args.args.iter().try_fold( + Punctuated::::new(), + |mut args, generic_arg| { + match generic_arg { + GenericArgument::Type(ty) => { + let type_tree = TypeTree::from_type(ty)?; + let alias_type = type_tree.get_alias_type()?; + let alias_type_tree = + alias_type.as_ref().map(TypeTree::from_type).transpose()?; + let type_tree = alias_type_tree.unwrap_or(type_tree); + + let path = type_tree + .path + .as_ref() + .expect("TypeTree must have a path") + .as_ref(); + + if let Some(default_type) = PrimitiveType::new(path) { + args.push(GenericArgument::Type(default_type.ty.clone())); + } else { + let inner = path.rewrite_path()?; + args.push(GenericArgument::Type(syn::Type::Path( + syn::parse_quote!(#inner), + ))) + } + } + other => args.push(other.clone()), + } + + Result::<_, Diagnostics>::Ok(args) + }, + )?; + + let angle_bracket_args = AngleBracketedGenericArguments { + args, + lt_token: anglebracketed_args.lt_token, + gt_token: anglebracketed_args.gt_token, + colon2_token: anglebracketed_args.colon2_token, + }; + + segment.arguments = PathArguments::AngleBracketed(angle_bracket_args); + } + + let segment_ident = &segment.ident; + let segment_type: Type = syn::parse_quote!(#segment_ident); + let type_tree = TypeTree::from_type(&segment_type)?; + let alias_type = type_tree.get_alias_type()?; + let alias_type_tree = alias_type.as_ref().map(TypeTree::from_type).transpose()?; + let type_tree = alias_type_tree.unwrap_or(type_tree); + + let path = type_tree + .path + .as_ref() + .expect("TypeTree for ident must have a path") + .as_ref(); + + if let Some(default_type) = PrimitiveType::new(path) { + let ty = &default_type.ty; + let ident: Ident = syn::parse_quote!(#ty); + + segment.ident = ident; + } else { + let ident = path + .get_ident() + .expect("Path of Ident must have Ident") + .clone(); + segment.ident = ident; + } + + let path = syn::Path { + segments: if last_segment == &segment { + self.segments.clone() + } else { + Punctuated::from_iter(std::iter::once(segment)) + }, + leading_colon: self.leading_colon, + }; + + Ok(path) + } +} + +impl TypeTree<'_> { + pub fn from_type(ty: &Type) -> Result, Diagnostics> { + Self::convert_types(Self::get_type_tree_values(ty)?).map(|mut type_tree| { + type_tree + .next() + .expect("TypeTree from type should have one TypeTree parent") + }) + } + + fn get_type_tree_values( + ty: &Type, + ) -> Result>, Diagnostics> { + let type_tree_values = match ty { + Type::Path(path) => { + TypeTreeValueIter::once(TypeTreeValue::TypePath(path)) + }, + // NOTE have to put this in the box to avoid compiler bug with recursive functions + // See here https://github.com/rust-lang/rust/pull/110844 and https://github.com/rust-lang/rust/issues/111906 + // This bug in fixed in Rust 1.79, but in order to support Rust 1.75 these need to be + // boxed. + Type::Reference(reference) => TypeTreeValueIter::Iter(Box::new(Self::get_type_tree_values(reference.elem.as_ref())?)), + // Type::Reference(reference) => Self::get_type_tree_values(reference.elem.as_ref())?, + Type::Tuple(tuple) => { + // Detect unit type () + if tuple.elems.is_empty() { return Ok(TypeTreeValueIter::once(TypeTreeValue::UnitType)) } + TypeTreeValueIter::once(TypeTreeValue::Tuple( + tuple.elems.iter().map(Self::get_type_tree_values).collect::, Diagnostics>>()?.into_iter().flatten().collect(), + tuple.span() + )) + }, + // NOTE have to put this in the box to avoid compiler bug with recursive functions + // See here https://github.com/rust-lang/rust/pull/110844 and https://github.com/rust-lang/rust/issues/111906 + // This bug in fixed in Rust 1.79, but in order to support Rust 1.75 these need to be + // boxed. + Type::Group(group) => TypeTreeValueIter::Iter(Box::new(Self::get_type_tree_values(group.elem.as_ref())?)), + // Type::Group(group) => Self::get_type_tree_values(group.elem.as_ref())?, + Type::Slice(slice) => TypeTreeValueIter::once(TypeTreeValue::Array(Self::get_type_tree_values(&slice.elem)?.collect(), slice.bracket_token.span.join())), + Type::Array(array) => TypeTreeValueIter::once(TypeTreeValue::Array(Self::get_type_tree_values(&array.elem)?.collect(), array.bracket_token.span.join())), + Type::TraitObject(trait_object) => { + trait_object + .bounds + .iter() + .find_map(|bound| { + match &bound { + syn::TypeParamBound::Trait(trait_bound) => Some(&trait_bound.path), + syn::TypeParamBound::Lifetime(_) => None, + syn::TypeParamBound::Verbatim(_) => None, + _ => todo!("TypeTree trait object found unrecognized TypeParamBound"), + } + }) + .map(|path| TypeTreeValueIter::once(TypeTreeValue::Path(path))).unwrap_or_else(TypeTreeValueIter::empty) + } + unexpected => return Err(Diagnostics::with_span(unexpected.span(), "unexpected type in component part get type path, expected one of: Path, Tuple, Reference, Group, Array, Slice, TraitObject")), + }; + + Ok(type_tree_values) + } + + fn convert_types<'p, P: IntoIterator>>( + paths: P, + ) -> Result>, Diagnostics> { + paths + .into_iter() + .map(|value| { + let path = match value { + TypeTreeValue::TypePath(type_path) => &type_path.path, + TypeTreeValue::Path(path) => path, + TypeTreeValue::Array(value, span) => { + let array: Path = Ident::new("Array", span).into(); + return Ok(TypeTree { + path: Some(Cow::Owned(array)), + span: Some(span), + value_type: ValueType::Object, + generic_type: Some(GenericType::Vec), + children: Some(match Self::convert_types(value) { + Ok(converted_values) => converted_values.collect(), + Err(diagnostics) => return Err(diagnostics), + }), + }); + } + TypeTreeValue::Tuple(tuple, span) => { + return Ok(TypeTree { + path: None, + span: Some(span), + children: Some(match Self::convert_types(tuple) { + Ok(converted_values) => converted_values.collect(), + Err(diagnostics) => return Err(diagnostics), + }), + generic_type: None, + value_type: ValueType::Tuple, + }) + } + TypeTreeValue::UnitType => { + return Ok(TypeTree { + path: None, + span: None, + value_type: ValueType::Tuple, + generic_type: None, + children: None, + }) + } + }; + + // there will always be one segment at least + let last_segment = path + .segments + .last() + .expect("at least one segment within path in TypeTree::convert_types"); + + if last_segment.arguments.is_empty() { + Ok(Self::convert(path, last_segment)) + } else { + Self::resolve_schema_type(path, last_segment) + } + }) + .collect::>, Diagnostics>>() + .map(IntoIterator::into_iter) + } + + // Only when type is a generic type we get to this function. + fn resolve_schema_type<'t>( + path: &'t Path, + last_segment: &'t PathSegment, + ) -> Result, Diagnostics> { + if last_segment.arguments.is_empty() { + return Err(Diagnostics::with_span( + last_segment.ident.span(), + "expected at least one angle bracket argument but was 0", + )); + }; + + let mut generic_schema_type = Self::convert(path, last_segment); + + let mut generic_types = match &last_segment.arguments { + PathArguments::AngleBracketed(angle_bracketed_args) => { + // if all type arguments are lifetimes we ignore the generic type + if angle_bracketed_args.args.iter().all(|arg| { + matches!( + arg, + GenericArgument::Lifetime(_) | GenericArgument::Const(_) + ) + }) { + None + } else { + Some( + angle_bracketed_args + .args + .iter() + .filter(|arg| { + !matches!( + arg, + GenericArgument::Lifetime(_) | GenericArgument::Const(_) + ) + }) + .map(|arg| match arg { + GenericArgument::Type(arg) => Ok(arg), + unexpected => Err(Diagnostics::with_span( + unexpected.span(), + "expected generic argument type or generic argument lifetime", + )), + }) + .collect::, Diagnostics>>()? + .into_iter(), + ) + } + } + _ => { + return Err(Diagnostics::with_span( + last_segment.ident.span(), + "unexpected path argument, expected angle bracketed path argument", + )) + } + }; + + generic_schema_type.children = generic_types.as_mut().map_try(|generic_type| { + generic_type + .map(Self::from_type) + .collect::, Diagnostics>>() + })?; + + Ok(generic_schema_type) + } + + fn convert<'t>(path: &'t Path, last_segment: &'t PathSegment) -> TypeTree<'t> { + let generic_type = Self::get_generic_type(last_segment); + let schema_type = SchemaType { + path: Cow::Borrowed(path), + nullable: matches!(generic_type, Some(GenericType::Option)), + }; + + TypeTree { + path: Some(Cow::Borrowed(path)), + span: Some(path.span()), + value_type: if schema_type.is_primitive() { + ValueType::Primitive + } else if schema_type.is_value() { + ValueType::Value + } else { + ValueType::Object + }, + generic_type, + children: None, + } + } + + // TODO should we recognize unknown generic types with `GenericType::Unknown` instead of `None`? + fn get_generic_type(segment: &PathSegment) -> Option { + if segment.arguments.is_empty() { + return None; + } + + match &*segment.ident.to_string() { + "HashMap" | "Map" | "BTreeMap" => Some(GenericType::Map), + #[cfg(feature = "indexmap")] + "IndexMap" => Some(GenericType::Map), + "Vec" => Some(GenericType::Vec), + "BTreeSet" | "HashSet" => Some(GenericType::Set), + "LinkedList" => Some(GenericType::LinkedList), + #[cfg(feature = "smallvec")] + "SmallVec" => Some(GenericType::SmallVec), + "Option" => Some(GenericType::Option), + "Cow" => Some(GenericType::Cow), + "Box" => Some(GenericType::Box), + #[cfg(feature = "rc_schema")] + "Arc" => Some(GenericType::Arc), + #[cfg(feature = "rc_schema")] + "Rc" => Some(GenericType::Rc), + "RefCell" => Some(GenericType::RefCell), + _ => None, + } + } + + /// Check whether [`TypeTreeValue`]'s [`syn::TypePath`] or any if it's `children`s [`syn::TypePath`] + /// is a given type as [`str`]. + pub fn is(&self, s: &str) -> bool { + let mut is = self + .path + .as_ref() + .map(|path| { + path.segments + .last() + .expect("expected at least one segment in TreeTypeValue path") + .ident + == s + }) + .unwrap_or(false); + + if let Some(ref children) = self.children { + is = is || children.iter().any(|child| child.is(s)); + } + + is + } + + /// `Object` virtual type is used when generic object is required in OpenAPI spec. Typically used + /// with `value_type` attribute to hinder the actual type. + pub fn is_object(&self) -> bool { + self.is("Object") + } + + /// `Value` virtual type is used when any JSON value is required in OpenAPI spec. Typically used + /// with `value_type` attribute for a member of type `serde_json::Value`. + pub fn is_value(&self) -> bool { + self.is("Value") + } + + /// Check whether the [`TypeTree`]'s `generic_type` is [`GenericType::Option`] + pub fn is_option(&self) -> bool { + matches!(self.generic_type, Some(GenericType::Option)) + } + + /// Check whether the [`TypeTree`]'s `generic_type` is [`GenericType::Map`] + pub fn is_map(&self) -> bool { + matches!(self.generic_type, Some(GenericType::Map)) + } + + /// Get [`syn::Generics`] for current [`TypeTree`]'s [`syn::Path`]. + pub fn get_path_generics(&self) -> syn::Result { + let mut generics = Generics::default(); + let segment = self + .path + .as_ref() + .ok_or_else(|| syn::Error::new(self.path.span(), "cannot get TypeTree::path, did you call this on `tuple` or `unit` type type tree?"))? + .segments + .last() + .expect("Path must have segments"); + + fn type_to_generic_params(ty: &Type) -> Vec { + match &ty { + Type::Path(path) => { + let mut params_vec: Vec = Vec::new(); + let last_segment = path + .path + .segments + .last() + .expect("TypePath must have a segment"); + let ident = &last_segment.ident; + params_vec.push(syn::parse_quote!(#ident)); + + params_vec + } + Type::Reference(reference) => type_to_generic_params(reference.elem.as_ref()), + _ => Vec::new(), + } + } + + fn angle_bracket_args_to_params( + args: &AngleBracketedGenericArguments, + ) -> impl Iterator + '_ { + args.args + .iter() + .filter_map(move |generic_argument| { + match generic_argument { + GenericArgument::Type(ty) => Some(type_to_generic_params(ty)), + GenericArgument::Lifetime(life) => { + Some(vec![GenericParam::Lifetime(syn::parse_quote!(#life))]) + } + _ => None, // other wise ignore + } + }) + .flatten() + } + + if let PathArguments::AngleBracketed(angle_bracketed_args) = &segment.arguments { + generics.lt_token = Some(angle_bracketed_args.lt_token); + generics.params = angle_bracket_args_to_params(angle_bracketed_args).collect(); + generics.gt_token = Some(angle_bracketed_args.gt_token); + }; + + Ok(generics) + } + + /// Get possible global alias defined in `fastapi_config::Config` for current `TypeTree`. + pub fn get_alias_type(&self) -> Result, Diagnostics> { + #[cfg(feature = "config")] + { + self.path + .as_ref() + .and_then(|path| path.segments.iter().last()) + .and_then(|last_segment| { + crate::CONFIG.aliases.get(&*last_segment.ident.to_string()) + }) + .map_try(|alias| syn::parse_str::(alias.as_ref())) + .map_err(|error| Diagnostics::new(error.to_string())) + } + + #[cfg(not(feature = "config"))] + Ok(None) + } +} + +impl PartialEq for TypeTree<'_> { + #[cfg(feature = "debug")] + fn eq(&self, other: &Self) -> bool { + self.path == other.path + && self.value_type == other.value_type + && self.generic_type == other.generic_type + && self.children == other.children + } + + #[cfg(not(feature = "debug"))] + fn eq(&self, other: &Self) -> bool { + let path_eg = match (self.path.as_ref(), other.path.as_ref()) { + (Some(Cow::Borrowed(self_path)), Some(Cow::Borrowed(other_path))) => { + self_path.into_token_stream().to_string() + == other_path.into_token_stream().to_string() + } + (Some(Cow::Owned(self_path)), Some(Cow::Owned(other_path))) => { + self_path.to_token_stream().to_string() + == other_path.into_token_stream().to_string() + } + (None, None) => true, + _ => false, + }; + + path_eg + && self.value_type == other.value_type + && self.generic_type == other.generic_type + && self.children == other.children + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum ValueType { + Primitive, + Object, + Tuple, + Value, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum GenericType { + Vec, + LinkedList, + Set, + #[cfg(feature = "smallvec")] + SmallVec, + Map, + Option, + Cow, + Box, + RefCell, + #[cfg(feature = "rc_schema")] + Arc, + #[cfg(feature = "rc_schema")] + Rc, +} + +trait Rename { + fn rename(rule: &RenameRule, value: &str) -> String; +} + +/// Performs a rename for given `value` based on given rules. If no rules were +/// provided returns [`None`] +/// +/// Method accepts 3 arguments. +/// * `value` to rename. +/// * `to` Optional rename to value for fields with _`rename`_ property. +/// * `container_rule` which is used to rename containers with _`rename_all`_ property. +fn rename<'s, R: Rename>( + value: &str, + to: Option>, + container_rule: Option<&RenameRule>, +) -> Option> { + let rename = to.and_then(|to| if !to.is_empty() { Some(to) } else { None }); + + rename.or_else(|| { + container_rule + .as_ref() + .map(|container_rule| Cow::Owned(R::rename(container_rule, value))) + }) +} + +/// Can be used to perform rename on container level e.g `struct`, `enum` or `enum` `variant` level. +struct VariantRename; + +impl Rename for VariantRename { + fn rename(rule: &RenameRule, value: &str) -> String { + rule.rename_variant(value) + } +} + +/// Can be used to perform rename on field level of a container e.g `struct`. +struct FieldRename; + +impl Rename for FieldRename { + fn rename(rule: &RenameRule, value: &str) -> String { + rule.rename(value) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Container<'c> { + pub generics: &'c Generics, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ComponentSchemaProps<'c> { + pub container: &'c Container<'c>, + pub type_tree: &'c TypeTree<'c>, + pub features: Vec, + pub description: Option<&'c ComponentDescription<'c>>, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum ComponentDescription<'c> { + CommentAttributes(&'c CommentAttributes), + Description(&'c Description), +} + +impl ToTokens for ComponentDescription<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let description = match self { + Self::CommentAttributes(attributes) => { + if attributes.is_empty() { + TokenStream::new() + } else { + attributes.as_formatted_string().to_token_stream() + } + } + Self::Description(description) => description.to_token_stream(), + }; + + if !description.is_empty() { + tokens.extend(quote! { + .description(Some(#description)) + }); + } + } +} + +/// Used to store possible inner field schema name and tokens if field contains any schema +/// references. E.g. field: Vec should have name: Foo::name(), tokens: Foo::schema() and +/// references: Foo::schemas() +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Default)] +pub struct SchemaReference { + pub name: TokenStream, + pub tokens: TokenStream, + pub references: TokenStream, + pub is_inline: bool, + pub no_recursion: bool, +} + +impl SchemaReference { + /// Check whether `SchemaReference` is partial. Partial schema reference occurs in situation + /// when reference schema tokens cannot be resolved e.g. type in question is generic argument. + fn is_partial(&self) -> bool { + self.tokens.is_empty() + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ComponentSchema { + tokens: TokenStream, + pub name_tokens: TokenStream, + pub schema_references: Vec, +} + +impl ComponentSchema { + pub fn new( + ComponentSchemaProps { + container, + type_tree, + mut features, + description, + }: ComponentSchemaProps, + ) -> Result { + let mut tokens = TokenStream::new(); + let mut name_tokens = TokenStream::new(); + let mut schema_references = Vec::::new(); + + match type_tree.generic_type { + Some(GenericType::Map) => ComponentSchema::map_to_tokens( + &mut tokens, + &mut schema_references, + container, + features, + type_tree, + description, + )?, + Some(GenericType::Vec | GenericType::LinkedList | GenericType::Set) => { + ComponentSchema::vec_to_tokens( + &mut tokens, + &mut schema_references, + container, + features, + type_tree, + description, + )? + } + #[cfg(feature = "smallvec")] + Some(GenericType::SmallVec) => ComponentSchema::vec_to_tokens( + &mut tokens, + &mut schema_references, + container, + features, + type_tree, + description, + )?, + Some(GenericType::Option) => { + // Add nullable feature if not already exists. Option is always nullable + if !features + .iter() + .any(|feature| matches!(feature, Feature::Nullable(_))) + { + features.push(Nullable::new().into()); + } + let child = type_tree + .children + .as_ref() + .expect("ComponentSchema generic container type should have children") + .iter() + .next() + .expect("ComponentSchema generic container type should have 1 child"); + let alias = child.get_alias_type()?; + let alias = alias.as_ref().map_try(TypeTree::from_type)?; + let child = alias.as_ref().unwrap_or(child); + + let schema = ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: child, + features, + description, + })?; + schema.to_tokens(&mut tokens); + + schema_references.extend(schema.schema_references); + } + Some(GenericType::Cow | GenericType::Box | GenericType::RefCell) => { + let child = type_tree + .children + .as_ref() + .expect("ComponentSchema generic container type should have children") + .iter() + .next() + .expect("ComponentSchema generic container type should have 1 child"); + let alias = child.get_alias_type()?; + let alias = alias.as_ref().map_try(TypeTree::from_type)?; + let child = alias.as_ref().unwrap_or(child); + + let schema = ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: child, + features, + description, + })?; + schema.to_tokens(&mut tokens); + + schema_references.extend(schema.schema_references); + } + #[cfg(feature = "rc_schema")] + Some(GenericType::Arc) | Some(GenericType::Rc) => { + let child = type_tree + .children + .as_ref() + .expect("ComponentSchema rc generic container type should have children") + .iter() + .next() + .expect("ComponentSchema rc generic container type should have 1 child"); + let alias = child.get_alias_type()?; + let alias = alias.as_ref().map_try(TypeTree::from_type)?; + let child = alias.as_ref().unwrap_or(child); + + let schema = ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: child, + features, + description, + })?; + schema.to_tokens(&mut tokens); + + schema_references.extend(schema.schema_references); + } + None => ComponentSchema::non_generic_to_tokens( + &mut tokens, + &mut name_tokens, + &mut schema_references, + container, + features, + type_tree, + description, + )?, + }; + + Ok(Self { + tokens, + name_tokens, + schema_references, + }) + } + + /// Create `.schema_type(...)` override token stream if nullable is true from given [`SchemaTypeInner`]. + fn get_schema_type_override( + nullable: Option, + schema_type_inner: SchemaTypeInner, + ) -> Option { + if let Some(nullable) = nullable { + let nullable_schema_type = nullable.into_schema_type_token_stream(); + let schema_type = if nullable.value() && !nullable_schema_type.is_empty() { + Some(quote! { + { + use std::iter::FromIterator; + fastapi::openapi::schema::SchemaType::from_iter([#schema_type_inner, #nullable_schema_type]) + } + }) + } else { + None + }; + + schema_type.map(|schema_type| quote! { .schema_type(#schema_type) }) + } else { + None + } + } + + fn map_to_tokens( + tokens: &mut TokenStream, + schema_references: &mut Vec, + container: &Container, + mut features: Vec, + type_tree: &TypeTree, + description_stream: Option<&ComponentDescription<'_>>, + ) -> Result<(), Diagnostics> { + let example = features.pop_by(|feature| matches!(feature, Feature::Example(_))); + let additional_properties = pop_feature!(features => Feature::AdditionalProperties(_)); + let nullable: Option = + pop_feature!(features => Feature::Nullable(_)).into_inner(); + let default = pop_feature!(features => Feature::Default(_)); + let default_tokens = as_tokens_or_diagnostics!(&default); + let deprecated = pop_feature!(features => Feature::Deprecated(_)).try_to_token_stream()?; + + let additional_properties = additional_properties + .as_ref() + .map_try(|feature| Ok(as_tokens_or_diagnostics!(feature)))? + .or_else_try(|| { + let children = type_tree + .children + .as_ref() + .expect("ComponentSchema Map type should have children"); + // Get propertyNames + let property_name = children + .first() + .expect("ComponentSchema Map type shouldu have 2 child, getting first"); + let property_name_alias = property_name.get_alias_type()?; + let property_name_alias = + property_name_alias.as_ref().map_try(TypeTree::from_type)?; + let property_name_child = property_name_alias.as_ref().unwrap_or(property_name); + + let mut property_name_features = features.clone(); + property_name_features.push(Feature::Inline(true.into())); + let property_name_schema = ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: property_name_child, + features: property_name_features, + description: None, + })?; + let property_name_tokens = property_name_schema.to_token_stream(); + + // Maps are treated as generic objects with no named properties and + // additionalProperties denoting the type + // maps have 2 child schemas and we are interested the second one of them + // which is used to determine the additional properties + let child = children + .get(1) + .expect("ComponentSchema Map type should have 2 child"); + let alias = child.get_alias_type()?; + let alias = alias.as_ref().map_try(TypeTree::from_type)?; + let child = alias.as_ref().unwrap_or(child); + + let schema_property = ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: child, + features, + description: None, + })?; + let schema_tokens = schema_property.to_token_stream(); + + schema_references.extend(schema_property.schema_references); + + Result::, Diagnostics>::Ok(Some(quote! { + .property_names(Some(#property_name_tokens)) + .additional_properties(Some(#schema_tokens)) + })) + })?; + + let schema_type = + ComponentSchema::get_schema_type_override(nullable, SchemaTypeInner::Object); + + tokens.extend(quote! { + fastapi::openapi::ObjectBuilder::new() + #schema_type + #additional_properties + #description_stream + #deprecated + #default_tokens + }); + + example.to_tokens(tokens) + } + + fn vec_to_tokens( + tokens: &mut TokenStream, + schema_references: &mut Vec, + container: &Container, + mut features: Vec, + type_tree: &TypeTree, + description_stream: Option<&ComponentDescription<'_>>, + ) -> Result<(), Diagnostics> { + let example = pop_feature!(features => Feature::Example(_)); + let xml = features.extract_vec_xml_feature(type_tree)?; + let max_items = pop_feature!(features => Feature::MaxItems(_)); + let min_items = pop_feature!(features => Feature::MinItems(_)); + let nullable: Option = + pop_feature!(features => Feature::Nullable(_)).into_inner(); + let default = pop_feature!(features => Feature::Default(_)); + let title = pop_feature!(features => Feature::Title(_)); + let deprecated = pop_feature!(features => Feature::Deprecated(_)).try_to_token_stream()?; + let content_encoding = pop_feature!(features => Feature::ContentEncoding(_)); + let content_media_type = pop_feature!(features => Feature::ContentMediaType(_)); + + let child = type_tree + .children + .as_ref() + .expect("ComponentSchema Vec should have children") + .iter() + .next() + .expect("ComponentSchema Vec should have 1 child"); + + #[cfg(feature = "smallvec")] + let child = if type_tree.generic_type == Some(GenericType::SmallVec) { + child + .children + .as_ref() + .expect("SmallVec should have children") + .iter() + .next() + .expect("SmallVec should have 1 child") + } else { + child + }; + let alias = child.get_alias_type()?; + let alias = alias.as_ref().map_try(TypeTree::from_type)?; + let child = alias.as_ref().unwrap_or(child); + + let component_schema = ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: child, + features, + description: None, + })?; + let component_schema_tokens = component_schema.to_token_stream(); + + schema_references.extend(component_schema.schema_references); + + let unique = match matches!(type_tree.generic_type, Some(GenericType::Set)) { + true => quote! { + .unique_items(true) + }, + false => quote! {}, + }; + let schema_type = + ComponentSchema::get_schema_type_override(nullable, SchemaTypeInner::Array); + + let schema = quote! { + fastapi::openapi::schema::ArrayBuilder::new() + #schema_type + .items(#component_schema_tokens) + #unique + }; + + let validate = |feature: &Feature| { + let type_path = &**type_tree.path.as_ref().unwrap(); + let schema_type = SchemaType { + path: Cow::Borrowed(type_path), + nullable: nullable + .map(|nullable| nullable.value()) + .unwrap_or_default(), + }; + feature.validate(&schema_type, type_tree); + }; + + tokens.extend(quote! { + #schema + #deprecated + #description_stream + }); + + if let Some(max_items) = max_items { + validate(&max_items); + tokens.extend(max_items.to_token_stream()) + } + + if let Some(min_items) = min_items { + validate(&min_items); + tokens.extend(min_items.to_token_stream()) + } + + content_encoding.to_tokens(tokens)?; + content_media_type.to_tokens(tokens)?; + default.to_tokens(tokens)?; + title.to_tokens(tokens)?; + example.to_tokens(tokens)?; + xml.to_tokens(tokens)?; + + Ok(()) + } + + fn non_generic_to_tokens( + tokens: &mut TokenStream, + name_tokens: &mut TokenStream, + schema_references: &mut Vec, + container: &Container, + mut features: Vec, + type_tree: &TypeTree, + description_stream: Option<&ComponentDescription<'_>>, + ) -> Result<(), Diagnostics> { + let nullable_feat: Option = + pop_feature!(features => Feature::Nullable(_)).into_inner(); + let nullable = nullable_feat + .map(|nullable| nullable.value()) + .unwrap_or_default(); + let deprecated = pop_feature!(features => Feature::Deprecated(_)).try_to_token_stream()?; + + match type_tree.value_type { + ValueType::Primitive => { + let type_path = &**type_tree.path.as_ref().unwrap(); + let schema_type = SchemaType { + path: Cow::Borrowed(type_path), + nullable, + }; + if schema_type.is_unsigned_integer() { + // add default minimum feature only when there is no explicit minimum + // provided + if !features + .iter() + .any(|feature| matches!(&feature, Feature::Minimum(_))) + { + features.push(Minimum::new(0f64, type_path.span()).into()); + } + } + + let schema_type_tokens = as_tokens_or_diagnostics!(&schema_type); + tokens.extend(quote! { + fastapi::openapi::ObjectBuilder::new().schema_type(#schema_type_tokens) + }); + + let format = KnownFormat::from_path(type_path)?; + if format.is_known_format() { + tokens.extend(quote! { + .format(Some(#format)) + }) + } + + description_stream.to_tokens(tokens); + tokens.extend(deprecated); + for feature in features.iter().filter(|feature| feature.is_validatable()) { + feature.validate(&schema_type, type_tree); + } + let _ = pop_feature!(features => Feature::NoRecursion(_)); // primitive types are not recursive + tokens.extend(features.to_token_stream()?); + } + ValueType::Value => { + // since OpenAPI 3.1 the type is an array, thus nullable should not be necessary + // for value type that is going to allow all types of content. + if type_tree.is_value() { + tokens.extend(quote! { + fastapi::openapi::ObjectBuilder::new() + .schema_type(fastapi::openapi::schema::SchemaType::AnyValue) + #description_stream #deprecated + }) + } + } + ValueType::Object => { + let is_inline = features.is_inline(); + + if type_tree.is_object() { + let nullable_schema_type = ComponentSchema::get_schema_type_override( + nullable_feat, + SchemaTypeInner::Object, + ); + tokens.extend(quote! { + fastapi::openapi::ObjectBuilder::new() + #nullable_schema_type + #description_stream #deprecated + }) + } else { + fn nullable_one_of_item(nullable: bool) -> Option { + if nullable { + Some( + quote! { .item(fastapi::openapi::schema::ObjectBuilder::new().schema_type(fastapi::openapi::schema::Type::Null)) }, + ) + } else { + None + } + } + let type_path = &**type_tree.path.as_ref().unwrap(); + let rewritten_path = type_path.rewrite_path()?; + let nullable_item = nullable_one_of_item(nullable); + let mut object_schema_reference = SchemaReference { + no_recursion: features + .iter() + .any(|feature| matches!(feature, Feature::NoRecursion(_))), + ..SchemaReference::default() + }; + + if let Some(children) = &type_tree.children { + let children_name = Self::compose_name( + Self::filter_const_generics(children, container.generics), + container.generics, + )?; + name_tokens.extend(quote! { std::borrow::Cow::Owned(format!("{}_{}", < #rewritten_path as fastapi::ToSchema >::name(), #children_name)) }); + } else { + name_tokens.extend( + quote! { format!("{}", < #rewritten_path as fastapi::ToSchema >::name()) }, + ); + } + + object_schema_reference.name = quote! { String::from(#name_tokens) }; + + let default = pop_feature!(features => Feature::Default(_)); + let default_tokens = as_tokens_or_diagnostics!(&default); + let title = pop_feature!(features => Feature::Title(_)); + let title_tokens = as_tokens_or_diagnostics!(&title); + + if is_inline { + let schema_type = SchemaType { + path: Cow::Borrowed(&rewritten_path), + nullable, + }; + let index = + if !schema_type.is_primitive() || type_tree.generic_type.is_none() { + container.generics.get_generic_type_param_index(type_tree) + } else { + None + }; + + object_schema_reference.is_inline = true; + let items_tokens = if let Some(children) = &type_tree.children { + schema_references.extend(Self::compose_child_references(children)?); + + let composed_generics = + Self::compose_generics(children, container.generics)? + .collect::>(); + + if index.is_some() { + quote_spanned! {type_path.span()=> + let _ = <#rewritten_path as fastapi::PartialSchema>::schema; + + if let Some(composed) = generics.get_mut(#index) { + composed.clone() + } else { + <#rewritten_path as fastapi::PartialSchema>::schema() + } + } + } else { + quote_spanned! {type_path.span()=> + <#rewritten_path as fastapi::__dev::ComposeSchema>::compose(#composed_generics.to_vec()) + } + } + } else { + quote_spanned! {type_path.span()=> + <#rewritten_path as fastapi::PartialSchema>::schema() + } + }; + object_schema_reference.tokens = items_tokens.clone(); + object_schema_reference.references = + quote! { <#rewritten_path as fastapi::ToSchema>::schemas(schemas) }; + + let description_tokens = description_stream.to_token_stream(); + let schema = if default.is_some() + || nullable + || title.is_some() + || !description_tokens.is_empty() + { + quote_spanned! {type_path.span()=> + fastapi::openapi::schema::OneOfBuilder::new() + #nullable_item + .item(#items_tokens) + #title_tokens + #default_tokens + #description_stream + } + } else { + items_tokens + }; + + schema.to_tokens(tokens); + } else { + let schema_type = SchemaType { + path: Cow::Borrowed(&rewritten_path), + nullable, + }; + let index = + if !schema_type.is_primitive() || type_tree.generic_type.is_none() { + container.generics.get_generic_type_param_index(type_tree) + } else { + None + }; + + // forcibly inline primitive type parameters, otherwise use references + if index.is_none() { + let reference_tokens = if let Some(children) = &type_tree.children { + let composed_generics = Self::compose_generics( + Self::filter_const_generics(children, container.generics), + container.generics, + )? + .collect::>(); + quote! { <#rewritten_path as fastapi::__dev::ComposeSchema>::compose(#composed_generics.to_vec()) } + } else { + quote! { <#rewritten_path as fastapi::PartialSchema>::schema() } + }; + object_schema_reference.tokens = reference_tokens; + } + // any case the references call should be passed down in generic and non + // non generic likewise. + object_schema_reference.references = + quote! { <#rewritten_path as fastapi::ToSchema>::schemas(schemas) }; + let composed_or_ref = |item_tokens: TokenStream| -> TokenStream { + if let Some(index) = &index { + quote_spanned! {type_path.span()=> + { + let _ = <#rewritten_path as fastapi::PartialSchema>::schema; + + if let Some(composed) = generics.get_mut(#index) { + composed.clone() + } else { + #item_tokens.into() + } + } + } + } else { + quote_spanned! {type_path.span()=> + #item_tokens + } + } + }; + + // TODO: refs support `summary` field but currently there is no such field + // on schemas more over there is no way to distinct the `summary` from + // `description` of the ref. Should we consider supporting the summary? + let schema = if default.is_some() || nullable || title.is_some() { + composed_or_ref(quote_spanned! {type_path.span()=> + fastapi::openapi::schema::OneOfBuilder::new() + #nullable_item + .item(fastapi::openapi::schema::RefBuilder::new() + #description_stream + .ref_location_from_schema_name(#name_tokens) + ) + #title_tokens + #default_tokens + }) + } else { + composed_or_ref(quote_spanned! {type_path.span()=> + fastapi::openapi::schema::RefBuilder::new() + #description_stream + .ref_location_from_schema_name(#name_tokens) + }) + }; + + schema.to_tokens(tokens); + } + + schema_references.push(object_schema_reference); + } + } + ValueType::Tuple => { + type_tree + .children + .as_ref() + .map_try(|children| { + let prefix_items = children + .iter() + .map(|child| { + let mut features = if child.is_option() { + vec![Feature::Nullable(Nullable::new())] + } else { + Vec::new() + }; + // Prefix item is always inlined + features.push(Feature::Inline(true.into())); + + match ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: child, + features, + description: None, + }) { + Ok(child) => Ok(quote! { + Into::::into(#child) + }), + Err(diagnostics) => Err(diagnostics), + } + }) + .collect::, Diagnostics>>()? + .into_iter() + .collect::>(); + + let nullable_schema_type = ComponentSchema::get_schema_type_override( + nullable_feat, + SchemaTypeInner::Array, + ); + Result::::Ok(quote! { + fastapi::openapi::schema::ArrayBuilder::new() + #nullable_schema_type + .items(fastapi::openapi::schema::ArrayItems::False) + .prefix_items(#prefix_items) + #description_stream + #deprecated + }) + })? + .unwrap_or_else(|| quote!(fastapi::openapi::schema::empty())) // TODO should + // this bee type "null"? + .to_tokens(tokens); + tokens.extend(features.to_token_stream()); + } + } + Ok(()) + } + + fn compose_name<'tr, I>( + children: I, + generics: &'tr Generics, + ) -> Result + where + I: IntoIterator>, + { + let children = children + .into_iter() + .map(|type_tree| { + let name = type_tree + .path + .as_deref() + .expect("Generic ValueType::Object must have path"); + let rewritten_name = name.rewrite_path()?; + + if let Some(children) = &type_tree.children { + let children_name = Self::compose_name(Self::filter_const_generics(children, generics), generics)?; + + Ok(quote! { std::borrow::Cow::Owned(format!("{}_{}", <#rewritten_name as fastapi::ToSchema>::name(), #children_name)) }) + } else { + Ok(quote! { <#rewritten_name as fastapi::ToSchema>::name() }) + } + }) + .collect::, Diagnostics>>()?; + + Ok(quote! { std::borrow::Cow::::Owned(#children.to_vec().join("_")) }) + } + + fn compose_generics<'v, I: IntoIterator>>( + children: I, + generics: &'v Generics, + ) -> Result + 'v, Diagnostics> + where + ::IntoIter: 'v, + { + let iter = children.into_iter().map(|child| { + let path = child + .path + .as_deref() + .expect("inline TypeTree ValueType::Object must have child path if generic"); + let rewritten_path = path.rewrite_path()?; + if let Some(children) = &child.children { + let items = Self::compose_generics(Self::filter_const_generics(children, generics), generics)?.collect::>(); + Ok(quote! { <#rewritten_path as fastapi::__dev::ComposeSchema>::compose(#items.to_vec()) }) + } else { + Ok(quote! { <#rewritten_path as fastapi::PartialSchema>::schema() }) + } + }).collect::, Diagnostics>>()? + .into_iter(); + + Ok(iter) + } + + fn filter_const_generics<'v, I: IntoIterator>>( + children: I, + generics: &'v Generics, + ) -> impl IntoIterator> + 'v + where + ::IntoIter: 'v, + { + children.into_iter().filter(|type_tree| { + let path = type_tree + .path + .as_deref() + .expect("child TypeTree must have a Path, did you call this on array or tuple?"); + let is_const = path + .get_ident() + .map(|path_ident| { + generics.params.iter().any( + |param| matches!(param, GenericParam::Const(ty) if ty.ident == *path_ident), + ) + }) + .unwrap_or(false); + + !is_const + }) + } + + fn compose_child_references<'a, I: IntoIterator> + 'a>( + children: I, + ) -> Result + 'a, Diagnostics> { + let iter = children.into_iter().map(|type_tree| { + if let Some(children) = &type_tree.children { + let iter = Self::compose_child_references(children)?; + Ok(ChildRefIter::Iter(Box::new(iter))) + } else if type_tree.value_type == ValueType::Object { + let type_path = type_tree + .path + .as_deref() + .expect("Object TypePath must have type path, compose child references"); + + let rewritten_path = type_path.rewrite_path()?; + + Ok(ChildRefIter::Once(std::iter::once(SchemaReference { + name: quote! { String::from(< #rewritten_path as fastapi::ToSchema >::name().as_ref()) }, + tokens: quote! { <#rewritten_path as fastapi::PartialSchema>::schema() }, + references: quote !{ <#rewritten_path as fastapi::ToSchema>::schemas(schemas) }, + is_inline: false, + no_recursion: false, + })) + ) + } else { + Ok(ChildRefIter::Empty) + } + }).collect::, Diagnostics>>()? + .into_iter() + .flatten(); + + Ok(iter) + } +} + +impl ToTokens for ComponentSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.tokens.to_tokens(tokens); + } +} + +enum ChildRefIter<'c, T> { + Iter(Box + 'c>), + Once(std::iter::Once), + Empty, +} + +impl<'a, T> Iterator for ChildRefIter<'a, T> { + type Item = T; + + fn next(&mut self) -> Option { + match self { + Self::Iter(iter) => iter.next(), + Self::Once(once) => once.next(), + Self::Empty => None, + } + } + + fn size_hint(&self) -> (usize, Option) { + match self { + Self::Iter(iter) => iter.size_hint(), + Self::Once(once) => once.size_hint(), + Self::Empty => (0, None), + } + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct FlattenedMapSchema { + tokens: TokenStream, +} + +impl FlattenedMapSchema { + pub fn new( + ComponentSchemaProps { + container, + type_tree, + mut features, + description, + }: ComponentSchemaProps, + ) -> Result { + let mut tokens = TokenStream::new(); + let deprecated = pop_feature!(features => Feature::Deprecated(_)).try_to_token_stream()?; + + let example = features.pop_by(|feature| matches!(feature, Feature::Example(_))); + let nullable = pop_feature!(features => Feature::Nullable(_)); + let default = pop_feature!(features => Feature::Default(_)); + let default_tokens = as_tokens_or_diagnostics!(&default); + + // Maps are treated as generic objects with no named properties and + // additionalProperties denoting the type + // maps have 2 child schemas and we are interested the second one of them + // which is used to determine the additional properties + let schema_property = ComponentSchema::new(ComponentSchemaProps { + container, + type_tree: type_tree + .children + .as_ref() + .expect("ComponentSchema Map type should have children") + .get(1) + .expect("ComponentSchema Map type should have 2 child"), + features, + description: None, + })?; + let schema_tokens = schema_property.to_token_stream(); + + tokens.extend(quote! { + #schema_tokens + #description + #deprecated + #default_tokens + }); + + example.to_tokens(&mut tokens)?; + nullable.to_tokens(&mut tokens)?; + + Ok(Self { tokens }) + } +} + +impl ToTokensDiagnostics for FlattenedMapSchema { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + self.tokens.to_tokens(tokens); + Ok(()) + } +} diff --git a/fastapi-gen/src/component/features.rs b/fastapi-gen/src/component/features.rs new file mode 100644 index 0000000..998f2bd --- /dev/null +++ b/fastapi-gen/src/component/features.rs @@ -0,0 +1,712 @@ +use std::{fmt::Display, mem}; + +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, ToTokens}; +use syn::parse::ParseStream; + +use crate::{ + as_tokens_or_diagnostics, schema_type::SchemaType, Diagnostics, OptionExt, ToTokensDiagnostics, +}; + +use self::validators::{AboveZeroF64, AboveZeroUsize, IsNumber, IsString, IsVec, ValidatorChain}; + +use super::TypeTree; + +pub mod attributes; +pub mod validation; +pub mod validators; + +pub trait FeatureLike: Parse { + fn get_name() -> std::borrow::Cow<'static, str> + where + Self: Sized; +} + +macro_rules! impl_feature { + ( $( $name:literal => )? $( #[$meta:meta] )* $vis:vis $key:ident $ty:ident $( $tt:tt )* ) => { + $( #[$meta] )* + $vis $key $ty $( $tt )* + + impl $crate::features::FeatureLike for $ty { + fn get_name() -> std::borrow::Cow<'static, str> { + impl_feature!( @name $ty name: $( $name )* ) + } + } + + impl std::fmt::Display for $ty { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = ::get_name(); + write!(f, "{name}", name = name.as_ref()) + } + } + }; + ( @name $ty:ident name: $name:literal ) => { + std::borrow::Cow::Borrowed($name) + }; + ( @name $ty:ident name: ) => { + { + let snake = $crate::component::serde::RenameRule::Snake; + let renamed = snake.rename_variant(stringify!($ty)); + std::borrow::Cow::Owned(renamed) + } + }; +} +use impl_feature; + +/// Define whether [`Feature`] variant is validatable or not +pub trait Validatable { + fn is_validatable(&self) -> bool { + false + } +} + +pub trait Validate: Validatable { + /// Perform validation check against schema type. + fn validate(&self, validator: impl validators::Validator) -> Option; +} + +pub trait Parse { + fn parse(input: ParseStream, attribute: Ident) -> syn::Result + where + Self: std::marker::Sized; +} + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Clone)] +pub enum Feature { + Example(attributes::Example), + Examples(attributes::Examples), + Default(attributes::Default), + Inline(attributes::Inline), + XmlAttr(attributes::XmlAttr), + Format(attributes::Format), + ValueType(attributes::ValueType), + WriteOnly(attributes::WriteOnly), + ReadOnly(attributes::ReadOnly), + Title(attributes::Title), + Nullable(attributes::Nullable), + Rename(attributes::Rename), + RenameAll(attributes::RenameAll), + Style(attributes::Style), + AllowReserved(attributes::AllowReserved), + Explode(attributes::Explode), + ParameterIn(attributes::ParameterIn), + IntoParamsNames(attributes::IntoParamsNames), + SchemaWith(attributes::SchemaWith), + Description(attributes::Description), + Deprecated(attributes::Deprecated), + As(attributes::As), + AdditionalProperties(attributes::AdditionalProperties), + Required(attributes::Required), + ContentEncoding(attributes::ContentEncoding), + ContentMediaType(attributes::ContentMediaType), + Discriminator(attributes::Discriminator), + Bound(attributes::Bound), + Ignore(attributes::Ignore), + NoRecursion(attributes::NoRecursion), + MultipleOf(validation::MultipleOf), + Maximum(validation::Maximum), + Minimum(validation::Minimum), + ExclusiveMaximum(validation::ExclusiveMaximum), + ExclusiveMinimum(validation::ExclusiveMinimum), + MaxLength(validation::MaxLength), + MinLength(validation::MinLength), + Pattern(validation::Pattern), + MaxItems(validation::MaxItems), + MinItems(validation::MinItems), + MaxProperties(validation::MaxProperties), + MinProperties(validation::MinProperties), +} + +impl Feature { + pub fn validate(&self, schema_type: &SchemaType, type_tree: &TypeTree) -> Option { + match self { + Feature::MultipleOf(multiple_of) => multiple_of.validate( + ValidatorChain::new(&IsNumber(schema_type)).next(&AboveZeroF64(&multiple_of.0)), + ), + Feature::Maximum(maximum) => maximum.validate(IsNumber(schema_type)), + Feature::Minimum(minimum) => minimum.validate(IsNumber(schema_type)), + Feature::ExclusiveMaximum(exclusive_maximum) => { + exclusive_maximum.validate(IsNumber(schema_type)) + } + Feature::ExclusiveMinimum(exclusive_minimum) => { + exclusive_minimum.validate(IsNumber(schema_type)) + } + Feature::MaxLength(max_length) => max_length.validate( + ValidatorChain::new(&IsString(schema_type)).next(&AboveZeroUsize(&max_length.0)), + ), + Feature::MinLength(min_length) => min_length.validate( + ValidatorChain::new(&IsString(schema_type)).next(&AboveZeroUsize(&min_length.0)), + ), + Feature::Pattern(pattern) => pattern.validate(IsString(schema_type)), + Feature::MaxItems(max_items) => max_items.validate( + ValidatorChain::new(&AboveZeroUsize(&max_items.0)).next(&IsVec(type_tree)), + ), + Feature::MinItems(min_items) => min_items.validate( + ValidatorChain::new(&AboveZeroUsize(&min_items.0)).next(&IsVec(type_tree)), + ), + unsupported => { + const SUPPORTED_VARIANTS: [&str; 10] = [ + "multiple_of", + "maximum", + "minimum", + "exclusive_maximum", + "exclusive_minimum", + "max_length", + "min_length", + "pattern", + "max_items", + "min_items", + ]; + panic!( + "Unsupported variant: `{unsupported}` for Validate::validate, expected one of: {variants}", + variants = SUPPORTED_VARIANTS.join(", ") + ) + } + } + } +} + +impl ToTokensDiagnostics for Feature { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let feature = match &self { + Feature::Default(default) => quote! { .default(#default) }, + Feature::Example(example) => quote! { .example(Some(#example)) }, + Feature::Examples(examples) => quote! { .examples(#examples) }, + Feature::XmlAttr(xml) => quote! { .xml(Some(#xml)) }, + Feature::Format(format) => quote! { .format(Some(#format)) }, + Feature::WriteOnly(write_only) => quote! { .write_only(Some(#write_only)) }, + Feature::ReadOnly(read_only) => quote! { .read_only(Some(#read_only)) }, + Feature::Title(title) => quote! { .title(Some(#title)) }, + Feature::Nullable(_nullable) => return Err(Diagnostics::new("Nullable does not support `ToTokens`")), + Feature::Rename(rename) => rename.to_token_stream(), + Feature::Style(style) => quote! { .style(Some(#style)) }, + Feature::ParameterIn(parameter_in) => quote! { .parameter_in(#parameter_in) }, + Feature::MultipleOf(multiple_of) => quote! { .multiple_of(Some(#multiple_of)) }, + Feature::AllowReserved(allow_reserved) => { + quote! { .allow_reserved(Some(#allow_reserved)) } + } + Feature::Explode(explode) => quote! { .explode(Some(#explode)) }, + Feature::Maximum(maximum) => quote! { .maximum(Some(#maximum)) }, + Feature::Minimum(minimum) => quote! { .minimum(Some(#minimum)) }, + Feature::ExclusiveMaximum(exclusive_maximum) => { + quote! { .exclusive_maximum(Some(#exclusive_maximum)) } + } + Feature::ExclusiveMinimum(exclusive_minimum) => { + quote! { .exclusive_minimum(Some(#exclusive_minimum)) } + } + Feature::MaxLength(max_length) => quote! { .max_length(Some(#max_length)) }, + Feature::MinLength(min_length) => quote! { .min_length(Some(#min_length)) }, + Feature::Pattern(pattern) => quote! { .pattern(Some(#pattern)) }, + Feature::MaxItems(max_items) => quote! { .max_items(Some(#max_items)) }, + Feature::MinItems(min_items) => quote! { .min_items(Some(#min_items)) }, + Feature::MaxProperties(max_properties) => { + quote! { .max_properties(Some(#max_properties)) } + } + Feature::MinProperties(min_properties) => { + quote! { .max_properties(Some(#min_properties)) } + } + Feature::SchemaWith(schema_with) => schema_with.to_token_stream(), + Feature::Description(description) => quote! { .description(Some(#description)) }, + Feature::Deprecated(deprecated) => quote! { .deprecated(Some(#deprecated)) }, + Feature::AdditionalProperties(additional_properties) => { + quote! { .additional_properties(Some(#additional_properties)) } + } + Feature::ContentEncoding(content_encoding) => quote! { .content_encoding(#content_encoding) }, + Feature::ContentMediaType(content_media_type) => quote! { .content_media_type(#content_media_type) }, + Feature::Discriminator(discriminator) => quote! { .discriminator(Some(#discriminator)) }, + Feature::Bound(_) => { + // specially handled on generating impl blocks. + TokenStream::new() + } + Feature::RenameAll(_) => { + return Err(Diagnostics::new("RenameAll feature does not support `ToTokens`")) + } + Feature::ValueType(_) => { + return Err(Diagnostics::new("ValueType feature does not support `ToTokens`") + .help("ValueType is supposed to be used with `TypeTree` in same manner as a resolved struct/field type.")) + } + Feature::Inline(_) => { + // inline feature is ignored by `ToTokens` + TokenStream::new() + } + Feature::NoRecursion(_) => return Err(Diagnostics::new("NoRecursion does not support `ToTokens`")), + Feature::IntoParamsNames(_) => { + return Err(Diagnostics::new("Names feature does not support `ToTokens`") + .help("Names is only used with IntoParams to artificially give names for unnamed struct type `IntoParams`.")) + } + Feature::As(_) => { + return Err(Diagnostics::new("As does not support `ToTokens`")) + } + Feature::Required(required) => { + let name = ::get_name(); + quote! { .#name(#required) } + } + Feature::Ignore(_) => return Err(Diagnostics::new("Ignore does not support `ToTokens`")), + }; + + tokens.extend(feature); + + Ok(()) + } +} + +impl ToTokensDiagnostics for Option { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + if let Some(this) = self { + this.to_tokens(tokens) + } else { + Ok(()) + } + } +} + +impl Display for Feature { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Feature::Default(default) => default.fmt(f), + Feature::Example(example) => example.fmt(f), + Feature::Examples(examples) => examples.fmt(f), + Feature::XmlAttr(xml) => xml.fmt(f), + Feature::Format(format) => format.fmt(f), + Feature::WriteOnly(write_only) => write_only.fmt(f), + Feature::ReadOnly(read_only) => read_only.fmt(f), + Feature::Title(title) => title.fmt(f), + Feature::Nullable(nullable) => nullable.fmt(f), + Feature::Rename(rename) => rename.fmt(f), + Feature::Style(style) => style.fmt(f), + Feature::ParameterIn(parameter_in) => parameter_in.fmt(f), + Feature::AllowReserved(allow_reserved) => allow_reserved.fmt(f), + Feature::Explode(explode) => explode.fmt(f), + Feature::RenameAll(rename_all) => rename_all.fmt(f), + Feature::ValueType(value_type) => value_type.fmt(f), + Feature::Inline(inline) => inline.fmt(f), + Feature::IntoParamsNames(names) => names.fmt(f), + Feature::MultipleOf(multiple_of) => multiple_of.fmt(f), + Feature::Maximum(maximum) => maximum.fmt(f), + Feature::Minimum(minimum) => minimum.fmt(f), + Feature::ExclusiveMaximum(exclusive_maximum) => exclusive_maximum.fmt(f), + Feature::ExclusiveMinimum(exclusive_minimum) => exclusive_minimum.fmt(f), + Feature::MaxLength(max_length) => max_length.fmt(f), + Feature::MinLength(min_length) => min_length.fmt(f), + Feature::Pattern(pattern) => pattern.fmt(f), + Feature::MaxItems(max_items) => max_items.fmt(f), + Feature::MinItems(min_items) => min_items.fmt(f), + Feature::MaxProperties(max_properties) => max_properties.fmt(f), + Feature::MinProperties(min_properties) => min_properties.fmt(f), + Feature::SchemaWith(schema_with) => schema_with.fmt(f), + Feature::Description(description) => description.fmt(f), + Feature::Deprecated(deprecated) => deprecated.fmt(f), + Feature::As(as_feature) => as_feature.fmt(f), + Feature::AdditionalProperties(additional_properties) => additional_properties.fmt(f), + Feature::Required(required) => required.fmt(f), + Feature::ContentEncoding(content_encoding) => content_encoding.fmt(f), + Feature::ContentMediaType(content_media_type) => content_media_type.fmt(f), + Feature::Discriminator(discriminator) => discriminator.fmt(f), + Feature::Bound(bound) => bound.fmt(f), + Feature::Ignore(ignore) => ignore.fmt(f), + Feature::NoRecursion(no_recursion) => no_recursion.fmt(f), + } + } +} + +impl Validatable for Feature { + fn is_validatable(&self) -> bool { + match &self { + Feature::Default(default) => default.is_validatable(), + Feature::Example(example) => example.is_validatable(), + Feature::Examples(examples) => examples.is_validatable(), + Feature::XmlAttr(xml) => xml.is_validatable(), + Feature::Format(format) => format.is_validatable(), + Feature::WriteOnly(write_only) => write_only.is_validatable(), + Feature::ReadOnly(read_only) => read_only.is_validatable(), + Feature::Title(title) => title.is_validatable(), + Feature::Nullable(nullable) => nullable.is_validatable(), + Feature::Rename(rename) => rename.is_validatable(), + Feature::Style(style) => style.is_validatable(), + Feature::ParameterIn(parameter_in) => parameter_in.is_validatable(), + Feature::AllowReserved(allow_reserved) => allow_reserved.is_validatable(), + Feature::Explode(explode) => explode.is_validatable(), + Feature::RenameAll(rename_all) => rename_all.is_validatable(), + Feature::ValueType(value_type) => value_type.is_validatable(), + Feature::Inline(inline) => inline.is_validatable(), + Feature::IntoParamsNames(names) => names.is_validatable(), + Feature::MultipleOf(multiple_of) => multiple_of.is_validatable(), + Feature::Maximum(maximum) => maximum.is_validatable(), + Feature::Minimum(minimum) => minimum.is_validatable(), + Feature::ExclusiveMaximum(exclusive_maximum) => exclusive_maximum.is_validatable(), + Feature::ExclusiveMinimum(exclusive_minimum) => exclusive_minimum.is_validatable(), + Feature::MaxLength(max_length) => max_length.is_validatable(), + Feature::MinLength(min_length) => min_length.is_validatable(), + Feature::Pattern(pattern) => pattern.is_validatable(), + Feature::MaxItems(max_items) => max_items.is_validatable(), + Feature::MinItems(min_items) => min_items.is_validatable(), + Feature::MaxProperties(max_properties) => max_properties.is_validatable(), + Feature::MinProperties(min_properties) => min_properties.is_validatable(), + Feature::SchemaWith(schema_with) => schema_with.is_validatable(), + Feature::Description(description) => description.is_validatable(), + Feature::Deprecated(deprecated) => deprecated.is_validatable(), + Feature::As(as_feature) => as_feature.is_validatable(), + Feature::AdditionalProperties(additional_properties) => { + additional_properties.is_validatable() + } + Feature::Required(required) => required.is_validatable(), + Feature::ContentEncoding(content_encoding) => content_encoding.is_validatable(), + Feature::ContentMediaType(content_media_type) => content_media_type.is_validatable(), + Feature::Discriminator(discriminator) => discriminator.is_validatable(), + Feature::Bound(bound) => bound.is_validatable(), + Feature::Ignore(ignore) => ignore.is_validatable(), + Feature::NoRecursion(no_recursion) => no_recursion.is_validatable(), + } + } +} + +macro_rules! is_validatable { + ( $( $ty:path $( = $validatable:literal )? ),* ) => { + $( + impl Validatable for $ty { + $( + fn is_validatable(&self) -> bool { + $validatable + } + )? + } + )* + }; +} + +is_validatable! { + attributes::Default, + attributes::Example, + attributes::Examples, + attributes::XmlAttr, + attributes::Format, + attributes::WriteOnly, + attributes::ReadOnly, + attributes::Title, + attributes::Nullable, + attributes::Rename, + attributes::RenameAll, + attributes::Style, + attributes::ParameterIn, + attributes::AllowReserved, + attributes::Explode, + attributes::ValueType, + attributes::Inline, + attributes::IntoParamsNames, + attributes::SchemaWith, + attributes::Description, + attributes::Deprecated, + attributes::As, + attributes::AdditionalProperties, + attributes::Required, + attributes::ContentEncoding, + attributes::ContentMediaType, + attributes::Discriminator, + attributes::Bound, + attributes::Ignore, + attributes::NoRecursion, + validation::MultipleOf = true, + validation::Maximum = true, + validation::Minimum = true, + validation::ExclusiveMaximum = true, + validation::ExclusiveMinimum = true, + validation::MaxLength = true, + validation::MinLength = true, + validation::Pattern = true, + validation::MaxItems = true, + validation::MinItems = true, + validation::MaxProperties, + validation::MinProperties +} + +macro_rules! parse_features { + ($ident:ident as $( $feature:path ),*) => { + { + fn parse(input: syn::parse::ParseStream) -> syn::Result> { + let names = [$( ::get_name(), )* ]; + let mut features = Vec::::new(); + let attributes = names.join(", "); + + while !input.is_empty() { + let ident = input.parse::().or_else(|_| { + input.parse::().map(|as_| syn::Ident::new("as", as_.span)) + }).map_err(|error| { + syn::Error::new( + error.span(), + format!("unexpected attribute, expected any of: {attributes}, {error}"), + ) + })?; + let name = &*ident.to_string(); + + $( + if name == ::get_name() { + features.push(<$feature as crate::component::features::Parse>::parse(input, ident)?.into()); + if !input.is_empty() { + input.parse::()?; + } + continue; + } + )* + + if !names.contains(&std::borrow::Cow::Borrowed(name)) { + return Err(syn::Error::new(ident.span(), format!("unexpected attribute: {name}, expected any of: {attributes}"))) + } + } + + Ok(features) + } + + parse($ident)? + } + }; + (@as_ident $( $tt:tt )* ) => { + $( $tt )* + } +} + +pub(crate) use parse_features; + +pub trait IsInline { + fn is_inline(&self) -> bool; +} + +impl IsInline for Vec { + fn is_inline(&self) -> bool { + self.iter() + .find_map(|feature| match feature { + Feature::Inline(inline) if inline.0 => Some(inline), + _ => None, + }) + .is_some() + } +} + +pub trait ToTokensExt { + fn to_token_stream(&self) -> Result; +} + +impl ToTokensExt for Vec { + fn to_token_stream(&self) -> Result { + Ok(self + .iter() + .map(|feature| Ok(as_tokens_or_diagnostics!(feature))) + .collect::, Diagnostics>>()? + .into_iter() + .fold(TokenStream::new(), |mut tokens, item| { + item.to_tokens(&mut tokens); + tokens + })) + } +} + +pub trait FeaturesExt { + fn pop_by(&mut self, op: impl FnMut(&Feature) -> bool) -> Option; + + /// Extract [`XmlAttr`] feature for given `type_tree` if it has generic type [`GenericType::Vec`] + fn extract_vec_xml_feature( + &mut self, + type_tree: &TypeTree, + ) -> Result, Diagnostics>; +} + +impl FeaturesExt for Vec { + fn pop_by(&mut self, op: impl FnMut(&Feature) -> bool) -> Option { + self.iter() + .position(op) + .map(|index| self.swap_remove(index)) + } + + fn extract_vec_xml_feature( + &mut self, + type_tree: &TypeTree, + ) -> Result, Diagnostics> { + self.iter_mut() + .find_map(|feature| match feature { + Feature::XmlAttr(xml_feature) => { + match xml_feature.split_for_vec(type_tree) { + Ok((vec_xml, value_xml)) => { + // replace the original xml attribute with split value xml + if let Some(mut xml) = value_xml { + mem::swap(xml_feature, &mut xml) + } + + Some(Ok(vec_xml.map(Feature::XmlAttr))) + } + Err(diagnostics) => Some(Err(diagnostics)), + } + } + _ => None, + }) + .and_then_try(|value| value) + } +} + +impl FeaturesExt for Option> { + fn pop_by(&mut self, op: impl FnMut(&Feature) -> bool) -> Option { + self.as_mut().and_then(|features| features.pop_by(op)) + } + + fn extract_vec_xml_feature( + &mut self, + type_tree: &TypeTree, + ) -> Result, Diagnostics> { + self.as_mut() + .and_then_try(|features| features.extract_vec_xml_feature(type_tree)) + } +} + +/// Pull out a `Feature` from `Vec` of features by given match predicate. +/// This macro can be called in two forms demonstrated below. +/// ```text +/// let _: Option = pop_feature!(features => Feature::Inline(_)); +/// let _: Option = pop_feature!(feature => Feature::Inline(_) as Option); +/// ``` +/// +/// The `as ...` syntax can be used to directly convert the `Feature` instance to it's inner form. +macro_rules! pop_feature { + ($features:ident => $( $ty:tt )* ) => {{ + pop_feature!( @inner $features $( $ty )* ) + }}; + ( @inner $features:ident $ty:tt :: $tv:tt ( $t:pat ) $( $tt:tt)* ) => { + { + let f = $features.pop_by(|feature| matches!(feature, $ty :: $tv ($t) ) ); + pop_feature!( @rest f $( $tt )* ) + } + }; + ( @rest $feature:ident as $ty:ty ) => { + { + let inner: $ty = $feature.into_inner(); + inner + } + + }; + ( @rest $($tt:tt)* ) => { + $($tt)* + }; +} + +pub(crate) use pop_feature; + +pub trait IntoInner { + fn into_inner(self) -> T; +} + +macro_rules! impl_feature_into_inner { + ( $( $feat:ident :: $impl:ident , )* ) => { + $( + impl IntoInner> for Option { + fn into_inner(self) -> Option<$feat::$impl> { + self.and_then(|feature| match feature { + Feature::$impl(value) => Some(value), + _ => None, + }) + } + } + )* + }; +} + +impl_feature_into_inner! { + attributes::Example, + attributes::Examples, + attributes::Default, + attributes::Inline, + attributes::XmlAttr, + attributes::Format, + attributes::ValueType, + attributes::WriteOnly, + attributes::ReadOnly, + attributes::Title, + attributes::Nullable, + attributes::Rename, + attributes::RenameAll, + attributes::Style, + attributes::AllowReserved, + attributes::Explode, + attributes::ParameterIn, + attributes::IntoParamsNames, + attributes::SchemaWith, + attributes::Description, + attributes::Deprecated, + attributes::As, + attributes::Required, + attributes::AdditionalProperties, + attributes::Discriminator, + attributes::Bound, + attributes::Ignore, + attributes::NoRecursion, + validation::MultipleOf, + validation::Maximum, + validation::Minimum, + validation::ExclusiveMaximum, + validation::ExclusiveMinimum, + validation::MaxLength, + validation::MinLength, + validation::Pattern, + validation::MaxItems, + validation::MinItems, + validation::MaxProperties, + validation::MinProperties, +} + +macro_rules! impl_into_inner { + ($ident:ident) => { + impl crate::component::features::IntoInner> for $ident { + fn into_inner(self) -> Vec { + self.0 + } + } + + impl crate::component::features::IntoInner>> for Option<$ident> { + fn into_inner(self) -> Option> { + self.map(crate::component::features::IntoInner::into_inner) + } + } + }; +} + +pub(crate) use impl_into_inner; + +pub trait Merge: IntoInner> { + fn merge(self, from: T) -> Self; +} + +macro_rules! impl_merge { + ( $($ident:ident),* ) => { + $( + impl AsMut> for $ident { + fn as_mut(&mut self) -> &mut Vec { + &mut self.0 + } + } + + impl crate::component::features::Merge<$ident> for $ident { + fn merge(mut self, from: $ident) -> Self { + use $crate::component::features::IntoInner; + let a = self.as_mut(); + let mut b = from.into_inner(); + + a.append(&mut b); + + self + } + } + )* + }; +} + +pub(crate) use impl_merge; + +impl IntoInner> for Vec { + fn into_inner(self) -> Vec { + self + } +} + +impl Merge> for Vec { + fn merge(mut self, mut from: Vec) -> Self { + self.append(&mut from); + self + } +} diff --git a/fastapi-gen/src/component/features/attributes.rs b/fastapi-gen/src/component/features/attributes.rs new file mode 100644 index 0000000..53ef814 --- /dev/null +++ b/fastapi-gen/src/component/features/attributes.rs @@ -0,0 +1,1041 @@ +use std::mem; + +use proc_macro2::{Ident, TokenStream}; +use quote::ToTokens; +use syn::parse::ParseStream; +use syn::punctuated::Punctuated; +use syn::token::Paren; +use syn::{Error, LitStr, Token, TypePath, WherePredicate}; + +use crate::component::serde::RenameRule; +use crate::component::{schema, GenericType, TypeTree}; +use crate::parse_utils::{LitBoolOrExprPath, LitStrOrExpr}; +use crate::path::parameter::{self, ParameterStyle}; +use crate::schema_type::KnownFormat; +use crate::{parse_utils, AnyValue, Array, Diagnostics}; + +use super::{impl_feature, Feature, Parse}; +use quote::quote; + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Default(pub(crate) Option); +} + +impl Default { + pub fn new_default_trait(struct_ident: Ident, field_ident: syn::Member) -> Self { + Self(Some(AnyValue::new_default_trait(struct_ident, field_ident))) + } +} + +impl Parse for Default { + fn parse(input: syn::parse::ParseStream, _: proc_macro2::Ident) -> syn::Result { + if input.peek(syn::Token![=]) { + parse_utils::parse_next(input, || AnyValue::parse_any(input)).map(|any| Self(Some(any))) + } else { + Ok(Self(None)) + } + } +} + +impl ToTokens for Default { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + match &self.0 { + Some(inner) => tokens.extend(quote! {Some(#inner)}), + None => tokens.extend(quote! {None}), + } + } +} + +impl From for Feature { + fn from(value: self::Default) -> Self { + Feature::Default(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Example(AnyValue); +} + +impl Parse for Example { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result { + parse_utils::parse_next(input, || AnyValue::parse_any(input)).map(Self) + } +} + +impl ToTokens for Example { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From for Feature { + fn from(value: Example) -> Self { + Feature::Example(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Examples(Vec); +} + +impl Parse for Examples { + fn parse(input: ParseStream, _: Ident) -> syn::Result + where + Self: std::marker::Sized, + { + let examples; + syn::parenthesized!(examples in input); + + Ok(Self( + Punctuated::::parse_terminated_with( + &examples, + AnyValue::parse_any, + )? + .into_iter() + .collect(), + )) + } +} + +impl ToTokens for Examples { + fn to_tokens(&self, tokens: &mut TokenStream) { + if !self.0.is_empty() { + let examples = Array::Borrowed(&self.0).to_token_stream(); + examples.to_tokens(tokens); + } + } +} + +impl From for Feature { + fn from(value: Examples) -> Self { + Feature::Examples(value) + } +} + +impl_feature! {"xml" => + #[derive(Default, Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct XmlAttr(schema::xml::XmlAttr); +} + +impl XmlAttr { + /// Split [`XmlAttr`] for [`GenericType::Vec`] returning tuple of [`XmlAttr`]s where first + /// one is for a vec and second one is for object field. + pub fn split_for_vec( + &mut self, + type_tree: &TypeTree, + ) -> Result<(Option, Option), Diagnostics> { + if matches!(type_tree.generic_type, Some(GenericType::Vec)) { + let mut value_xml = mem::take(self); + let vec_xml = schema::xml::XmlAttr::with_wrapped( + mem::take(&mut value_xml.0.is_wrapped), + mem::take(&mut value_xml.0.wrap_name), + ); + + Ok((Some(XmlAttr(vec_xml)), Some(value_xml))) + } else { + self.validate_xml(&self.0)?; + + Ok((None, Some(mem::take(self)))) + } + } + + #[inline] + fn validate_xml(&self, xml: &schema::xml::XmlAttr) -> Result<(), Diagnostics> { + if let Some(wrapped_ident) = xml.is_wrapped.as_ref() { + Err(Diagnostics::with_span( + wrapped_ident.span(), + "cannot use `wrapped` attribute in non slice field type", + ) + .help("Try removing `wrapped` attribute or make your field `Vec`")) + } else { + Ok(()) + } + } +} + +impl Parse for XmlAttr { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result { + let xml; + syn::parenthesized!(xml in input); + xml.parse::().map(Self) + } +} + +impl ToTokens for XmlAttr { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From for Feature { + fn from(value: XmlAttr) -> Self { + Feature::XmlAttr(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Format(KnownFormat); +} + +impl Parse for Format { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result { + parse_utils::parse_next(input, || input.parse::()).map(Self) + } +} + +impl ToTokens for Format { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From for Feature { + fn from(value: Format) -> Self { + Feature::Format(value) + } +} + +impl_feature! { + #[derive(Clone, Copy)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct WriteOnly(bool); +} + +impl Parse for WriteOnly { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for WriteOnly { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From for Feature { + fn from(value: WriteOnly) -> Self { + Feature::WriteOnly(value) + } +} + +impl_feature! { + #[derive(Clone, Copy)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct ReadOnly(bool); +} + +impl Parse for ReadOnly { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for ReadOnly { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From for Feature { + fn from(value: ReadOnly) -> Self { + Feature::ReadOnly(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Title(String); +} + +impl Parse for Title { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result { + parse_utils::parse_next_literal_str(input).map(Self) + } +} + +impl ToTokens for Title { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From for Feature { + fn from(value: Title) -> Self { + Feature::Title(value) + } +} + +impl_feature! { + #[derive(Clone, Copy)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Nullable(bool); +} + +impl Nullable { + pub fn new() -> Self { + Self(true) + } + + pub fn value(&self) -> bool { + self.0 + } + + pub fn into_schema_type_token_stream(self) -> proc_macro2::TokenStream { + if self.0 { + quote! {fastapi::openapi::schema::Type::Null} + } else { + proc_macro2::TokenStream::new() + } + } +} + +impl Parse for Nullable { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for Nullable { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From<Nullable> for Feature { + fn from(value: Nullable) -> Self { + Feature::Nullable(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Rename(String); +} + +impl Rename { + pub fn into_value(self) -> String { + self.0 + } +} + +impl Parse for Rename { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_next_literal_str(input).map(Self) + } +} + +impl ToTokens for Rename { + fn to_tokens(&self, tokens: &mut TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From<Rename> for Feature { + fn from(value: Rename) -> Self { + Feature::Rename(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct RenameAll(RenameRule); + +} +impl RenameAll { + pub fn as_rename_rule(&self) -> &RenameRule { + &self.0 + } +} + +impl Parse for RenameAll { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + let litstr = parse_utils::parse_next(input, || input.parse::<LitStr>())?; + + litstr + .value() + .parse::<RenameRule>() + .map_err(|error| syn::Error::new(litstr.span(), error.to_string())) + .map(Self) + } +} + +impl From<RenameAll> for Feature { + fn from(value: RenameAll) -> Self { + Feature::RenameAll(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Style(ParameterStyle); +} + +impl From<ParameterStyle> for Style { + fn from(style: ParameterStyle) -> Self { + Self(style) + } +} + +impl Parse for Style { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_next(input, || input.parse::<ParameterStyle>().map(Self)) + } +} + +impl ToTokens for Style { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens) + } +} + +impl From<Style> for Feature { + fn from(value: Style) -> Self { + Feature::Style(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct ParameterIn(parameter::ParameterIn); +} + +impl Parse for ParameterIn { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_next(input, || input.parse::<parameter::ParameterIn>().map(Self)) + } +} + +impl ToTokens for ParameterIn { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<ParameterIn> for Feature { + fn from(value: ParameterIn) -> Self { + Feature::ParameterIn(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct AllowReserved(bool); +} + +impl Parse for AllowReserved { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for AllowReserved { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens) + } +} + +impl From<AllowReserved> for Feature { + fn from(value: AllowReserved) -> Self { + Feature::AllowReserved(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Explode(bool); +} + +impl Parse for Explode { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for Explode { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens) + } +} + +impl From<Explode> for Feature { + fn from(value: Explode) -> Self { + Feature::Explode(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct ValueType(syn::Type); +} + +impl ValueType { + /// Create [`TypeTree`] from current [`syn::Type`]. + pub fn as_type_tree(&self) -> Result<TypeTree, Diagnostics> { + TypeTree::from_type(&self.0) + } +} + +impl Parse for ValueType { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_next(input, || input.parse::<syn::Type>()).map(Self) + } +} + +impl From<ValueType> for Feature { + fn from(value: ValueType) -> Self { + Feature::ValueType(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Inline(pub(super) bool); +} + +impl Parse for Inline { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl From<bool> for Inline { + fn from(value: bool) -> Self { + Inline(value) + } +} + +impl From<Inline> for Feature { + fn from(value: Inline) -> Self { + Feature::Inline(value) + } +} + +impl_feature! {"names" => + /// Specify names of unnamed fields with `names(...) attribute for `IntoParams` derive. + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct IntoParamsNames(Vec<String>); +} + +impl IntoParamsNames { + pub fn into_values(self) -> Vec<String> { + self.0 + } +} + +impl Parse for IntoParamsNames { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + Ok(Self( + parse_utils::parse_comma_separated_within_parenthesis::<LitStr>(input)? + .iter() + .map(LitStr::value) + .collect(), + )) + } +} + +impl From<IntoParamsNames> for Feature { + fn from(value: IntoParamsNames) -> Self { + Feature::IntoParamsNames(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct SchemaWith(TypePath); +} + +impl Parse for SchemaWith { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> { + parse_utils::parse_next(input, || input.parse::<TypePath>().map(Self)) + } +} + +impl ToTokens for SchemaWith { + fn to_tokens(&self, tokens: &mut TokenStream) { + let path = &self.0; + tokens.extend(quote! { + #path() + }) + } +} + +impl From<SchemaWith> for Feature { + fn from(value: SchemaWith) -> Self { + Feature::SchemaWith(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Description(parse_utils::LitStrOrExpr); +} + +impl Parse for Description { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_next_literal_str_or_expr(input).map(Self) + } +} + +impl ToTokens for Description { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<String> for Description { + fn from(value: String) -> Self { + Self(value.into()) + } +} + +impl From<Description> for Feature { + fn from(value: Description) -> Self { + Self::Description(value) + } +} + +impl_feature! { + /// Deprecated feature parsed from macro attributes. + /// + /// This feature supports only syntax parsed from fastapi specific macro attributes, it does not + /// support Rust `#[deprecated]` attribute. + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Deprecated(bool); +} + +impl Parse for Deprecated { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for Deprecated { + fn to_tokens(&self, tokens: &mut TokenStream) { + let deprecated: crate::Deprecated = self.0.into(); + deprecated.to_tokens(tokens); + } +} + +impl From<Deprecated> for Feature { + fn from(value: Deprecated) -> Self { + Self::Deprecated(value) + } +} + +impl From<bool> for Deprecated { + fn from(value: bool) -> Self { + Self(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct As(pub TypePath); +} + +impl As { + /// Returns this `As` attribute type path formatted as string supported by OpenAPI spec whereas + /// double colons (::) are replaced with dot (.). + pub fn to_schema_formatted_string(&self) -> String { + // See: https://github.com/nxpkg/fastapi/pull/187#issuecomment-1173101405 + // :: are not officially supported in the spec + self.0 + .path + .segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::<Vec<_>>() + .join(".") + } +} + +impl Parse for As { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_next(input, || input.parse()).map(Self) + } +} + +impl From<As> for Feature { + fn from(value: As) -> Self { + Self::As(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct AdditionalProperties(bool); +} + +impl Parse for AdditionalProperties { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for AdditionalProperties { + fn to_tokens(&self, tokens: &mut TokenStream) { + let additional_properties = &self.0; + tokens.extend(quote!( + fastapi::openapi::schema::AdditionalProperties::FreeForm( + #additional_properties + ) + )) + } +} + +impl From<AdditionalProperties> for Feature { + fn from(value: AdditionalProperties) -> Self { + Self::AdditionalProperties(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Required(pub bool); +} + +impl Required { + pub fn is_true(&self) -> bool { + self.0 + } +} + +impl Parse for Required { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_bool_or_true(input).map(Self) + } +} + +impl ToTokens for Required { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens) + } +} + +impl From<crate::Required> for Required { + fn from(value: crate::Required) -> Self { + if value == crate::Required::True { + Self(true) + } else { + Self(false) + } + } +} + +impl From<bool> for Required { + fn from(value: bool) -> Self { + Self(value) + } +} + +impl From<Required> for Feature { + fn from(value: Required) -> Self { + Self::Required(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct ContentEncoding(String); +} + +impl Parse for ContentEncoding { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_next_literal_str(input).map(Self) + } +} + +impl ToTokens for ContentEncoding { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<ContentEncoding> for Feature { + fn from(value: ContentEncoding) -> Self { + Self::ContentEncoding(value) + } +} + +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct ContentMediaType(String); +} + +impl Parse for ContentMediaType { + fn parse(input: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_next_literal_str(input).map(Self) + } +} + +impl ToTokens for ContentMediaType { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<ContentMediaType> for Feature { + fn from(value: ContentMediaType) -> Self { + Self::ContentMediaType(value) + } +} + +// discriminator = ... +// discriminator(property_name = ..., mapping( +// (value = ...), +// (value2 = ...) +// )) +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Discriminator(LitStrOrExpr, Punctuated<(LitStrOrExpr, LitStrOrExpr), Token![,]>, Ident); +} + +impl Discriminator { + fn new(attribute: Ident) -> Self { + Self(LitStrOrExpr::default(), Punctuated::default(), attribute) + } + + pub fn get_attribute(&self) -> &Ident { + &self.2 + } +} + +impl Parse for Discriminator { + fn parse(input: ParseStream, attribute: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + let lookahead = input.lookahead1(); + if lookahead.peek(Token![=]) { + parse_utils::parse_next_literal_str_or_expr(input) + .map(|property_name| Self(property_name, Punctuated::new(), attribute)) + } else if lookahead.peek(Paren) { + let discriminator_stream; + syn::parenthesized!(discriminator_stream in input); + + let mut discriminator = Discriminator::new(attribute); + + while !discriminator_stream.is_empty() { + let property = discriminator_stream.parse::<Ident>()?; + let name = &*property.to_string(); + + match name { + "property_name" => { + discriminator.0 = + parse_utils::parse_next_literal_str_or_expr(&discriminator_stream)? + } + "mapping" => { + let mapping_stream; + syn::parenthesized!(mapping_stream in &discriminator_stream); + let mappings: Punctuated<(LitStrOrExpr, LitStrOrExpr), Token![,]> = + Punctuated::parse_terminated_with(&mapping_stream, |input| { + let inner; + syn::parenthesized!(inner in input); + + let key = inner.parse::<LitStrOrExpr>()?; + inner.parse::<Token![=]>()?; + let value = inner.parse::<LitStrOrExpr>()?; + + Ok((key, value)) + })?; + discriminator.1 = mappings; + } + unexpected => { + return Err(Error::new( + property.span(), + format!( + "unexpected identifier {}, expected any of: property_name, mapping", + unexpected + ), + )) + } + } + + if !discriminator_stream.is_empty() { + discriminator_stream.parse::<Token![,]>()?; + } + } + + Ok(discriminator) + } else { + Err(lookahead.error()) + } + } +} + +impl ToTokens for Discriminator { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Discriminator(property_name, mapping, _) = self; + + struct Mapping<'m>(&'m LitStrOrExpr, &'m LitStrOrExpr); + + impl ToTokens for Mapping<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let Mapping(property_name, value) = *self; + + tokens.extend(quote! { + (#property_name, #value) + }) + } + } + + let discriminator = if !mapping.is_empty() { + let mapping = mapping + .iter() + .map(|(key, value)| Mapping(key, value)) + .collect::<Array<Mapping>>(); + + quote! { + fastapi::openapi::schema::Discriminator::with_mapping(#property_name, #mapping) + } + } else { + quote! { + fastapi::openapi::schema::Discriminator::new(#property_name) + } + }; + + discriminator.to_tokens(tokens); + } +} + +impl From<Discriminator> for Feature { + fn from(value: Discriminator) -> Self { + Self::Discriminator(value) + } +} + +// bound = "GenericTy: Trait" +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Bound(pub(crate) Punctuated<WherePredicate, Token![,]>); +} + +impl Parse for Bound { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> { + let litstr = parse_utils::parse_next(input, || input.parse::<LitStr>())?; + let bounds = + syn::parse::Parser::parse_str(<Punctuated<_, _>>::parse_terminated, &litstr.value()) + .map_err(|err| syn::Error::new(litstr.span(), err.to_string()))?; + Ok(Self(bounds)) + } +} + +impl ToTokens for Bound { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From<Bound> for Feature { + fn from(value: Bound) -> Self { + Feature::Bound(value) + } +} + +impl_feature! { + /// Ignore feature parsed from macro attributes. + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Ignore(pub LitBoolOrExprPath); +} + +impl Parse for Ignore { + fn parse(input: syn::parse::ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + parse_utils::parse_next_literal_bool_or_call(input).map(Self) + } +} + +impl ToTokens for Ignore { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(self.0.to_token_stream()) + } +} + +impl From<Ignore> for Feature { + fn from(value: Ignore) -> Self { + Self::Ignore(value) + } +} + +impl From<bool> for Ignore { + fn from(value: bool) -> Self { + Self(value.into()) + } +} + +// Nothing to parse, it is considered to be set when attribute itself is parsed via +// `parse_features!`. +impl_feature! { + #[derive(Clone)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct NoRecursion; +} + +impl Parse for NoRecursion { + fn parse(_: ParseStream, _: Ident) -> syn::Result<Self> + where + Self: std::marker::Sized, + { + Ok(Self) + } +} + +impl From<NoRecursion> for Feature { + fn from(value: NoRecursion) -> Self { + Self::NoRecursion(value) + } +} diff --git a/fastapi-gen/src/component/features/validation.rs b/fastapi-gen/src/component/features/validation.rs new file mode 100644 index 0000000..27ae7fe --- /dev/null +++ b/fastapi-gen/src/component/features/validation.rs @@ -0,0 +1,520 @@ +use std::str::FromStr; + +use proc_macro2::{Ident, Literal, Span, TokenStream, TokenTree}; +use quote::{quote, ToTokens}; +use syn::parse::ParseStream; +use syn::LitStr; + +use crate::{parse_utils, Diagnostics}; + +use super::validators::Validator; +use super::{impl_feature, Feature, Parse, Validate}; + +#[inline] +fn from_str<T: FromStr>(number: &str, span: Span) -> syn::Result<T> +where + <T as std::str::FromStr>::Err: std::fmt::Display, +{ + T::from_str(number).map_err(|error| syn::Error::new(span, error)) +} + +#[derive(Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct NumberValue { + minus: bool, + pub lit: Literal, +} + +impl NumberValue { + pub fn try_from_str<T>(&self) -> syn::Result<T> + where + T: FromStr, + <T as std::str::FromStr>::Err: std::fmt::Display, + { + let number = if self.minus { + format!("-{}", &self.lit) + } else { + self.lit.to_string() + }; + + let parsed = from_str::<T>(&number, self.lit.span())?; + Ok(parsed) + } +} + +impl syn::parse::Parse for NumberValue { + fn parse(input: ParseStream) -> syn::Result<Self> { + let mut minus = false; + let result = input.step(|cursor| { + let mut rest = *cursor; + + while let Some((tt, next)) = rest.token_tree() { + match &tt { + TokenTree::Punct(punct) if punct.as_char() == '-' => { + minus = true; + } + TokenTree::Literal(lit) => return Ok((lit.clone(), next)), + _ => (), + } + rest = next; + } + Err(cursor.error("no `literal` value found after this point")) + })?; + + Ok(Self { minus, lit: result }) + } +} + +impl ToTokens for NumberValue { + fn to_tokens(&self, tokens: &mut TokenStream) { + let punct = if self.minus { Some(quote! {-}) } else { None }; + let lit = &self.lit; + + tokens.extend(quote! { + #punct #lit + }) + } +} + +#[inline] +fn parse_next_number_value(input: ParseStream) -> syn::Result<NumberValue> { + use syn::parse::Parse; + parse_utils::parse_next(input, || NumberValue::parse(input)) +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct MultipleOf(pub(super) NumberValue, Ident); +} + +impl Validate for MultipleOf { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!( "`multiple_of` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-multipleof`")), + _ => None + } + } +} + +impl Parse for MultipleOf { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for MultipleOf { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<MultipleOf> for Feature { + fn from(value: MultipleOf) -> Self { + Feature::MultipleOf(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Maximum(pub(super) NumberValue, Ident); +} + +impl Validate for Maximum { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`maximum` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-maximum`")), + _ => None, + } + } +} + +impl Parse for Maximum { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for Maximum { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<Maximum> for Feature { + fn from(value: Maximum) -> Self { + Feature::Maximum(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Minimum(NumberValue, Ident); +} + +impl Minimum { + pub fn new(value: f64, span: Span) -> Self { + Self( + NumberValue { + minus: value < 0.0, + lit: Literal::f64_suffixed(value), + }, + Ident::new("empty", span), + ) + } +} + +impl Validate for Minimum { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some( + Diagnostics::with_span(self.1.span(), format!("`minimum` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-minimum`") + ), + _ => None, + } + } +} + +impl Parse for Minimum { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for Minimum { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<Minimum> for Feature { + fn from(value: Minimum) -> Self { + Feature::Minimum(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct ExclusiveMaximum(NumberValue, Ident); +} + +impl Validate for ExclusiveMaximum { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`exclusive_maximum` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-exclusivemaximum`")), + _ => None, + } + } +} + +impl Parse for ExclusiveMaximum { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for ExclusiveMaximum { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<ExclusiveMaximum> for Feature { + fn from(value: ExclusiveMaximum) -> Self { + Feature::ExclusiveMaximum(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct ExclusiveMinimum(NumberValue, Ident); +} + +impl Validate for ExclusiveMinimum { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`exclusive_minimum` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-exclusiveminimum`")), + _ => None, + } + } +} + +impl Parse for ExclusiveMinimum { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for ExclusiveMinimum { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<ExclusiveMinimum> for Feature { + fn from(value: ExclusiveMinimum) -> Self { + Feature::ExclusiveMinimum(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct MaxLength(pub(super) NumberValue, Ident); +} + +impl Validate for MaxLength { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`max_length` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-maxlength`")), + _ => None, + } + } +} + +impl Parse for MaxLength { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for MaxLength { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<MaxLength> for Feature { + fn from(value: MaxLength) -> Self { + Feature::MaxLength(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct MinLength(pub(super) NumberValue, Ident); +} + +impl Validate for MinLength { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`min_length` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-minlength`")), + _ => None, + } + } +} + +impl Parse for MinLength { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for MinLength { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<MinLength> for Feature { + fn from(value: MinLength) -> Self { + Feature::MinLength(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct Pattern(String, Ident); +} + +impl Validate for Pattern { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`pattern` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-pattern`") + ), + _ => None, + } + } +} + +impl Parse for Pattern { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_utils::parse_next(input, || input.parse::<LitStr>()) + .map(|pattern| Self(pattern.value(), ident)) + } +} + +impl ToTokens for Pattern { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<Pattern> for Feature { + fn from(value: Pattern) -> Self { + Feature::Pattern(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct MaxItems(pub(super) NumberValue, Ident); +} + +impl Validate for MaxItems { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`max_items` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-maxitems")), + _ => None, + } + } +} + +impl Parse for MaxItems { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for MaxItems { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<MaxItems> for Feature { + fn from(value: MaxItems) -> Self { + Feature::MaxItems(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct MinItems(pub(super) NumberValue, Ident); +} + +impl Validate for MinItems { + fn validate(&self, validator: impl Validator) -> Option<Diagnostics> { + match validator.is_valid() { + Err(error) => Some(Diagnostics::with_span(self.1.span(), format!("`min_items` error: {}", error)) + .help("See more details: `http://json-schema.org/draft/2020-12/json-schema-validation.html#name-minitems")), + _ => None, + } + } +} + +impl Parse for MinItems { + fn parse(input: ParseStream, ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ident)) + } +} + +impl ToTokens for MinItems { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<MinItems> for Feature { + fn from(value: MinItems) -> Self { + Feature::MinItems(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct MaxProperties(NumberValue, ()); +} + +impl Parse for MaxProperties { + fn parse(input: ParseStream, _ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ())) + } +} + +impl ToTokens for MaxProperties { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<MaxProperties> for Feature { + fn from(value: MaxProperties) -> Self { + Feature::MaxProperties(value) + } +} + +impl_feature! { + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub struct MinProperties(NumberValue, ()); +} + +impl Parse for MinProperties { + fn parse(input: ParseStream, _ident: Ident) -> syn::Result<Self> + where + Self: Sized, + { + parse_next_number_value(input).map(|number| Self(number, ())) + } +} + +impl ToTokens for MinProperties { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +impl From<MinProperties> for Feature { + fn from(value: MinProperties) -> Self { + Feature::MinProperties(value) + } +} diff --git a/fastapi-gen/src/component/features/validators.rs b/fastapi-gen/src/component/features/validators.rs new file mode 100644 index 0000000..f0ba2fe --- /dev/null +++ b/fastapi-gen/src/component/features/validators.rs @@ -0,0 +1,122 @@ +use crate::component::{GenericType, TypeTree}; +use crate::schema_type::SchemaType; + +use super::validation::NumberValue; + +pub trait Validator { + fn is_valid(&self) -> Result<(), &'static str>; +} + +pub struct IsNumber<'a>(pub &'a SchemaType<'a>); + +impl Validator for IsNumber<'_> { + fn is_valid(&self) -> Result<(), &'static str> { + if self.0.is_number() { + Ok(()) + } else { + Err("can only be used with `number` type") + } + } +} + +pub struct IsString<'a>(pub(super) &'a SchemaType<'a>); + +impl Validator for IsString<'_> { + fn is_valid(&self) -> Result<(), &'static str> { + if self.0.is_string() { + Ok(()) + } else { + Err("can only be used with `string` type") + } + } +} + +pub struct IsInteger<'a>(&'a SchemaType<'a>); + +impl Validator for IsInteger<'_> { + fn is_valid(&self) -> Result<(), &'static str> { + if self.0.is_integer() { + Ok(()) + } else { + Err("can only be used with `integer` type") + } + } +} + +pub struct IsVec<'a>(pub(super) &'a TypeTree<'a>); + +impl Validator for IsVec<'_> { + fn is_valid(&self) -> Result<(), &'static str> { + if self.0.generic_type == Some(GenericType::Vec) { + Ok(()) + } else { + Err("can only be used with `Vec`, `array` or `slice` types") + } + } +} + +pub struct AboveZeroUsize<'a>(pub(super) &'a NumberValue); + +impl Validator for AboveZeroUsize<'_> { + fn is_valid(&self) -> Result<(), &'static str> { + let usize: usize = self + .0 + .try_from_str() + .map_err(|_| "invalid type, expected `usize`")?; + + if usize != 0 { + Ok(()) + } else { + Err("can only be above zero value") + } + } +} + +pub struct AboveZeroF64<'a>(pub(super) &'a NumberValue); + +impl Validator for AboveZeroF64<'_> { + fn is_valid(&self) -> Result<(), &'static str> { + let float: f64 = self + .0 + .try_from_str() + .map_err(|_| "invalid type, expected `f64`")?; + if float > 0.0 { + Ok(()) + } else { + Err("can only be above zero value") + } + } +} + +pub struct ValidatorChain<'c> { + inner: &'c dyn Validator, + next: Option<&'c dyn Validator>, +} + +impl Validator for ValidatorChain<'_> { + fn is_valid(&self) -> Result<(), &'static str> { + self.inner.is_valid().and_then(|_| { + if let Some(validator) = self.next.as_ref() { + validator.is_valid() + } else { + // if there is no next validator consider it valid + Ok(()) + } + }) + } +} + +impl<'c> ValidatorChain<'c> { + pub fn new(validator: &'c dyn Validator) -> Self { + Self { + inner: validator, + next: None, + } + } + + pub fn next(mut self, validator: &'c dyn Validator) -> Self { + self.next = Some(validator); + + self + } +} diff --git a/fastapi-gen/src/component/into_params.rs b/fastapi-gen/src/component/into_params.rs new file mode 100644 index 0000000..bf589a7 --- /dev/null +++ b/fastapi-gen/src/component/into_params.rs @@ -0,0 +1,502 @@ +use std::borrow::Cow; + +use proc_macro2::TokenStream; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{ + parse::Parse, punctuated::Punctuated, spanned::Spanned, token::Comma, Attribute, Data, Field, + Generics, Ident, +}; + +use crate::{ + component::{ + self, + features::{ + self, + attributes::{ + AdditionalProperties, AllowReserved, Example, Explode, Format, Ignore, Inline, + IntoParamsNames, Nullable, ReadOnly, Rename, RenameAll, SchemaWith, Style, + WriteOnly, XmlAttr, + }, + validation::{ + ExclusiveMaximum, ExclusiveMinimum, MaxItems, MaxLength, Maximum, MinItems, + MinLength, Minimum, MultipleOf, Pattern, + }, + }, + FieldRename, + }, + doc_comment::CommentAttributes, + parse_utils::LitBoolOrExprPath, + Array, Diagnostics, OptionExt, Required, ToTokensDiagnostics, +}; + +use super::{ + features::{ + impl_into_inner, impl_merge, parse_features, pop_feature, Feature, FeaturesExt, IntoInner, + Merge, ToTokensExt, + }, + serde::{self, SerdeContainer, SerdeValue}, + ComponentSchema, Container, TypeTree, +}; + +impl_merge!(IntoParamsFeatures, FieldFeatures); + +/// Container attribute `#[into_params(...)]`. +pub struct IntoParamsFeatures(Vec<Feature>); + +impl Parse for IntoParamsFeatures { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + Ok(Self(parse_features!( + input as Style, + features::attributes::ParameterIn, + IntoParamsNames, + RenameAll + ))) + } +} + +impl_into_inner!(IntoParamsFeatures); + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct IntoParams { + /// Attributes tagged on the whole struct or enum. + pub attrs: Vec<Attribute>, + /// Generics required to complete the definition. + pub generics: Generics, + /// Data within the struct or enum. + pub data: Data, + /// Name of the struct or enum. + pub ident: Ident, +} + +impl ToTokensDiagnostics for IntoParams { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let ident = &self.ident; + let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); + + let mut into_params_features = self + .attrs + .iter() + .filter(|attr| attr.path().is_ident("into_params")) + .map(|attribute| { + attribute + .parse_args::<IntoParamsFeatures>() + .map(IntoParamsFeatures::into_inner) + .map_err(Diagnostics::from) + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .reduce(|acc, item| acc.merge(item)); + let serde_container = serde::parse_container(&self.attrs)?; + + // #[param] is only supported over fields + if self.attrs.iter().any(|attr| attr.path().is_ident("param")) { + return Err(Diagnostics::with_span( + ident.span(), + "found `param` attribute in unsupported context", + ) + .help("Did you mean `into_params`?")); + } + + let names = into_params_features.as_mut().and_then(|features| { + let into_params_names = pop_feature!(features => Feature::IntoParamsNames(_)); + IntoInner::<Option<IntoParamsNames>>::into_inner(into_params_names) + .map(|names| names.into_values()) + }); + + let style = pop_feature!(into_params_features => Feature::Style(_)); + let parameter_in = pop_feature!(into_params_features => Feature::ParameterIn(_)); + let rename_all = pop_feature!(into_params_features => Feature::RenameAll(_)); + + let params = self + .get_struct_fields(&names.as_ref())? + .enumerate() + .map(|(index, field)| { + let field_features = match parse_field_features(field) { + Ok(features) => features, + Err(error) => return Err(error), + }; + match serde::parse_value(&field.attrs) { + Ok(serde_value) => Ok((index, field, serde_value, field_features)), + Err(diagnostics) => Err(diagnostics) + } + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .filter_map(|(index, field, field_serde_params, field_features)| { + if field_serde_params.skip { + None + } else { + Some((index, field, field_serde_params, field_features)) + } + }) + .map(|(index, field, field_serde_params, field_features)| { + let name = names.as_ref() + .map_try(|names| names.get(index).ok_or_else(|| Diagnostics::with_span( + ident.span(), + format!("There is no name specified in the names(...) container attribute for tuple struct field {}", index), + ))); + let name = match name { + Ok(name) => name, + Err(diagnostics) => return Err(diagnostics) + }; + let param = Param::new(field, field_serde_params, field_features, FieldParamContainerAttributes { + rename_all: rename_all.as_ref().and_then(|feature| { + match feature { + Feature::RenameAll(rename_all) => Some(rename_all), + _ => None + } + }), + style: &style, + parameter_in: ¶meter_in, + name, + }, &serde_container, &self.generics)?; + + + Ok(param.to_token_stream()) + }) + .collect::<Result<Array<TokenStream>, Diagnostics>>()?; + + tokens.extend(quote! { + impl #impl_generics fastapi::IntoParams for #ident #ty_generics #where_clause { + fn into_params(parameter_in_provider: impl Fn() -> Option<fastapi::openapi::path::ParameterIn>) -> Vec<fastapi::openapi::path::Parameter> { + #params.into_iter().filter(Option::is_some).flatten().collect() + } + } + }); + + Ok(()) + } +} + +fn parse_field_features(field: &Field) -> Result<Vec<Feature>, Diagnostics> { + Ok(field + .attrs + .iter() + .filter(|attribute| attribute.path().is_ident("param")) + .map(|attribute| { + attribute + .parse_args::<FieldFeatures>() + .map(FieldFeatures::into_inner) + }) + .collect::<Result<Vec<_>, syn::Error>>()? + .into_iter() + .reduce(|acc, item| acc.merge(item)) + .unwrap_or_default()) +} + +impl IntoParams { + fn get_struct_fields( + &self, + field_names: &Option<&Vec<String>>, + ) -> Result<impl Iterator<Item = &Field>, Diagnostics> { + let ident = &self.ident; + match &self.data { + Data::Struct(data_struct) => match &data_struct.fields { + syn::Fields::Named(named_fields) => { + if field_names.is_some() { + return Err(Diagnostics::with_span( + ident.span(), + "`#[into_params(names(...))]` is not supported attribute on a struct with named fields") + ); + } + Ok(named_fields.named.iter()) + } + syn::Fields::Unnamed(unnamed_fields) => { + match self.validate_unnamed_field_names(&unnamed_fields.unnamed, field_names) { + None => Ok(unnamed_fields.unnamed.iter()), + Some(diagnostics) => Err(diagnostics), + } + } + _ => Err(Diagnostics::with_span( + ident.span(), + "Unit type struct is not supported", + )), + }, + _ => Err(Diagnostics::with_span( + ident.span(), + "Only struct type is supported", + )), + } + } + + fn validate_unnamed_field_names( + &self, + unnamed_fields: &Punctuated<Field, Comma>, + field_names: &Option<&Vec<String>>, + ) -> Option<Diagnostics> { + let ident = &self.ident; + match field_names { + Some(names) => { + if names.len() != unnamed_fields.len() { + Some(Diagnostics::with_span( + ident.span(), + format!("declared names amount '{}' does not match to the unnamed fields amount '{}' in type: {}", + names.len(), unnamed_fields.len(), ident) + ) + .help(r#"Did you forget to add a field name to `#[into_params(names(... , "field_name"))]`"#) + .help("Or have you added extra name but haven't defined a type?") + ) + } else { + None + } + } + None => Some( + Diagnostics::with_span( + ident.span(), + "struct with unnamed fields must have explicit name declarations.", + ) + .help(format!( + "Try defining `#[into_params(names(...))]` over your type: {}", + ident + )), + ), + } + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct FieldParamContainerAttributes<'a> { + /// See [`IntoParamsAttr::style`]. + style: &'a Option<Feature>, + /// See [`IntoParamsAttr::names`]. The name that applies to this field. + name: Option<&'a String>, + /// See [`IntoParamsAttr::parameter_in`]. + parameter_in: &'a Option<Feature>, + /// Custom rename all if serde attribute is not present. + rename_all: Option<&'a RenameAll>, +} + +struct FieldFeatures(Vec<Feature>); + +impl_into_inner!(FieldFeatures); + +impl Parse for FieldFeatures { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + Ok(Self(parse_features!( + // param features + input as component::features::attributes::ValueType, + Rename, + Style, + AllowReserved, + Example, + Explode, + SchemaWith, + component::features::attributes::Required, + // param schema features + Inline, + Format, + component::features::attributes::Default, + WriteOnly, + ReadOnly, + Nullable, + XmlAttr, + MultipleOf, + Maximum, + Minimum, + ExclusiveMaximum, + ExclusiveMinimum, + MaxLength, + MinLength, + Pattern, + MaxItems, + MinItems, + AdditionalProperties, + Ignore + ))) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct Param { + tokens: TokenStream, +} + +impl Param { + fn new( + field: &Field, + field_serde_params: SerdeValue, + field_features: Vec<Feature>, + container_attributes: FieldParamContainerAttributes<'_>, + serde_container: &SerdeContainer, + generics: &Generics, + ) -> Result<Self, Diagnostics> { + let mut tokens = TokenStream::new(); + let field_serde_params = &field_serde_params; + let ident = &field.ident; + let mut name = &*ident + .as_ref() + .map(|ident| ident.to_string()) + .or_else(|| container_attributes.name.cloned()) + .ok_or_else(|| + Diagnostics::with_span(field.span(), "No name specified for unnamed field.") + .help("Try adding #[into_params(names(...))] container attribute to specify the name for this field") + )?; + + if name.starts_with("r#") { + name = &name[2..]; + } + + let (schema_features, mut param_features) = + Param::resolve_field_features(field_features, &container_attributes) + .map_err(Diagnostics::from)?; + + let ignore = pop_feature!(param_features => Feature::Ignore(_)); + let rename = pop_feature!(param_features => Feature::Rename(_) as Option<Rename>) + .map(|rename| rename.into_value()); + let rename_to = field_serde_params + .rename + .as_deref() + .map(Cow::Borrowed) + .or(rename.map(Cow::Owned)); + let rename_all = serde_container.rename_all.as_ref().or(container_attributes + .rename_all + .map(|rename_all| rename_all.as_rename_rule())); + let name = super::rename::<FieldRename>(name, rename_to, rename_all) + .unwrap_or(Cow::Borrowed(name)); + let type_tree = TypeTree::from_type(&field.ty)?; + + tokens.extend(quote! { fastapi::openapi::path::ParameterBuilder::new() + .name(#name) + }); + tokens.extend( + if let Some(ref parameter_in) = &container_attributes.parameter_in { + parameter_in.to_token_stream() + } else { + quote! { + .parameter_in(parameter_in_provider().unwrap_or_default()) + } + }, + ); + + if let Some(deprecated) = super::get_deprecated(&field.attrs) { + tokens.extend(quote! { .deprecated(Some(#deprecated)) }); + } + + let schema_with = pop_feature!(param_features => Feature::SchemaWith(_)); + if let Some(schema_with) = schema_with { + let schema_with = crate::as_tokens_or_diagnostics!(&schema_with); + tokens.extend(quote! { .schema(Some(#schema_with)).build() }); + } else { + let description = + CommentAttributes::from_attributes(&field.attrs).as_formatted_string(); + if !description.is_empty() { + tokens.extend(quote! { .description(Some(#description))}) + } + + let value_type = pop_feature!(param_features => Feature::ValueType(_) as Option<features::attributes::ValueType>); + let component = value_type + .as_ref() + .map_try(|value_type| value_type.as_type_tree())? + .unwrap_or(type_tree); + let alias_type = component.get_alias_type()?; + let alias_type_tree = alias_type.as_ref().map_try(TypeTree::from_type)?; + let component = alias_type_tree.as_ref().unwrap_or(&component); + + let required: Option<features::attributes::Required> = + pop_feature!(param_features => Feature::Required(_)).into_inner(); + let component_required = + !component.is_option() && super::is_required(field_serde_params, serde_container); + + let required = match (required, component_required) { + (Some(required_feature), _) => Into::<Required>::into(required_feature.is_true()), + (None, component_required) => Into::<Required>::into(component_required), + }; + + tokens.extend(quote! { + .required(#required) + }); + tokens.extend(param_features.to_token_stream()?); + + let schema = ComponentSchema::new(component::ComponentSchemaProps { + type_tree: component, + features: schema_features, + description: None, + container: &Container { generics }, + })?; + let schema_tokens = schema.to_token_stream(); + + tokens.extend(quote! { .schema(Some(#schema_tokens)).build() }); + } + + let tokens = match ignore { + Some(Feature::Ignore(Ignore(LitBoolOrExprPath::LitBool(bool)))) => { + quote_spanned! { + bool.span() => if #bool { + None + } else { + Some(#tokens) + } + } + } + Some(Feature::Ignore(Ignore(LitBoolOrExprPath::ExprPath(path)))) => { + quote_spanned! { + path.span() => if #path() { + None + } else { + Some(#tokens) + } + } + } + _ => quote! { Some(#tokens) }, + }; + + Ok(Self { tokens }) + } + + /// Resolve [`Param`] features and split features into two [`Vec`]s. Features are split by + /// whether they should be rendered in [`Param`] itself or in [`Param`]s schema. + /// + /// Method returns a tuple containing two [`Vec`]s of [`Feature`]. + fn resolve_field_features( + mut field_features: Vec<Feature>, + container_attributes: &FieldParamContainerAttributes<'_>, + ) -> Result<(Vec<Feature>, Vec<Feature>), syn::Error> { + if let Some(ref style) = container_attributes.style { + if !field_features + .iter() + .any(|feature| matches!(&feature, Feature::Style(_))) + { + field_features.push(style.clone()); // could try to use cow to avoid cloning + }; + } + + Ok(field_features.into_iter().fold( + (Vec::<Feature>::new(), Vec::<Feature>::new()), + |(mut schema_features, mut param_features), feature| { + match feature { + Feature::Inline(_) + | Feature::Format(_) + | Feature::Default(_) + | Feature::WriteOnly(_) + | Feature::ReadOnly(_) + | Feature::Nullable(_) + | Feature::XmlAttr(_) + | Feature::MultipleOf(_) + | Feature::Maximum(_) + | Feature::Minimum(_) + | Feature::ExclusiveMaximum(_) + | Feature::ExclusiveMinimum(_) + | Feature::MaxLength(_) + | Feature::MinLength(_) + | Feature::Pattern(_) + | Feature::MaxItems(_) + | Feature::MinItems(_) + | Feature::AdditionalProperties(_) => { + schema_features.push(feature); + } + _ => { + param_features.push(feature); + } + }; + + (schema_features, param_features) + }, + )) + } +} + +impl ToTokens for Param { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.tokens.to_tokens(tokens) + } +} diff --git a/fastapi-gen/src/component/schema.rs b/fastapi-gen/src/component/schema.rs new file mode 100644 index 0000000..f0bb1b5 --- /dev/null +++ b/fastapi-gen/src/component/schema.rs @@ -0,0 +1,970 @@ +use std::borrow::{Borrow, Cow}; + +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{ + parse_quote, punctuated::Punctuated, spanned::Spanned, token::Comma, Attribute, Data, Field, + Fields, FieldsNamed, FieldsUnnamed, Generics, Variant, +}; + +use crate::{ + as_tokens_or_diagnostics, + component::features::attributes::{Rename, Title, ValueType}, + doc_comment::CommentAttributes, + parse_utils::LitBoolOrExprPath, + Array, AttributesExt, Diagnostics, OptionExt, ToTokensDiagnostics, +}; + +use self::{ + enums::{MixedEnum, PlainEnum}, + features::{ + EnumFeatures, FromAttributes, MixedEnumFeatures, NamedFieldFeatures, + NamedFieldStructFeatures, UnnamedFieldStructFeatures, + }, +}; + +use super::{ + features::{ + attributes::{self, As, Bound, Description, NoRecursion, RenameAll}, + parse_features, pop_feature, Feature, FeaturesExt, IntoInner, ToTokensExt, + }, + serde::{self, SerdeContainer, SerdeValue}, + ComponentDescription, ComponentSchema, FieldRename, FlattenedMapSchema, SchemaReference, + TypeTree, VariantRename, +}; + +mod enums; +mod features; +pub mod xml; + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Root<'p> { + pub ident: &'p Ident, + pub generics: &'p Generics, + pub attributes: &'p [Attribute], +} + +pub struct Schema<'a> { + ident: &'a Ident, + attributes: &'a [Attribute], + generics: &'a Generics, + data: &'a Data, +} + +impl<'a> Schema<'a> { + pub fn new( + data: &'a Data, + attributes: &'a [Attribute], + ident: &'a Ident, + generics: &'a Generics, + ) -> Result<Self, Diagnostics> { + Ok(Self { + data, + ident, + attributes, + generics, + }) + } +} + +impl ToTokensDiagnostics for Schema<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + let ident = self.ident; + let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); + let mut where_clause = where_clause.map_or(parse_quote!(where), |w| w.clone()); + + let root = Root { + ident, + generics: self.generics, + attributes: self.attributes, + }; + let variant = SchemaVariant::new(self.data, &root)?; + let (generic_references, schema_references): (Vec<_>, Vec<_>) = variant + .get_schema_references() + .filter(|schema_reference| !schema_reference.no_recursion) + .partition(|schema_reference| schema_reference.is_partial()); + + struct SchemaRef<'a>(&'a TokenStream, &'a TokenStream, &'a TokenStream, bool); + impl ToTokens for SchemaRef<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let SchemaRef(name, ref_tokens, ..) = self; + tokens.extend(quote! { (#name, #ref_tokens) }); + } + } + let schema_refs = schema_references + .iter() + .map(|schema_reference| { + SchemaRef( + &schema_reference.name, + &schema_reference.tokens, + &schema_reference.references, + schema_reference.is_inline, + ) + }) + .collect::<Array<SchemaRef>>(); + + let references = schema_refs.iter().fold( + TokenStream::new(), + |mut tokens, SchemaRef(_, _, references, _)| { + tokens.extend(quote!( #references; )); + + tokens + }, + ); + let generic_references = generic_references + .into_iter() + .map(|schema_reference| { + let reference = &schema_reference.references; + quote! {#reference;} + }) + .collect::<TokenStream>(); + + let schema_refs = schema_refs + .iter() + .filter(|SchemaRef(_, _, _, is_inline)| { + #[cfg(feature = "config")] + { + (matches!( + crate::CONFIG.schema_collect, + fastapi_config::SchemaCollect::NonInlined + ) && !is_inline) + || matches!( + crate::CONFIG.schema_collect, + fastapi_config::SchemaCollect::All + ) + } + #[cfg(not(feature = "config"))] + !is_inline + }) + .collect::<Array<_>>(); + + let name = if let Some(schema_as) = variant.get_schema_as() { + schema_as.to_schema_formatted_string() + } else { + ident.to_string() + }; + + // TODO refactor this to avoid clone + if let Some(Bound(bound)) = variant.get_schema_bound() { + where_clause.predicates.extend(bound.clone()); + } else { + for param in self.generics.type_params() { + let param = ¶m.ident; + where_clause + .predicates + .push(parse_quote!(#param : fastapi::ToSchema)) + } + } + + tokens.extend(quote! { + impl #impl_generics fastapi::__dev::ComposeSchema for #ident #ty_generics #where_clause { + fn compose( + mut generics: Vec<fastapi::openapi::RefOr<fastapi::openapi::schema::Schema>> + ) -> fastapi::openapi::RefOr<fastapi::openapi::schema::Schema> { + #variant.into() + } + } + + impl #impl_generics fastapi::ToSchema for #ident #ty_generics #where_clause { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(#name) + } + + fn schemas(schemas: &mut Vec<(String, fastapi::openapi::RefOr<fastapi::openapi::schema::Schema>)>) { + schemas.extend(#schema_refs); + #references; + #generic_references + } + } + }); + Ok(()) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum SchemaVariant<'a> { + Named(NamedStructSchema), + Unnamed(UnnamedStructSchema), + Enum(EnumSchema<'a>), + Unit(UnitStructVariant), +} + +impl<'a> SchemaVariant<'a> { + pub fn new(data: &'a Data, root: &'a Root<'a>) -> Result<SchemaVariant<'a>, Diagnostics> { + match data { + Data::Struct(content) => match &content.fields { + Fields::Unnamed(fields) => { + let FieldsUnnamed { unnamed, .. } = fields; + let unnamed_features = root + .attributes + .parse_features::<UnnamedFieldStructFeatures>()? + .into_inner() + .unwrap_or_default(); + + Ok(Self::Unnamed(UnnamedStructSchema::new( + root, + unnamed, + unnamed_features, + )?)) + } + Fields::Named(fields) => { + let FieldsNamed { named, .. } = fields; + let named_features = root + .attributes + .parse_features::<NamedFieldStructFeatures>()? + .into_inner() + .unwrap_or_default(); + + Ok(Self::Named(NamedStructSchema::new( + root, + named, + named_features, + )?)) + } + Fields::Unit => Ok(Self::Unit(UnitStructVariant::new(root)?)), + }, + Data::Enum(content) => Ok(Self::Enum(EnumSchema::new(root, &content.variants)?)), + _ => Err(Diagnostics::with_span( + root.ident.span(), + "unexpected data type, expected syn::Data::Struct or syn::Data::Enum", + )), + } + } + + fn get_schema_as(&self) -> &Option<As> { + match self { + Self::Enum(schema) => &schema.schema_as, + Self::Named(schema) => &schema.schema_as, + Self::Unnamed(schema) => &schema.schema_as, + _ => &None, + } + } + + fn get_schema_references(&self) -> impl Iterator<Item = &SchemaReference> { + match self { + Self::Named(schema) => schema.fields_references.iter(), + Self::Unnamed(schema) => schema.schema_references.iter(), + Self::Enum(schema) => schema.schema_references.iter(), + _ => [].iter(), + } + } + + fn get_schema_bound(&self) -> Option<&Bound> { + match self { + SchemaVariant::Named(schema) => schema.bound.as_ref(), + SchemaVariant::Unnamed(schema) => schema.bound.as_ref(), + SchemaVariant::Enum(schema) => schema.bound.as_ref(), + SchemaVariant::Unit(_) => None, + } + } +} + +impl ToTokens for SchemaVariant<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::Enum(schema) => schema.to_tokens(tokens), + Self::Named(schema) => schema.to_tokens(tokens), + Self::Unnamed(schema) => schema.to_tokens(tokens), + Self::Unit(unit) => unit.to_tokens(tokens), + } + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct UnitStructVariant(TokenStream); + +impl UnitStructVariant { + fn new(root: &Root<'_>) -> Result<Self, Diagnostics> { + let mut tokens = quote! { + fastapi::openapi::Object::builder() + .schema_type(fastapi::openapi::schema::SchemaType::AnyValue) + .default(Some(serde_json::Value::Null)) + }; + + let mut features = features::parse_schema_features_with(root.attributes, |input| { + Ok(parse_features!(input as Title, Description)) + })? + .unwrap_or_default(); + + let description = pop_feature!(features => Feature::Description(_) as Option<Description>); + + let comment = CommentAttributes::from_attributes(root.attributes); + let description = description + .as_ref() + .map(ComponentDescription::Description) + .or(Some(ComponentDescription::CommentAttributes(&comment))); + + description.to_tokens(&mut tokens); + tokens.extend(features.to_token_stream()); + + Ok(Self(tokens)) + } +} + +impl ToTokens for UnitStructVariant { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct NamedStructSchema { + tokens: TokenStream, + pub schema_as: Option<As>, + fields_references: Vec<SchemaReference>, + bound: Option<Bound>, + is_all_of: bool, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct NamedStructFieldOptions<'a> { + property: Property, + renamed_field: Option<Cow<'a, str>>, + required: Option<super::features::attributes::Required>, + is_option: bool, + ignore: Option<LitBoolOrExprPath>, +} + +impl NamedStructSchema { + pub fn new( + root: &Root, + fields: &Punctuated<Field, Comma>, + mut features: Vec<Feature>, + ) -> Result<Self, Diagnostics> { + let mut tokens = TokenStream::new(); + + let rename_all = pop_feature!(features => Feature::RenameAll(_) as Option<RenameAll>); + let schema_as = pop_feature!(features => Feature::As(_) as Option<As>); + let description: Option<Description> = + pop_feature!(features => Feature::Description(_)).into_inner(); + let bound = pop_feature!(features => Feature::Bound(_) as Option<Bound>); + + let container_rules = serde::parse_container(root.attributes)?; + + let mut fields_vec = fields + .iter() + .filter_map(|field| { + let mut field_name = Cow::Owned(field.ident.as_ref().unwrap().to_string()); + + if Borrow::<str>::borrow(&field_name).starts_with("r#") { + field_name = Cow::Owned(field_name[2..].to_string()); + } + + let field_rules = serde::parse_value(&field.attrs); + let field_rules = match field_rules { + Ok(field_rules) => field_rules, + Err(diagnostics) => return Some(Err(diagnostics)), + }; + let field_options = Self::get_named_struct_field_options( + root, + field, + &features, + &field_rules, + &container_rules, + ); + + match field_options { + Ok(Some(field_options)) => { + Some(Ok((field_options, field_rules, field_name, field))) + } + Ok(_) => None, + Err(options_diagnostics) => Some(Err(options_diagnostics)), + } + }) + .collect::<Result<Vec<_>, Diagnostics>>()?; + + let fields_references = fields_vec + .iter_mut() + .filter_map(|(field_options, field_rules, ..)| { + match (&mut field_options.property, field_rules.skip) { + (Property::Schema(schema), false) => { + Some(std::mem::take(&mut schema.schema_references)) + } + _ => None, + } + }) + .flatten() + .collect::<Vec<_>>(); + + let mut object_tokens_empty = true; + let object_tokens = fields_vec + .iter() + .filter(|(_, field_rules, ..)| !field_rules.skip && !field_rules.flatten) + .map(|(property, field_rules, field_name, field)| { + Ok(( + property, + field_rules, + field_name, + field, + as_tokens_or_diagnostics!(&property.property), + )) + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .fold( + quote! { let mut object = fastapi::openapi::ObjectBuilder::new(); }, + |mut object_tokens, + ( + NamedStructFieldOptions { + renamed_field, + required, + is_option, + ignore, + .. + }, + field_rules, + field_name, + _field, + field_schema, + )| { + object_tokens_empty = false; + let rename_to = field_rules + .rename + .as_deref() + .map(Cow::Borrowed) + .or(renamed_field.as_ref().cloned()); + let rename_all = container_rules.rename_all.as_ref().or(rename_all + .as_ref() + .map(|rename_all| rename_all.as_rename_rule())); + + let name = + super::rename::<FieldRename>(field_name.borrow(), rename_to, rename_all) + .unwrap_or(Cow::Borrowed(field_name.borrow())); + + let mut property_tokens = quote! { + object = object.property(#name, #field_schema) + }; + let component_required = + !is_option && super::is_required(field_rules, &container_rules); + let required = match (required, component_required) { + (Some(required), _) => required.is_true(), + (None, component_required) => component_required, + }; + + if required { + property_tokens.extend(quote! { + .required(#name) + }) + } + + object_tokens.extend(match ignore { + Some(LitBoolOrExprPath::LitBool(bool)) => quote_spanned! { + bool.span() => if !#bool { + #property_tokens; + } + }, + Some(LitBoolOrExprPath::ExprPath(path)) => quote_spanned! { + path.span() => if !#path() { + #property_tokens; + } + }, + None => quote! { #property_tokens; }, + }); + + object_tokens + }, + ); + + let mut object_tokens = quote! { + { #object_tokens; object } + }; + + let flatten_fields = fields_vec + .iter() + .filter(|(_, field_rules, ..)| field_rules.flatten) + .collect::<Vec<_>>(); + + let all_of = if !flatten_fields.is_empty() { + let mut flattened_tokens = TokenStream::new(); + let mut flattened_map_field = None; + + for (options, _, _, field) in flatten_fields { + let NamedStructFieldOptions { property, .. } = options; + let property_schema = as_tokens_or_diagnostics!(property); + + match property { + Property::Schema(_) | Property::SchemaWith(_) => { + flattened_tokens.extend(quote! { .item(#property_schema) }) + } + Property::FlattenedMap(_) => { + match flattened_map_field { + None => { + object_tokens.extend( + quote! { .additional_properties(Some(#property_schema)) }, + ); + flattened_map_field = Some(field); + } + Some(flattened_map_field) => { + return Err(Diagnostics::with_span( + fields.span(), + format!("The structure `{}` contains multiple flattened map fields.", root.ident)) + .note( + format!("first flattened map field was declared here as `{}`", + flattened_map_field.ident.as_ref().unwrap())) + .note(format!("second flattened map field was declared here as `{}`", field.ident.as_ref().unwrap())) + ); + } + } + } + } + } + + if flattened_tokens.is_empty() { + tokens.extend(object_tokens); + false + } else { + tokens.extend(quote! { + fastapi::openapi::AllOfBuilder::new() + #flattened_tokens + + }); + if !object_tokens_empty { + tokens.extend(quote! { + .item(#object_tokens) + }); + } + true + } + } else { + tokens.extend(object_tokens); + false + }; + + if !all_of && container_rules.deny_unknown_fields { + tokens.extend(quote! { + .additional_properties(Some(fastapi::openapi::schema::AdditionalProperties::FreeForm(false))) + }); + } + + if root.attributes.has_deprecated() + && !features + .iter() + .any(|feature| matches!(feature, Feature::Deprecated(_))) + { + features.push(Feature::Deprecated(true.into())); + } + + let _ = pop_feature!(features => Feature::NoRecursion(_)); + tokens.extend(features.to_token_stream()?); + + let comments = CommentAttributes::from_attributes(root.attributes); + let description = description + .as_ref() + .map(ComponentDescription::Description) + .or(Some(ComponentDescription::CommentAttributes(&comments))); + + description.to_tokens(&mut tokens); + + Ok(Self { + tokens, + schema_as, + fields_references, + bound, + is_all_of: all_of, + }) + } + + fn get_named_struct_field_options<'a>( + root: &Root, + field: &Field, + features: &[Feature], + field_rules: &SerdeValue, + container_rules: &SerdeContainer, + ) -> Result<Option<NamedStructFieldOptions<'a>>, Diagnostics> { + let type_tree = &mut TypeTree::from_type(&field.ty)?; + + let mut field_features = field + .attrs + .parse_features::<NamedFieldFeatures>()? + .into_inner() + .unwrap_or_default(); + + if features + .iter() + .any(|feature| matches!(feature, Feature::NoRecursion(_))) + { + field_features.push(Feature::NoRecursion(NoRecursion)); + } + + let schema_default = features.iter().any(|f| matches!(f, Feature::Default(_))); + let serde_default = container_rules.default; + + if (schema_default || serde_default) + && !field_features + .iter() + .any(|f| matches!(f, Feature::Default(_))) + { + let field_ident = field.ident.as_ref().unwrap().to_owned(); + + // TODO refactor the clone away + field_features.push(Feature::Default( + crate::features::attributes::Default::new_default_trait( + root.ident.clone(), + field_ident.into(), + ), + )); + } + + if field.attrs.has_deprecated() + && !field_features + .iter() + .any(|feature| matches!(feature, Feature::Deprecated(_))) + { + field_features.push(Feature::Deprecated(true.into())); + } + + let rename_field = + pop_feature!(field_features => Feature::Rename(_)).and_then(|feature| match feature { + Feature::Rename(rename) => Some(Cow::Owned(rename.into_value())), + _ => None, + }); + + let value_type = pop_feature!(field_features => Feature::ValueType(_) as Option<ValueType>); + let override_type_tree = value_type + .as_ref() + .map_try(|value_type| value_type.as_type_tree())?; + let comments = CommentAttributes::from_attributes(&field.attrs); + let description = &ComponentDescription::CommentAttributes(&comments); + + let schema_with = pop_feature!(field_features => Feature::SchemaWith(_)); + let required = pop_feature!(field_features => Feature::Required(_) as Option<crate::component::features::attributes::Required>); + let type_tree = override_type_tree.as_ref().unwrap_or(type_tree); + + let alias_type = type_tree.get_alias_type()?; + let alias_type_tree = alias_type.as_ref().map_try(TypeTree::from_type)?; + let type_tree = alias_type_tree.as_ref().unwrap_or(type_tree); + + let is_option = type_tree.is_option(); + + let ignore = match pop_feature!(field_features => Feature::Ignore(_)) { + Some(Feature::Ignore(attributes::Ignore(bool_or_exp))) => Some(bool_or_exp), + _ => None, + }; + + Ok(Some(NamedStructFieldOptions { + property: if let Some(schema_with) = schema_with { + Property::SchemaWith(schema_with) + } else { + let props = super::ComponentSchemaProps { + type_tree, + features: field_features, + description: Some(description), + container: &super::Container { + generics: root.generics, + }, + }; + if field_rules.flatten && type_tree.is_map() { + Property::FlattenedMap(FlattenedMapSchema::new(props)?) + } else { + let schema = ComponentSchema::new(props)?; + Property::Schema(schema) + } + }, + renamed_field: rename_field, + required, + is_option, + ignore, + })) + } +} + +impl ToTokens for NamedStructSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.tokens.to_tokens(tokens); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct UnnamedStructSchema { + tokens: TokenStream, + schema_as: Option<As>, + schema_references: Vec<SchemaReference>, + bound: Option<Bound>, +} + +impl UnnamedStructSchema { + fn new( + root: &Root, + fields: &Punctuated<Field, Comma>, + mut features: Vec<Feature>, + ) -> Result<Self, Diagnostics> { + let mut tokens = TokenStream::new(); + let schema_as = pop_feature!(features => Feature::As(_) as Option<As>); + let description: Option<Description> = + pop_feature!(features => Feature::Description(_)).into_inner(); + let bound = pop_feature!(features => Feature::Bound(_) as Option<Bound>); + + let fields_len = fields.len(); + let first_field = fields.first().unwrap(); + let first_part = &TypeTree::from_type(&first_field.ty)?; + + let all_fields_are_same = fields_len == 1 + || fields + .iter() + .skip(1) + .map(|field| TypeTree::from_type(&field.ty)) + .collect::<Result<Vec<TypeTree>, Diagnostics>>()? + .iter() + .all(|schema_part| first_part == schema_part); + + if root.attributes.has_deprecated() + && !features + .iter() + .any(|feature| matches!(feature, Feature::Deprecated(_))) + { + features.push(Feature::Deprecated(true.into())); + } + let mut schema_references = Vec::<SchemaReference>::new(); + if all_fields_are_same { + let value_type = pop_feature!(features => Feature::ValueType(_) as Option<ValueType>); + let override_type_tree = value_type + .as_ref() + .map_try(|value_type| value_type.as_type_tree())?; + + if fields_len == 1 { + let inline = features::parse_schema_features_with(&first_field.attrs, |input| { + Ok(parse_features!( + input as super::features::attributes::Inline + )) + })? + .unwrap_or_default(); + + features.extend(inline); + + if pop_feature!(features => Feature::Default(crate::features::attributes::Default(None))) + .is_some() + { + let index: syn::Index = 0.into(); + // TODO refactor the clone away + features.push(Feature::Default( + crate::features::attributes::Default::new_default_trait(root.ident.clone(), index.into()), + )); + } + } + + let comments = CommentAttributes::from_attributes(root.attributes); + let description = description + .as_ref() + .map(ComponentDescription::Description) + .or(Some(ComponentDescription::CommentAttributes(&comments))); + let type_tree = override_type_tree.as_ref().unwrap_or(first_part); + + let alias_type = type_tree.get_alias_type()?; + let alias_type_tree = alias_type.as_ref().map_try(TypeTree::from_type)?; + let type_tree = alias_type_tree.as_ref().unwrap_or(type_tree); + + let mut schema = ComponentSchema::new(super::ComponentSchemaProps { + type_tree, + features, + description: description.as_ref(), + container: &super::Container { + generics: root.generics, + }, + })?; + + tokens.extend(schema.to_token_stream()); + schema_references = std::mem::take(&mut schema.schema_references); + } else { + // Struct that has multiple unnamed fields is serialized to array by default with serde. + // See: https://serde.rs/json.html + // Typically OpenAPI does not support multi type arrays thus we simply consider the case + // as generic object array + tokens.extend(quote! { + fastapi::openapi::ObjectBuilder::new() + }); + + tokens.extend(features.to_token_stream()?) + } + + if fields_len > 1 { + let comments = CommentAttributes::from_attributes(root.attributes); + let description = description + .as_ref() + .map(ComponentDescription::Description) + .or(Some(ComponentDescription::CommentAttributes(&comments))); + tokens.extend(quote! { + .to_array_builder() + .max_items(Some(#fields_len)) + .min_items(Some(#fields_len)) + #description + }) + } + + Ok(UnnamedStructSchema { + tokens, + schema_as, + schema_references, + bound, + }) + } +} + +impl ToTokens for UnnamedStructSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.tokens.to_tokens(tokens); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct EnumSchema<'a> { + schema_type: EnumSchemaType<'a>, + schema_as: Option<As>, + schema_references: Vec<SchemaReference>, + bound: Option<Bound>, +} + +impl<'e> EnumSchema<'e> { + pub fn new( + parent: &'e Root<'e>, + variants: &'e Punctuated<Variant, Comma>, + ) -> Result<Self, Diagnostics> { + if variants + .iter() + .all(|variant| matches!(variant.fields, Fields::Unit)) + { + #[cfg(feature = "repr")] + let mut features = { + if parent + .attributes + .iter() + .any(|attr| attr.path().is_ident("repr")) + { + features::parse_schema_features_with(parent.attributes, |input| { + Ok(parse_features!( + input as super::features::attributes::Example, + super::features::attributes::Examples, + super::features::attributes::Default, + super::features::attributes::Title, + crate::component::features::attributes::Deprecated, + As + )) + })? + .unwrap_or_default() + } else { + parent + .attributes + .parse_features::<EnumFeatures>()? + .into_inner() + .unwrap_or_default() + } + }; + #[cfg(not(feature = "repr"))] + let mut features = { + parent + .attributes + .parse_features::<EnumFeatures>()? + .into_inner() + .unwrap_or_default() + }; + + let schema_as = pop_feature!(features => Feature::As(_) as Option<As>); + let bound = pop_feature!(features => Feature::Bound(_) as Option<Bound>); + + if parent.attributes.has_deprecated() { + features.push(Feature::Deprecated(true.into())) + } + + Ok(Self { + schema_type: EnumSchemaType::Plain(PlainEnum::new(parent, variants, features)?), + schema_as, + schema_references: Vec::new(), + bound, + }) + } else { + let mut enum_features = parent + .attributes + .parse_features::<MixedEnumFeatures>()? + .into_inner() + .unwrap_or_default(); + let schema_as = pop_feature!(enum_features => Feature::As(_) as Option<As>); + let bound = pop_feature!(enum_features => Feature::Bound(_) as Option<Bound>); + + if parent.attributes.has_deprecated() { + enum_features.push(Feature::Deprecated(true.into())) + } + let mut mixed_enum = MixedEnum::new(parent, variants, enum_features)?; + let schema_references = std::mem::take(&mut mixed_enum.schema_references); + Ok(Self { + schema_type: EnumSchemaType::Mixed(mixed_enum), + schema_as, + schema_references, + bound, + }) + } + } +} + +impl ToTokens for EnumSchema<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.schema_type.to_tokens(tokens) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum EnumSchemaType<'e> { + Mixed(MixedEnum<'e>), + Plain(PlainEnum<'e>), +} + +impl ToTokens for EnumSchemaType<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let (attributes, description) = match self { + Self::Mixed(mixed) => { + mixed.to_tokens(tokens); + (mixed.root.attributes, &mixed.description) + } + Self::Plain(plain) => { + plain.to_tokens(tokens); + (plain.root.attributes, &plain.description) + } + }; + + let comments = CommentAttributes::from_attributes(attributes); + let description = description + .as_ref() + .map(ComponentDescription::Description) + .or(Some(ComponentDescription::CommentAttributes(&comments))); + + description.to_tokens(tokens); + } +} + +fn rename_enum_variant<'s>( + name: &str, + features: &mut Vec<Feature>, + variant_rules: &'s SerdeValue, + container_rules: &'s SerdeContainer, + rename_all: Option<&RenameAll>, +) -> Option<Cow<'s, str>> { + let rename = pop_feature!(features => Feature::Rename(_) as Option<Rename>) + .map(|rename| rename.into_value()); + let rename_to = variant_rules + .rename + .as_deref() + .map(Cow::Borrowed) + .or(rename.map(Cow::Owned)); + + let rename_all = container_rules.rename_all.as_ref().or(rename_all + .as_ref() + .map(|rename_all| rename_all.as_rename_rule())); + + super::rename::<VariantRename>(name, rename_to, rename_all) +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum Property { + Schema(ComponentSchema), + SchemaWith(Feature), + FlattenedMap(FlattenedMapSchema), +} + +impl ToTokensDiagnostics for Property { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + match self { + Self::Schema(schema) => schema.to_tokens(tokens), + Self::FlattenedMap(schema) => schema.to_tokens(tokens)?, + Self::SchemaWith(schema_with) => schema_with.to_tokens(tokens)?, + } + Ok(()) + } +} diff --git a/fastapi-gen/src/component/schema/enums.rs b/fastapi-gen/src/component/schema/enums.rs new file mode 100644 index 0000000..263162f --- /dev/null +++ b/fastapi-gen/src/component/schema/enums.rs @@ -0,0 +1,1053 @@ +use std::{borrow::Cow, ops::Deref}; + +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::{punctuated::Punctuated, spanned::Spanned, token::Comma, Fields, TypePath, Variant}; + +use crate::{ + component::{ + features::{ + attributes::{ + Deprecated, Description, Discriminator, Example, Examples, NoRecursion, Rename, + RenameAll, Title, + }, + parse_features, pop_feature, Feature, IntoInner, IsInline, ToTokensExt, + }, + schema::features::{ + EnumNamedFieldVariantFeatures, EnumUnnamedFieldVariantFeatures, FromAttributes, + }, + serde::{SerdeContainer, SerdeEnumRepr, SerdeValue}, + FeaturesExt, SchemaReference, TypeTree, ValueType, + }, + doc_comment::CommentAttributes, + schema_type::SchemaType, + Array, AttributesExt, Diagnostics, ToTokensDiagnostics, +}; + +use super::{features, serde, NamedStructSchema, Root, UnnamedStructSchema}; + +#[cfg_attr(feature = "debug", derive(Debug))] +enum PlainEnumRepr<'p> { + Plain(Array<'p, TokenStream>), + Repr(Array<'p, TokenStream>, syn::TypePath), +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct PlainEnum<'e> { + pub root: &'e Root<'e>, + enum_variant: PlainEnumRepr<'e>, + serde_enum_repr: SerdeEnumRepr, + features: Vec<Feature>, + pub description: Option<Description>, +} + +impl<'e> PlainEnum<'e> { + pub fn new( + root: &'e Root, + variants: &Punctuated<Variant, Comma>, + mut features: Vec<Feature>, + ) -> Result<Self, Diagnostics> { + #[cfg(feature = "repr")] + let repr_type_path = PlainEnum::get_repr_type(root.attributes)?; + + #[cfg(not(feature = "repr"))] + let repr_type_path = None; + + let rename_all = pop_feature!(features => Feature::RenameAll(_) as Option<RenameAll>); + let description = pop_feature!(features => Feature::Description(_) as Option<Description>); + + let container_rules = serde::parse_container(root.attributes)?; + let variants_iter = variants + .iter() + .map(|variant| match serde::parse_value(&variant.attrs) { + Ok(variant_rules) => Ok((variant, variant_rules)), + Err(diagnostics) => Err(diagnostics), + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .filter_map(|(variant, variant_rules)| { + if variant_rules.skip { + None + } else { + Some((variant, variant_rules)) + } + }); + + let enum_variant = match repr_type_path { + Some(repr_type_path) => PlainEnumRepr::Repr( + variants_iter + .map(|(variant, _)| { + let ty = &variant.ident; + quote! { + Self::#ty as #repr_type_path + } + }) + .collect::<Array<TokenStream>>(), + repr_type_path, + ), + None => PlainEnumRepr::Plain( + variants_iter + .map(|(variant, variant_rules)| { + let parsed_features_result = + features::parse_schema_features_with(&variant.attrs, |input| { + Ok(parse_features!(input as Rename)) + }); + + match parsed_features_result { + Ok(variant_features) => { + Ok((variant, variant_rules, variant_features.unwrap_or_default())) + } + Err(diagnostics) => Err(diagnostics), + } + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .map(|(variant, variant_rules, mut variant_features)| { + let name = &*variant.ident.to_string(); + let renamed = super::rename_enum_variant( + name, + &mut variant_features, + &variant_rules, + &container_rules, + rename_all.as_ref(), + ); + + renamed.unwrap_or(Cow::Borrowed(name)).to_token_stream() + }) + .collect::<Array<TokenStream>>(), + ), + }; + + Ok(Self { + root, + enum_variant, + features, + serde_enum_repr: container_rules.enum_repr, + description, + }) + } + + #[cfg(feature = "repr")] + fn get_repr_type(attributes: &[syn::Attribute]) -> Result<Option<syn::TypePath>, syn::Error> { + attributes + .iter() + .find_map(|attr| { + if attr.path().is_ident("repr") { + Some(attr.parse_args::<syn::TypePath>()) + } else { + None + } + }) + .transpose() + } +} + +impl ToTokens for PlainEnum<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + let (variants, schema_type, enum_type) = match &self.enum_variant { + PlainEnumRepr::Plain(items) => ( + Roo::Ref(items), + Roo::Owned(SchemaType { + nullable: false, + path: Cow::Owned(syn::parse_quote!(str)), + }), + Roo::Owned(quote! { &str }), + ), + PlainEnumRepr::Repr(repr, repr_type) => ( + Roo::Ref(repr), + Roo::Owned(SchemaType { + nullable: false, + path: Cow::Borrowed(&repr_type.path), + }), + Roo::Owned(repr_type.path.to_token_stream()), + ), + }; + + match &self.serde_enum_repr { + SerdeEnumRepr::ExternallyTagged => { + EnumSchema::<PlainSchema>::with_types(variants, schema_type, enum_type) + .to_tokens(tokens); + } + SerdeEnumRepr::InternallyTagged { tag } => { + let items = variants + .iter() + .map(|item| Array::Owned(vec![item])) + .collect::<Array<_>>(); + let schema_type = schema_type.as_ref(); + let enum_type = enum_type.as_ref(); + + OneOf { + items: &items + .iter() + .map(|item| { + EnumSchema::<PlainSchema>::with_types( + Roo::Ref(item), + Roo::Ref(schema_type), + Roo::Ref(enum_type), + ) + .tagged(tag) + }) + .collect(), + discriminator: None, + } + .to_tokens(tokens) + } + SerdeEnumRepr::Untagged => { + // Even though untagged enum might have multiple variants, but unit type variants + // all will result `null` empty schema thus returning one empty schema is + // sufficient instead of returning one of N * `null` schema. + EnumSchema::<TokenStream>::untagged().to_tokens(tokens); + } + SerdeEnumRepr::AdjacentlyTagged { tag, content } => { + let items = variants + .iter() + .map(|item| Array::Owned(vec![item])) + .collect::<Array<_>>(); + let schema_type = schema_type.as_ref(); + let enum_type = enum_type.as_ref(); + + OneOf { + items: &items + .iter() + .map(|item| { + EnumSchema::<ObjectSchema>::adjacently_tagged( + PlainSchema::new( + item.deref(), + Roo::Ref(schema_type), + Roo::Ref(enum_type), + ), + content, + ) + .tag(tag, PlainSchema::for_name(content)) + }) + .collect(), + discriminator: None, + } + .to_tokens(tokens) + } + // This should not be possible as serde should not let that happen + SerdeEnumRepr::UnfinishedAdjacentlyTagged { .. } => { + unreachable!("Invalid serde enum repr, serde should have panicked and not reach here, plain enum") + } + }; + + tokens.extend(self.features.to_token_stream()); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct MixedEnum<'p> { + pub root: &'p Root<'p>, + pub tokens: TokenStream, + pub description: Option<Description>, + pub schema_references: Vec<SchemaReference>, +} + +impl<'p> MixedEnum<'p> { + pub fn new( + root: &'p Root, + variants: &Punctuated<Variant, Comma>, + mut features: Vec<Feature>, + ) -> Result<Self, Diagnostics> { + let attributes = root.attributes; + let container_rules = serde::parse_container(attributes)?; + + let rename_all = pop_feature!(features => Feature::RenameAll(_) as Option<RenameAll>); + let description = pop_feature!(features => Feature::Description(_) as Option<Description>); + let discriminator = pop_feature!(features => Feature::Discriminator(_)); + + let variants = variants + .iter() + .map(|variant| match serde::parse_value(&variant.attrs) { + Ok(variant_rules) => Ok((variant, variant_rules)), + Err(diagnostics) => Err(diagnostics), + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .filter_map(|(variant, variant_rules)| { + if variant_rules.skip { + None + } else { + let variant_features = match &variant.fields { + Fields::Named(_) => { + match variant + .attrs + .parse_features::<EnumNamedFieldVariantFeatures>() + { + Ok(features) => features.into_inner().unwrap_or_default(), + Err(diagnostics) => return Some(Err(diagnostics)), + } + } + Fields::Unnamed(_) => { + match variant + .attrs + .parse_features::<EnumUnnamedFieldVariantFeatures>() + { + Ok(features) => features.into_inner().unwrap_or_default(), + Err(diagnostics) => return Some(Err(diagnostics)), + } + } + Fields::Unit => { + let parse_unit_features = + features::parse_schema_features_with(&variant.attrs, |input| { + Ok(parse_features!( + input as Title, + Rename, + Example, + Examples, + Deprecated + )) + }); + + match parse_unit_features { + Ok(features) => features.unwrap_or_default(), + Err(diagnostics) => return Some(Err(diagnostics)), + } + } + }; + + Some(Ok((variant, variant_rules, variant_features))) + } + }) + .collect::<Result<Vec<_>, Diagnostics>>()?; + + // discriminator is only supported when all variants are unnamed with single non primitive + // field + let discriminator_supported = variants + .iter() + .all(|(variant, _, features)| + matches!(&variant.fields, Fields::Unnamed(unnamed) if unnamed.unnamed.len() == 1 + && TypeTree::from_type(&unnamed.unnamed.first().unwrap().ty).expect("unnamed field should be valid TypeTree").value_type == ValueType::Object + && !features.is_inline()) + ) + && matches!(container_rules.enum_repr, SerdeEnumRepr::Untagged); + + if discriminator.is_some() && !discriminator_supported { + let discriminator: Discriminator = + IntoInner::<Option<Discriminator>>::into_inner(discriminator).unwrap(); + return Err(Diagnostics::with_span( + discriminator.get_attribute().span(), + "Found discriminator in not discriminator supported context", + ).help("`discriminator` is only supported on enums with `#[serde(untagged)]` having unnamed field variants with single reference field.") + .note("Unnamed field variants with inlined or primitive schemas does not support discriminator.") + .note("Read more about discriminators from the specs <https://spec.openapis.org/oas/latest.html#discriminator-object>")); + } + + let mut items = variants + .into_iter() + .map(|(variant, variant_serde_rules, mut variant_features)| { + if features + .iter() + .any(|feature| matches!(feature, Feature::NoRecursion(_))) + { + variant_features.push(Feature::NoRecursion(NoRecursion)); + } + MixedEnumContent::new( + variant, + root, + &container_rules, + rename_all.as_ref(), + variant_serde_rules, + variant_features, + ) + }) + .collect::<Result<Vec<MixedEnumContent>, Diagnostics>>()?; + + let schema_references = items + .iter_mut() + .flat_map(|item| std::mem::take(&mut item.schema_references)) + .collect::<Vec<_>>(); + + let one_of_enum = OneOf { + items: &Array::Owned(items), + discriminator, + }; + + let _ = pop_feature!(features => Feature::NoRecursion(_)); + let mut tokens = one_of_enum.to_token_stream(); + tokens.extend(features.to_token_stream()); + + Ok(Self { + root, + tokens, + description, + schema_references, + }) + } +} + +impl ToTokens for MixedEnum<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.tokens.to_tokens(tokens); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct MixedEnumContent { + tokens: TokenStream, + schema_references: Vec<SchemaReference>, +} + +impl MixedEnumContent { + fn new( + variant: &Variant, + root: &Root, + serde_container: &SerdeContainer, + rename_all: Option<&RenameAll>, + variant_serde_rules: SerdeValue, + mut variant_features: Vec<Feature>, + ) -> Result<Self, Diagnostics> { + let mut tokens = TokenStream::new(); + let name = variant.ident.to_string(); + // TODO support `description = ...` attribute via Feature::Description + // let description = + // pop_feature!(variant_features => Feature::Description(_) as Option<Description>); + let variant_description = + CommentAttributes::from_attributes(&variant.attrs).as_formatted_string(); + let description: Option<Description> = + (!variant_description.is_empty()).then(|| variant_description.into()); + if let Some(description) = description { + variant_features.push(Feature::Description(description)) + } + + if variant.attrs.has_deprecated() { + variant_features.push(Feature::Deprecated(true.into())) + } + + let mut schema_references: Vec<SchemaReference> = Vec::new(); + match &variant.fields { + Fields::Named(named) => { + let (variant_tokens, references) = + MixedEnumContent::get_named_tokens_with_schema_references( + root, + MixedEnumVariant { + variant, + fields: &named.named, + name, + }, + variant_features, + serde_container, + variant_serde_rules, + rename_all, + )?; + schema_references.extend(references); + variant_tokens.to_tokens(&mut tokens); + } + Fields::Unnamed(unnamed) => { + let (variant_tokens, references) = + MixedEnumContent::get_unnamed_tokens_with_schema_reference( + root, + MixedEnumVariant { + variant, + fields: &unnamed.unnamed, + name, + }, + variant_features, + serde_container, + variant_serde_rules, + rename_all, + )?; + + schema_references.extend(references); + variant_tokens.to_tokens(&mut tokens); + } + Fields::Unit => { + let variant_tokens = MixedEnumContent::get_unit_tokens( + name, + variant_features, + serde_container, + variant_serde_rules, + rename_all, + ); + variant_tokens.to_tokens(&mut tokens); + } + } + + Ok(Self { + tokens, + schema_references, + }) + } + + fn get_named_tokens_with_schema_references( + root: &Root, + variant: MixedEnumVariant, + mut variant_features: Vec<Feature>, + serde_container: &SerdeContainer, + variant_serde_rules: SerdeValue, + rename_all: Option<&RenameAll>, + ) -> Result<(TokenStream, Vec<SchemaReference>), Diagnostics> { + let MixedEnumVariant { + variant, + fields, + name, + } = variant; + + let renamed = super::rename_enum_variant( + &name, + &mut variant_features, + &variant_serde_rules, + serde_container, + rename_all, + ); + let name = renamed.unwrap_or(Cow::Owned(name)); + + let root = &Root { + ident: &variant.ident, + attributes: &variant.attrs, + generics: root.generics, + }; + + let tokens_with_schema_references = match &serde_container.enum_repr { + SerdeEnumRepr::ExternallyTagged => { + let (enum_features, variant_features) = + MixedEnumContent::split_enum_features(variant_features); + let schema = NamedStructSchema::new(root, fields, variant_features)?; + let schema_tokens = schema.to_token_stream(); + + ( + EnumSchema::<ObjectSchema>::new(name.as_ref(), schema_tokens) + .features(enum_features) + .to_token_stream(), + schema.fields_references, + ) + } + SerdeEnumRepr::InternallyTagged { tag } => { + let (enum_features, variant_features) = + MixedEnumContent::split_enum_features(variant_features); + let schema = NamedStructSchema::new(root, fields, variant_features)?; + + let mut schema_tokens = schema.to_token_stream(); + ( + if schema.is_all_of { + let object_builder_tokens = + quote! { fastapi::openapi::schema::Object::builder() }; + let enum_schema_tokens = + EnumSchema::<ObjectSchema>::tagged(object_builder_tokens) + .tag(tag, PlainSchema::for_name(name.as_ref())) + .features(enum_features) + .to_token_stream(); + schema_tokens.extend(quote! { + .item(#enum_schema_tokens) + }); + schema_tokens + } else { + EnumSchema::<ObjectSchema>::tagged(schema_tokens) + .tag(tag, PlainSchema::for_name(name.as_ref())) + .features(enum_features) + .to_token_stream() + }, + schema.fields_references, + ) + } + SerdeEnumRepr::Untagged => { + let schema = NamedStructSchema::new(root, fields, variant_features)?; + (schema.to_token_stream(), schema.fields_references) + } + SerdeEnumRepr::AdjacentlyTagged { tag, content } => { + let (enum_features, variant_features) = + MixedEnumContent::split_enum_features(variant_features); + let schema = NamedStructSchema::new(root, fields, variant_features)?; + + let schema_tokens = schema.to_token_stream(); + ( + EnumSchema::<ObjectSchema>::adjacently_tagged(schema_tokens, content) + .tag(tag, PlainSchema::for_name(name.as_ref())) + .features(enum_features) + .to_token_stream(), + schema.fields_references, + ) + } + SerdeEnumRepr::UnfinishedAdjacentlyTagged { .. } => unreachable!( + "Invalid serde enum repr, serde should have panicked before reaching here" + ), + }; + + Ok(tokens_with_schema_references) + } + + fn get_unnamed_tokens_with_schema_reference( + root: &Root, + variant: MixedEnumVariant, + mut variant_features: Vec<Feature>, + serde_container: &SerdeContainer, + variant_serde_rules: SerdeValue, + rename_all: Option<&RenameAll>, + ) -> Result<(TokenStream, Vec<SchemaReference>), Diagnostics> { + let MixedEnumVariant { + variant, + fields, + name, + } = variant; + + let renamed = super::rename_enum_variant( + &name, + &mut variant_features, + &variant_serde_rules, + serde_container, + rename_all, + ); + let name = renamed.unwrap_or(Cow::Owned(name)); + + let root = &Root { + ident: &variant.ident, + attributes: &variant.attrs, + generics: root.generics, + }; + + let tokens_with_schema_reference = match &serde_container.enum_repr { + SerdeEnumRepr::ExternallyTagged => { + let (enum_features, variant_features) = + MixedEnumContent::split_enum_features(variant_features); + let schema = UnnamedStructSchema::new(root, fields, variant_features)?; + + let schema_tokens = schema.to_token_stream(); + ( + EnumSchema::<ObjectSchema>::new(name.as_ref(), schema_tokens) + .features(enum_features) + .to_token_stream(), + schema.schema_references, + ) + } + SerdeEnumRepr::InternallyTagged { tag } => { + let (enum_features, variant_features) = + MixedEnumContent::split_enum_features(variant_features); + let schema = UnnamedStructSchema::new(root, fields, variant_features)?; + + let schema_tokens = schema.to_token_stream(); + + let is_reference = fields + .iter() + .map(|field| TypeTree::from_type(&field.ty)) + .collect::<Result<Vec<TypeTree>, Diagnostics>>()? + .iter() + .any(|type_tree| type_tree.value_type == ValueType::Object); + + ( + EnumSchema::<InternallyTaggedUnnamedSchema>::new(schema_tokens, is_reference) + .tag(tag, PlainSchema::for_name(name.as_ref())) + .features(enum_features) + .to_token_stream(), + schema.schema_references, + ) + } + SerdeEnumRepr::Untagged => { + let schema = UnnamedStructSchema::new(root, fields, variant_features)?; + (schema.to_token_stream(), schema.schema_references) + } + SerdeEnumRepr::AdjacentlyTagged { tag, content } => { + if fields.len() > 1 { + return Err(Diagnostics::with_span(variant.span(), + "Unnamed (tuple) enum variants are unsupported for internally tagged enums using the `tag = ` serde attribute") + .help("Try using a different serde enum representation") + .note("See more about enum limitations here: `https://serde.rs/enum-representations.html#internally-tagged`") + ); + } + + let (enum_features, variant_features) = + MixedEnumContent::split_enum_features(variant_features); + let schema = UnnamedStructSchema::new(root, fields, variant_features)?; + + let schema_tokens = schema.to_token_stream(); + ( + EnumSchema::<ObjectSchema>::adjacently_tagged(schema_tokens, content) + .tag(tag, PlainSchema::for_name(name.as_ref())) + .features(enum_features) + .to_token_stream(), + schema.schema_references, + ) + } + SerdeEnumRepr::UnfinishedAdjacentlyTagged { .. } => unreachable!( + "Invalid serde enum repr, serde should have panicked before reaching here" + ), + }; + + Ok(tokens_with_schema_reference) + } + + fn get_unit_tokens( + name: String, + mut variant_features: Vec<Feature>, + serde_container: &SerdeContainer, + variant_serde_rules: SerdeValue, + rename_all: Option<&RenameAll>, + ) -> TokenStream { + let renamed = super::rename_enum_variant( + &name, + &mut variant_features, + &variant_serde_rules, + serde_container, + rename_all, + ); + let name = renamed.unwrap_or(Cow::Owned(name)); + + match &serde_container.enum_repr { + SerdeEnumRepr::ExternallyTagged => EnumSchema::<PlainSchema>::new(name.as_ref()) + .features(variant_features) + .to_token_stream(), + SerdeEnumRepr::InternallyTagged { tag } => { + EnumSchema::<PlainSchema>::new(name.as_ref()) + .tagged(tag) + .features(variant_features) + .to_token_stream() + } + SerdeEnumRepr::Untagged => { + let v: EnumSchema = EnumSchema::untagged().features(variant_features); + v.to_token_stream() + } + SerdeEnumRepr::AdjacentlyTagged { tag, .. } => { + EnumSchema::<PlainSchema>::new(name.as_ref()) + .tagged(tag) + .features(variant_features) + .to_token_stream() + } + SerdeEnumRepr::UnfinishedAdjacentlyTagged { .. } => unreachable!( + "Invalid serde enum repr, serde should have panicked before reaching here" + ), + } + } + + fn split_enum_features(variant_features: Vec<Feature>) -> (Vec<Feature>, Vec<Feature>) { + let (enum_features, variant_features): (Vec<_>, Vec<_>) = + variant_features.into_iter().partition(|feature| { + matches!( + feature, + Feature::Title(_) + | Feature::Example(_) + | Feature::Examples(_) + | Feature::Default(_) + | Feature::Description(_) + | Feature::Deprecated(_) + ) + }); + + (enum_features, variant_features) + } +} + +impl ToTokens for MixedEnumContent { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.tokens.to_tokens(tokens); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct MixedEnumVariant<'v> { + variant: &'v syn::Variant, + fields: &'v Punctuated<syn::Field, Comma>, + name: String, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct EnumSchema<T = TokenStream> { + features: Vec<Feature>, + untagged: bool, + content: Option<T>, +} + +impl<T> EnumSchema<T> { + fn untagged() -> EnumSchema<T> { + Self { + untagged: true, + features: Vec::new(), + content: None, + } + } + + fn features(mut self, features: Vec<Feature>) -> Self { + self.features = features; + + self + } +} + +impl<T> ToTokens for EnumSchema<T> +where + T: ToTokens, +{ + fn to_tokens(&self, tokens: &mut TokenStream) { + if let Some(content) = &self.content { + tokens.extend(content.to_token_stream()); + } + + if self.untagged { + tokens.extend(quote! { + fastapi::openapi::schema::Object::builder() + .schema_type(fastapi::openapi::schema::Type::Null) + .default(Some(serde_json::Value::Null)) + }) + } + + tokens.extend(self.features.to_token_stream()); + } +} + +impl<'a> EnumSchema<ObjectSchema> { + fn new<T: ToTokens>(name: &'a str, item: T) -> Self { + let content = quote! { + fastapi::openapi::schema::Object::builder() + .property(#name, #item) + .required(#name) + }; + + Self { + content: Some(ObjectSchema(content)), + features: Vec::new(), + untagged: false, + } + } + + fn tagged<T: ToTokens>(item: T) -> Self { + let content = item.to_token_stream(); + + Self { + content: Some(ObjectSchema(content)), + features: Vec::new(), + untagged: false, + } + } + + fn tag(mut self, tag: &'a str, tag_schema: PlainSchema) -> Self { + let content = self.content.get_or_insert(ObjectSchema::default()); + + content.0.extend(quote! { + .property(#tag, fastapi::openapi::schema::Object::builder() #tag_schema) + .required(#tag) + }); + + self + } + + fn adjacently_tagged<T: ToTokens>(item: T, content: &str) -> Self { + let content = quote! { + fastapi::openapi::schema::Object::builder() + .property(#content, #item) + .required(#content) + }; + + Self { + content: Some(ObjectSchema(content)), + features: Vec::new(), + untagged: false, + } + } +} + +impl EnumSchema<InternallyTaggedUnnamedSchema> { + fn new<T: ToTokens>(item: T, is_reference: bool) -> Self { + let schema = item.to_token_stream(); + + let tokens = if is_reference { + quote! { + fastapi::openapi::schema::AllOfBuilder::new() + .item(#schema) + } + } else { + quote! { + #schema + .schema_type(fastapi::openapi::schema::Type::Object) + } + }; + + Self { + content: Some(InternallyTaggedUnnamedSchema(tokens, is_reference)), + untagged: false, + features: Vec::new(), + } + } + + fn tag(mut self, tag: &str, tag_schema: PlainSchema) -> Self { + let content = self + .content + .get_or_insert(InternallyTaggedUnnamedSchema::default()); + let is_reference = content.1; + + if is_reference { + content.0.extend(quote! { + .item( + fastapi::openapi::schema::Object::builder() + .property(#tag, fastapi::openapi::schema::Object::builder() #tag_schema) + .required(#tag) + ) + }); + } else { + content.0.extend(quote! { + .property(#tag, fastapi::openapi::schema::Object::builder() #tag_schema) + .required(#tag) + }); + } + + self + } +} + +impl<'a> EnumSchema<PlainSchema> { + fn new<N: ToTokens>(name: N) -> Self { + let plain_schema = PlainSchema::for_name(name); + + Self { + content: Some(PlainSchema(quote! { + fastapi::openapi::schema::Object::builder() #plain_schema + })), + untagged: false, + features: Vec::new(), + } + } + + fn with_types<T: ToTokens>( + items: Roo<'a, Array<'a, T>>, + schema_type: Roo<'a, SchemaType<'a>>, + enum_type: Roo<'a, TokenStream>, + ) -> Self { + let plain_schema = PlainSchema::new(&items, schema_type, enum_type); + + Self { + content: Some(PlainSchema(quote! { + fastapi::openapi::schema::Object::builder() #plain_schema + })), + untagged: false, + features: Vec::new(), + } + } + + fn tagged(mut self, tag: &str) -> Self { + if let Some(content) = self.content { + let plain_schema = content.0; + self.content = Some(PlainSchema( + quote! { + fastapi::openapi::schema::Object::builder() + .property(#tag, #plain_schema ) + .required(#tag) + } + .to_token_stream(), + )); + } + + self + } +} + +#[derive(Default)] +struct ObjectSchema(TokenStream); + +impl ToTokens for ObjectSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +#[derive(Default)] +struct InternallyTaggedUnnamedSchema(TokenStream, bool); + +impl ToTokens for InternallyTaggedUnnamedSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +#[derive(Default)] +struct PlainSchema(TokenStream); + +impl PlainSchema { + fn get_default_types() -> (Roo<'static, SchemaType<'static>>, Roo<'static, TokenStream>) { + let type_path: TypePath = syn::parse_quote!(str); + let schema_type = SchemaType { + path: Cow::Owned(type_path.path), + nullable: false, + }; + let enum_type = quote! { &str }; + + (Roo::Owned(schema_type), Roo::Owned(enum_type)) + } + + fn new<'a, T: ToTokens>( + items: &[T], + schema_type: Roo<'a, SchemaType<'a>>, + enum_type: Roo<'a, TokenStream>, + ) -> Self { + let schema_type = schema_type.to_token_stream(); + let enum_type = enum_type.as_ref(); + let items = Array::Borrowed(items); + let len = items.len(); + + let plain_enum = quote! { + .schema_type(#schema_type) + .enum_values::<[#enum_type; #len], #enum_type>(Some(#items)) + }; + + Self(plain_enum.to_token_stream()) + } + + fn for_name<N: ToTokens>(name: N) -> Self { + let (schema_type, enum_type) = Self::get_default_types(); + let name = &[name.to_token_stream()]; + Self::new(name, schema_type, enum_type) + } +} + +impl ToTokens for PlainSchema { + fn to_tokens(&self, tokens: &mut TokenStream) { + self.0.to_tokens(tokens); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct OneOf<'a, T: ToTokens> { + items: &'a Array<'a, T>, + discriminator: Option<Feature>, +} + +impl<'a, T> ToTokens for OneOf<'a, T> +where + T: ToTokens, +{ + fn to_tokens(&self, tokens: &mut TokenStream) { + let items = self.items; + let len = items.len(); + + // concat items + let items_as_tokens = items.iter().fold(TokenStream::new(), |mut items, item| { + items.extend(quote! { + .item(#item) + }); + + items + }); + + // discriminator tokens will not fail + let discriminator = self.discriminator.to_token_stream(); + + tokens.extend(quote! { + Into::<fastapi::openapi::schema::OneOfBuilder>::into(fastapi::openapi::OneOf::with_capacity(#len)) + #items_as_tokens + #discriminator + }); + } +} + +/// `RefOrOwned` is simple `Cow` like type to wrap either `ref` or owned value. This allows passing +/// either owned or referenced values as if they were owned like the `Cow` does but this works with +/// non cloneable types. Thus values cannot be modified but they can be passed down as re-referenced +/// values by dereffing the original value. `Roo::Ref(original.deref())`. +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum Roo<'t, T> { + Ref(&'t T), + Owned(T), +} + +impl<'t, T> Deref for Roo<'t, T> { + type Target = T; + + fn deref(&self) -> &Self::Target { + match self { + Self::Ref(t) => t, + Self::Owned(t) => t, + } + } +} + +impl<'t, T> AsRef<T> for Roo<'t, T> { + fn as_ref(&self) -> &T { + self.deref() + } +} diff --git a/fastapi-gen/src/component/schema/features.rs b/fastapi-gen/src/component/schema/features.rs new file mode 100644 index 0000000..7db9126 --- /dev/null +++ b/fastapi-gen/src/component/schema/features.rs @@ -0,0 +1,273 @@ +use syn::{ + parse::{Parse, ParseBuffer, ParseStream}, + Attribute, +}; + +use crate::{ + component::features::{ + attributes::{ + AdditionalProperties, As, Bound, ContentEncoding, ContentMediaType, Deprecated, + Description, Discriminator, Example, Examples, Format, Ignore, Inline, NoRecursion, + Nullable, ReadOnly, Rename, RenameAll, Required, SchemaWith, Title, ValueType, + WriteOnly, XmlAttr, + }, + impl_into_inner, impl_merge, parse_features, + validation::{ + ExclusiveMaximum, ExclusiveMinimum, MaxItems, MaxLength, MaxProperties, Maximum, + MinItems, MinLength, MinProperties, Minimum, MultipleOf, Pattern, + }, + Feature, Merge, + }, + Diagnostics, +}; + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct NamedFieldStructFeatures(Vec<Feature>); + +impl Parse for NamedFieldStructFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(NamedFieldStructFeatures(parse_features!( + input as Example, + Examples, + XmlAttr, + Title, + RenameAll, + MaxProperties, + MinProperties, + As, + crate::component::features::attributes::Default, + Deprecated, + Description, + Bound, + NoRecursion + ))) + } +} + +impl_into_inner!(NamedFieldStructFeatures); + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct UnnamedFieldStructFeatures(Vec<Feature>); + +impl Parse for UnnamedFieldStructFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(UnnamedFieldStructFeatures(parse_features!( + input as Example, + Examples, + crate::component::features::attributes::Default, + Title, + Format, + ValueType, + As, + Deprecated, + Description, + ContentEncoding, + ContentMediaType, + Bound, + NoRecursion + ))) + } +} + +impl_into_inner!(UnnamedFieldStructFeatures); + +pub struct EnumFeatures(Vec<Feature>); + +impl Parse for EnumFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(EnumFeatures(parse_features!( + input as Example, + Examples, + crate::component::features::attributes::Default, + Title, + RenameAll, + As, + Deprecated, + Description, + Bound + ))) + } +} + +impl_into_inner!(EnumFeatures); + +pub struct MixedEnumFeatures(Vec<Feature>); + +impl Parse for MixedEnumFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(MixedEnumFeatures(parse_features!( + input as Example, + Examples, + crate::component::features::attributes::Default, + Title, + RenameAll, + As, + Deprecated, + Description, + Discriminator, + NoRecursion + ))) + } +} + +impl_into_inner!(MixedEnumFeatures); + +pub struct NamedFieldFeatures(Vec<Feature>); + +impl Parse for NamedFieldFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(NamedFieldFeatures(parse_features!( + input as Example, + Examples, + ValueType, + Format, + crate::component::features::attributes::Default, + WriteOnly, + ReadOnly, + XmlAttr, + Inline, + Nullable, + Rename, + MultipleOf, + Maximum, + Minimum, + ExclusiveMaximum, + ExclusiveMinimum, + MaxLength, + MinLength, + Pattern, + MaxItems, + MinItems, + SchemaWith, + AdditionalProperties, + Required, + Deprecated, + ContentEncoding, + ContentMediaType, + Ignore, + NoRecursion + ))) + } +} + +impl_into_inner!(NamedFieldFeatures); + +pub struct EnumNamedFieldVariantFeatures(Vec<Feature>); + +impl Parse for EnumNamedFieldVariantFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(EnumNamedFieldVariantFeatures(parse_features!( + input as Example, + Examples, + crate::component::features::attributes::Default, + XmlAttr, + Title, + Rename, + RenameAll, + Deprecated, + MaxProperties, + MinProperties, + NoRecursion + ))) + } +} + +impl_into_inner!(EnumNamedFieldVariantFeatures); + +pub struct EnumUnnamedFieldVariantFeatures(Vec<Feature>); + +impl Parse for EnumUnnamedFieldVariantFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(EnumUnnamedFieldVariantFeatures(parse_features!( + input as Example, + Examples, + crate::component::features::attributes::Default, + Title, + Format, + ValueType, + Rename, + Deprecated, + NoRecursion + ))) + } +} + +impl_into_inner!(EnumUnnamedFieldVariantFeatures); + +pub trait FromAttributes { + fn parse_features<T>(&self) -> Result<Option<T>, Diagnostics> + where + T: Parse + Merge<T>; +} + +impl FromAttributes for &'_ [Attribute] { + fn parse_features<T>(&self) -> Result<Option<T>, Diagnostics> + where + T: Parse + Merge<T>, + { + parse_schema_features::<T>(self) + } +} + +impl FromAttributes for Vec<Attribute> { + fn parse_features<T>(&self) -> Result<Option<T>, Diagnostics> + where + T: Parse + Merge<T>, + { + parse_schema_features::<T>(self) + } +} + +impl_merge!( + NamedFieldStructFeatures, + UnnamedFieldStructFeatures, + EnumFeatures, + MixedEnumFeatures, + NamedFieldFeatures, + EnumNamedFieldVariantFeatures, + EnumUnnamedFieldVariantFeatures +); + +pub fn parse_schema_features<T: Sized + Parse + Merge<T>>( + attributes: &[Attribute], +) -> Result<Option<T>, Diagnostics> { + Ok(attributes + .iter() + .filter(|attribute| { + attribute + .path() + .get_ident() + .map(|ident| *ident == "schema") + .unwrap_or(false) + }) + .map(|attribute| attribute.parse_args::<T>().map_err(Diagnostics::from)) + .collect::<Result<Vec<T>, Diagnostics>>()? + .into_iter() + .reduce(|acc, item| acc.merge(item))) +} + +pub fn parse_schema_features_with< + T: Merge<T>, + P: for<'r> FnOnce(&'r ParseBuffer<'r>) -> syn::Result<T> + Copy, +>( + attributes: &[Attribute], + parser: P, +) -> Result<Option<T>, Diagnostics> { + Ok(attributes + .iter() + .filter(|attribute| { + attribute + .path() + .get_ident() + .map(|ident| *ident == "schema") + .unwrap_or(false) + }) + .map(|attributes| { + attributes + .parse_args_with(parser) + .map_err(Diagnostics::from) + }) + .collect::<Result<Vec<T>, Diagnostics>>()? + .into_iter() + .reduce(|acc, item| acc.merge(item))) +} diff --git a/fastapi-gen/src/component/schema/xml.rs b/fastapi-gen/src/component/schema/xml.rs new file mode 100644 index 0000000..9398f02 --- /dev/null +++ b/fastapi-gen/src/component/schema/xml.rs @@ -0,0 +1,134 @@ +use proc_macro2::Ident; +use quote::{quote, ToTokens}; +use syn::{parenthesized, parse::Parse, token::Paren, Error, LitStr, Token}; + +use crate::parse_utils; + +#[derive(Default, Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct XmlAttr { + pub name: Option<String>, + pub namespace: Option<String>, + pub prefix: Option<String>, + pub is_attribute: bool, + pub is_wrapped: Option<Ident>, + pub wrap_name: Option<String>, +} + +impl XmlAttr { + pub fn with_wrapped(is_wrapped: Option<Ident>, wrap_name: Option<String>) -> Self { + Self { + is_wrapped, + wrap_name, + ..Default::default() + } + } +} + +impl Parse for XmlAttr { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + const EXPECTED_ATTRIBUTE_MESSAGE: &str = + "unexpected attribute, expected any of: name, namespace, prefix, attribute, wrapped"; + let mut xml = XmlAttr::default(); + + while !input.is_empty() { + let attribute = input + .parse::<Ident>() + .map_err(|error| Error::new(error.span(), EXPECTED_ATTRIBUTE_MESSAGE))?; + let attribute_name = &*attribute.to_string(); + + match attribute_name { + "name" => { + xml.name = + Some(parse_utils::parse_next(input, || input.parse::<LitStr>())?.value()) + } + "namespace" => { + xml.namespace = + Some(parse_utils::parse_next(input, || input.parse::<LitStr>())?.value()) + } + "prefix" => { + xml.prefix = + Some(parse_utils::parse_next(input, || input.parse::<LitStr>())?.value()) + } + "attribute" => xml.is_attribute = parse_utils::parse_bool_or_true(input)?, + "wrapped" => { + // wrapped or wrapped(name = "wrap_name") + if input.peek(Paren) { + let group; + parenthesized!(group in input); + + let wrapped_attribute = group.parse::<Ident>().map_err(|error| { + Error::new( + error.span(), + format!("unexpected attribute, expected: name, {error}"), + ) + })?; + if wrapped_attribute != "name" { + return Err(Error::new( + wrapped_attribute.span(), + "unexpected wrapped attribute, expected: name", + )); + } + group.parse::<Token![=]>()?; + xml.wrap_name = Some(group.parse::<LitStr>()?.value()); + } + xml.is_wrapped = Some(attribute); + } + _ => return Err(Error::new(attribute.span(), EXPECTED_ATTRIBUTE_MESSAGE)), + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(xml) + } +} + +impl ToTokens for XmlAttr { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + tokens.extend(quote! { + fastapi::openapi::xml::XmlBuilder::new() + }); + + if let Some(ref name) = self.name { + tokens.extend(quote! { + .name(Some(#name)) + }) + } + + if let Some(ref namespace) = self.namespace { + tokens.extend(quote! { + .namespace(Some(#namespace)) + }) + } + + if let Some(ref prefix) = self.prefix { + tokens.extend(quote! { + .prefix(Some(#prefix)) + }) + } + + if self.is_attribute { + tokens.extend(quote! { + .attribute(Some(true)) + }) + } + + if self.is_wrapped.is_some() { + tokens.extend(quote! { + .wrapped(Some(true)) + }); + + // if is wrapped and wrap name is defined use wrap name instead + if let Some(ref wrap_name) = self.wrap_name { + tokens.extend(quote! { + .name(Some(#wrap_name)) + }) + } + } + + tokens.extend(quote! { .build() }) + } +} diff --git a/fastapi-gen/src/component/serde.rs b/fastapi-gen/src/component/serde.rs new file mode 100644 index 0000000..e20c613 --- /dev/null +++ b/fastapi-gen/src/component/serde.rs @@ -0,0 +1,510 @@ +//! Provides serde related features parsing serde attributes from types. + +use std::str::FromStr; + +use proc_macro2::{Ident, Span, TokenTree}; +use syn::{buffer::Cursor, Attribute, Error}; + +use crate::Diagnostics; + +#[inline] +fn parse_next_lit_str(next: Cursor) -> Option<(String, Span)> { + match next.token_tree() { + Some((tt, next)) => match tt { + TokenTree::Punct(punct) if punct.as_char() == '=' => parse_next_lit_str(next), + TokenTree::Literal(literal) => { + Some((literal.to_string().replace('\"', ""), literal.span())) + } + _ => None, + }, + _ => None, + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct SerdeValue { + pub skip: bool, + pub rename: Option<String>, + pub default: bool, + pub flatten: bool, + pub skip_serializing_if: bool, + pub double_option: bool, +} + +impl SerdeValue { + const SERDE_WITH_DOUBLE_OPTION: &'static str = "::serde_with::rust::double_option"; +} + +impl SerdeValue { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let mut value = Self::default(); + + input.step(|cursor| { + let mut rest = *cursor; + while let Some((tt, next)) = rest.token_tree() { + match tt { + TokenTree::Ident(ident) + if ident == "skip" + || ident == "skip_serializing" + || ident == "skip_deserializing" => + { + value.skip = true + } + TokenTree::Ident(ident) if ident == "skip_serializing_if" => { + value.skip_serializing_if = true + } + TokenTree::Ident(ident) if ident == "with" => { + value.double_option = parse_next_lit_str(next) + .and_then(|(literal, _)| { + if literal == SerdeValue::SERDE_WITH_DOUBLE_OPTION { + Some(true) + } else { + None + } + }) + .unwrap_or(false); + } + TokenTree::Ident(ident) if ident == "flatten" => value.flatten = true, + TokenTree::Ident(ident) if ident == "rename" => { + if let Some((literal, _)) = parse_next_lit_str(next) { + value.rename = Some(literal) + }; + } + TokenTree::Ident(ident) if ident == "default" => value.default = true, + _ => (), + } + + rest = next; + } + Ok(((), rest)) + })?; + + Ok(value) + } +} + +/// The [Serde Enum representation](https://serde.rs/enum-representations.html) being used +/// The default case (when no serde attributes are present) is `ExternallyTagged`. +#[derive(Clone, Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub enum SerdeEnumRepr { + #[default] + ExternallyTagged, + InternallyTagged { + tag: String, + }, + AdjacentlyTagged { + tag: String, + content: String, + }, + Untagged, + /// This is a variant that can never happen because `serde` will not accept it. + /// With the current implementation it is necessary to have it as an intermediate state when parsing the + /// attributes + UnfinishedAdjacentlyTagged { + content: String, + }, +} + +/// Attributes defined within a `#[serde(...)]` container attribute. +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub struct SerdeContainer { + pub rename_all: Option<RenameRule>, + pub enum_repr: SerdeEnumRepr, + pub default: bool, + pub deny_unknown_fields: bool, +} + +impl SerdeContainer { + /// Parse a single serde attribute, currently supported attributes are: + /// * `rename_all = ...` + /// * `tag = ...` + /// * `content = ...` + /// * `untagged = ...` + /// * `default = ...` + /// * `deny_unknown_fields` + fn parse_attribute(&mut self, ident: Ident, next: Cursor) -> syn::Result<()> { + match ident.to_string().as_str() { + "rename_all" => { + if let Some((literal, span)) = parse_next_lit_str(next) { + self.rename_all = Some( + literal + .parse::<RenameRule>() + .map_err(|error| Error::new(span, error.to_string()))?, + ); + } + } + "tag" => { + if let Some((literal, span)) = parse_next_lit_str(next) { + self.enum_repr = match &self.enum_repr { + SerdeEnumRepr::ExternallyTagged => { + SerdeEnumRepr::InternallyTagged { tag: literal } + } + SerdeEnumRepr::UnfinishedAdjacentlyTagged { content } => { + SerdeEnumRepr::AdjacentlyTagged { + tag: literal, + content: content.clone(), + } + } + SerdeEnumRepr::InternallyTagged { .. } + | SerdeEnumRepr::AdjacentlyTagged { .. } => { + return Err(syn::Error::new(span, "Duplicate serde tag argument")); + } + SerdeEnumRepr::Untagged => { + return Err(syn::Error::new(span, "Untagged enum cannot have tag")) + } + }; + } + } + "content" => { + if let Some((literal, span)) = parse_next_lit_str(next) { + self.enum_repr = match &self.enum_repr { + SerdeEnumRepr::InternallyTagged { tag } => { + SerdeEnumRepr::AdjacentlyTagged { + tag: tag.clone(), + content: literal, + } + } + SerdeEnumRepr::ExternallyTagged => { + SerdeEnumRepr::UnfinishedAdjacentlyTagged { content: literal } + } + SerdeEnumRepr::AdjacentlyTagged { .. } + | SerdeEnumRepr::UnfinishedAdjacentlyTagged { .. } => { + return Err(syn::Error::new(span, "Duplicate serde content argument")) + } + SerdeEnumRepr::Untagged => { + return Err(syn::Error::new(span, "Untagged enum cannot have content")) + } + }; + } + } + "untagged" => { + self.enum_repr = SerdeEnumRepr::Untagged; + } + "default" => { + self.default = true; + } + "deny_unknown_fields" => { + self.deny_unknown_fields = true; + } + _ => {} + } + Ok(()) + } + + /// Parse the attributes inside a `#[serde(...)]` container attribute. + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let mut container = Self::default(); + + input.step(|cursor| { + let mut rest = *cursor; + while let Some((tt, next)) = rest.token_tree() { + if let TokenTree::Ident(ident) = tt { + container.parse_attribute(ident, next)? + } + + rest = next; + } + Ok(((), rest)) + })?; + + Ok(container) + } +} + +pub fn parse_value(attributes: &[Attribute]) -> Result<SerdeValue, Diagnostics> { + Ok(attributes + .iter() + .filter(|attribute| attribute.path().is_ident("serde")) + .map(|serde_attribute| { + serde_attribute + .parse_args_with(SerdeValue::parse) + .map_err(Diagnostics::from) + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .fold(SerdeValue::default(), |mut acc, value| { + if value.skip { + acc.skip = value.skip; + } + if value.skip_serializing_if { + acc.skip_serializing_if = value.skip_serializing_if; + } + if value.rename.is_some() { + acc.rename = value.rename; + } + if value.flatten { + acc.flatten = value.flatten; + } + if value.default { + acc.default = value.default; + } + if value.double_option { + acc.double_option = value.double_option; + } + + acc + })) +} + +pub fn parse_container(attributes: &[Attribute]) -> Result<SerdeContainer, Diagnostics> { + Ok(attributes + .iter() + .filter(|attribute| attribute.path().is_ident("serde")) + .map(|serde_attribute| { + serde_attribute + .parse_args_with(SerdeContainer::parse) + .map_err(Diagnostics::from) + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .fold(SerdeContainer::default(), |mut acc, value| { + if value.default { + acc.default = value.default; + } + if value.deny_unknown_fields { + acc.deny_unknown_fields = value.deny_unknown_fields; + } + match value.enum_repr { + SerdeEnumRepr::ExternallyTagged => {} + SerdeEnumRepr::Untagged + | SerdeEnumRepr::InternallyTagged { .. } + | SerdeEnumRepr::AdjacentlyTagged { .. } + | SerdeEnumRepr::UnfinishedAdjacentlyTagged { .. } => { + acc.enum_repr = value.enum_repr; + } + } + if value.rename_all.is_some() { + acc.rename_all = value.rename_all; + } + + acc + })) +} + +#[derive(Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[cfg_attr(test, derive(PartialEq, Eq))] +pub enum RenameRule { + Lower, + Upper, + Camel, + Snake, + ScreamingSnake, + Pascal, + Kebab, + ScreamingKebab, +} + +impl RenameRule { + pub fn rename(&self, value: &str) -> String { + match self { + RenameRule::Lower => value.to_ascii_lowercase(), + RenameRule::Upper => value.to_ascii_uppercase(), + RenameRule::Camel => { + let mut camel_case = String::new(); + + let mut upper = false; + for letter in value.chars() { + if letter == '_' { + upper = true; + continue; + } + + if upper { + camel_case.push(letter.to_ascii_uppercase()); + upper = false; + } else { + camel_case.push(letter) + } + } + + camel_case + } + RenameRule::Snake => value.to_string(), + RenameRule::ScreamingSnake => Self::Snake.rename(value).to_ascii_uppercase(), + RenameRule::Pascal => { + let mut pascal_case = String::from(&value[..1].to_ascii_uppercase()); + pascal_case.push_str(&Self::Camel.rename(&value[1..])); + + pascal_case + } + RenameRule::Kebab => Self::Snake.rename(value).replace('_', "-"), + RenameRule::ScreamingKebab => Self::Kebab.rename(value).to_ascii_uppercase(), + } + } + + pub fn rename_variant(&self, variant: &str) -> String { + match self { + RenameRule::Lower => variant.to_ascii_lowercase(), + RenameRule::Upper => variant.to_ascii_uppercase(), + RenameRule::Camel => { + let mut snake_case = String::from(&variant[..1].to_ascii_lowercase()); + snake_case.push_str(&variant[1..]); + + snake_case + } + RenameRule::Snake => { + let mut snake_case = String::new(); + + for (index, letter) in variant.char_indices() { + if index > 0 && letter.is_uppercase() { + snake_case.push('_'); + } + snake_case.push(letter); + } + + snake_case.to_ascii_lowercase() + } + RenameRule::ScreamingSnake => Self::Snake.rename_variant(variant).to_ascii_uppercase(), + RenameRule::Pascal => variant.to_string(), + RenameRule::Kebab => Self::Snake.rename_variant(variant).replace('_', "-"), + RenameRule::ScreamingKebab => Self::Kebab.rename_variant(variant).to_ascii_uppercase(), + } + } +} + +const RENAME_RULE_NAME_MAPPING: [(&str, RenameRule); 8] = [ + ("lowercase", RenameRule::Lower), + ("UPPERCASE", RenameRule::Upper), + ("PascalCase", RenameRule::Pascal), + ("camelCase", RenameRule::Camel), + ("snake_case", RenameRule::Snake), + ("SCREAMING_SNAKE_CASE", RenameRule::ScreamingSnake), + ("kebab-case", RenameRule::Kebab), + ("SCREAMING-KEBAB-CASE", RenameRule::ScreamingKebab), +]; + +impl FromStr for RenameRule { + type Err = Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + let expected_one_of = RENAME_RULE_NAME_MAPPING + .into_iter() + .map(|(name, _)| format!(r#""{name}""#)) + .collect::<Vec<_>>() + .join(", "); + RENAME_RULE_NAME_MAPPING + .into_iter() + .find_map(|(case, rule)| if case == s { Some(rule) } else { None }) + .ok_or_else(|| { + Error::new( + Span::call_site(), + format!(r#"unexpected rename rule, expected one of: {expected_one_of}"#), + ) + }) + } +} + +#[cfg(test)] +mod tests { + use super::{parse_container, RenameRule, SerdeContainer, RENAME_RULE_NAME_MAPPING}; + use syn::{parse_quote, Attribute}; + + macro_rules! test_rename_rule { + ( $($case:expr=> $value:literal = $expected:literal)* ) => { + #[test] + fn rename_all_rename_rules() { + $( + let value = $case.rename($value); + assert_eq!(value, $expected, "expected case: {} => {} != {}", stringify!($case), $value, $expected); + )* + } + }; + } + + macro_rules! test_rename_variant_rule { + ( $($case:expr=> $value:literal = $expected:literal)* ) => { + #[test] + fn rename_all_rename_variant_rules() { + $( + let value = $case.rename_variant($value); + assert_eq!(value, $expected, "expected case: {} => {} != {}", stringify!($case), $value, $expected); + )* + } + }; + } + + test_rename_rule! { + RenameRule::Lower=> "single" = "single" + RenameRule::Upper=> "single" = "SINGLE" + RenameRule::Pascal=> "single" = "Single" + RenameRule::Camel=> "single" = "single" + RenameRule::Snake=> "single" = "single" + RenameRule::ScreamingSnake=> "single" = "SINGLE" + RenameRule::Kebab=> "single" = "single" + RenameRule::ScreamingKebab=> "single" = "SINGLE" + + RenameRule::Lower=> "multi_value" = "multi_value" + RenameRule::Upper=> "multi_value" = "MULTI_VALUE" + RenameRule::Pascal=> "multi_value" = "MultiValue" + RenameRule::Camel=> "multi_value" = "multiValue" + RenameRule::Snake=> "multi_value" = "multi_value" + RenameRule::ScreamingSnake=> "multi_value" = "MULTI_VALUE" + RenameRule::Kebab=> "multi_value" = "multi-value" + RenameRule::ScreamingKebab=> "multi_value" = "MULTI-VALUE" + } + + test_rename_variant_rule! { + RenameRule::Lower=> "Single" = "single" + RenameRule::Upper=> "Single" = "SINGLE" + RenameRule::Pascal=> "Single" = "Single" + RenameRule::Camel=> "Single" = "single" + RenameRule::Snake=> "Single" = "single" + RenameRule::ScreamingSnake=> "Single" = "SINGLE" + RenameRule::Kebab=> "Single" = "single" + RenameRule::ScreamingKebab=> "Single" = "SINGLE" + + RenameRule::Lower=> "MultiValue" = "multivalue" + RenameRule::Upper=> "MultiValue" = "MULTIVALUE" + RenameRule::Pascal=> "MultiValue" = "MultiValue" + RenameRule::Camel=> "MultiValue" = "multiValue" + RenameRule::Snake=> "MultiValue" = "multi_value" + RenameRule::ScreamingSnake=> "MultiValue" = "MULTI_VALUE" + RenameRule::Kebab=> "MultiValue" = "multi-value" + RenameRule::ScreamingKebab=> "MultiValue" = "MULTI-VALUE" + } + + #[test] + fn test_serde_rename_rule_from_str() { + for (s, _) in RENAME_RULE_NAME_MAPPING { + s.parse::<RenameRule>().unwrap(); + } + } + + #[test] + fn test_serde_parse_container() { + let default_attribute_1: syn::Attribute = parse_quote! { + #[serde(default)] + }; + let default_attribute_2: syn::Attribute = parse_quote! { + #[serde(default)] + }; + let deny_unknown_fields_attribute: syn::Attribute = parse_quote! { + #[serde(deny_unknown_fields)] + }; + let unsupported_attribute: syn::Attribute = parse_quote! { + #[serde(expecting = "...")] + }; + let attributes: &[Attribute] = &[ + default_attribute_1, + default_attribute_2, + deny_unknown_fields_attribute, + unsupported_attribute, + ]; + + let expected = SerdeContainer { + default: true, + deny_unknown_fields: true, + ..Default::default() + }; + + let result = parse_container(attributes).expect("parse success"); + assert_eq!(expected, result); + } +} diff --git a/fastapi-gen/src/doc_comment.rs b/fastapi-gen/src/doc_comment.rs new file mode 100644 index 0000000..1105136 --- /dev/null +++ b/fastapi-gen/src/doc_comment.rs @@ -0,0 +1,61 @@ +use syn::{Attribute, Expr, Lit, Meta}; + +const DOC_ATTRIBUTE_TYPE: &str = "doc"; + +/// CommentAttributes holds Vec of parsed doc comments +#[cfg_attr(feature = "debug", derive(Debug))] +pub(crate) struct CommentAttributes(pub(crate) Vec<String>); + +impl CommentAttributes { + /// Creates new [`CommentAttributes`] instance from [`Attribute`] slice filtering out all + /// other attributes which are not `doc` comments + pub(crate) fn from_attributes(attributes: &[Attribute]) -> Self { + let mut docs = attributes + .iter() + .filter_map(|attr| { + if !matches!(attr.path().get_ident(), Some(ident) if ident == DOC_ATTRIBUTE_TYPE) { + return None; + } + // ignore `#[doc(hidden)]` and similar tags. + if let Meta::NameValue(name_value) = &attr.meta { + if let Expr::Lit(ref doc_comment) = name_value.value { + if let Lit::Str(ref doc) = doc_comment.lit { + let mut doc = doc.value(); + // NB. Only trim trailing whitespaces. Leading whitespaces are handled + // below. + doc.truncate(doc.trim_end().len()); + return Some(doc); + } + } + } + None + }) + .collect::<Vec<_>>(); + // Calculate the minimum indentation of all non-empty lines and strip them. + // This can get rid of typical single space after doc comment start `///`, but not messing + // up indentation of markdown list or code. + let min_indent = docs + .iter() + .filter(|s| !s.is_empty()) + // Only recognize ASCII space, not unicode multi-bytes ones. + // `str::trim_ascii_start` requires 1.80 which is greater than our MSRV yet. + .map(|s| s.len() - s.trim_start_matches(' ').len()) + .min() + .unwrap_or(0); + for line in &mut docs { + if !line.is_empty() { + line.drain(..min_indent); + } + } + Self(docs) + } + + pub(crate) fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Returns found `doc comments` as formatted `String` joining them all with `\n` _(new line)_. + pub(crate) fn as_formatted_string(&self) -> String { + self.0.join("\n") + } +} diff --git a/fastapi-gen/src/ext.rs b/fastapi-gen/src/ext.rs new file mode 100644 index 0000000..aee08d8 --- /dev/null +++ b/fastapi-gen/src/ext.rs @@ -0,0 +1,534 @@ +use std::borrow::Cow; + +use proc_macro2::TokenStream; +use quote::ToTokens; +use syn::spanned::Spanned; +use syn::Generics; +use syn::{punctuated::Punctuated, token::Comma, ItemFn}; + +use crate::component::{ComponentSchema, ComponentSchemaProps, Container, TypeTree}; +use crate::path::media_type::MediaTypePathExt; +use crate::path::{HttpMethod, PathTypeTree}; +use crate::{Diagnostics, ToTokensDiagnostics}; + +#[cfg(feature = "auto_into_responses")] +pub mod auto_types; + +#[cfg(feature = "actix_extras")] +pub mod actix; + +#[cfg(feature = "axum_extras")] +pub mod axum; + +#[cfg(feature = "rocket_extras")] +pub mod rocket; + +/// Represents single argument of handler operation. +#[cfg_attr( + not(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + )), + allow(dead_code) +)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ValueArgument<'a> { + pub name: Option<Cow<'a, str>>, + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + pub argument_in: ArgumentIn, + pub type_tree: Option<TypeTree<'a>>, +} + +#[cfg(feature = "actix_extras")] +impl<'v> From<(MacroArg, TypeTree<'v>)> for ValueArgument<'v> { + fn from((macro_arg, primitive_arg): (MacroArg, TypeTree<'v>)) -> Self { + Self { + name: match macro_arg { + MacroArg::Path(path) => Some(Cow::Owned(path.name)), + #[cfg(feature = "rocket_extras")] + MacroArg::Query(_) => None, + }, + type_tree: Some(primitive_arg), + argument_in: ArgumentIn::Path, + } + } +} + +#[cfg_attr( + not(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + )), + allow(dead_code) +)] +/// Represents Identifier with `parameter_in` provider function which is used to +/// update the `parameter_in` to [`Parameter::Struct`]. +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct IntoParamsType<'a> { + pub parameter_in_provider: TokenStream, + pub type_path: Option<Cow<'a, syn::Path>>, +} + +impl<'i> From<(Option<Cow<'i, syn::Path>>, TokenStream)> for IntoParamsType<'i> { + fn from((type_path, parameter_in_provider): (Option<Cow<'i, syn::Path>>, TokenStream)) -> Self { + IntoParamsType { + parameter_in_provider, + type_path, + } + } +} + +#[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" +))] +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(PartialEq, Eq)] +pub enum ArgumentIn { + Path, + #[cfg(feature = "rocket_extras")] + Query, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ExtSchema<'a>(TypeTree<'a>); + +impl<'t> From<TypeTree<'t>> for ExtSchema<'t> { + fn from(value: TypeTree<'t>) -> ExtSchema<'t> { + Self(value) + } +} + +impl ExtSchema<'_> { + fn get_actual_body(&self) -> Cow<'_, TypeTree<'_>> { + let actual_body_type = get_actual_body_type(&self.0); + + actual_body_type.map(|actual_body| { + if let Some(option_type) = find_option_type_tree(actual_body) { + let path = option_type.path.clone(); + Cow::Owned(TypeTree { + children: Some(vec![actual_body.clone()]), + generic_type: Some(crate::component::GenericType::Option), + value_type: crate::component::ValueType::Object, + span: Some(path.span()), + path, + }) + } else { + Cow::Borrowed(actual_body) + } + }).expect("ExtSchema must have actual request body resoved from TypeTree of handler fn argument") + } + + pub fn get_type_tree(&self) -> Result<Option<Cow<'_, TypeTree<'_>>>, Diagnostics> { + Ok(Some(Cow::Borrowed(&self.0))) + } + + pub fn get_default_content_type(&self) -> Result<Cow<'static, str>, Diagnostics> { + let type_tree = &self.0; + + let content_type = if type_tree.is("Bytes") { + Cow::Borrowed("application/octet-stream") + } else if type_tree.is("Form") { + Cow::Borrowed("application/x-www-form-urlencoded") + } else { + let get_actual_body = self.get_actual_body(); + let actual_body = get_actual_body.as_ref(); + + actual_body.get_default_content_type() + }; + + Ok(content_type) + } + + pub fn get_component_schema(&self) -> Result<Option<ComponentSchema>, Diagnostics> { + use crate::OptionExt; + + let type_tree = &self.0; + let actual_body_type = get_actual_body_type(type_tree); + + actual_body_type.and_then_try(|body_type| body_type.get_component_schema()) + } +} + +impl ToTokensDiagnostics for ExtSchema<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let get_actual_body = self.get_actual_body(); + let type_tree = get_actual_body.as_ref(); + + let component_tokens = ComponentSchema::new(ComponentSchemaProps { + type_tree, + features: Vec::new(), + description: None, + container: &Container { + generics: &Generics::default(), + }, + })?; + component_tokens.to_tokens(tokens); + + Ok(()) + } +} + +fn get_actual_body_type<'t>(ty: &'t TypeTree<'t>) -> Option<&'t TypeTree<'t>> { + ty.path + .as_deref() + .expect("RequestBody TypeTree must have syn::Path") + .segments + .iter() + .find_map(|segment| match &*segment.ident.to_string() { + "Json" => Some( + ty.children + .as_deref() + .expect("Json must have children") + .first() + .expect("Json must have one child"), + ), + "Form" => Some( + ty.children + .as_deref() + .expect("Form must have children") + .first() + .expect("Form must have one child"), + ), + "Option" => get_actual_body_type( + ty.children + .as_deref() + .expect("Option must have children") + .first() + .expect("Option must have one child"), + ), + "Bytes" => Some(ty), + _ => match ty.children { + Some(ref children) => get_actual_body_type(children.first().expect( + "Must have first child when children has been defined in get_actual_body_type", + )), + None => None, + }, + }) +} + +fn find_option_type_tree<'t>(ty: &'t TypeTree) -> Option<&'t TypeTree<'t>> { + let eq = ty.generic_type == Some(crate::component::GenericType::Option); + + if !eq { + ty.children + .as_ref() + .and_then(|children| children.iter().find_map(find_option_type_tree)) + } else { + Some(ty) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct MacroPath { + pub path: String, + #[allow(unused)] // this is needed only if axum, actix or rocket + pub args: Vec<MacroArg>, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum MacroArg { + #[cfg_attr( + not(any(feature = "actix_extras", feature = "rocket_extras")), + allow(dead_code) + )] + Path(ArgValue), + #[cfg(feature = "rocket_extras")] + Query(ArgValue), +} + +impl MacroArg { + /// Get ordering by name + #[cfg(feature = "rocket_extras")] + fn by_name(a: &MacroArg, b: &MacroArg) -> std::cmp::Ordering { + a.get_value().name.cmp(&b.get_value().name) + } + + #[cfg(feature = "rocket_extras")] + fn get_value(&self) -> &ArgValue { + match self { + MacroArg::Path(path) => path, + MacroArg::Query(query) => query, + } + } +} + +#[derive(PartialEq, Eq, PartialOrd, Ord)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ArgValue { + pub name: String, + pub original_name: String, +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ResolvedOperation { + pub methods: Vec<HttpMethod>, + pub path: String, + #[allow(unused)] // this is needed only if axum, actix or rocket + pub body: String, +} + +#[allow(unused)] +pub type Arguments<'a> = ( + Option<Vec<ValueArgument<'a>>>, + Option<Vec<IntoParamsType<'a>>>, + Option<ExtSchema<'a>>, +); + +#[allow(unused)] +pub trait ArgumentResolver { + fn resolve_arguments( + _: &'_ Punctuated<syn::FnArg, Comma>, + _: Option<Vec<MacroArg>>, + _: String, + ) -> Result<Arguments, Diagnostics> { + Ok((None, None, None)) + } +} + +pub trait PathResolver { + fn resolve_path(_: &Option<String>) -> Option<MacroPath> { + None + } +} + +pub trait PathOperationResolver { + fn resolve_operation(_: &ItemFn) -> Result<Option<ResolvedOperation>, Diagnostics> { + Ok(None) + } +} + +pub struct PathOperations; + +#[cfg(not(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" +)))] +impl ArgumentResolver for PathOperations {} + +#[cfg(not(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" +)))] +impl PathResolver for PathOperations {} + +#[cfg(not(any(feature = "actix_extras", feature = "rocket_extras")))] +impl PathOperationResolver for PathOperations {} + +#[cfg(any( + feature = "actix_extras", + feature = "axum_extras", + feature = "rocket_extras" +))] +pub mod fn_arg { + + use proc_macro2::Ident; + #[cfg(any(feature = "actix_extras", feature = "axum_extras"))] + use quote::quote; + use syn::spanned::Spanned; + use syn::PatStruct; + use syn::{punctuated::Punctuated, token::Comma, Pat, PatType}; + + use crate::component::TypeTree; + #[cfg(any(feature = "actix_extras", feature = "axum_extras"))] + use crate::component::ValueType; + use crate::Diagnostics; + + /// Http operation handler functions fn argument. + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct FnArg<'a> { + pub ty: TypeTree<'a>, + pub arg_type: FnArgType<'a>, + } + + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(PartialEq, Eq, PartialOrd, Ord)] + pub enum FnArgType<'t> { + Single(&'t Ident), + Destructed(Vec<&'t Ident>), + } + + #[cfg(feature = "rocket_extras")] + impl FnArgType<'_> { + /// Get best effort name `Ident` for the type. For `FnArgType::Tuple` types it will take the first one + /// from `Vec`. + pub(super) fn get_name(&self) -> &Ident { + match self { + Self::Single(ident) => ident, + // perform best effort name, by just taking the first one from the list + Self::Destructed(tuple) => tuple + .first() + .expect("Expected at least one argument in FnArgType::Tuple"), + } + } + } + + impl<'a> From<(TypeTree<'a>, FnArgType<'a>)> for FnArg<'a> { + fn from((ty, arg_type): (TypeTree<'a>, FnArgType<'a>)) -> Self { + Self { ty, arg_type } + } + } + + impl<'a> Ord for FnArg<'a> { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.arg_type.cmp(&other.arg_type) + } + } + + impl<'a> PartialOrd for FnArg<'a> { + fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> { + Some(self.arg_type.cmp(&other.arg_type)) + } + } + + impl<'a> PartialEq for FnArg<'a> { + fn eq(&self, other: &Self) -> bool { + self.ty == other.ty && self.arg_type == other.arg_type + } + } + + impl<'a> Eq for FnArg<'a> {} + + pub fn get_fn_args( + fn_args: &Punctuated<syn::FnArg, Comma>, + ) -> Result<impl Iterator<Item = FnArg>, Diagnostics> { + fn_args + .iter() + .filter_map(|arg| { + let pat_type = match get_fn_arg_pat_type(arg) { + Ok(pat_type) => pat_type, + Err(diagnostics) => return Some(Err(diagnostics)), + }; + + match pat_type.pat.as_ref() { + syn::Pat::Wild(_) => None, + _ => { + let arg_name = match get_pat_fn_arg_type(pat_type.pat.as_ref()) { + Ok(arg_type) => arg_type, + Err(diagnostics) => return Some(Err(diagnostics)), + }; + match TypeTree::from_type(&pat_type.ty) { + Ok(type_tree) => Some(Ok((type_tree, arg_name))), + Err(diagnostics) => Some(Err(diagnostics)), + } + } + } + }) + .map(|value| value.map(FnArg::from)) + .collect::<Result<Vec<FnArg>, Diagnostics>>() + .map(IntoIterator::into_iter) + } + + #[inline] + fn get_pat_fn_arg_type(pat: &Pat) -> Result<FnArgType<'_>, Diagnostics> { + let arg_name = match pat { + syn::Pat::Ident(ident) => Ok(FnArgType::Single(&ident.ident)), + syn::Pat::Tuple(tuple) => { + tuple.elems.iter().map(|item| { + match item { + syn::Pat::Ident(ident) => Ok(&ident.ident), + _ => Err(Diagnostics::with_span(item.span(), "expected syn::Ident in get_pat_fn_arg_type Pat::Tuple")) + } + }).collect::<Result<Vec<_>, Diagnostics>>().map(FnArgType::Destructed) + }, + syn::Pat::TupleStruct(tuple_struct) => { + get_pat_fn_arg_type(tuple_struct.elems.first().as_ref().expect( + "PatTuple expected to have at least one element, cannot get fn argument", + )) + }, + syn::Pat::Struct(PatStruct { fields, ..}) => { + let idents = fields.iter() + .flat_map(|field| Ok(match get_pat_fn_arg_type(&field.pat) { + Ok(field_type) => field_type, + Err(diagnostics) => return Err(diagnostics), + })) + .fold(Vec::<&'_ Ident>::new(), |mut idents, field_type| { + if let FnArgType::Single(ident) = field_type { + idents.push(ident) + } + idents + }); + + Ok(FnArgType::Destructed(idents)) + } + _ => Err(Diagnostics::with_span(pat.span(), "unexpected syn::Pat, expected syn::Pat::Ident,in get_fn_args, cannot get fn argument name")), + }; + arg_name + } + + #[inline] + fn get_fn_arg_pat_type(fn_arg: &syn::FnArg) -> Result<&PatType, Diagnostics> { + match fn_arg { + syn::FnArg::Typed(value) => Ok(value), + _ => Err(Diagnostics::with_span( + fn_arg.span(), + "unexpected fn argument type, expected FnArg::Typed", + )), + } + } + + #[cfg(any(feature = "actix_extras", feature = "axum_extras"))] + pub(super) fn with_parameter_in( + arg: FnArg<'_>, + ) -> Option<( + Option<std::borrow::Cow<'_, syn::Path>>, + proc_macro2::TokenStream, + )> { + let parameter_in_provider = if arg.ty.is("Path") { + quote! { || Some (fastapi::openapi::path::ParameterIn::Path) } + } else if arg.ty.is("Query") { + quote! { || Some(fastapi::openapi::path::ParameterIn::Query) } + } else { + quote! { || None } + }; + + let type_path = arg + .ty + .children + .expect("FnArg TypeTree generic type Path must have children") + .into_iter() + .next() + .unwrap() + .path; + + Some((type_path, parameter_in_provider)) + } + + // if type is either Path or Query with direct children as Object types without generics + #[cfg(any(feature = "actix_extras", feature = "axum_extras"))] + pub(super) fn is_into_params(fn_arg: &FnArg) -> bool { + use crate::component::GenericType; + let mut ty = &fn_arg.ty; + + if fn_arg.ty.generic_type == Some(GenericType::Option) { + ty = fn_arg + .ty + .children + .as_ref() + .expect("FnArg Option must have children") + .first() + .expect("FnArg Option must have 1 child"); + } + + (ty.is("Path") || ty.is("Query")) + && ty + .children + .as_ref() + .map(|children| { + children.iter().all(|child| { + matches!(child.value_type, ValueType::Object) + && child.generic_type.is_none() + }) + }) + .unwrap_or(false) + } +} diff --git a/fastapi-gen/src/ext/actix.rs b/fastapi-gen/src/ext/actix.rs new file mode 100644 index 0000000..ee741d7 --- /dev/null +++ b/fastapi-gen/src/ext/actix.rs @@ -0,0 +1,271 @@ +use std::borrow::Cow; + +use proc_macro2::{Ident, TokenTree}; +use regex::{Captures, Regex}; +use syn::{parse::Parse, punctuated::Punctuated, token::Comma, ItemFn, LitStr}; + +use crate::{ + component::{TypeTree, ValueType}, + ext::ArgValue, + path::HttpMethod, + Diagnostics, +}; + +use super::{ + fn_arg::{self, FnArg}, + ArgumentIn, ArgumentResolver, Arguments, MacroArg, MacroPath, PathOperationResolver, + PathOperations, PathResolver, ResolvedOperation, ValueArgument, +}; + +impl ArgumentResolver for PathOperations { + fn resolve_arguments( + fn_args: &Punctuated<syn::FnArg, Comma>, + macro_args: Option<Vec<MacroArg>>, + _: String, + ) -> Result<Arguments, Diagnostics> { + let (into_params_args, value_args): (Vec<FnArg>, Vec<FnArg>) = + fn_arg::get_fn_args(fn_args)?.partition(fn_arg::is_into_params); + + if let Some(macro_args) = macro_args { + let (primitive_args, body) = split_path_args_and_request(value_args); + + Ok(( + Some( + macro_args + .into_iter() + .zip(primitive_args) + .map(into_value_argument) + .collect(), + ), + Some( + into_params_args + .into_iter() + .flat_map(fn_arg::with_parameter_in) + .map(Into::into) + .collect(), + ), + body.into_iter().next().map(Into::into), + )) + } else { + let (_, body) = split_path_args_and_request(value_args); + Ok(( + None, + Some( + into_params_args + .into_iter() + .flat_map(fn_arg::with_parameter_in) + .map(Into::into) + .collect(), + ), + body.into_iter().next().map(Into::into), + )) + } + } +} + +fn split_path_args_and_request( + value_args: Vec<FnArg>, +) -> ( + impl Iterator<Item = TypeTree>, + impl Iterator<Item = TypeTree>, +) { + let (path_args, body_types): (Vec<FnArg>, Vec<FnArg>) = value_args + .into_iter() + .filter(|arg| { + arg.ty.is("Path") || arg.ty.is("Json") || arg.ty.is("Form") || arg.ty.is("Bytes") + }) + .partition(|arg| arg.ty.is("Path")); + + ( + path_args + .into_iter() + .flat_map(|path_arg| { + path_arg + .ty + .children + .expect("Path argument must have children") + }) + .flat_map(|path_arg| match path_arg.value_type { + ValueType::Primitive => vec![path_arg], + ValueType::Tuple => path_arg + .children + .expect("ValueType::Tuple will always have children"), + ValueType::Object | ValueType::Value => { + unreachable!("Value arguments does not have ValueType::Object arguments") + } + }), + body_types.into_iter().map(|json| json.ty), + ) +} + +fn into_value_argument((macro_arg, primitive_arg): (MacroArg, TypeTree)) -> ValueArgument { + ValueArgument { + name: match macro_arg { + MacroArg::Path(path) => Some(Cow::Owned(path.name)), + #[cfg(feature = "rocket_extras")] + MacroArg::Query(_) => None, + }, + type_tree: Some(primitive_arg), + argument_in: ArgumentIn::Path, + } +} + +impl PathOperationResolver for PathOperations { + fn resolve_operation(item_fn: &ItemFn) -> Result<Option<ResolvedOperation>, Diagnostics> { + item_fn + .attrs + .iter() + .find_map(|attribute| { + if is_valid_actix_route_attribute(attribute.path().get_ident()) { + match attribute.parse_args::<Route>() { + Ok(route) => { + let attribute_path = attribute.path().get_ident() + .expect("actix-web route macro must have ident"); + let methods: Vec<HttpMethod> = if *attribute_path == "route" { + route.methods.into_iter().map(|method| { + method.to_lowercase().parse::<HttpMethod>() + .expect("Should never fail, validity of HTTP method is checked before parsing") + }).collect() + } else { + // if user used #[connect(...)] macro, return error + match HttpMethod::from_ident(attribute_path) { + Ok(http_method) => { vec![http_method]}, + Err(error) => return Some( + Err( + error.help( + format!(r#"If you want operation to be documented and executed on `{method}` try using `#[route(...)]` e.g. `#[route("/path", method = "GET", method = "{method}")]`"#, method = attribute_path.to_string().to_uppercase()) + ) + ) + ) + } + }; + Some(Ok(ResolvedOperation { + path: route.path, + methods, + body: String::new(), + })) + } + Err(error) => Some(Err(Into::<Diagnostics>::into(error))), + } + } else { + None + } + }) + .transpose() + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct Route { + path: String, + methods: Vec<String>, +} + +impl Parse for Route { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + const ALLOWED_METHODS: [&str; 8] = [ + "GET", "POST", "PUT", "DELETE", "HEAD", "OPTIONS", "TRACE", "PATCH", + ]; + // OpenAPI spec does not support CONNECT thus we do not resolve it + + enum PrevToken { + Method, + Equals, + } + + let path = input.parse::<LitStr>()?.value(); + let mut parsed_methods: Vec<String> = Vec::new(); + + input.step(|cursor| { + let mut rest = *cursor; + + let mut prev_token: Option<PrevToken> = None; + while let Some((tt, next)) = rest.token_tree() { + match &tt { + TokenTree::Ident(ident) if *ident == "method" => { + prev_token = Some(PrevToken::Method); + rest = next + } + TokenTree::Punct(punct) + if punct.as_char() == '=' + && matches!(prev_token, Some(PrevToken::Method)) => + { + prev_token = Some(PrevToken::Equals); + rest = next + } + TokenTree::Literal(literal) + if matches!(prev_token, Some(PrevToken::Equals)) => + { + let value = literal.to_string(); + let method = &value[1..value.len() - 1]; + + if ALLOWED_METHODS.contains(&method) { + parsed_methods.push(String::from(method)); + } + + prev_token = None; + rest = next; + } + _ => rest = next, + } + } + Ok(((), rest)) + })?; + + Ok(Route { + path, + methods: parsed_methods, + }) + } +} + +impl PathResolver for PathOperations { + fn resolve_path(path: &Option<String>) -> Option<MacroPath> { + path.as_ref().map(|path| { + let regex = Regex::new(r"\{[a-zA-Z0-9_][^{}]*}").unwrap(); + + let mut args = Vec::<MacroArg>::with_capacity(regex.find_iter(path).count()); + MacroPath { + path: regex + .replace_all(path, |captures: &Captures| { + let mut capture = &captures[0]; + let original_name = String::from(capture); + + if capture.contains("_:") { + // replace unnamed capture with generic 'arg0' name + args.push(MacroArg::Path(ArgValue { + name: String::from("arg0"), + original_name, + })); + "{arg0}".to_string() + } else if let Some(colon) = capture.find(':') { + // replace colon (:) separated regexp with empty string + capture = &capture[1..colon]; + + args.push(MacroArg::Path(ArgValue { + name: String::from(capture), + original_name, + })); + + format!("{{{capture}}}") + } else { + args.push(MacroArg::Path(ArgValue { + name: String::from(&capture[1..capture.len() - 1]), + original_name, + })); + // otherwise return the capture itself + capture.to_string() + } + }) + .to_string(), + args, + } + }) + } +} + +#[inline] +fn is_valid_actix_route_attribute(ident: Option<&Ident>) -> bool { + matches!(ident, Some(operation) if ["get", "post", "put", "delete", "head", "connect", "options", "trace", "patch", "route"] + .iter().any(|expected_operation| operation == expected_operation)) +} diff --git a/fastapi-gen/src/ext/auto_types.rs b/fastapi-gen/src/ext/auto_types.rs new file mode 100644 index 0000000..84f2ec5 --- /dev/null +++ b/fastapi-gen/src/ext/auto_types.rs @@ -0,0 +1,15 @@ +use syn::{ItemFn, TypePath}; + +pub fn parse_fn_operation_responses(fn_op: &ItemFn) -> Option<&TypePath> { + match &fn_op.sig.output { + syn::ReturnType::Type(_, item) => get_type_path(item.as_ref()), + syn::ReturnType::Default => None, // default return type () should result no responses + } +} + +fn get_type_path(ty: &syn::Type) -> Option<&TypePath> { + match ty { + syn::Type::Path(ty_path) => Some(ty_path), + _ => None, + } +} diff --git a/fastapi-gen/src/ext/axum.rs b/fastapi-gen/src/ext/axum.rs new file mode 100644 index 0000000..8147fa1 --- /dev/null +++ b/fastapi-gen/src/ext/axum.rs @@ -0,0 +1,141 @@ +use std::borrow::Cow; + +use regex::Captures; +use syn::{punctuated::Punctuated, token::Comma}; + +use crate::{ + component::{TypeTree, ValueType}, + Diagnostics, +}; + +use super::{ + fn_arg::{self, FnArg, FnArgType}, + ArgValue, ArgumentResolver, Arguments, MacroArg, MacroPath, PathOperations, PathResolver, + ValueArgument, +}; + +// axum framework is only able to resolve handler function arguments. +// `PathResolver` and `PathOperationResolver` is not supported in axum. +impl ArgumentResolver for PathOperations { + fn resolve_arguments( + args: &'_ Punctuated<syn::FnArg, Comma>, + macro_args: Option<Vec<super::MacroArg>>, + _: String, + ) -> Result<Arguments<'_>, Diagnostics> { + let (into_params_args, value_args): (Vec<FnArg>, Vec<FnArg>) = + fn_arg::get_fn_args(args)?.partition(fn_arg::is_into_params); + + let (value_args, body) = split_value_args_and_request_body(value_args); + + Ok(( + Some( + value_args + .zip(macro_args.unwrap_or_default()) + .map(|(value_arg, macro_arg)| ValueArgument { + name: match macro_arg { + MacroArg::Path(path) => Some(Cow::Owned(path.name)), + #[cfg(feature = "rocket_extras")] + MacroArg::Query(_) => None, + }, + argument_in: value_arg.argument_in, + type_tree: value_arg.type_tree, + }) + .collect(), + ), + Some( + into_params_args + .into_iter() + .flat_map(fn_arg::with_parameter_in) + .map(Into::into) + .collect(), + ), + body.into_iter().next().map(Into::into), + )) + } +} + +fn split_value_args_and_request_body( + value_args: Vec<FnArg>, +) -> ( + impl Iterator<Item = super::ValueArgument<'_>>, + impl Iterator<Item = TypeTree<'_>>, +) { + let (path_args, body_types): (Vec<FnArg>, Vec<FnArg>) = value_args + .into_iter() + .filter(|arg| { + arg.ty.is("Path") || arg.ty.is("Json") || arg.ty.is("Form") || arg.ty.is("Bytes") + }) + .partition(|arg| arg.ty.is("Path")); + + ( + path_args + .into_iter() + .filter(|arg| arg.ty.is("Path")) + .flat_map(|path_arg| { + match ( + path_arg.arg_type, + path_arg.ty.children.expect("Path must have children"), + ) { + (FnArgType::Single(name), path_children) => path_children + .into_iter() + .flat_map(|ty| match ty.value_type { + ValueType::Tuple => ty + .children + .expect("ValueType::Tuple will always have children") + .into_iter() + .map(|ty| to_value_argument(None, ty)) + .collect(), + ValueType::Primitive => { + vec![to_value_argument(Some(Cow::Owned(name.to_string())), ty)] + } + ValueType::Object | ValueType::Value => unreachable!("Cannot get here"), + }) + .collect::<Vec<_>>(), + (FnArgType::Destructed(tuple), path_children) => tuple + .iter() + .zip(path_children.into_iter().flat_map(|child| { + child + .children + .expect("ValueType::Tuple will always have children") + })) + .map(|(name, ty)| to_value_argument(Some(Cow::Owned(name.to_string())), ty)) + .collect::<Vec<_>>(), + } + }), + body_types.into_iter().map(|body| body.ty), + ) +} + +fn to_value_argument<'a>(name: Option<Cow<'a, str>>, ty: TypeTree<'a>) -> ValueArgument<'a> { + ValueArgument { + name, + type_tree: Some(ty), + argument_in: super::ArgumentIn::Path, + } +} + +impl PathResolver for PathOperations { + fn resolve_path(path: &Option<String>) -> Option<MacroPath> { + path.as_ref().map(|path| { + let regex = regex::Regex::new(r"\{[a-zA-Z0-9][^{}]*}").unwrap(); + + let mut args = Vec::<MacroArg>::with_capacity(regex.find_iter(path).count()); + MacroPath { + path: regex + .replace_all(path, |captures: &Captures| { + let capture = &captures[0]; + let original_name = String::from(capture); + + args.push(MacroArg::Path(ArgValue { + name: String::from(&capture[1..capture.len() - 1]), + original_name, + })); + // otherwise return the capture itself + capture.to_string() + }) + .to_string(), + args, + } + }) + } +} diff --git a/fastapi-gen/src/ext/rocket.rs b/fastapi-gen/src/ext/rocket.rs new file mode 100644 index 0000000..e8a70d8 --- /dev/null +++ b/fastapi-gen/src/ext/rocket.rs @@ -0,0 +1,345 @@ +use std::{borrow::Cow, str::FromStr}; + +use proc_macro2::{Ident, TokenStream}; +use quote::quote; +use regex::{Captures, Regex}; +use syn::{parse::Parse, LitStr, Token}; + +use crate::{ + component::{GenericType, ValueType}, + ext::{ArgValue, ArgumentIn, MacroArg, ValueArgument}, + path::HttpMethod, + Diagnostics, OptionExt, +}; + +use super::{ + fn_arg::{self, FnArg}, + ArgumentResolver, Arguments, MacroPath, PathOperationResolver, PathOperations, PathResolver, + ResolvedOperation, +}; + +const ANONYMOUS_ARG: &str = "<_>"; + +impl ArgumentResolver for PathOperations { + fn resolve_arguments( + fn_args: &syn::punctuated::Punctuated<syn::FnArg, syn::token::Comma>, + macro_args: Option<Vec<MacroArg>>, + body: String, + ) -> Result<Arguments<'_>, Diagnostics> { + let mut args = fn_arg::get_fn_args(fn_args)?.collect::<Vec<_>>(); + args.sort_unstable(); + let (into_params_args, value_args): (Vec<FnArg>, Vec<FnArg>) = + args.into_iter().partition(is_into_params); + + Ok(macro_args + .map(|args| { + let (anonymous_args, named_args): (Vec<MacroArg>, Vec<MacroArg>) = + args.into_iter().partition(is_anonymous_arg); + + let body = into_params_args + .iter() + .find(|arg| *arg.arg_type.get_name() == body) + .map(|arg| arg.ty.clone()) + .map(Into::into); + + ( + Some( + value_args + .into_iter() + .flat_map(with_argument_in(&named_args)) + .map(to_value_arg) + .chain(anonymous_args.into_iter().map(to_anonymous_value_arg)) + .collect(), + ), + Some( + into_params_args + .into_iter() + .flat_map(with_parameter_in(&named_args)) + .map(Into::into) + .collect(), + ), + body, + ) + }) + .unwrap_or_else(|| (None, None, None))) + } +} + +fn to_value_arg((arg, argument_in): (FnArg, ArgumentIn)) -> ValueArgument { + ValueArgument { + type_tree: Some(arg.ty), + argument_in, + name: Some(Cow::Owned(arg.arg_type.get_name().to_string())), + } +} + +fn to_anonymous_value_arg<'a>(macro_arg: MacroArg) -> ValueArgument<'a> { + let (name, argument_in) = match macro_arg { + MacroArg::Path(arg_value) => (arg_value.name, ArgumentIn::Path), + MacroArg::Query(arg_value) => (arg_value.name, ArgumentIn::Query), + }; + + ValueArgument { + type_tree: None, + argument_in, + name: Some(Cow::Owned(name)), + } +} + +fn with_parameter_in( + named_args: &[MacroArg], +) -> impl Fn(FnArg) -> Option<(Option<Cow<'_, syn::Path>>, TokenStream)> + '_ { + move |arg: FnArg| { + let parameter_in = named_args.iter().find_map(|macro_arg| match macro_arg { + MacroArg::Path(path) => { + if arg.arg_type.get_name() == &*path.name { + Some(quote! { || Some(fastapi::openapi::path::ParameterIn::Path) }) + } else { + None + } + } + MacroArg::Query(query) => { + if arg.arg_type.get_name() == &*query.name { + Some(quote! { || Some(fastapi::openapi::path::ParameterIn::Query) }) + } else { + None + } + } + }); + + Some(arg.ty.path).zip(parameter_in) + } +} + +fn with_argument_in(named_args: &[MacroArg]) -> impl Fn(FnArg) -> Option<(FnArg, ArgumentIn)> + '_ { + move |arg: FnArg| { + let argument_in = named_args.iter().find_map(|macro_arg| match macro_arg { + MacroArg::Path(path) => { + if arg.arg_type.get_name() == &*path.name { + Some(ArgumentIn::Path) + } else { + None + } + } + MacroArg::Query(query) => { + if arg.arg_type.get_name() == &*query.name { + Some(ArgumentIn::Query) + } else { + None + } + } + }); + + Some(arg).zip(argument_in) + } +} + +#[inline] +fn is_into_params(fn_arg: &FnArg) -> bool { + let mut ty = &fn_arg.ty; + + if fn_arg.ty.generic_type == Some(GenericType::Option) { + ty = fn_arg + .ty + .children + .as_ref() + .expect("FnArg Option must have children") + .first() + .expect("FnArg Option must have 1 child"); + } + matches!(ty.value_type, ValueType::Object) && ty.generic_type.is_none() +} + +#[inline] +fn is_anonymous_arg(arg: &MacroArg) -> bool { + matches!(arg, MacroArg::Path(path) if path.original_name == ANONYMOUS_ARG) + || matches!(arg, MacroArg::Query(query) if query.original_name == ANONYMOUS_ARG) +} + +impl PathOperationResolver for PathOperations { + fn resolve_operation( + ast_fn: &syn::ItemFn, + ) -> Result<Option<super::ResolvedOperation>, Diagnostics> { + ast_fn + .attrs + .iter() + .find(|attribute| is_valid_route_type(attribute.path().get_ident())) + .map_try( + |attribute| match attribute.parse_args::<Path>().map_err(Diagnostics::from) { + Ok(path) => Ok((path, attribute)), + Err(diagnostics) => Err(diagnostics), + }, + )? + .map_try( + |( + Path { + path, + operation, + body, + }, + attribute, + )| { + if !operation.is_empty() { + Ok(ResolvedOperation { + methods: vec![HttpMethod::from_str(&operation).unwrap()], + path, + body, + }) + } else { + Ok(ResolvedOperation { + methods: vec![HttpMethod::from_ident( + attribute.path().get_ident().unwrap(), + )?], + path, + body, + }) + } + }, + ) + } +} + +struct Path { + path: String, + operation: String, + body: String, +} + +impl Parse for Path { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let has_data = |input: syn::parse::ParseStream| -> bool { + let fork = input.fork(); + if fork.peek(syn::Ident) { + matches!(fork.parse::<syn::Ident>(), Ok(data) if data == "data") + } else { + false + } + }; + + let parse_body = |input: syn::parse::ParseStream| -> syn::Result<String> { + input.parse::<syn::Ident>()?; // data + input.parse::<Token![=]>()?; + + input + .parse::<LitStr>() + .map(|value| value.value().replace(['<', '>'], "")) + }; + + let (path, operation, body) = if input.peek(syn::Ident) { + // expect format (GET, uri = "url...", data = ...) + let ident = input.parse::<Ident>()?; + input.parse::<Token![,]>()?; + input.parse::<Ident>()?; // explicitly 'uri' + input.parse::<Token![=]>()?; + let uri = input.parse::<LitStr>()?.value(); + let operation = ident.to_string().to_lowercase(); + + if !input.is_empty() && input.peek(Token![,]) { + input.parse::<Token![,]>()?; + } + let body = if has_data(input) { + parse_body(input)? + } else { + String::new() + }; + + (uri, operation, body) + } else { + // expect format ("url...", data = ...) + let uri = input.parse::<LitStr>()?.value(); + if !input.is_empty() && input.peek(Token![,]) { + input.parse::<Token![,]>()?; + } + let body = if has_data(input) { + parse_body(input)? + } else { + String::new() + }; + + (uri, String::new(), body) + }; + + // ignore rest of the tokens from rocket path attribute macro + input.step(|cursor| { + let mut rest = *cursor; + while let Some((_, next)) = rest.token_tree() { + rest = next; + } + Ok(((), rest)) + })?; + + Ok(Self { + path, + operation, + body, + }) + } +} + +#[inline] +fn is_valid_route_type(ident: Option<&Ident>) -> bool { + matches!(ident, Some(operation) if ["get", "post", "put", "delete", "head", "options", "patch", "route"] + .iter().any(|expected_operation| operation == expected_operation)) +} + +impl PathResolver for PathOperations { + fn resolve_path(path: &Option<String>) -> Option<MacroPath> { + path.as_ref().map(|whole_path| { + let regex = Regex::new(r"<[a-zA-Z0-9_][^<>]*>").unwrap(); + + whole_path + .split_once('?') + .or(Some((whole_path, ""))) + .map(|(path, query)| { + let mut names = + Vec::<MacroArg>::with_capacity(regex.find_iter(whole_path).count()); + let mut underscore_count = 0; + + let mut format_arg = + |captures: &Captures, resolved_arg_op: fn(ArgValue) -> MacroArg| { + let capture = &captures[0]; + let original_name = String::from(capture); + + let mut arg = capture + .replace("..", "") + .replace('<', "{") + .replace('>', "}"); + + if arg == "{_}" { + arg = format!("{{arg{underscore_count}}}"); + names.push(resolved_arg_op(ArgValue { + name: String::from(&arg[1..arg.len() - 1]), + original_name, + })); + underscore_count += 1; + } else { + names.push(resolved_arg_op(ArgValue { + name: String::from(&arg[1..arg.len() - 1]), + original_name, + })) + } + + arg + }; + + let path = regex.replace_all(path, |captures: &Captures| { + format_arg(captures, MacroArg::Path) + }); + + if !query.is_empty() { + regex.replace_all(query, |captures: &Captures| { + format_arg(captures, MacroArg::Query) + }); + } + + names.sort_unstable_by(MacroArg::by_name); + + MacroPath { + args: names, + path: path.to_string(), + } + }) + .unwrap() + }) + } +} diff --git a/fastapi-gen/src/lib.rs b/fastapi-gen/src/lib.rs new file mode 100644 index 0000000..386bc65 --- /dev/null +++ b/fastapi-gen/src/lib.rs @@ -0,0 +1,3864 @@ +//! This is **private** fastapi codegen library and is not used alone. +//! +//! The library contains macro implementations for fastapi library. Content +//! of the library documentation is available through **fastapi** library itself. +//! Consider browsing via the **fastapi** crate so all links will work correctly. + +#![cfg_attr(doc_cfg, feature(doc_cfg))] +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] + +#[cfg(all(feature = "decimal", feature = "decimal_float"))] +compile_error!("`decimal` and `decimal_float` are mutually exclusive feature flags"); + +#[cfg(all( + feature = "actix_extras", + feature = "axum_extras", + feature = "rocket_extras" +))] +compile_error!( + "`actix_extras`, `axum_extras` and `rocket_extras` are mutually exclusive feature flags" +); + +use std::{ + borrow::{Borrow, Cow}, + error::Error, + fmt::Display, + mem, + ops::Deref, +}; + +use component::schema::Schema; +use doc_comment::CommentAttributes; + +use component::into_params::IntoParams; +use ext::{PathOperationResolver, PathOperations, PathResolver}; +use openapi::OpenApi; +use proc_macro::TokenStream; +use quote::{quote, quote_spanned, ToTokens, TokenStreamExt}; + +use proc_macro2::{Group, Ident, Punct, Span, TokenStream as TokenStream2}; +use syn::{ + bracketed, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + token::Bracket, + DeriveInput, ExprPath, GenericParam, ItemFn, Lit, LitStr, Member, Token, +}; + +mod component; +mod doc_comment; +mod ext; +mod openapi; +mod path; +mod schema_type; +mod security_requirement; + +use crate::path::{Path, PathAttr}; + +use self::{ + component::{ + features::{self, Feature}, + ComponentSchema, ComponentSchemaProps, TypeTree, + }, + openapi::parse_openapi_attrs, + path::response::derive::{IntoResponses, ToResponse}, +}; + +#[cfg(feature = "config")] +static CONFIG: once_cell::sync::Lazy<fastapi_config::Config> = + once_cell::sync::Lazy::new(fastapi_config::Config::read_from_file); + +#[proc_macro_derive(ToSchema, attributes(schema))] +/// Generate reusable OpenAPI schema to be used +/// together with [`OpenApi`][openapi_derive]. +/// +/// This is `#[derive]` implementation for [`ToSchema`][to_schema] trait. The macro accepts one +/// `schema` +/// attribute optionally which can be used to enhance generated documentation. The attribute can be placed +/// at item level or field and variant levels in structs and enum. +/// +/// You can use the Rust's own `#[deprecated]` attribute on any struct, enum or field to mark it as deprecated and it will +/// reflect to the generated OpenAPI spec. +/// +/// `#[deprecated]` attribute supports adding additional details such as a reason and or since version but this is is not supported in +/// OpenAPI. OpenAPI has only a boolean flag to determine deprecation. While it is totally okay to declare deprecated with reason +/// `#[deprecated = "There is better way to do this"]` the reason would not render in OpenAPI spec. +/// +/// Doc comments on fields will resolve to field descriptions in generated OpenAPI doc. On struct +/// level doc comments will resolve to object descriptions. +/// +/// Schemas derived with `ToSchema` will be automatically collected from usage. In case of looping +/// schema tree _`no_recursion`_ attribute must be used to break from recurring into infinite loop. +/// See [more details from example][derive@ToSchema#examples]. All arguments of generic schemas +/// must implement `ToSchema` trait. +/// +/// ```rust +/// /// This is a pet +/// #[derive(fastapi::ToSchema)] +/// struct Pet { +/// /// Name for your pet +/// name: String, +/// } +/// ``` +/// +/// # Named Field Struct Optional Configuration Options for `#[schema(...)]` +/// +/// * `description = ...` Can be literal string or Rust expression e.g. _`const`_ reference or +/// `include_str!(...)` statement. This can be used to override **default** description what is +/// resolved from doc comments of the type. +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// _`example`_ Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `xml(...)` Can be used to define [`Xml`][xml] object properties applicable to Structs. +/// * `title = ...` Literal string value. Can be used to define title for struct in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// struct. +/// * `rename_all = ...` Supports same syntax as _serde_ _`rename_all`_ attribute. Will rename all fields +/// of the structs accordingly. If both _serde_ `rename_all` and _schema_ _`rename_all`_ are defined +/// __serde__ will take precedence. +/// * `as = ...` Can be used to define alternative path and name for the schema what will be used in +/// the OpenAPI. E.g _`as = path::to::Pet`_. This would make the schema appear in the generated +/// OpenAPI spec as _`path.to.Pet`_. This same name will be used throughout the OpenAPI generated +/// with `fastapi` when the type is being referenced in [`OpenApi`][openapi_derive] derive macro +/// or in [`fastapi::path(...)`][path_macro] macro. +/// * `bound = ...` Can be used to override default trait bounds on generated `impl`s. +/// See [Generic schemas section](#generic-schemas) below for more details. +/// * `default` Can be used to populate default values on all fields using the struct's +/// [`Default`] implementation. +/// * `deprecated` Can be used to mark all fields as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the fields as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +/// * `max_properties = ...` Can be used to define maximum number of properties this struct can +/// contain. Value must be a number. +/// * `min_properties = ...` Can be used to define minimum number of properties this struct can +/// contain. Value must be a number. +///* `no_recursion` Is used to break from recursion in case of looping schema tree e.g. `Pet` -> +/// `Owner` -> `Pet`. _`no_recursion`_ attribute must be used within `Ower` type not to allow +/// recurring into `Pet`. Failing to do so will cause infinite loop and runtime **panic**. On +/// struct level the _`no_recursion`_ rule will be applied to all of its fields. +/// +/// ## Named Fields Optional Configuration Options for `#[schema(...)]` +/// +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// _`example`_ Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `default = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `format = ...` May either be variant of the [`KnownFormat`][known_format] enum, or otherwise +/// an open value as a string. By default the format is derived from the type of the property +/// according OpenApi spec. +/// * `write_only` Defines property is only used in **write** operations *POST,PUT,PATCH* but not in *GET* +/// * `read_only` Defines property is only used in **read** operations *GET* but not in *POST,PUT,PATCH* +/// * `xml(...)` Can be used to define [`Xml`][xml] object properties applicable to named fields. +/// See configuration options at xml attributes of [`ToSchema`][to_schema_xml] +/// * `value_type = ...` Can be used to override default type derived from type of the field used in OpenAPI spec. +/// This is useful in cases where the default type does not correspond to the actual type e.g. when +/// any third-party types are used which are not [`ToSchema`][to_schema]s nor [`primitive` types][primitive]. +/// The value can be any Rust type what normally could be used to serialize to JSON, or either virtual type _`Object`_ +/// or _`Value`_. +/// _`Object`_ will be rendered as generic OpenAPI object _(`type: object`)_. +/// _`Value`_ will be rendered as any OpenAPI value (i.e. no `type` restriction). +/// * `inline` If the type of this field implements [`ToSchema`][to_schema], then the schema definition +/// will be inlined. **warning:** Don't use this for recursive data types! +/// +/// **Note!**<br>Using `inline` with generic arguments might lead to incorrect spec generation. +/// This is due to the fact that during compilation we cannot know how to treat the generic +/// argument and there is difference whether it is a primitive type or another generic type. +/// * `required = ...` Can be used to enforce required status for the field. [See +/// rules][derive@ToSchema#field-nullability-and-required-rules] +/// * `nullable` Defines property is nullable (note this is different to non-required). +/// * `rename = ...` Supports same syntax as _serde_ _`rename`_ attribute. Will rename field +/// accordingly. If both _serde_ `rename` and _schema_ _`rename`_ are defined __serde__ will take +/// precedence. +/// * `multiple_of = ...` Can be used to define multiplier for a value. Value is considered valid +/// division will result an `integer`. Value must be strictly above _`0`_. +/// * `maximum = ...` Can be used to define inclusive upper bound to a `number` value. +/// * `minimum = ...` Can be used to define inclusive lower bound to a `number` value. +/// * `exclusive_maximum = ...` Can be used to define exclusive upper bound to a `number` value. +/// * `exclusive_minimum = ...` Can be used to define exclusive lower bound to a `number` value. +/// * `max_length = ...` Can be used to define maximum length for `string` types. +/// * `min_length = ...` Can be used to define minimum length for `string` types. +/// * `pattern = ...` Can be used to define valid regular expression in _ECMA-262_ dialect the field value must match. +/// * `max_items = ...` Can be used to define maximum items allowed for `array` fields. Value must +/// be non-negative integer. +/// * `min_items = ...` Can be used to define minimum items allowed for `array` fields. Value must +/// be non-negative integer. +/// * `schema_with = ...` Use _`schema`_ created by provided function reference instead of the +/// default derived _`schema`_. The function must match to `fn() -> Into<RefOr<Schema>>`. It does +/// not accept arguments and must return anything that can be converted into `RefOr<Schema>`. +/// * `additional_properties = ...` Can be used to define free form types for maps such as +/// [`HashMap`](std::collections::HashMap) and [`BTreeMap`](std::collections::BTreeMap). +/// Free form type enables use of arbitrary types within map values. +/// Supports formats _`additional_properties`_ and _`additional_properties = true`_. +/// * `deprecated` Can be used to mark the field as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the field as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +/// * `content_encoding = ...` Can be used to define content encoding used for underlying schema object. +/// See [`Object::content_encoding`][schema_object_encoding] +/// * `content_media_type = ...` Can be used to define MIME type of a string for underlying schema object. +/// See [`Object::content_media_type`][schema_object_media_type] +///* `ignore` or `ignore = ...` Can be used to skip the field from being serialized to OpenAPI schema. It accepts either a literal `bool` value +/// or a path to a function that returns `bool` (`Fn() -> bool`). +///* `no_recursion` Is used to break from recursion in case of looping schema tree e.g. `Pet` -> +/// `Owner` -> `Pet`. _`no_recursion`_ attribute must be used within `Ower` type not to allow +/// recurring into `Pet`. Failing to do so will cause infinite loop and runtime **panic**. +/// +/// #### Field nullability and required rules +/// +/// Field is considered _`required`_ if +/// * it is not `Option` field +/// * and it does not have _`skip_serializing_if`_ property +/// * and it does not have _`serde_with`_ _[`double_option`](https://docs.rs/serde_with/latest/serde_with/rust/double_option/index.html)_ +/// * and it does not have default value provided with serde _`default`_ +/// attribute +/// +/// Field is considered _`nullable`_ when field type is _`Option`_. +/// +/// ## Xml attribute Configuration Options +/// +/// * `xml(name = "...")` Will set name for property or type. +/// * `xml(namespace = "...")` Will set namespace for xml element which needs to be valid uri. +/// * `xml(prefix = "...")` Will set prefix for name. +/// * `xml(attribute)` Will translate property to xml attribute instead of xml element. +/// * `xml(wrapped)` Will make wrapped xml element. +/// * `xml(wrapped(name = "wrap_name"))` Will override the wrapper elements name. +/// +/// See [`Xml`][xml] for more details. +/// +/// # Unnamed Field Struct Optional Configuration Options for `#[schema(...)]` +/// +/// * `description = ...` Can be literal string or Rust expression e.g. [_`const`_][const] reference or +/// `include_str!(...)` statement. This can be used to override **default** description what is +/// resolved from doc comments of the type. +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// _`example`_ Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `default = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. If no value +/// is specified, and the struct has only one field, the field's default value in the schema will be +/// set from the struct's [`Default`] implementation. +/// * `format = ...` May either be variant of the [`KnownFormat`][known_format] enum, or otherwise +/// an open value as a string. By default the format is derived from the type of the property +/// according OpenApi spec. +/// * `value_type = ...` Can be used to override default type derived from type of the field used in OpenAPI spec. +/// This is useful in cases where the default type does not correspond to the actual type e.g. when +/// any third-party types are used which are not [`ToSchema`][to_schema]s nor [`primitive` types][primitive]. +/// The value can be any Rust type what normally could be used to serialize to JSON or either virtual type _`Object`_ +/// or _`Value`_. +/// _`Object`_ will be rendered as generic OpenAPI object _(`type: object`)_. +/// _`Value`_ will be rendered as any OpenAPI value (i.e. no `type` restriction). +/// * `title = ...` Literal string value. Can be used to define title for struct in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// struct. +/// * `as = ...` Can be used to define alternative path and name for the schema what will be used in +/// the OpenAPI. E.g _`as = path::to::Pet`_. This would make the schema appear in the generated +/// OpenAPI spec as _`path.to.Pet`_. This same name will be used throughout the OpenAPI generated +/// with `fastapi` when the type is being referenced in [`OpenApi`][openapi_derive] derive macro +/// or in [`fastapi::path(...)`][path_macro] macro. +/// * `bound = ...` Can be used to override default trait bounds on generated `impl`s. +/// See [Generic schemas section](#generic-schemas) below for more details. +/// * `deprecated` Can be used to mark the field as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the field as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +/// * `content_encoding = ...` Can be used to define content encoding used for underlying schema object. +/// See [`Object::content_encoding`][schema_object_encoding] +/// * `content_media_type = ...` Can be used to define MIME type of a string for underlying schema object. +/// See [`Object::content_media_type`][schema_object_media_type] +///* `no_recursion` Is used to break from recursion in case of looping schema tree e.g. `Pet` -> +/// `Owner` -> `Pet`. _`no_recursion`_ attribute must be used within `Ower` type not to allow +/// recurring into `Pet`. Failing to do so will cause infinite loop and runtime **panic**. +/// +/// # Enum Optional Configuration Options for `#[schema(...)]` +/// +/// ## Plain Enum having only `Unit` variants Optional Configuration Options for `#[schema(...)]` +/// +/// * `description = ...` Can be literal string or Rust expression e.g. [_`const`_][const] reference or +/// `include_str!(...)` statement. This can be used to override **default** description what is +/// resolved from doc comments of the type. +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// _`example`_ Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `default = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `title = ...` Literal string value. Can be used to define title for enum in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// enum. +/// * `rename_all = ...` Supports same syntax as _serde_ _`rename_all`_ attribute. Will rename all +/// variants of the enum accordingly. If both _serde_ `rename_all` and _schema_ _`rename_all`_ +/// are defined __serde__ will take precedence. +/// * `as = ...` Can be used to define alternative path and name for the schema what will be used in +/// the OpenAPI. E.g _`as = path::to::Pet`_. This would make the schema appear in the generated +/// OpenAPI spec as _`path.to.Pet`_. This same name will be used throughout the OpenAPI generated +/// with `fastapi` when the type is being referenced in [`OpenApi`][openapi_derive] derive macro +/// or in [`fastapi::path(...)`][path_macro] macro. +/// * `bound = ...` Can be used to override default trait bounds on generated `impl`s. +/// See [Generic schemas section](#generic-schemas) below for more details. +/// * `deprecated` Can be used to mark the enum as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the enum as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +/// +/// ### Plain Enum Variant Optional Configuration Options for `#[schema(...)]` +/// +/// * `rename = ...` Supports same syntax as _serde_ _`rename`_ attribute. Will rename variant +/// accordingly. If both _serde_ `rename` and _schema_ _`rename`_ are defined __serde__ will take +/// precedence. **Note!** [`Repr enum`][macro@ToSchema#repr-attribute-support] variant does not +/// support _`rename`_. +/// +/// ## Mixed Enum Optional Configuration Options for `#[schema(...)]` +/// +/// * `description = ...` Can be literal string or Rust expression e.g. [_`const`_][const] reference or +/// `include_str!(...)` statement. This can be used to override **default** description what is +/// resolved from doc comments of the type. +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// * `default = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `title = ...` Literal string value. Can be used to define title for enum in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// enum. +/// * `rename_all = ...` Supports same syntax as _serde_ _`rename_all`_ attribute. Will rename all +/// variants of the enum accordingly. If both _serde_ `rename_all` and _schema_ _`rename_all`_ +/// are defined __serde__ will take precedence. +/// * `as = ...` Can be used to define alternative path and name for the schema what will be used in +/// the OpenAPI. E.g _`as = path::to::Pet`_. This would make the schema appear in the generated +/// OpenAPI spec as _`path.to.Pet`_. This same name will be used throughout the OpenAPI generated +/// with `fastapi` when the type is being referenced in [`OpenApi`][openapi_derive] derive macro +/// or in [`fastapi::path(...)`][path_macro] macro. +/// * `bound = ...` Can be used to override default trait bounds on generated `impl`s. +/// See [Generic schemas section](#generic-schemas) below for more details. +/// * `deprecated` Can be used to mark the enum as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the enum as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +/// * `discriminator = ...` or `discriminator(...)` Can be used to define OpenAPI discriminator +/// field for enums with single unnamed _`ToSchema`_ reference field. See the [discriminator +/// syntax][derive@ToSchema#schemadiscriminator-syntax]. +///* `no_recursion` Is used to break from recursion in case of looping schema tree e.g. `Pet` -> +/// `Owner` -> `Pet`. _`no_recursion`_ attribute must be used within `Ower` type not to allow +/// recurring into `Pet`. Failing to do so will cause infinite loop and runtime **panic**. On +/// enum level the _`no_recursion`_ rule will be applied to all of its variants. +/// +/// ### `#[schema(discriminator)]` syntax +/// +/// Discriminator can **only** be used with enums having **`#[serde(untagged)]`** attribute and +/// each variant must have only one unnamed field schema reference to type implementing +/// _`ToSchema`_. +/// +/// **Simple form `discriminator = ...`** +/// +/// Can be literal string or expression e.g. [_`const`_][const] reference. It can be defined as +/// _`discriminator = "value"`_ where the assigned value is the +/// discriminator field that must exists in each variant referencing schema. +/// +/// **Complex form `discriminator(...)`** +/// +/// * `property_name = ...` Can be literal string or expression e.g. [_`const`_][const] reference. +/// * mapping `key` Can be literal string or expression e.g. [_`const`_][const] reference. +/// * mapping `value` Can be literal string or expression e.g. [_`const`_][const] reference. +/// +/// Additionally discriminator can be defined with custom mappings as show below. The _`mapping`_ +/// values defines _**key = value**_ pairs where _**key**_ is the expected value for _**property_name**_ field +/// and _**value**_ is schema to map. +/// ```text +/// discriminator(property_name = "my_field", mapping( +/// ("value" = "#/components/schemas/Schema1"), +/// ("value2" = "#/components/schemas/Schema2") +/// )) +/// ``` +/// +/// ### Mixed Enum Named Field Variant Optional Configuration Options for `#[serde(schema)]` +/// +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// * `default = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `title = ...` Literal string value. Can be used to define title for enum variant in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// enum. +/// * `xml(...)` Can be used to define [`Xml`][xml] object properties applicable to Structs. +/// * `rename = ...` Supports same syntax as _serde_ _`rename`_ attribute. Will rename variant +/// accordingly. If both _serde_ `rename` and _schema_ _`rename`_ are defined __serde__ will take +/// precedence. +/// * `rename_all = ...` Supports same syntax as _serde_ _`rename_all`_ attribute. Will rename all +/// variant fields accordingly. If both _serde_ `rename_all` and _schema_ _`rename_all`_ +/// are defined __serde__ will take precedence. +/// * `deprecated` Can be used to mark the enum as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the enum as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +/// * `max_properties = ...` Can be used to define maximum number of properties this struct can +/// contain. Value must be a number. +/// * `min_properties = ...` Can be used to define minimum number of properties this struct can +/// contain. Value must be a number. +///* `no_recursion` Is used to break from recursion in case of looping schema tree e.g. `Pet` -> +/// `Owner` -> `Pet`. _`no_recursion`_ attribute must be used within `Ower` type not to allow +/// recurring into `Pet`. Failing to do so will cause infinite loop and runtime **panic**. On +/// named field variant level the _`no_recursion`_ rule will be applied to all of its fields. +/// +/// ## Mixed Enum Unnamed Field Variant Optional Configuration Options for `#[serde(schema)]` +/// +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// _`example`_ Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `default = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `title = ...` Literal string value. Can be used to define title for enum variant in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// struct. +/// * `rename = ...` Supports same syntax as _serde_ _`rename`_ attribute. Will rename variant +/// accordingly. If both _serde_ `rename` and _schema_ _`rename`_ are defined __serde__ will take +/// precedence. +/// * `format = ...` May either be variant of the [`KnownFormat`][known_format] enum, or otherwise +/// an open value as a string. By default the format is derived from the type of the property +/// according OpenApi spec. +/// * `value_type = ...` Can be used to override default type derived from type of the field used in OpenAPI spec. +/// This is useful in cases where the default type does not correspond to the actual type e.g. when +/// any third-party types are used which are not [`ToSchema`][to_schema]s nor [`primitive` types][primitive]. +/// The value can be any Rust type what normally could be used to serialize to JSON or either virtual type _`Object`_ +/// or _`Value`_. +/// _`Object`_ will be rendered as generic OpenAPI object _(`type: object`)_. +/// _`Value`_ will be rendered as any OpenAPI value (i.e. no `type` restriction). +/// * `deprecated` Can be used to mark the field as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the field as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +///* `no_recursion` Is used to break from recursion in case of looping schema tree e.g. `Pet` -> +/// `Owner` -> `Pet`. _`no_recursion`_ attribute must be used within `Ower` type not to allow +/// recurring into `Pet`. Failing to do so will cause infinite loop and runtime **panic**. +/// +/// #### Mixed Enum Unnamed Field Variant's Field Configuration Options +/// +/// * `inline` If the type of this field implements [`ToSchema`][to_schema], then the schema definition +/// will be inlined. **warning:** Don't use this for recursive data types! +/// +/// **Note!**<br>Using `inline` with generic arguments might lead to incorrect spec generation. +/// This is due to the fact that during compilation we cannot know how to treat the generic +/// argument and there is difference whether it is a primitive type or another generic type. +/// +/// _**Inline unnamed field variant schemas.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// # #[derive(ToSchema)] +/// # enum Number { +/// # One, +/// # } +/// # +/// # #[derive(ToSchema)] +/// # enum Color { +/// # Spade, +/// # } +/// #[derive(ToSchema)] +/// enum Card { +/// Number(#[schema(inline)] Number), +/// Color(#[schema(inline)] Color), +/// } +/// ``` +/// +/// ## Mixed Enum Unit Field Variant Optional Configuration Options for `#[serde(schema)]` +/// +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// _`example`_ Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `title = ...` Literal string value. Can be used to define title for enum variant in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// struct. +/// * `rename = ...` Supports same syntax as _serde_ _`rename`_ attribute. Will rename variant +/// accordingly. If both _serde_ `rename` and _schema_ _`rename`_ are defined __serde__ will take +/// precedence. +/// * `deprecated` Can be used to mark the field as deprecated in the generated OpenAPI spec but +/// not in the code. If you'd like to mark the field as deprecated in the code as well use +/// Rust's own `#[deprecated]` attribute instead. +/// +/// # Partial `#[serde(...)]` attributes support +/// +/// ToSchema derive has partial support for [serde attributes]. These supported attributes will reflect to the +/// generated OpenAPI doc. For example if _`#[serde(skip)]`_ is defined the attribute will not show up in the OpenAPI spec at all since it will not never +/// be serialized anyway. Similarly the _`rename`_ and _`rename_all`_ will reflect to the generated OpenAPI doc. +/// +/// * `rename_all = "..."` Supported at the container level. +/// * `rename = "..."` Supported **only** at the field or variant level. +/// * `skip = "..."` Supported **only** at the field or variant level. +/// * `skip_serializing = "..."` Supported **only** at the field or variant level. +/// * `skip_deserializing = "..."` Supported **only** at the field or variant level. +/// * `skip_serializing_if = "..."` Supported **only** at the field level. +/// * `with = ...` Supported **only at field level.** +/// * `tag = "..."` Supported at the container level. +/// * `content = "..."` Supported at the container level, allows [adjacently-tagged enums](https://serde.rs/enum-representations.html#adjacently-tagged). +/// This attribute requires that a `tag` is present, otherwise serde will trigger a compile-time +/// failure. +/// * `untagged` Supported at the container level. Allows [untagged +/// enum representation](https://serde.rs/enum-representations.html#untagged). +/// * `default` Supported at the container level and field level according to [serde attributes]. +/// * `deny_unknown_fields` Supported at the container level. +/// * `flatten` Supported at the field level. +/// +/// Other _`serde`_ attributes works as is but does not have any effect on the generated OpenAPI doc. +/// +/// **Note!** `tag` attribute has some limitations like it cannot be used with **tuple types**. See more at +/// [enum representation docs](https://serde.rs/enum-representations.html). +/// +/// **Note!** `with` attribute is used in tandem with [serde_with](https://github.com/jonasbb/serde_with) to recognize +/// _[`double_option`](https://docs.rs/serde_with/latest/serde_with/rust/double_option/index.html)_ from **field value**. +/// _`double_option`_ is **only** supported attribute from _`serde_with`_ crate. +/// +/// ```rust +/// # use serde::Serialize; +/// # use fastapi::ToSchema; +/// #[derive(Serialize, ToSchema)] +/// struct Foo(String); +/// +/// #[derive(Serialize, ToSchema)] +/// #[serde(rename_all = "camelCase")] +/// enum Bar { +/// UnitValue, +/// #[serde(rename_all = "camelCase")] +/// NamedFields { +/// #[serde(rename = "id")] +/// named_id: &'static str, +/// name_list: Option<Vec<String>> +/// }, +/// UnnamedFields(Foo), +/// #[serde(skip)] +/// SkipMe, +/// } +/// ``` +/// +/// _**Add custom `tag` to change JSON representation to be internally tagged.**_ +/// ```rust +/// # use serde::Serialize; +/// # use fastapi::ToSchema; +/// #[derive(Serialize, ToSchema)] +/// struct Foo(String); +/// +/// #[derive(Serialize, ToSchema)] +/// #[serde(tag = "tag")] +/// enum Bar { +/// UnitValue, +/// NamedFields { +/// id: &'static str, +/// names: Option<Vec<String>> +/// }, +/// } +/// ``` +/// +/// _**Add serde `default` attribute for MyValue struct. Similarly `default` could be added to +/// individual fields as well. If `default` is given the field's affected will be treated +/// as optional.**_ +/// ```rust +/// #[derive(fastapi::ToSchema, serde::Deserialize, Default)] +/// #[serde(default)] +/// struct MyValue { +/// field: String +/// } +/// ``` +/// +/// # `#[repr(...)]` attribute support +/// +/// [Serde repr](https://github.com/dtolnay/serde-repr) allows field-less enums be represented by +/// their numeric value. +/// +/// * `repr(u*)` for unsigned integer. +/// * `repr(i*)` for signed integer. +/// +/// **Supported schema attributes** +/// +/// * `example = ...` Can be any value e.g. literal, method reference or _`json!(...)`_. +/// **Deprecated since OpenAPI 3.0, using `examples` is preferred instead.** +/// * `examples(..., ...)` Comma separated list defining multiple _`examples`_ for the schema. Each +/// _`example`_ Can be any value e.g. literal, method reference or _`json!(...)`_. +/// * `title = ...` Literal string value. Can be used to define title for enum in OpenAPI +/// document. Some OpenAPI code generation libraries also use this field as a name for the +/// struct. +/// * `as = ...` Can be used to define alternative path and name for the schema what will be used in +/// the OpenAPI. E.g _`as = path::to::Pet`_. This would make the schema appear in the generated +/// OpenAPI spec as _`path.to.Pet`_. This same name will be used throughout the OpenAPI generated +/// with `fastapi` when the type is being referenced in [`OpenApi`][openapi_derive] derive macro +/// or in [`fastapi::path(...)`][path_macro] macro. +/// +/// _**Create enum with numeric values.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// #[repr(u8)] +/// #[schema(default = default_value, example = 2)] +/// enum Mode { +/// One = 1, +/// Two, +/// } +/// +/// fn default_value() -> u8 { +/// 1 +/// } +/// ``` +/// +/// _**You can use `skip` and `tag` attributes from serde.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema, serde::Serialize)] +/// #[repr(i8)] +/// #[serde(tag = "code")] +/// enum ExitCode { +/// Error = -1, +/// #[serde(skip)] +/// Unknown = 0, +/// Ok = 1, +/// } +/// ``` +/// +/// # Generic schemas +/// +/// Fastapi supports full set of deeply nested generics as shown below. The type will implement +/// [`ToSchema`][to_schema] if and only if all the generic types implement `ToSchema` by default. +/// That is in Rust `impl<T> ToSchema for MyType<T> where T: Schema { ... }`. +/// You can also specify `bound = ...` on the item to override the default auto bounds. +/// +/// The _`as = ...`_ attribute is used to define the prefixed or alternative name for the component +/// in question. This same name will be used throughout the OpenAPI generated with `fastapi` when +/// the type is being referenced in [`OpenApi`][openapi_derive] derive macro or in [`fastapi::path(...)`][path_macro] macro. +/// +/// ```rust +/// # use fastapi::ToSchema; +/// # use std::borrow::Cow; +/// #[derive(ToSchema)] +/// #[schema(as = path::MyType<T>)] +/// struct Type<T> { +/// t: T, +/// } +/// +/// #[derive(ToSchema)] +/// struct Person<'p, T: Sized, P> { +/// id: usize, +/// name: Option<Cow<'p, str>>, +/// field: T, +/// t: P, +/// } +/// +/// #[derive(ToSchema)] +/// #[schema(as = path::to::PageList)] +/// struct Page<T> { +/// total: usize, +/// page: usize, +/// pages: usize, +/// items: Vec<T>, +/// } +/// +/// #[derive(ToSchema)] +/// #[schema(as = path::to::Element<T>)] +/// enum E<T> { +/// One(T), +/// Many(Vec<T>), +/// } +/// ``` +/// When generic types are registered to the `OpenApi` the full type declaration must be provided. +/// See the full example in test [schema_generics.rs](https://github.com/nxpkg/fastapi/blob/master/fastapi-gen/tests/schema_generics.rs) +/// +/// # Examples +/// +/// _**Simple example of a Pet with descriptions and object level example.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// /// This is a pet. +/// #[derive(ToSchema)] +/// #[schema(example = json!({"name": "bob the cat", "id": 0}))] +/// struct Pet { +/// /// Unique id of a pet. +/// id: u64, +/// /// Name of a pet. +/// name: String, +/// /// Age of a pet if known. +/// age: Option<i32>, +/// } +/// ``` +/// +/// _**The `schema` attribute can also be placed at field level as follows.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// struct Pet { +/// #[schema(example = 1, default = 0)] +/// id: u64, +/// name: String, +/// age: Option<i32>, +/// } +/// ``` +/// +/// _**You can also use method reference for attribute values.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// struct Pet { +/// #[schema(example = u64::default, default = u64::default)] +/// id: u64, +/// #[schema(default = default_name)] +/// name: String, +/// age: Option<i32>, +/// } +/// +/// fn default_name() -> String { +/// "bob".to_string() +/// } +/// ``` +/// +/// _**For enums and unnamed field structs you can define `schema` at type level.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// #[schema(example = "Bus")] +/// enum VehicleType { +/// Rocket, Car, Bus, Submarine +/// } +/// ``` +/// +/// _**Also you write mixed enum combining all above types.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// enum ErrorResponse { +/// InvalidCredentials, +/// #[schema(default = String::default, example = "Pet not found")] +/// NotFound(String), +/// System { +/// #[schema(example = "Unknown system failure")] +/// details: String, +/// } +/// } +/// ``` +/// +/// _**It is possible to specify the title of each variant to help generators create named structures.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// enum ErrorResponse { +/// #[schema(title = "InvalidCredentials")] +/// InvalidCredentials, +/// #[schema(title = "NotFound")] +/// NotFound(String), +/// } +/// ``` +/// +/// _**Use `xml` attribute to manipulate xml output.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// #[schema(xml(name = "user", prefix = "u", namespace = "https://user.xml.schema.test"))] +/// struct User { +/// #[schema(xml(attribute, prefix = "u"))] +/// id: i64, +/// #[schema(xml(name = "user_name", prefix = "u"))] +/// username: String, +/// #[schema(xml(wrapped(name = "linkList"), name = "link"))] +/// links: Vec<String>, +/// #[schema(xml(wrapped, name = "photo_url"))] +/// photos_urls: Vec<String> +/// } +/// ``` +/// +/// _**Use of Rust's own `#[deprecated]` attribute will reflect to generated OpenAPI spec.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// #[deprecated] +/// struct User { +/// id: i64, +/// username: String, +/// links: Vec<String>, +/// #[deprecated] +/// photos_urls: Vec<String> +/// } +/// ``` +/// +/// _**Enforce type being used in OpenAPI spec to [`String`] with `value_type` and set format to octet stream +/// with [`SchemaFormat::KnownFormat(KnownFormat::Binary)`][binary].**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// struct Post { +/// id: i32, +/// #[schema(value_type = String, format = Binary)] +/// value: Vec<u8>, +/// } +/// ``` +/// +/// _**Enforce type being used in OpenAPI spec to [`String`] with `value_type` option.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// #[schema(value_type = String)] +/// struct Value(i64); +/// ``` +/// +/// _**Override the `Bar` reference with a `custom::NewBar` reference.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// # mod custom { +/// # #[derive(fastapi::ToSchema)] +/// # pub struct NewBar; +/// # } +/// # +/// # struct Bar; +/// #[derive(ToSchema)] +/// struct Value { +/// #[schema(value_type = custom::NewBar)] +/// field: Bar, +/// }; +/// ``` +/// +/// _**Use a virtual `Object` type to render generic `object` _(`type: object`)_ in OpenAPI spec.**_ +/// ```rust +/// # use fastapi::ToSchema; +/// # mod custom { +/// # struct NewBar; +/// # } +/// # +/// # struct Bar; +/// #[derive(ToSchema)] +/// struct Value { +/// #[schema(value_type = Object)] +/// field: Bar, +/// }; +/// ``` +/// More examples for _`value_type`_ in [`IntoParams` derive docs][into_params]. +/// +/// _**Serde `rename` / `rename_all` will take precedence over schema `rename` / `rename_all`.**_ +/// ```rust +/// #[derive(fastapi::ToSchema, serde::Deserialize)] +/// #[serde(rename_all = "lowercase")] +/// #[schema(rename_all = "UPPERCASE")] +/// enum Random { +/// #[serde(rename = "string_value")] +/// #[schema(rename = "custom_value")] +/// String(String), +/// +/// Number { +/// id: i32, +/// } +/// } +/// ``` +/// +/// _**Add `title` to the enum.**_ +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// #[schema(title = "UserType")] +/// enum UserType { +/// Admin, +/// Moderator, +/// User, +/// } +/// ``` +/// +/// _**Example with validation attributes.**_ +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// struct Item { +/// #[schema(maximum = 10, minimum = 5, multiple_of = 2.5)] +/// id: i32, +/// #[schema(max_length = 10, min_length = 5, pattern = "[a-z]*")] +/// value: String, +/// #[schema(max_items = 5, min_items = 1)] +/// items: Vec<String>, +/// } +/// ```` +/// +/// _**Use `schema_with` to manually implement schema for a field.**_ +/// ```rust +/// # use fastapi::openapi::schema::{Object, ObjectBuilder}; +/// fn custom_type() -> Object { +/// ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::String) +/// .format(Some(fastapi::openapi::SchemaFormat::Custom( +/// "email".to_string(), +/// ))) +/// .description(Some("this is the description")) +/// .build() +/// } +/// +/// #[derive(fastapi::ToSchema)] +/// struct Value { +/// #[schema(schema_with = custom_type)] +/// id: String, +/// } +/// ``` +/// +/// _**Use `as` attribute to change the name and the path of the schema in the generated OpenAPI +/// spec.**_ +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// #[schema(as = api::models::person::Person)] +/// struct Person { +/// name: String, +/// } +/// ``` +/// +/// _**Use `bound` attribute to override the default impl bounds.**_ +/// +/// `bound = ...` accepts a string containing zero or more where-predicates separated by comma, as +/// the similar syntax to [`serde(bound = ...)`](https://serde.rs/container-attrs.html#bound). +/// If `bound = ...` exists, the default auto bounds (requiring all generic types to implement +/// `ToSchema`) will not be applied anymore, and only the specified predicates are added to the +/// `where` clause of generated `impl` blocks. +/// +/// ```rust +/// // Override the default bounds to only require `T: ToSchema`, ignoring unused `U`. +/// #[derive(fastapi::ToSchema, serde::Serialize)] +/// #[schema(bound = "T: fastapi::ToSchema")] +/// struct Partial<T, U> { +/// used_in_api: T, +/// #[serde(skip)] +/// not_in_api: std::marker::PhantomData<U>, +/// } +/// +/// // Just remove the auto-bounds. So we got `Unused<T>: ToSchema` for any `T`. +/// #[derive(fastapi::ToSchema, serde::Serialize)] +/// #[schema(bound = "")] +/// struct Unused<T> { +/// #[serde(skip)] +/// _marker: std::marker::PhantomData<T>, +/// } +/// ``` +/// +/// _**Use `no_recursion` attribute to break from looping schema tree e.g. `Pet` -> `Owner` -> +/// `Pet`.**_ +/// +/// `no_recursion` attribute can be provided on named field of a struct, on unnamed struct or unnamed +/// enum variant. It must be provided in case of looping schema tree in order to stop recursion. +/// Failing to do so will cause runtime **panic**. +/// ```rust +/// # use fastapi::ToSchema; +/// # +/// #[derive(ToSchema)] +/// pub struct Pet { +/// name: String, +/// owner: Owner, +/// } +/// +/// #[derive(ToSchema)] +/// pub struct Owner { +/// name: String, +/// #[schema(no_recursion)] +/// pets: Vec<Pet>, +/// } +/// ``` +/// +/// [to_schema]: trait.ToSchema.html +/// [known_format]: openapi/schema/enum.KnownFormat.html +/// [binary]: openapi/schema/enum.KnownFormat.html#variant.Binary +/// [xml]: openapi/xml/struct.Xml.html +/// [into_params]: derive.IntoParams.html +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +/// [serde attributes]: https://serde.rs/attributes.html +/// [discriminator]: openapi/schema/struct.Discriminator.html +/// [enum_schema]: derive.ToSchema.html#enum-optional-configuration-options-for-schema +/// [openapi_derive]: derive.OpenApi.html +/// [to_schema_xml]: macro@ToSchema#xml-attribute-configuration-options +/// [schema_object_encoding]: openapi/schema/struct.Object.html#structfield.content_encoding +/// [schema_object_media_type]: openapi/schema/struct.Object.html#structfield.content_media_type +/// [path_macro]: macro@path +/// [const]: https://doc.rust-lang.org/std/keyword.const.html +pub fn derive_to_schema(input: TokenStream) -> TokenStream { + let DeriveInput { + attrs, + ident, + data, + generics, + .. + } = syn::parse_macro_input!(input); + + Schema::new(&data, &attrs, &ident, &generics) + .as_ref() + .map_or_else(Diagnostics::to_token_stream, Schema::to_token_stream) + .into() +} + +#[proc_macro_attribute] +/// Path attribute macro implements OpenAPI path for the decorated function. +/// +/// This is a `#[derive]` implementation for [`Path`][path] trait. Macro accepts set of attributes that can +/// be used to configure and override default values what are resolved automatically. +/// +/// You can use the Rust's own `#[deprecated]` attribute on functions to mark it as deprecated and it will +/// reflect to the generated OpenAPI spec. Only **parameters** has a special **deprecated** attribute to define them as deprecated. +/// +/// `#[deprecated]` attribute supports adding additional details such as a reason and or since version but this is is not supported in +/// OpenAPI. OpenAPI has only a boolean flag to determine deprecation. While it is totally okay to declare deprecated with reason +/// `#[deprecated = "There is better way to do this"]` the reason would not render in OpenAPI spec. +/// +/// Doc comment at decorated function will be used for _`description`_ and _`summary`_ of the path. +/// First line of the doc comment will be used as the _`summary`_ while the remaining lines will be +/// used as _`description`_. +/// ```rust +/// /// This is a summary of the operation +/// /// +/// /// The rest of the doc comment will be included to operation description. +/// #[fastapi::path(get, path = "/operation")] +/// fn operation() {} +/// ``` +/// +/// # Path Attributes +/// +/// * `operation` _**Must be first parameter!**_ Accepted values are known HTTP operations such as +/// _`get, post, put, delete, head, options, patch, trace`_. +/// +/// * `method(get, head, ...)` Http methods for the operation. This allows defining multiple +/// HTTP methods at once for single operation. Either _`operation`_ or _`method(...)`_ _**must be +/// provided.**_ +/// +/// * `path = "..."` Must be OpenAPI format compatible str with arguments within curly braces. E.g _`{id}`_ +/// +/// * `impl_for = ...` Optional type to implement the [`Path`][path] trait. By default a new type +/// is used for the implementation. +/// +/// * `operation_id = ...` Unique operation id for the endpoint. By default this is mapped to function name. +/// The operation_id can be any valid expression (e.g. string literals, macro invocations, variables) so long +/// as its result can be converted to a `String` using `String::from`. +/// +/// * `context_path = "..."` Can add optional scope for **path**. The **context_path** will be prepended to beginning of **path**. +/// This is particularly useful when **path** does not contain the full path to the endpoint. For example if web framework +/// allows operation to be defined under some context path or scope which does not reflect to the resolved path then this +/// **context_path** can become handy to alter the path. +/// +/// * `tag = "..."` Can be used to group operations. Operations with same tag are grouped together. By default +/// this is derived from the module path of the handler that is given to [`OpenApi`][openapi]. +/// +/// * `tags = ["tag1", ...]` Can be used to group operations. Operations with same tag are grouped +/// together. Tags attribute can be used to add additional _tags_ for the operation. If both +/// _`tag`_ and _`tags`_ are provided then they will be combined to a single _`tags`_ array. +/// +/// * `request_body = ... | request_body(...)` Defining request body indicates that the request is expecting request body within +/// the performed request. +/// +/// * `responses(...)` Slice of responses the endpoint is going to possibly return to the caller. +/// +/// * `params(...)` Slice of params that the endpoint accepts. +/// +/// * `security(...)` List of [`SecurityRequirement`][security]s local to the path operation. +/// +/// # Request Body Attributes +/// +/// ## Simple format definition by `request_body = ...` +/// * _`request_body = Type`_, _`request_body = inline(Type)`_ or _`request_body = ref("...")`_. +/// The given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec or Map etc. +/// With _`inline(...)`_ the schema will be inlined instead of a referenced which is the default for +/// [`ToSchema`][to_schema] types. _`ref("./external.json")`_ can be used to reference external +/// json file for body schema. **Note!** Fastapi does **not** guarantee that free form _`ref`_ is accessible via +/// OpenAPI doc or Swagger UI, users are responsible for making these guarantees. +/// +/// ## Advanced format definition by `request_body(...)` +/// +/// With advanced format the request body supports defining either one or multiple request bodies by `content` attribute. +/// +/// ### Common request body attributes +/// +/// * `description = "..."` Define the description for the request body object as str. +/// +/// * `example = ...` Can be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// +/// * `examples(...)` Define multiple examples for single request body. This attribute is mutually +/// exclusive to the _`example`_ attribute and if both are defined this will override the _`example`_. +/// This has same syntax as _`examples(...)`_ in [Response Attributes](#response-attributes) +/// _examples(...)_ +/// +/// ### Single request body content +/// +/// * `content = ...` Can be _`content = Type`_, _`content = inline(Type)`_ or _`content = ref("...")`_. The +/// given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec +/// or Map etc. With _`inline(...)`_ the schema will be inlined instead of a referenced +/// which is the default for [`ToSchema`][to_schema] types. _`ref("./external.json")`_ +/// can be used to reference external json file for body schema. **Note!** Fastapi does **not** guarantee +/// that free form _`ref`_ is accessible via OpenAPI doc or Swagger UI, users are responsible for making +/// these guarantees. +/// +/// * `content_type = "..."` Can be used to override the default behavior +/// of auto resolving the content type from the `content` attribute. If defined the value should be valid +/// content type such as _`application/json`_ . By default the content type is _`text/plain`_ +/// for [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and _`application/json`_ +/// for struct and mixed enum types. +/// +/// _**Example of single request body definitions.**_ +/// ```text +/// request_body(content = String, description = "Xml as string request", content_type = "text/xml"), +/// request_body(content_type = "application/json"), +/// request_body = Pet, +/// request_body = Option<[Pet]>, +/// ``` +/// +/// ### Multiple request body content +/// +/// * `content(...)` Can be tuple of content tuples according to format below. +/// ```text +/// ( schema ) +/// ( schema = "content/type", example = ..., examples(..., ...) ) +/// ( "content/type", ), +/// ( "content/type", example = ..., examples(..., ...) ) +/// ``` +/// +/// First argument of content tuple is _`schema`_, which is optional as long as either _`schema`_ +/// or _`content/type`_ is defined. The _`schema`_ and _`content/type`_ is separated with equals +/// (=) sign. Optionally content tuple supports defining _`example`_ and _`examples`_ arguments. See +/// [common request body attributes][macro@path#common-request-body-attributes] +/// +/// _**Example of multiple request body definitions.**_ +/// +/// ```text +/// // guess the content type for Pet and Pet2 +/// request_body(description = "Common description", +/// content( +/// (Pet), +/// (Pet2) +/// ) +/// ), +/// // define explicit content types +/// request_body(description = "Common description", +/// content( +/// (Pet = "application/json", examples(..., ...), example = ...), +/// (Pet2 = "text/xml", examples(..., ...), example = ...) +/// ) +/// ), +/// // omit schema and accept arbitrary content types +/// request_body(description = "Common description", +/// content( +/// ("application/json"), +/// ("text/xml", examples(..., ...), example = ...) +/// ) +/// ), +/// ``` +/// +/// # Response Attributes +/// +/// * `status = ...` Is either a valid http status code integer. E.g. _`200`_ or a string value representing +/// a range such as _`"4XX"`_ or `"default"` or a valid _`http::status::StatusCode`_. +/// _`StatusCode`_ can either be use path to the status code or _status code_ constant directly. +/// +/// * `description = "..."` Define description for the response as str. +/// +/// * `body = ...` Optional response body object type. When left empty response does not expect to send any +/// response body. Can be _`body = Type`_, _`body = inline(Type)`_, or _`body = ref("...")`_. +/// The given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec or Map etc. +/// With _`inline(...)`_ the schema will be inlined instead of a referenced which is the default for +/// [`ToSchema`][to_schema] types. _`ref("./external.json")`_ +/// can be used to reference external json file for body schema. **Note!** Fastapi does **not** guarantee +/// that free form _`ref`_ is accessible via OpenAPI doc or Swagger UI, users are responsible for making +/// these guarantees. +/// +/// * `content_type = "..."` Can be used to override the default behavior +/// of auto resolving the content type from the `body` attribute. If defined the value should be valid +/// content type such as _`application/json`_ . By default the content type is _`text/plain`_ +/// for [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and _`application/json`_ +/// for struct and mixed enum types. +/// +/// * `headers(...)` Slice of response headers that are returned back to a caller. +/// +/// * `example = ...` Can be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// +/// * `response = ...` Type what implements [`ToResponse`][to_response_trait] trait. This can alternatively be used to +/// define response attributes. _`response`_ attribute cannot co-exist with other than _`status`_ attribute. +/// +/// * `content((...), (...))` Can be used to define multiple return types for single response status. Supports same syntax as +/// [multiple request body content][`macro@path#multiple-request-body-content`]. +/// +/// * `examples(...)` Define multiple examples for single response. This attribute is mutually +/// exclusive to the _`example`_ attribute and if both are defined this will override the _`example`_. +/// +/// * `links(...)` Define a map of operations links that can be followed from the response. +/// +/// ## Response `examples(...)` syntax +/// +/// * `name = ...` This is first attribute and value must be literal string. +/// * `summary = ...` Short description of example. Value must be literal string. +/// * `description = ...` Long description of example. Attribute supports markdown for rich text +/// representation. Value must be literal string. +/// * `value = ...` Example value. It must be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// * `external_value = ...` Define URI to literal example value. This is mutually exclusive to +/// the _`value`_ attribute. Value must be literal string. +/// +/// _**Example of example definition.**_ +/// ```text +/// ("John" = (summary = "This is John", value = json!({"name": "John"}))) +/// ``` +/// +/// ## Response `links(...)` syntax +/// +/// * `operation_ref = ...` Define a relative or absolute URI reference to an OAS operation. This field is +/// mutually exclusive of the _`operation_id`_ field, and **must** point to an [Operation Object][operation]. +/// Value can be be [`str`] or an expression such as [`include_str!`][include_str] or static +/// [`const`][const] reference. +/// +/// * `operation_id = ...` Define the name of an existing, resolvable OAS operation, as defined with a unique +/// _`operation_id`_. This field is mutually exclusive of the _`operation_ref`_ field. +/// Value can be be [`str`] or an expression such as [`include_str!`][include_str] or static +/// [`const`][const] reference. +/// +/// * `parameters(...)` A map representing parameters to pass to an operation as specified with _`operation_id`_ +/// or identified by _`operation_ref`_. The key is parameter name to be used and value can +/// be any value supported by JSON or an [expression][expression] e.g. `$path.id` +/// * `name = ...` Define name for the parameter. +/// Value can be be [`str`] or an expression such as [`include_str!`][include_str] or static +/// [`const`][const] reference. +/// * `value` = Any value that can be supported by JSON or an [expression][expression]. +/// +/// _**Example of parameters syntax:**_ +/// ```text +/// parameters( +/// ("name" = value), +/// ("name" = value) +/// ), +/// ``` +/// +/// * `request_body = ...` Define a literal value or an [expression][expression] to be used as request body when +/// operation is called +/// +/// * `description = ...` Define description of the link. Value supports Markdown syntax.Value can be be [`str`] or +/// an expression such as [`include_str!`][include_str] or static [`const`][const] reference. +/// +/// * `server(...)` Define [Server][server] object to be used by the target operation. See +/// [server syntax][server_derive_syntax] +/// +/// **Links syntax example:** See the full example below in [examples](#examples). +/// ```text +/// responses( +/// (status = 200, description = "success response", +/// links( +/// ("link_name" = ( +/// operation_id = "test_links", +/// parameters(("key" = "value"), ("json_value" = json!(1))), +/// request_body = "this is body", +/// server(url = "http://localhost") +/// )) +/// ) +/// ) +/// ) +/// ``` +/// +/// **Minimal response format:** +/// ```text +/// responses( +/// (status = 200, description = "success response"), +/// (status = 404, description = "resource missing"), +/// (status = "5XX", description = "server error"), +/// (status = StatusCode::INTERNAL_SERVER_ERROR, description = "internal server error"), +/// (status = IM_A_TEAPOT, description = "happy easter") +/// ) +/// ``` +/// +/// **More complete Response:** +/// ```text +/// responses( +/// (status = 200, description = "Success response", body = Pet, content_type = "application/json", +/// headers(...), +/// example = json!({"id": 1, "name": "bob the cat"}) +/// ) +/// ) +/// ``` +/// +/// **Multiple response return types with _`content(...)`_ attribute:** +/// +/// _**Define multiple response return types for single response status with their own example.**_ +/// ```text +/// responses( +/// (status = 200, content( +/// (User = "application/vnd.user.v1+json", example = json!(User {id: "id".to_string()})), +/// (User2 = "application/vnd.user.v2+json", example = json!(User2 {id: 2})) +/// ) +/// ) +/// ) +/// ``` +/// +/// ### Using `ToResponse` for reusable responses +/// +/// _**`ReusableResponse` must be a type that implements [`ToResponse`][to_response_trait].**_ +/// ```text +/// responses( +/// (status = 200, response = ReusableResponse) +/// ) +/// ``` +/// +/// _**[`ToResponse`][to_response_trait] can also be inlined to the responses map.**_ +/// ```text +/// responses( +/// (status = 200, response = inline(ReusableResponse)) +/// ) +/// ``` +/// +/// ## Responses from `IntoResponses` +/// +/// _**Responses for a path can be specified with one or more types that implement +/// [`IntoResponses`][into_responses_trait].**_ +/// ```text +/// responses(MyResponse) +/// ``` +/// +/// # Response Header Attributes +/// +/// * `name` Name of the header. E.g. _`x-csrf-token`_ +/// +/// * `type` Additional type of the header value. Can be `Type` or `inline(Type)`. +/// The given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec or Map etc. +/// With _`inline(...)`_ the schema will be inlined instead of a referenced which is the default for +/// [`ToSchema`][to_schema] types. **Reminder!** It's up to the user to use valid type for the +/// response header. +/// +/// * `description = "..."` Can be used to define optional description for the response header as str. +/// +/// **Header supported formats:** +/// +/// ```text +/// ("x-csrf-token"), +/// ("x-csrf-token" = String, description = "New csrf token"), +/// ``` +/// +/// # Params Attributes +/// +/// The list of attributes inside the `params(...)` attribute can take two forms: [Tuples](#tuples) or [IntoParams +/// Type](#intoparams-type). +/// +/// ## Tuples +/// +/// In the tuples format, parameters are specified using the following attributes inside a list of +/// tuples separated by commas: +/// +/// * `name` _**Must be the first argument**_. Define the name for parameter. +/// +/// * `parameter_type` Define possible type for the parameter. Can be `Type` or `inline(Type)`. +/// The given _`Type`_ can be any Rust type that is JSON parseable. It can be Option, Vec or Map etc. +/// With _`inline(...)`_ the schema will be inlined instead of a referenced which is the default for +/// [`ToSchema`][to_schema] types. Parameter type is placed after `name` with +/// equals sign E.g. _`"id" = string`_ +/// +/// * `in` _**Must be placed after name or parameter_type**_. Define the place of the parameter. +/// This must be one of the variants of [`openapi::path::ParameterIn`][in_enum]. +/// E.g. _`Path, Query, Header, Cookie`_ +/// +/// * `deprecated` Define whether the parameter is deprecated or not. Can optionally be defined +/// with explicit `bool` value as _`deprecated = bool`_. +/// +/// * `description = "..."` Define possible description for the parameter as str. +/// +/// * `style = ...` Defines how parameters are serialized by [`ParameterStyle`][style]. Default values are based on _`in`_ attribute. +/// +/// * `explode` Defines whether new _`parameter=value`_ is created for each parameter within _`object`_ or _`array`_. +/// +/// * `allow_reserved` Defines whether reserved characters _`:/?#[]@!$&'()*+,;=`_ is allowed within value. +/// +/// * `example = ...` Can method reference or _`json!(...)`_. Given example +/// will override any example in underlying parameter type. +/// +/// ##### Parameter type attributes +/// +/// These attributes supported when _`parameter_type`_ is present. Either by manually providing one +/// or otherwise resolved e.g from path macro argument when _`actix_extras`_ crate feature is +/// enabled. +/// +/// * `format = ...` May either be variant of the [`KnownFormat`][known_format] enum, or otherwise +/// an open value as a string. By default the format is derived from the type of the property +/// according OpenApi spec. +/// +/// * `write_only` Defines property is only used in **write** operations *POST,PUT,PATCH* but not in *GET* +/// +/// * `read_only` Defines property is only used in **read** operations *GET* but not in *POST,PUT,PATCH* +/// +/// * `xml(...)` Can be used to define [`Xml`][xml] object properties for the parameter type. +/// See configuration options at xml attributes of [`ToSchema`][to_schema_xml] +/// +/// * `nullable` Defines property is nullable (note this is different to non-required). +/// +/// * `multiple_of = ...` Can be used to define multiplier for a value. Value is considered valid +/// division will result an `integer`. Value must be strictly above _`0`_. +/// +/// * `maximum = ...` Can be used to define inclusive upper bound to a `number` value. +/// +/// * `minimum = ...` Can be used to define inclusive lower bound to a `number` value. +/// +/// * `exclusive_maximum = ...` Can be used to define exclusive upper bound to a `number` value. +/// +/// * `exclusive_minimum = ...` Can be used to define exclusive lower bound to a `number` value. +/// +/// * `max_length = ...` Can be used to define maximum length for `string` types. +/// +/// * `min_length = ...` Can be used to define minimum length for `string` types. +/// +/// * `pattern = ...` Can be used to define valid regular expression in _ECMA-262_ dialect the field value must match. +/// +/// * `max_items = ...` Can be used to define maximum items allowed for `array` fields. Value must +/// be non-negative integer. +/// +/// * `min_items = ...` Can be used to define minimum items allowed for `array` fields. Value must +/// be non-negative integer. +/// +/// ##### Parameter Formats +/// ```test +/// ("name" = ParameterType, ParameterIn, ...) +/// ("name", ParameterIn, ...) +/// ``` +/// +/// **For example:** +/// +/// ```text +/// params( +/// ("limit" = i32, Query), +/// ("x-custom-header" = String, Header, description = "Custom header"), +/// ("id" = String, Path, deprecated, description = "Pet database id"), +/// ("name", Path, deprecated, description = "Pet name"), +/// ( +/// "value" = inline(Option<[String]>), +/// Query, +/// description = "Value description", +/// style = Form, +/// allow_reserved, +/// deprecated, +/// explode, +/// example = json!(["Value"])), +/// max_length = 10, +/// min_items = 1 +/// ) +/// ) +/// ``` +/// +/// ## IntoParams Type +/// +/// In the IntoParams parameters format, the parameters are specified using an identifier for a type +/// that implements [`IntoParams`][into_params]. See [`IntoParams`][into_params] for an +/// example. +/// +/// ```text +/// params(MyParameters) +/// ``` +/// +/// **Note!** that `MyParameters` can also be used in combination with the [tuples +/// representation](#tuples) or other structs. +/// ```text +/// params( +/// MyParameters1, +/// MyParameters2, +/// ("id" = String, Path, deprecated, description = "Pet database id"), +/// ) +/// ``` +/// +/// # Security Requirement Attributes +/// +/// * `name` Define the name for security requirement. This must match to name of existing +/// [`SecurityScheme`][security_scheme]. +/// * `scopes = [...]` Define the list of scopes needed. These must be scopes defined already in +/// existing [`SecurityScheme`][security_scheme]. +/// +/// **Security Requirement supported formats:** +/// +/// ```text +/// (), +/// ("name" = []), +/// ("name" = ["scope1", "scope2"]), +/// ("name" = ["scope1", "scope2"], "name2" = []), +/// ``` +/// +/// Leaving empty _`()`_ creates an empty [`SecurityRequirement`][security] this is useful when +/// security requirement is optional for operation. +/// +/// You can define multiple security requirements within same parenthesis separated by comma. This +/// allows you to define keys that must be simultaneously provided for the endpoint / API. +/// +/// _**Following could be explained as: Security is optional and if provided it must either contain +/// `api_key` or `key AND key2`.**_ +/// ```text +/// (), +/// ("api_key" = []), +/// ("key" = [], "key2" = []), +/// ``` +/// +/// # actix_extras feature support for actix-web +/// +/// **actix_extras** feature gives **fastapi** ability to parse path operation information from **actix-web** types and macros. +/// +/// 1. Ability to parse `path` from **actix-web** path attribute macros e.g. _`#[get(...)]`_ or +/// `#[route(...)]`. +/// 2. Ability to parse [`std::primitive`] or [`String`] or [`tuple`] typed `path` parameters from **actix-web** _`web::Path<...>`_. +/// 3. Ability to parse `path` and `query` parameters form **actix-web** _`web::Path<...>`_, _`web::Query<...>`_ types +/// with [`IntoParams`][into_params] trait. +/// +/// See the **actix_extras** in action in examples [todo-actix](https://github.com/nxpkg/fastapi/tree/master/examples/todo-actix). +/// +/// With **actix_extras** feature enabled the you can leave out definitions for **path**, **operation** +/// and **parameter types**. +/// ```rust +/// use actix_web::{get, web, HttpResponse, Responder}; +/// use serde_json::json; +/// +/// /// Get Pet by id +/// #[fastapi::path( +/// responses( +/// (status = 200, description = "Pet found from database") +/// ), +/// params( +/// ("id", description = "Pet id"), +/// ) +/// )] +/// #[get("/pet/{id}")] +/// async fn get_pet_by_id(id: web::Path<i32>) -> impl Responder { +/// HttpResponse::Ok().json(json!({ "pet": format!("{:?}", &id.into_inner()) })) +/// } +/// ``` +/// +/// With **actix_extras** you may also not to list any _**params**_ if you do not want to specify any description for them. Params are +/// resolved from path and the argument types of handler +/// ```rust +/// use actix_web::{get, web, HttpResponse, Responder}; +/// use serde_json::json; +/// +/// /// Get Pet by id +/// #[fastapi::path( +/// responses( +/// (status = 200, description = "Pet found from database") +/// ) +/// )] +/// #[get("/pet/{id}")] +/// async fn get_pet_by_id(id: web::Path<i32>) -> impl Responder { +/// HttpResponse::Ok().json(json!({ "pet": format!("{:?}", &id.into_inner()) })) +/// } +/// ``` +/// +/// # rocket_extras feature support for rocket +/// +/// **rocket_extras** feature enhances path operation parameter support. It gives **fastapi** ability to parse `path`, `path parameters` +/// and `query parameters` based on arguments given to **rocket** proc macros such as _**`#[get(...)]`**_. +/// +/// 1. It is able to parse parameter types for [primitive types][primitive], [`String`], [`Vec`], [`Option`] or [`std::path::PathBuf`] +/// type. +/// 2. It is able to determine `parameter_in` for [`IntoParams`][into_params] trait used for `FromForm` type of query parameters. +/// +/// See the **rocket_extras** in action in examples [rocket-todo](https://github.com/nxpkg/fastapi/tree/master/examples/rocket-todo). +/// +/// +/// # axum_extras feature support for axum +/// +/// **axum_extras** feature enhances parameter support for path operation in following ways. +/// +/// 1. It allows users to use tuple style path parameters e.g. _`Path((id, name)): Path<(i32, String)>`_ and resolves +/// parameter names and types from it. +/// 2. It enhances [`IntoParams` derive][into_params_derive] functionality by automatically resolving _`parameter_in`_ from +/// _`Path<...>`_ or _`Query<...>`_ handler function arguments. +/// +/// _**Resole path argument types from tuple style handler arguments.**_ +/// ```rust +/// # use axum::extract::Path; +/// /// Get todo by id and name. +/// #[fastapi::path( +/// get, +/// path = "/todo/{id}", +/// params( +/// ("id", description = "Todo id"), +/// ("name", description = "Todo name") +/// ), +/// responses( +/// (status = 200, description = "Get todo success", body = String) +/// ) +/// )] +/// async fn get_todo( +/// Path((id, name)): Path<(i32, String)> +/// ) -> String { +/// String::new() +/// } +/// ``` +/// +/// _**Use `IntoParams` to resolve query parameters.**_ +/// ```rust +/// # use serde::Deserialize; +/// # use fastapi::IntoParams; +/// # use axum::{extract::Query, Json}; +/// #[derive(Deserialize, IntoParams)] +/// struct TodoSearchQuery { +/// /// Search by value. Search is incase sensitive. +/// value: String, +/// /// Search by `done` status. +/// done: bool, +/// } +/// +/// /// Search Todos by query params. +/// #[fastapi::path( +/// get, +/// path = "/todo/search", +/// params( +/// TodoSearchQuery +/// ), +/// responses( +/// (status = 200, description = "List matching todos by query", body = [String]) +/// ) +/// )] +/// async fn search_todos( +/// query: Query<TodoSearchQuery>, +/// ) -> Json<Vec<String>> { +/// Json(vec![]) +/// } +/// ``` +/// +/// # Defining file uploads +/// +/// File uploads can be defined in accordance to Open API specification [file uploads][file_uploads]. +/// +/// +/// _**Example sending `jpg` and `png` images as `application/octet-stream`.**_ +/// ```rust +/// #[fastapi::path( +/// post, +/// request_body( +/// content( +/// ("image/png"), +/// ("image/jpg"), +/// ), +/// ), +/// path = "/test_images" +/// )] +/// async fn test_images(_body: Vec<u8>) {} +/// ``` +/// +/// _**Example of sending `multipart` form.**_ +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// struct MyForm { +/// order_id: i32, +/// #[schema(content_media_type = "application/octet-stream")] +/// file_bytes: Vec<u8>, +/// } +/// +/// #[fastapi::path( +/// post, +/// request_body(content = inline(MyForm), content_type = "multipart/form-data"), +/// path = "/test_multipart" +/// )] +/// async fn test_multipart(_body: MyForm) {} +/// ``` +/// +/// _**Example of sending arbitrary binary content as `application/octet-stream`.**_ +/// ```rust +/// #[fastapi::path( +/// post, +/// request_body = Vec<u8>, +/// path = "/test-octet-stream", +/// responses( +/// (status = 200, description = "success response") +/// ), +/// )] +/// async fn test_octet_stream(_body: Vec<u8>) {} +/// ``` +/// +/// _**Example of sending `png` image as `base64` encoded.**_ +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// #[schema(content_encoding = "base64")] +/// struct MyPng(String); +/// +/// #[fastapi::path( +/// post, +/// request_body(content = inline(MyPng), content_type = "image/png"), +/// path = "/test_png", +/// responses( +/// (status = 200, description = "success response") +/// ), +/// )] +/// async fn test_png(_body: MyPng) {} +/// ``` +/// +/// # Examples +/// +/// _**More complete example.**_ +/// ```rust +/// # #[derive(fastapi::ToSchema)] +/// # struct Pet { +/// # id: u64, +/// # name: String, +/// # } +/// # +/// #[fastapi::path( +/// post, +/// operation_id = "custom_post_pet", +/// path = "/pet", +/// tag = "pet_handlers", +/// request_body(content = Pet, description = "Pet to store the database", content_type = "application/json"), +/// responses( +/// (status = 200, description = "Pet stored successfully", body = Pet, content_type = "application/json", +/// headers( +/// ("x-cache-len" = String, description = "Cache length") +/// ), +/// example = json!({"id": 1, "name": "bob the cat"}) +/// ), +/// ), +/// params( +/// ("x-csrf-token" = String, Header, deprecated, description = "Current csrf token of user"), +/// ), +/// security( +/// (), +/// ("my_auth" = ["read:items", "edit:items"]), +/// ("token_jwt" = []) +/// ) +/// )] +/// fn post_pet(pet: Pet) -> Pet { +/// Pet { +/// id: 4, +/// name: "bob the cat".to_string(), +/// } +/// } +/// ``` +/// +/// _**More minimal example with the defaults.**_ +/// ```rust +/// # #[derive(fastapi::ToSchema)] +/// # struct Pet { +/// # id: u64, +/// # name: String, +/// # } +/// # +/// #[fastapi::path( +/// post, +/// path = "/pet", +/// request_body = Pet, +/// responses( +/// (status = 200, description = "Pet stored successfully", body = Pet, +/// headers( +/// ("x-cache-len", description = "Cache length") +/// ) +/// ), +/// ), +/// params( +/// ("x-csrf-token", Header, description = "Current csrf token of user"), +/// ) +/// )] +/// fn post_pet(pet: Pet) -> Pet { +/// Pet { +/// id: 4, +/// name: "bob the cat".to_string(), +/// } +/// } +/// ``` +/// +/// _**Use of Rust's own `#[deprecated]` attribute will reflect to the generated OpenAPI spec and mark this operation as deprecated.**_ +/// ```rust +/// # use actix_web::{get, web, HttpResponse, Responder}; +/// # use serde_json::json; +/// #[fastapi::path( +/// responses( +/// (status = 200, description = "Pet found from database") +/// ), +/// params( +/// ("id", description = "Pet id"), +/// ) +/// )] +/// #[get("/pet/{id}")] +/// #[deprecated] +/// async fn get_pet_by_id(id: web::Path<i32>) -> impl Responder { +/// HttpResponse::Ok().json(json!({ "pet": format!("{:?}", &id.into_inner()) })) +/// } +/// ``` +/// +/// _**Define context path for endpoint. The resolved **path** shown in OpenAPI doc will be `/api/pet/{id}`.**_ +/// ```rust +/// # use actix_web::{get, web, HttpResponse, Responder}; +/// # use serde_json::json; +/// #[fastapi::path( +/// context_path = "/api", +/// responses( +/// (status = 200, description = "Pet found from database") +/// ) +/// )] +/// #[get("/pet/{id}")] +/// async fn get_pet_by_id(id: web::Path<i32>) -> impl Responder { +/// HttpResponse::Ok().json(json!({ "pet": format!("{:?}", &id.into_inner()) })) +/// } +/// ``` +/// +/// _**Example with multiple return types**_ +/// ```rust +/// # trait User {} +/// # #[derive(fastapi::ToSchema)] +/// # struct User1 { +/// # id: String +/// # } +/// # impl User for User1 {} +/// # #[derive(fastapi::ToSchema)] +/// # struct User2 { +/// # id: String +/// # } +/// # impl User for User2 {} +/// #[fastapi::path( +/// get, +/// path = "/user", +/// responses( +/// (status = 200, content( +/// (User1 = "application/vnd.user.v1+json", example = json!({"id": "id".to_string()})), +/// (User2 = "application/vnd.user.v2+json", example = json!({"id": 2})) +/// ) +/// ) +/// ) +/// )] +/// fn get_user() -> Box<dyn User> { +/// Box::new(User1 {id: "id".to_string()}) +/// } +/// ```` +/// +/// _**Example with multiple examples on single response.**_ +/// ```rust +/// # #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] +/// # struct User { +/// # name: String +/// # } +/// #[fastapi::path( +/// get, +/// path = "/user", +/// responses( +/// (status = 200, body = User, +/// examples( +/// ("Demo" = (summary = "This is summary", description = "Long description", +/// value = json!(User{name: "Demo".to_string()}))), +/// ("John" = (summary = "Another user", value = json!({"name": "John"}))) +/// ) +/// ) +/// ) +/// )] +/// fn get_user() -> User { +/// User {name: "John".to_string()} +/// } +/// ``` +/// +/// _**Example of using links in response.**_ +/// ```rust +/// # use serde_json::json; +/// #[fastapi::path( +/// get, +/// path = "/test-links", +/// responses( +/// (status = 200, description = "success response", +/// links( +/// ("getFoo" = ( +/// operation_id = "test_links", +/// parameters(("key" = "value"), ("json_value" = json!(1))), +/// request_body = "this is body", +/// server(url = "http://localhost") +/// )), +/// ("getBar" = ( +/// operation_ref = "this is ref" +/// )) +/// ) +/// ) +/// ), +/// )] +/// async fn test_links() -> &'static str { +/// "" +/// } +/// ``` +/// +/// [in_enum]: openapi/path/enum.ParameterIn.html +/// [path]: trait.Path.html +/// [to_schema]: trait.ToSchema.html +/// [openapi]: derive.OpenApi.html +/// [security]: openapi/security/struct.SecurityRequirement.html +/// [security_scheme]: openapi/security/enum.SecurityScheme.html +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +/// [into_params]: trait.IntoParams.html +/// [style]: openapi/path/enum.ParameterStyle.html +/// [into_responses_trait]: trait.IntoResponses.html +/// [into_params_derive]: derive.IntoParams.html +/// [to_response_trait]: trait.ToResponse.html +/// [known_format]: openapi/schema/enum.KnownFormat.html +/// [xml]: openapi/xml/struct.Xml.html +/// [to_schema_xml]: macro@ToSchema#xml-attribute-configuration-options +/// [relative_references]: https://spec.openapis.org/oas/latest.html#relative-references-in-uris +/// [operation]: openapi/path/struct.Operation.html +/// [expression]: https://spec.openapis.org/oas/latest.html#runtime-expressions +/// [const]: https://doc.rust-lang.org/std/keyword.const.html +/// [include_str]: https://doc.rust-lang.org/std/macro.include_str.html +/// [server_derive_syntax]: derive.OpenApi.html#servers-attribute-syntax +/// [server]: openapi/server/struct.Server.html +/// [file_uploads]: <https://spec.openapis.org/oas/v3.1.0.html#considerations-for-file-uploads> +pub fn path(attr: TokenStream, item: TokenStream) -> TokenStream { + let path_attribute = syn::parse_macro_input!(attr as PathAttr); + + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras", + feature = "auto_into_responses" + ))] + let mut path_attribute = path_attribute; + + let ast_fn = match syn::parse::<ItemFn>(item) { + Ok(ast_fn) => ast_fn, + Err(error) => return error.into_compile_error().into_token_stream().into(), + }; + + #[cfg(feature = "auto_into_responses")] + { + if let Some(responses) = ext::auto_types::parse_fn_operation_responses(&ast_fn) { + path_attribute.responses_from_into_responses(responses); + }; + } + + let mut resolved_methods = match PathOperations::resolve_operation(&ast_fn) { + Ok(operation) => operation, + Err(diagnostics) => return diagnostics.into_token_stream().into(), + }; + let resolved_path = PathOperations::resolve_path( + &resolved_methods + .as_mut() + .map(|operation| mem::take(&mut operation.path).to_string()) + .or_else(|| path_attribute.path.as_ref().map(|path| path.to_string())), // cannot use mem take because we need this later + ); + + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + let mut resolved_path = resolved_path; + + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + { + use ext::ArgumentResolver; + use path::parameter::Parameter; + let path_args = resolved_path.as_mut().map(|path| mem::take(&mut path.args)); + let body = resolved_methods + .as_mut() + .map(|path| mem::take(&mut path.body)) + .unwrap_or_default(); + + let (arguments, into_params_types, body) = + match PathOperations::resolve_arguments(&ast_fn.sig.inputs, path_args, body) { + Ok(args) => args, + Err(diagnostics) => return diagnostics.into_token_stream().into(), + }; + + let parameters = arguments + .into_iter() + .flatten() + .map(Parameter::from) + .chain(into_params_types.into_iter().flatten().map(Parameter::from)); + path_attribute.update_parameters_ext(parameters); + + path_attribute.update_request_body(body); + } + + let path = Path::new(path_attribute, &ast_fn.sig.ident) + .ext_methods(resolved_methods.map(|operation| operation.methods)) + .path(resolved_path.map(|path| path.path)) + .doc_comments(CommentAttributes::from_attributes(&ast_fn.attrs).0) + .deprecated(ast_fn.attrs.has_deprecated()); + + let handler = path::handler::Handler { + path, + handler_fn: &ast_fn, + }; + handler.to_token_stream().into() +} + +#[proc_macro_derive(OpenApi, attributes(openapi))] +/// Generate OpenApi base object with defaults from +/// project settings. +/// +/// This is `#[derive]` implementation for [`OpenApi`][openapi] trait. The macro accepts one `openapi` argument. +/// +/// # OpenApi `#[openapi(...)]` attributes +/// +/// * `paths(...)` List of method references having attribute [`#[fastapi::path]`][path] macro. +/// * `components(schemas(...), responses(...))` Takes available _`component`_ configurations. Currently only +/// _`schema`_ and _`response`_ components are supported. +/// * `schemas(...)` List of [`ToSchema`][to_schema]s in OpenAPI schema. +/// * `responses(...)` List of types that implement [`ToResponse`][to_response_trait]. +/// * `modifiers(...)` List of items implementing [`Modify`][modify] trait for runtime OpenApi modification. +/// See the [trait documentation][modify] for more details. +/// * `security(...)` List of [`SecurityRequirement`][security]s global to all operations. +/// See more details in [`#[fastapi::path(...)]`][path] [attribute macro security options][path_security]. +/// * `tags(...)` List of [`Tag`][tags]s which must match the tag _**path operation**_. Tags can be used to +/// define extra information for the API to produce richer documentation. See [tags attribute syntax][tags_syntax]. +/// * `external_docs(...)` Can be used to reference external resource to the OpenAPI doc for extended documentation. +/// External docs can be in [`OpenApi`][openapi_struct] or in [`Tag`][tags] level. +/// * `servers(...)` Define [`servers`][servers] as derive argument to the _`OpenApi`_. Servers +/// are completely optional and thus can be omitted from the declaration. See [servers attribute +/// syntax][servers_syntax] +/// * `info(...)` Declare [`Info`][info] attribute values used to override the default values +/// generated from Cargo environment variables. **Note!** Defined attributes will override the +/// whole attribute from generated values of Cargo environment variables. E.g. defining +/// `contact(name = ...)` will ultimately override whole contact of info and not just partially +/// the name. See [info attribute syntax][info_syntax] +/// * `nest(...)` Allows nesting [`OpenApi`][openapi_struct]s to this _`OpenApi`_ instance. Nest +/// takes comma separated list of tuples of nested `OpenApi`s. _`OpenApi`_ instance must +/// implement [`OpenApi`][openapi] trait. Nesting allows defining one `OpenApi` per defined path. +/// If more instances is defined only latest one will be rentained. +/// See the _[nest(...) attribute syntax below]( #nest-attribute-syntax )_ +/// +/// +/// OpenApi derive macro will also derive [`Info`][info] for OpenApi specification using Cargo +/// environment variables. +/// +/// * env `CARGO_PKG_NAME` map to info `title` +/// * env `CARGO_PKG_VERSION` map to info `version` +/// * env `CARGO_PKG_DESCRIPTION` map info `description` +/// * env `CARGO_PKG_AUTHORS` map to contact `name` and `email` **only first author will be used** +/// * env `CARGO_PKG_LICENSE` map to info `license` +/// +/// # `info(...)` attribute syntax +/// +/// * `title = ...` Define title of the API. It can be [`str`] or an +/// expression such as [`include_str!`][include_str] or static [`const`][const] reference. +/// * `terms_of_service = ...` Define URL to the Terms of Service for the API. It can be [`str`] or an +/// expression such as [`include_str!`][include_str] or static [`const`][const] reference. Value +/// must be valid URL. +/// * `description = ...` Define description of the API. Markdown can be used for rich text +/// representation. It can be [`str`] or an expression such as [`include_str!`][include_str] or static +/// [`const`][const] reference. +/// * `version = ...` Override default version from _`Cargo.toml`_. Value can be [`str`] or an +/// expression such as [`include_str!`][include_str] or static [`const`][const] reference. +/// * `contact(...)` Used to override the whole contact generated from environment variables. +/// * `name = ...` Define identifying name of contact person / organization. It Can be a literal string. +/// * `email = ...` Define email address of the contact person / organization. It can be a literal string. +/// * `url = ...` Define URL pointing to the contact information. It must be in URL formatted string. +/// * `license(...)` Used to override the whole license generated from environment variables. +/// * `name = ...` License name of the API. It can be a literal string. +/// * `url = ...` Define optional URL of the license. It must be URL formatted string. +/// +/// # `tags(...)` attribute syntax +/// +/// * `name = ...` Must be provided, can be [`str`] or an expression such as [`include_str!`][include_str] +/// or static [`const`][const] reference. +/// * `description = ...` Optional description for the tag. Can be either or static [`str`] +/// or an expression e.g. _`include_str!(...)`_ macro call or reference to static [`const`][const]. +/// * `external_docs(...)` Optional links to external documents. +/// * `url = ...` Mandatory URL for external documentation. +/// * `description = ...` Optional description for the _`url`_ link. +/// +/// # `servers(...)` attribute syntax +/// +/// * `url = ...` Define the url for server. It can be literal string. +/// * `description = ...` Define description for the server. It can be literal string. +/// * `variables(...)` Can be used to define variables for the url. +/// * `name = ...` Is the first argument within parentheses. It must be literal string. +/// * `default = ...` Defines a default value for the variable if nothing else will be +/// provided. If _`enum_values`_ is defined the _`default`_ must be found within the enum +/// options. It can be a literal string. +/// * `description = ...` Define the description for the variable. It can be a literal string. +/// * `enum_values(...)` Define list of possible values for the variable. Values must be +/// literal strings. +/// +/// _**Example server variable definition.**_ +/// ```text +/// ("username" = (default = "demo", description = "Default username for API")), +/// ("port" = (enum_values("8080", "5000", "4545"))) +/// ``` +/// +/// # `nest(...)` attribute syntax +/// +/// * `path = ...` Define mandatory path for nesting the [`OpenApi`][openapi_struct]. +/// * `api = ...` Define mandatory path to struct that implements [`OpenApi`][openapi] trait. +/// The fully qualified path (_`path::to`_) will become the default _`tag`_ for the nested +/// `OpenApi` endpoints if provided. +/// * `tags = [...]` Define optional tags what are appended to the existing list of tags. +/// +/// _**Example of nest definition**_ +/// ```text +/// (path = "path/to/nest", api = path::to::NestableApi), +/// (path = "path/to/nest", api = path::to::NestableApi, tags = ["nestableapi", ...]) +/// ``` +/// +/// # Examples +/// +/// _**Define OpenApi schema with some paths and components.**_ +/// ```rust +/// # use fastapi::{OpenApi, ToSchema}; +/// # +/// #[derive(ToSchema)] +/// struct Pet { +/// name: String, +/// age: i32, +/// } +/// +/// #[derive(ToSchema)] +/// enum Status { +/// Active, InActive, Locked, +/// } +/// +/// #[fastapi::path(get, path = "/pet")] +/// fn get_pet() -> Pet { +/// Pet { +/// name: "bob".to_string(), +/// age: 8, +/// } +/// } +/// +/// #[fastapi::path(get, path = "/status")] +/// fn get_status() -> Status { +/// Status::Active +/// } +/// +/// #[derive(OpenApi)] +/// #[openapi( +/// paths(get_pet, get_status), +/// components(schemas(Pet, Status)), +/// security( +/// (), +/// ("my_auth" = ["read:items", "edit:items"]), +/// ("token_jwt" = []) +/// ), +/// tags( +/// (name = "pets::api", description = "All about pets", +/// external_docs(url = "http://more.about.pets.api", description = "Find out more")) +/// ), +/// external_docs(url = "http://more.about.our.apis", description = "More about our APIs") +/// )] +/// struct ApiDoc; +/// ``` +/// +/// _**Define servers to OpenApi.**_ +/// ```rust +/// # use fastapi::OpenApi; +/// #[derive(OpenApi)] +/// #[openapi( +/// servers( +/// (url = "http://localhost:8989", description = "Local server"), +/// (url = "http://api.{username}:{port}", description = "Remote API", +/// variables( +/// ("username" = (default = "demo", description = "Default username for API")), +/// ("port" = (default = "8080", enum_values("8080", "5000", "3030"), description = "Supported ports for API")) +/// ) +/// ) +/// ) +/// )] +/// struct ApiDoc; +/// ``` +/// +/// _**Define info attribute values used to override auto generated ones from Cargo environment +/// variables.**_ +/// ```compile_fail +/// # use fastapi::OpenApi; +/// #[derive(OpenApi)] +/// #[openapi(info( +/// title = "title override", +/// description = include_str!("./path/to/content"), // fail compile cause no such file +/// contact(name = "Test") +/// ))] +/// struct ApiDoc; +/// ``` +/// +/// _**Create OpenAPI with reusable response.**_ +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// struct Person { +/// name: String, +/// } +/// +/// /// Person list response +/// #[derive(fastapi::ToResponse)] +/// struct PersonList(Vec<Person>); +/// +/// #[fastapi::path( +/// get, +/// path = "/person-list", +/// responses( +/// (status = 200, response = PersonList) +/// ) +/// )] +/// fn get_persons() -> Vec<Person> { +/// vec![] +/// } +/// +/// #[derive(fastapi::OpenApi)] +/// #[openapi( +/// components( +/// schemas(Person), +/// responses(PersonList) +/// ) +/// )] +/// struct ApiDoc; +/// ``` +/// +/// _**Nest _`UserApi`_ to the current api doc instance.**_ +/// ```rust +/// # use fastapi::OpenApi; +/// # +/// #[fastapi::path(get, path = "/api/v1/status")] +/// fn test_path_status() {} +/// +/// #[fastapi::path(get, path = "/test")] +/// fn user_test_path() {} +/// +/// #[derive(OpenApi)] +/// #[openapi(paths(user_test_path))] +/// struct UserApi; +/// +/// #[derive(OpenApi)] +/// #[openapi( +/// paths( +/// test_path_status +/// ), +/// nest( +/// (path = "/api/v1/user", api = UserApi), +/// ) +/// )] +/// struct ApiDoc; +/// ``` +/// +/// [openapi]: trait.OpenApi.html +/// [openapi_struct]: openapi/struct.OpenApi.html +/// [to_schema]: derive.ToSchema.html +/// [path]: attr.path.html +/// [modify]: trait.Modify.html +/// [info]: openapi/info/struct.Info.html +/// [security]: openapi/security/struct.SecurityRequirement.html +/// [path_security]: attr.path.html#security-requirement-attributes +/// [tags]: openapi/tag/struct.Tag.html +/// [to_response_trait]: trait.ToResponse.html +/// [servers]: openapi/server/index.html +/// [const]: https://doc.rust-lang.org/std/keyword.const.html +/// [tags_syntax]: #tags-attribute-syntax +/// [info_syntax]: #info-attribute-syntax +/// [servers_syntax]: #servers-attribute-syntax +/// [include_str]: https://doc.rust-lang.org/std/macro.include_str.html +pub fn openapi(input: TokenStream) -> TokenStream { + let DeriveInput { attrs, ident, .. } = syn::parse_macro_input!(input); + + parse_openapi_attrs(&attrs) + .map(|openapi_attr| OpenApi(openapi_attr, ident).to_token_stream()) + .map_or_else(syn::Error::into_compile_error, ToTokens::into_token_stream) + .into() +} + +#[proc_macro_derive(IntoParams, attributes(param, into_params))] +/// Generate [path parameters][path_params] from struct's +/// fields. +/// +/// This is `#[derive]` implementation for [`IntoParams`][into_params] trait. +/// +/// Typically path parameters need to be defined within [`#[fastapi::path(...params(...))]`][path_params] section +/// for the endpoint. But this trait eliminates the need for that when [`struct`][struct]s are used to define parameters. +/// Still [`std::primitive`] and [`String`] path parameters or [`tuple`] style path parameters need to be defined +/// within `params(...)` section if description or other than default configuration need to be given. +/// +/// You can use the Rust's own `#[deprecated]` attribute on field to mark it as +/// deprecated and it will reflect to the generated OpenAPI spec. +/// +/// `#[deprecated]` attribute supports adding additional details such as a reason and or since version +/// but this is is not supported in OpenAPI. OpenAPI has only a boolean flag to determine deprecation. +/// While it is totally okay to declare deprecated with reason +/// `#[deprecated = "There is better way to do this"]` the reason would not render in OpenAPI spec. +/// +/// Doc comment on struct fields will be used as description for the generated parameters. +/// ```rust +/// #[derive(fastapi::IntoParams)] +/// struct Query { +/// /// Query todo items by name. +/// name: String +/// } +/// ``` +/// +/// # IntoParams Container Attributes for `#[into_params(...)]` +/// +/// The following attributes are available for use in on the container attribute `#[into_params(...)]` for the struct +/// deriving `IntoParams`: +/// +/// * `names(...)` Define comma separated list of names for unnamed fields of struct used as a path parameter. +/// __Only__ supported on __unnamed structs__. +/// * `style = ...` Defines how all parameters are serialized by [`ParameterStyle`][style]. Default +/// values are based on _`parameter_in`_ attribute. +/// * `parameter_in = ...` = Defines where the parameters of this field are used with a value from +/// [`openapi::path::ParameterIn`][in_enum]. There is no default value, if this attribute is not +/// supplied, then the value is determined by the `parameter_in_provider` in +/// [`IntoParams::into_params()`](trait.IntoParams.html#tymethod.into_params). +/// * `rename_all = ...` Can be provided to alternatively to the serde's `rename_all` attribute. Effectively provides same functionality. +/// +/// Use `names` to define name for single unnamed argument. +/// ```rust +/// # use fastapi::IntoParams; +/// # +/// #[derive(IntoParams)] +/// #[into_params(names("id"))] +/// struct Id(u64); +/// ``` +/// +/// Use `names` to define names for multiple unnamed arguments. +/// ```rust +/// # use fastapi::IntoParams; +/// # +/// #[derive(IntoParams)] +/// #[into_params(names("id", "name"))] +/// struct IdAndName(u64, String); +/// ``` +/// +/// # IntoParams Field Attributes for `#[param(...)]` +/// +/// The following attributes are available for use in the `#[param(...)]` on struct fields: +/// +/// * `style = ...` Defines how the parameter is serialized by [`ParameterStyle`][style]. Default values are based on _`parameter_in`_ attribute. +/// +/// * `explode` Defines whether new _`parameter=value`_ pair is created for each parameter within _`object`_ or _`array`_. +/// +/// * `allow_reserved` Defines whether reserved characters _`:/?#[]@!$&'()*+,;=`_ is allowed within value. +/// +/// * `example = ...` Can be method reference or _`json!(...)`_. Given example +/// will override any example in underlying parameter type. +/// +/// * `value_type = ...` Can be used to override default type derived from type of the field used in OpenAPI spec. +/// This is useful in cases where the default type does not correspond to the actual type e.g. when +/// any third-party types are used which are not [`ToSchema`][to_schema]s nor [`primitive` types][primitive]. +/// The value can be any Rust type what normally could be used to serialize to JSON, or either virtual type _`Object`_ +/// or _`Value`_. +/// _`Object`_ will be rendered as generic OpenAPI object _(`type: object`)_. +/// _`Value`_ will be rendered as any OpenAPI value (i.e. no `type` restriction). +/// +/// * `inline` If set, the schema for this field's type needs to be a [`ToSchema`][to_schema], and +/// the schema definition will be inlined. +/// +/// * `default = ...` Can be method reference or _`json!(...)`_. +/// +/// * `format = ...` May either be variant of the [`KnownFormat`][known_format] enum, or otherwise +/// an open value as a string. By default the format is derived from the type of the property +/// according OpenApi spec. +/// +/// * `write_only` Defines property is only used in **write** operations *POST,PUT,PATCH* but not in *GET*. +/// +/// * `read_only` Defines property is only used in **read** operations *GET* but not in *POST,PUT,PATCH*. +/// +/// * `xml(...)` Can be used to define [`Xml`][xml] object properties applicable to named fields. +/// See configuration options at xml attributes of [`ToSchema`][to_schema_xml] +/// +/// * `nullable` Defines property is nullable (note this is different to non-required). +/// +/// * `required = ...` Can be used to enforce required status for the parameter. [See +/// rules][derive@IntoParams#field-nullability-and-required-rules] +/// +/// * `rename = ...` Can be provided to alternatively to the serde's `rename` attribute. Effectively provides same functionality. +/// +/// * `multiple_of = ...` Can be used to define multiplier for a value. Value is considered valid +/// division will result an `integer`. Value must be strictly above _`0`_. +/// +/// * `maximum = ...` Can be used to define inclusive upper bound to a `number` value. +/// +/// * `minimum = ...` Can be used to define inclusive lower bound to a `number` value. +/// +/// * `exclusive_maximum = ...` Can be used to define exclusive upper bound to a `number` value. +/// +/// * `exclusive_minimum = ...` Can be used to define exclusive lower bound to a `number` value. +/// +/// * `max_length = ...` Can be used to define maximum length for `string` types. +/// +/// * `min_length = ...` Can be used to define minimum length for `string` types. +/// +/// * `pattern = ...` Can be used to define valid regular expression in _ECMA-262_ dialect the field value must match. +/// +/// * `max_items = ...` Can be used to define maximum items allowed for `array` fields. Value must +/// be non-negative integer. +/// +/// * `min_items = ...` Can be used to define minimum items allowed for `array` fields. Value must +/// be non-negative integer. +/// +/// * `schema_with = ...` Use _`schema`_ created by provided function reference instead of the +/// default derived _`schema`_. The function must match to `fn() -> Into<RefOr<Schema>>`. It does +/// not accept arguments and must return anything that can be converted into `RefOr<Schema>`. +/// +/// * `additional_properties = ...` Can be used to define free form types for maps such as +/// [`HashMap`](std::collections::HashMap) and [`BTreeMap`](std::collections::BTreeMap). +/// Free form type enables use of arbitrary types within map values. +/// Supports formats _`additional_properties`_ and _`additional_properties = true`_. +/// +/// * `ignore` or `ignore = ...` Can be used to skip the field from being serialized to OpenAPI schema. It accepts either a literal `bool` value +/// or a path to a function that returns `bool` (`Fn() -> bool`). +/// +/// #### Field nullability and required rules +/// +/// Same rules for nullability and required status apply for _`IntoParams`_ field attributes as for +/// _`ToSchema`_ field attributes. [See the rules][`derive@ToSchema#field-nullability-and-required-rules`]. +/// +/// # Partial `#[serde(...)]` attributes support +/// +/// IntoParams derive has partial support for [serde attributes]. These supported attributes will reflect to the +/// generated OpenAPI doc. The following attributes are currently supported: +/// +/// * `rename_all = "..."` Supported at the container level. +/// * `rename = "..."` Supported **only** at the field level. +/// * `default` Supported at the container level and field level according to [serde attributes]. +/// * `skip_serializing_if = "..."` Supported **only** at the field level. +/// * `with = ...` Supported **only** at field level. +/// * `skip_serializing = "..."` Supported **only** at the field or variant level. +/// * `skip_deserializing = "..."` Supported **only** at the field or variant level. +/// * `skip = "..."` Supported **only** at the field level. +/// +/// Other _`serde`_ attributes will impact the serialization but will not be reflected on the generated OpenAPI doc. +/// +/// # Examples +/// +/// _**Demonstrate [`IntoParams`][into_params] usage with resolving `Path` and `Query` parameters +/// with _`actix-web`_**_. +/// ```rust +/// use actix_web::{get, HttpResponse, Responder}; +/// use actix_web::web::{Path, Query}; +/// use serde::Deserialize; +/// use serde_json::json; +/// use fastapi::IntoParams; +/// +/// #[derive(Deserialize, IntoParams)] +/// struct PetPathArgs { +/// /// Id of pet +/// id: i64, +/// /// Name of pet +/// name: String, +/// } +/// +/// #[derive(Deserialize, IntoParams)] +/// struct Filter { +/// /// Age filter for pets +/// #[deprecated] +/// #[param(style = Form, explode, allow_reserved, example = json!([10]))] +/// age: Option<Vec<i32>>, +/// } +/// +/// #[fastapi::path( +/// params(PetPathArgs, Filter), +/// responses( +/// (status = 200, description = "success response") +/// ) +/// )] +/// #[get("/pet/{id}/{name}")] +/// async fn get_pet(pet: Path<PetPathArgs>, query: Query<Filter>) -> impl Responder { +/// HttpResponse::Ok().json(json!({ "id": pet.id })) +/// } +/// ``` +/// +/// _**Demonstrate [`IntoParams`][into_params] usage with the `#[into_params(...)]` container attribute to +/// be used as a path query, and inlining a schema query field:**_ +/// ```rust +/// use serde::Deserialize; +/// use fastapi::{IntoParams, ToSchema}; +/// +/// #[derive(Deserialize, ToSchema)] +/// #[serde(rename_all = "snake_case")] +/// enum PetKind { +/// Dog, +/// Cat, +/// } +/// +/// #[derive(Deserialize, IntoParams)] +/// #[into_params(style = Form, parameter_in = Query)] +/// struct PetQuery { +/// /// Name of pet +/// name: Option<String>, +/// /// Age of pet +/// age: Option<i32>, +/// /// Kind of pet +/// #[param(inline)] +/// kind: PetKind +/// } +/// +/// #[fastapi::path( +/// get, +/// path = "/get_pet", +/// params(PetQuery), +/// responses( +/// (status = 200, description = "success response") +/// ) +/// )] +/// async fn get_pet(query: PetQuery) { +/// // ... +/// } +/// ``` +/// +/// _**Override `String` with `i64` using `value_type` attribute.**_ +/// ```rust +/// # use fastapi::IntoParams; +/// # +/// #[derive(IntoParams)] +/// #[into_params(parameter_in = Query)] +/// struct Filter { +/// #[param(value_type = i64)] +/// id: String, +/// } +/// ``` +/// +/// _**Override `String` with `Object` using `value_type` attribute. _`Object`_ will render as `type: object` in OpenAPI spec.**_ +/// ```rust +/// # use fastapi::IntoParams; +/// # +/// #[derive(IntoParams)] +/// #[into_params(parameter_in = Query)] +/// struct Filter { +/// #[param(value_type = Object)] +/// id: String, +/// } +/// ``` +/// +/// _**You can use a generic type to override the default type of the field.**_ +/// ```rust +/// # use fastapi::IntoParams; +/// # +/// #[derive(IntoParams)] +/// #[into_params(parameter_in = Query)] +/// struct Filter { +/// #[param(value_type = Option<String>)] +/// id: String +/// } +/// ``` +/// +/// _**You can even override a [`Vec`] with another one.**_ +/// ```rust +/// # use fastapi::IntoParams; +/// # +/// #[derive(IntoParams)] +/// #[into_params(parameter_in = Query)] +/// struct Filter { +/// #[param(value_type = Vec<i32>)] +/// id: Vec<String> +/// } +/// ``` +/// +/// _**We can override value with another [`ToSchema`][to_schema].**_ +/// ```rust +/// # use fastapi::{IntoParams, ToSchema}; +/// # +/// #[derive(ToSchema)] +/// struct Id { +/// value: i64, +/// } +/// +/// #[derive(IntoParams)] +/// #[into_params(parameter_in = Query)] +/// struct Filter { +/// #[param(value_type = Id)] +/// id: String +/// } +/// ``` +/// +/// _**Example with validation attributes.**_ +/// ```rust +/// #[derive(fastapi::IntoParams)] +/// struct Item { +/// #[param(maximum = 10, minimum = 5, multiple_of = 2.5)] +/// id: i32, +/// #[param(max_length = 10, min_length = 5, pattern = "[a-z]*")] +/// value: String, +/// #[param(max_items = 5, min_items = 1)] +/// items: Vec<String>, +/// } +/// ```` +/// +/// _**Use `schema_with` to manually implement schema for a field.**_ +/// ```rust +/// # use fastapi::openapi::schema::{Object, ObjectBuilder}; +/// fn custom_type() -> Object { +/// ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::String) +/// .format(Some(fastapi::openapi::SchemaFormat::Custom( +/// "email".to_string(), +/// ))) +/// .description(Some("this is the description")) +/// .build() +/// } +/// +/// #[derive(fastapi::IntoParams)] +/// #[into_params(parameter_in = Query)] +/// struct Query { +/// #[param(schema_with = custom_type)] +/// email: String, +/// } +/// ``` +/// +/// [to_schema]: trait.ToSchema.html +/// [known_format]: openapi/schema/enum.KnownFormat.html +/// [xml]: openapi/xml/struct.Xml.html +/// [into_params]: trait.IntoParams.html +/// [path_params]: attr.path.html#params-attributes +/// [struct]: https://doc.rust-lang.org/std/keyword.struct.html +/// [style]: openapi/path/enum.ParameterStyle.html +/// [in_enum]: openapi/path/enum.ParameterIn.html +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +/// [serde attributes]: https://serde.rs/attributes.html +/// [to_schema_xml]: macro@ToSchema#xml-attribute-configuration-options +pub fn into_params(input: TokenStream) -> TokenStream { + let DeriveInput { + attrs, + ident, + generics, + data, + .. + } = syn::parse_macro_input!(input); + + let into_params = IntoParams { + attrs, + generics, + data, + ident, + }; + + into_params.to_token_stream().into() +} + +#[proc_macro_derive(ToResponse, attributes(response, content, to_schema))] +/// Generate reusable OpenAPI response that can be used +/// in [`fastapi::path`][path] or in [`OpenApi`][openapi]. +/// +/// This is `#[derive]` implementation for [`ToResponse`][to_response] trait. +/// +/// +/// _`#[response]`_ attribute can be used to alter and add [response attributes](#toresponse-response-attributes). +/// +/// _`#[content]`_ attributes is used to make enum variant a content of a specific type for the +/// response. +/// +/// _`#[to_schema]`_ attribute is used to inline a schema for a response in unnamed structs or +/// enum variants with `#[content]` attribute. **Note!** [`ToSchema`] need to be implemented for +/// the field or variant type. +/// +/// Type derived with _`ToResponse`_ uses provided doc comment as a description for the response. It +/// can alternatively be overridden with _`description = ...`_ attribute. +/// +/// _`ToResponse`_ can be used in four different ways to generate OpenAPI response component. +/// +/// 1. By decorating `struct` or `enum` with [`derive@ToResponse`] derive macro. This will create a +/// response with inlined schema resolved from the fields of the `struct` or `variants` of the +/// enum. +/// +/// ```rust +/// # use fastapi::ToResponse; +/// #[derive(ToResponse)] +/// #[response(description = "Person response returns single Person entity")] +/// struct Person { +/// name: String, +/// } +/// ``` +/// +/// 2. By decorating unnamed field `struct` with [`derive@ToResponse`] derive macro. Unnamed field struct +/// allows users to use new type pattern to define one inner field which is used as a schema for +/// the generated response. This allows users to define `Vec` and `Option` response types. +/// Additionally these types can also be used with `#[to_schema]` attribute to inline the +/// field's type schema if it implements [`ToSchema`] derive macro. +/// +/// ```rust +/// # #[derive(fastapi::ToSchema)] +/// # struct Person { +/// # name: String, +/// # } +/// /// Person list response +/// #[derive(fastapi::ToResponse)] +/// struct PersonList(Vec<Person>); +/// ``` +/// +/// 3. By decorating unit struct with [`derive@ToResponse`] derive macro. Unit structs will produce a +/// response without body. +/// +/// ```rust +/// /// Success response which does not have body. +/// #[derive(fastapi::ToResponse)] +/// struct SuccessResponse; +/// ``` +/// +/// 4. By decorating `enum` with variants having `#[content(...)]` attribute. This allows users to +/// define multiple response content schemas to single response according to OpenAPI spec. +/// **Note!** Enum with _`content`_ attribute in variants cannot have enum level _`example`_ or +/// _`examples`_ defined. Instead examples need to be defined per variant basis. Additionally +/// these variants can also be used with `#[to_schema]` attribute to inline the variant's type schema +/// if it implements [`ToSchema`] derive macro. +/// +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// struct Admin { +/// name: String, +/// } +/// #[derive(fastapi::ToSchema)] +/// struct Admin2 { +/// name: String, +/// id: i32, +/// } +/// +/// #[derive(fastapi::ToResponse)] +/// enum Person { +/// #[response(examples( +/// ("Person1" = (value = json!({"name": "name1"}))), +/// ("Person2" = (value = json!({"name": "name2"}))) +/// ))] +/// Admin(#[content("application/vnd-custom-v1+json")] Admin), +/// +/// #[response(example = json!({"name": "name3", "id": 1}))] +/// Admin2(#[content("application/vnd-custom-v2+json")] #[to_schema] Admin2), +/// } +/// ``` +/// +/// # ToResponse `#[response(...)]` attributes +/// +/// * `description = "..."` Define description for the response as str. This can be used to +/// override the default description resolved from doc comments if present. +/// +/// * `content_type = "..."` Can be used to override the default behavior +/// of auto resolving the content type from the `body` attribute. If defined the value should be valid +/// content type such as _`application/json`_ . By default the content type is _`text/plain`_ +/// for [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and _`application/json`_ +/// for struct and mixed enum types. +/// +/// * `headers(...)` Slice of response headers that are returned back to a caller. +/// +/// * `example = ...` Can be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// +/// * `examples(...)` Define multiple examples for single response. This attribute is mutually +/// exclusive to the _`example`_ attribute and if both are defined this will override the _`example`_. +/// * `name = ...` This is first attribute and value must be literal string. +/// * `summary = ...` Short description of example. Value must be literal string. +/// * `description = ...` Long description of example. Attribute supports markdown for rich text +/// representation. Value must be literal string. +/// * `value = ...` Example value. It must be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// * `external_value = ...` Define URI to literal example value. This is mutually exclusive to +/// the _`value`_ attribute. Value must be literal string. +/// +/// _**Example of example definition.**_ +/// ```text +/// ("John" = (summary = "This is John", value = json!({"name": "John"}))) +/// ``` +/// +/// # Examples +/// +/// _**Use reusable response in operation handler.**_ +/// ```rust +/// #[derive(fastapi::ToResponse)] +/// struct PersonResponse { +/// value: String +/// } +/// +/// #[derive(fastapi::OpenApi)] +/// #[openapi(components(responses(PersonResponse)))] +/// struct Doc; +/// +/// #[fastapi::path( +/// get, +/// path = "/api/person", +/// responses( +/// (status = 200, response = PersonResponse) +/// ) +/// )] +/// fn get_person() -> PersonResponse { +/// PersonResponse { value: "person".to_string() } +/// } +/// ``` +/// +/// _**Create a response from named struct.**_ +/// ```rust +/// /// This is description +/// /// +/// /// It will also be used in `ToSchema` if present +/// #[derive(fastapi::ToSchema, fastapi::ToResponse)] +/// #[response( +/// description = "Override description for response", +/// content_type = "text/xml" +/// )] +/// #[response( +/// example = json!({"name": "the name"}), +/// headers( +/// ("csrf-token", description = "response csrf token"), +/// ("random-id" = i32) +/// ) +/// )] +/// struct Person { +/// name: String, +/// } +/// ``` +/// +/// _**Create inlined person list response.**_ +/// ```rust +/// # #[derive(fastapi::ToSchema)] +/// # struct Person { +/// # name: String, +/// # } +/// /// Person list response +/// #[derive(fastapi::ToResponse)] +/// struct PersonList(#[to_schema] Vec<Person>); +/// ``` +/// +/// _**Create enum response from variants.**_ +/// ```rust +/// #[derive(fastapi::ToResponse)] +/// enum PersonType { +/// Value(String), +/// Foobar, +/// } +/// ``` +/// +/// [to_response]: trait.ToResponse.html +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +/// [path]: attr.path.html +/// [openapi]: derive.OpenApi.html +pub fn to_response(input: TokenStream) -> TokenStream { + let DeriveInput { + attrs, + ident, + generics, + data, + .. + } = syn::parse_macro_input!(input); + + ToResponse::new(attrs, &data, generics, ident) + .as_ref() + .map_or_else(Diagnostics::to_token_stream, ToResponse::to_token_stream) + .into() +} + +#[proc_macro_derive( + IntoResponses, + attributes(response, to_schema, ref_response, to_response) +)] +/// Generate responses with status codes what +/// can be attached to the [`fastapi::path`][path_into_responses]. +/// +/// This is `#[derive]` implementation of [`IntoResponses`][into_responses] trait. [`derive@IntoResponses`] +/// can be used to decorate _`structs`_ and _`enums`_ to generate response maps that can be used in +/// [`fastapi::path`][path_into_responses]. If _`struct`_ is decorated with [`derive@IntoResponses`] it will be +/// used to create a map of responses containing single response. Decorating _`enum`_ with +/// [`derive@IntoResponses`] will create a map of responses with a response for each variant of the _`enum`_. +/// +/// Named field _`struct`_ decorated with [`derive@IntoResponses`] will create a response with inlined schema +/// generated from the body of the struct. This is a conveniency which allows users to directly +/// create responses with schemas without first creating a separate [response][to_response] type. +/// +/// Unit _`struct`_ behaves similarly to then named field struct. Only difference is that it will create +/// a response without content since there is no inner fields. +/// +/// Unnamed field _`struct`_ decorated with [`derive@IntoResponses`] will by default create a response with +/// referenced [schema][to_schema] if field is object or schema if type is [primitive +/// type][primitive]. _`#[to_schema]`_ attribute at field of unnamed _`struct`_ can be used to inline +/// the schema if type of the field implements [`ToSchema`][to_schema] trait. Alternatively +/// _`#[to_response]`_ and _`#[ref_response]`_ can be used at field to either reference a reusable +/// [response][to_response] or inline a reusable [response][to_response]. In both cases the field +/// type is expected to implement [`ToResponse`][to_response] trait. +/// +/// +/// Enum decorated with [`derive@IntoResponses`] will create a response for each variant of the _`enum`_. +/// Each variant must have it's own _`#[response(...)]`_ definition. Unit variant will behave same +/// as unit _`struct`_ by creating a response without content. Similarly named field variant and +/// unnamed field variant behaves the same as it was named field _`struct`_ and unnamed field +/// _`struct`_. +/// +/// _`#[response]`_ attribute can be used at named structs, unnamed structs, unit structs and enum +/// variants to alter [response attributes](#intoresponses-response-attributes) of responses. +/// +/// Doc comment on a _`struct`_ or _`enum`_ variant will be used as a description for the response. +/// It can also be overridden with _`description = "..."`_ attribute. +/// +/// # IntoResponses `#[response(...)]` attributes +/// +/// * `status = ...` Must be provided. Is either a valid http status code integer. E.g. _`200`_ or a +/// string value representing a range such as _`"4XX"`_ or `"default"` or a valid _`http::status::StatusCode`_. +/// _`StatusCode`_ can either be use path to the status code or _status code_ constant directly. +/// +/// * `description = "..."` Define description for the response as str. This can be used to +/// override the default description resolved from doc comments if present. +/// +/// * `content_type = "..."` Can be used to override the default behavior +/// of auto resolving the content type from the `body` attribute. If defined the value should be valid +/// content type such as _`application/json`_ . By default the content type is _`text/plain`_ +/// for [primitive Rust types][primitive], `application/octet-stream` for _`[u8]`_ and _`application/json`_ +/// for struct and mixed enum types. +/// +/// * `headers(...)` Slice of response headers that are returned back to a caller. +/// +/// * `example = ...` Can be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// +/// * `examples(...)` Define multiple examples for single response. This attribute is mutually +/// exclusive to the _`example`_ attribute and if both are defined this will override the _`example`_. +/// * `name = ...` This is first attribute and value must be literal string. +/// * `summary = ...` Short description of example. Value must be literal string. +/// * `description = ...` Long description of example. Attribute supports markdown for rich text +/// representation. Value must be literal string. +/// * `value = ...` Example value. It must be _`json!(...)`_. _`json!(...)`_ should be something that +/// _`serde_json::json!`_ can parse as a _`serde_json::Value`_. +/// * `external_value = ...` Define URI to literal example value. This is mutually exclusive to +/// the _`value`_ attribute. Value must be literal string. +/// +/// _**Example of example definition.**_ +/// ```text +/// ("John" = (summary = "This is John", value = json!({"name": "John"}))) +/// ``` +/// +/// # Examples +/// +/// _**Use `IntoResponses` to define [`fastapi::path`][path] responses.**_ +/// ```rust +/// #[derive(fastapi::ToSchema)] +/// struct BadRequest { +/// message: String, +/// } +/// +/// #[derive(fastapi::IntoResponses)] +/// enum UserResponses { +/// /// Success response +/// #[response(status = 200)] +/// Success { value: String }, +/// +/// #[response(status = 404)] +/// NotFound, +/// +/// #[response(status = 400)] +/// BadRequest(BadRequest), +/// } +/// +/// #[fastapi::path( +/// get, +/// path = "/api/user", +/// responses( +/// UserResponses +/// ) +/// )] +/// fn get_user() -> UserResponses { +/// UserResponses::NotFound +/// } +/// ``` +/// _**Named struct response with inlined schema.**_ +/// ```rust +/// /// This is success response +/// #[derive(fastapi::IntoResponses)] +/// #[response(status = 200)] +/// struct SuccessResponse { +/// value: String, +/// } +/// ``` +/// +/// _**Unit struct response without content.**_ +/// ```rust +/// #[derive(fastapi::IntoResponses)] +/// #[response(status = NOT_FOUND)] +/// struct NotFound; +/// ``` +/// +/// _**Unnamed struct response with inlined response schema.**_ +/// ```rust +/// # #[derive(fastapi::ToSchema)] +/// # struct Foo; +/// #[derive(fastapi::IntoResponses)] +/// #[response(status = 201)] +/// struct CreatedResponse(#[to_schema] Foo); +/// ``` +/// +/// _**Enum with multiple responses.**_ +/// ```rust +/// # #[derive(fastapi::ToResponse)] +/// # struct Response { +/// # message: String, +/// # } +/// # #[derive(fastapi::ToSchema)] +/// # struct BadRequest {} +/// #[derive(fastapi::IntoResponses)] +/// enum UserResponses { +/// /// Success response description. +/// #[response(status = 200)] +/// Success { value: String }, +/// +/// #[response(status = 404)] +/// NotFound, +/// +/// #[response(status = 400)] +/// BadRequest(BadRequest), +/// +/// #[response(status = 500)] +/// ServerError(#[ref_response] Response), +/// +/// #[response(status = 418)] +/// TeaPot(#[to_response] Response), +/// } +/// ``` +/// +/// [into_responses]: trait.IntoResponses.html +/// [to_schema]: trait.ToSchema.html +/// [to_response]: trait.ToResponse.html +/// [path_into_responses]: attr.path.html#responses-from-intoresponses +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +/// [path]: macro@crate::path +pub fn into_responses(input: TokenStream) -> TokenStream { + let DeriveInput { + attrs, + ident, + generics, + data, + .. + } = syn::parse_macro_input!(input); + + let into_responses = IntoResponses { + attributes: attrs, + ident, + generics, + data, + }; + + into_responses.to_token_stream().into() +} + +/// Create OpenAPI Schema from arbitrary type. +/// +/// This macro provides a quick way to render arbitrary types as OpenAPI Schema Objects. It +/// supports two call formats. +/// 1. With type only +/// 2. With _`#[inline]`_ attribute to inline the referenced schemas. +/// +/// By default the macro will create references `($ref)` for non primitive types like _`Pet`_. +/// However when used with _`#[inline]`_ the non [`primitive`][primitive] type schemas will +/// be inlined to the schema output. +/// +/// ```rust +/// # use fastapi::openapi::{RefOr, schema::Schema}; +/// # #[derive(fastapi::ToSchema)] +/// # struct Pet {id: i32}; +/// let schema: RefOr<Schema> = fastapi::schema!(Vec<Pet>).into(); +/// +/// // with inline +/// let schema: RefOr<Schema> = fastapi::schema!(#[inline] Vec<Pet>).into(); +/// ``` +/// +/// # Examples +/// +/// _**Create vec of pets schema.**_ +/// ```rust +/// # use fastapi::openapi::schema::{Schema, Array, Object, ObjectBuilder, SchemaFormat, +/// # KnownFormat, Type}; +/// # use fastapi::openapi::RefOr; +/// #[derive(fastapi::ToSchema)] +/// struct Pet { +/// id: i32, +/// name: String, +/// } +/// +/// let schema: RefOr<Schema> = fastapi::schema!(#[inline] Vec<Pet>).into(); +/// // will output +/// let generated = RefOr::T(Schema::Array( +/// Array::new( +/// ObjectBuilder::new() +/// .property("id", ObjectBuilder::new() +/// .schema_type(Type::Integer) +/// .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) +/// .build()) +/// .required("id") +/// .property("name", Object::with_type(Type::String)) +/// .required("name") +/// ) +/// )); +/// # assert_json_diff::assert_json_eq!(serde_json::to_value(&schema).unwrap(), serde_json::to_value(&generated).unwrap()); +/// ``` +/// +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +#[proc_macro] +pub fn schema(input: TokenStream) -> TokenStream { + struct Schema { + inline: bool, + ty: syn::Type, + } + impl Parse for Schema { + fn parse(input: ParseStream) -> syn::Result<Self> { + let inline = if input.peek(Token![#]) && input.peek2(Bracket) { + input.parse::<Token![#]>()?; + + let inline; + bracketed!(inline in input); + let i = inline.parse::<Ident>()?; + i == "inline" + } else { + false + }; + + let ty = input.parse()?; + + Ok(Self { inline, ty }) + } + } + + let schema = syn::parse_macro_input!(input as Schema); + let type_tree = match TypeTree::from_type(&schema.ty) { + Ok(type_tree) => type_tree, + Err(diagnostics) => return diagnostics.into_token_stream().into(), + }; + + let generics = match type_tree.get_path_generics() { + Ok(generics) => generics, + Err(error) => return error.into_compile_error().into(), + }; + + let schema = ComponentSchema::new(ComponentSchemaProps { + features: vec![Feature::Inline(schema.inline.into())], + type_tree: &type_tree, + description: None, + container: &component::Container { + generics: &generics, + }, + }); + + let schema = match schema { + Ok(schema) => schema.to_token_stream(), + Err(diagnostics) => return diagnostics.to_token_stream().into(), + }; + + quote! { + { + let mut generics: Vec<fastapi::openapi::RefOr<fastapi::openapi::schema::Schema>> = Vec::new(); + #schema + } + } + .into() +} + +/// Tokenizes slice or Vec of tokenizable items as array either with reference (`&[...]`) +/// or without correctly to OpenAPI JSON. +#[cfg_attr(feature = "debug", derive(Debug))] +enum Array<'a, T> +where + T: Sized + ToTokens, +{ + Owned(Vec<T>), + #[allow(dead_code)] + Borrowed(&'a [T]), +} + +impl<V> FromIterator<V> for Array<'_, V> +where + V: Sized + ToTokens, +{ + fn from_iter<T: IntoIterator<Item = V>>(iter: T) -> Self { + Self::Owned(iter.into_iter().collect()) + } +} + +impl<'a, T> Deref for Array<'a, T> +where + T: Sized + ToTokens, +{ + type Target = [T]; + + fn deref(&self) -> &Self::Target { + match self { + Self::Owned(vec) => vec.as_slice(), + Self::Borrowed(slice) => slice, + } + } +} + +impl<T> ToTokens for Array<'_, T> +where + T: Sized + ToTokens, +{ + fn to_tokens(&self, tokens: &mut TokenStream2) { + let values = match self { + Self::Owned(values) => values.iter(), + Self::Borrowed(values) => values.iter(), + }; + + tokens.append(Group::new( + proc_macro2::Delimiter::Bracket, + values + .fold(Punctuated::new(), |mut punctuated, item| { + punctuated.push_value(item); + punctuated.push_punct(Punct::new(',', proc_macro2::Spacing::Alone)); + + punctuated + }) + .to_token_stream(), + )); + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum Deprecated { + True, + False, +} + +impl From<bool> for Deprecated { + fn from(bool: bool) -> Self { + if bool { + Self::True + } else { + Self::False + } + } +} + +impl ToTokens for Deprecated { + fn to_tokens(&self, tokens: &mut TokenStream2) { + tokens.extend(match self { + Self::False => quote! { fastapi::openapi::Deprecated::False }, + Self::True => quote! { fastapi::openapi::Deprecated::True }, + }) + } +} + +#[derive(PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +enum Required { + True, + False, +} + +impl From<bool> for Required { + fn from(bool: bool) -> Self { + if bool { + Self::True + } else { + Self::False + } + } +} + +impl From<features::attributes::Required> for Required { + fn from(value: features::attributes::Required) -> Self { + let features::attributes::Required(required) = value; + crate::Required::from(required) + } +} + +impl ToTokens for Required { + fn to_tokens(&self, tokens: &mut TokenStream2) { + tokens.extend(match self { + Self::False => quote! { fastapi::openapi::Required::False }, + Self::True => quote! { fastapi::openapi::Required::True }, + }) + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct ExternalDocs { + url: String, + description: Option<String>, +} + +impl Parse for ExternalDocs { + fn parse(input: ParseStream) -> syn::Result<Self> { + const EXPECTED_ATTRIBUTE: &str = "unexpected attribute, expected any of: url, description"; + + let mut external_docs = ExternalDocs::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>().map_err(|error| { + syn::Error::new(error.span(), format!("{EXPECTED_ATTRIBUTE}, {error}")) + })?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "url" => { + external_docs.url = parse_utils::parse_next_literal_str(input)?; + } + "description" => { + external_docs.description = Some(parse_utils::parse_next_literal_str(input)?); + } + _ => return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE)), + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(external_docs) + } +} + +impl ToTokens for ExternalDocs { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let url = &self.url; + tokens.extend(quote! { + fastapi::openapi::external_docs::ExternalDocsBuilder::new() + .url(#url) + }); + + if let Some(ref description) = self.description { + tokens.extend(quote! { + .description(Some(#description)) + }); + } + + tokens.extend(quote! { .build() }) + } +} + +/// Represents OpenAPI Any value used in example and default fields. +#[derive(Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +enum AnyValue { + String(TokenStream2), + Json(TokenStream2), + DefaultTrait { + struct_ident: Ident, + field_ident: Member, + }, +} + +impl AnyValue { + /// Parse `json!(...)` as [`AnyValue::Json`] + fn parse_json(input: ParseStream) -> syn::Result<Self> { + parse_utils::parse_json_token_stream(input).map(AnyValue::Json) + } + + fn parse_any(input: ParseStream) -> syn::Result<Self> { + if input.peek(Lit) { + let punct = input.parse::<Option<Token![-]>>()?; + let lit = input.parse::<Lit>().unwrap(); + + Ok(AnyValue::Json(quote! { #punct #lit})) + } else { + let fork = input.fork(); + let is_json = if fork.peek(syn::Ident) && fork.peek2(Token![!]) { + let ident = fork.parse::<Ident>().unwrap(); + ident == "json" + } else { + false + }; + + if is_json { + let json = parse_utils::parse_json_token_stream(input)?; + + Ok(AnyValue::Json(json)) + } else { + let method = input.parse::<ExprPath>().map_err(|error| { + syn::Error::new( + error.span(), + "expected literal value, json!(...) or method reference", + ) + })?; + + Ok(AnyValue::Json(quote! { #method() })) + } + } + } + + fn parse_lit_str_or_json(input: ParseStream) -> syn::Result<Self> { + if input.peek(LitStr) { + Ok(AnyValue::String( + input.parse::<LitStr>().unwrap().to_token_stream(), + )) + } else { + Ok(AnyValue::Json(parse_utils::parse_json_token_stream(input)?)) + } + } + + fn new_default_trait(struct_ident: Ident, field_ident: Member) -> Self { + Self::DefaultTrait { + struct_ident, + field_ident, + } + } +} + +impl ToTokens for AnyValue { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + Self::Json(json) => tokens.extend(quote! { + serde_json::json!(#json) + }), + Self::String(string) => string.to_tokens(tokens), + Self::DefaultTrait { + struct_ident, + field_ident, + } => tokens.extend(quote! { + serde_json::to_value(#struct_ident::default().#field_ident).unwrap() + }), + } + } +} + +trait OptionExt<T> { + fn map_try<F, U, E>(self, f: F) -> Result<Option<U>, E> + where + F: FnOnce(T) -> Result<U, E>; + fn and_then_try<F, U, E>(self, f: F) -> Result<Option<U>, E> + where + F: FnOnce(T) -> Result<Option<U>, E>; + fn or_else_try<F, U>(self, f: F) -> Result<Option<T>, U> + where + F: FnOnce() -> Result<Option<T>, U>; +} + +impl<T> OptionExt<T> for Option<T> { + fn map_try<F, U, E>(self, f: F) -> Result<Option<U>, E> + where + F: FnOnce(T) -> Result<U, E>, + { + if let Some(v) = self { + f(v).map(Some) + } else { + Ok(None) + } + } + + fn and_then_try<F, U, E>(self, f: F) -> Result<Option<U>, E> + where + F: FnOnce(T) -> Result<Option<U>, E>, + { + if let Some(v) = self { + match f(v) { + Ok(inner) => Ok(inner), + Err(error) => Err(error), + } + } else { + Ok(None) + } + } + + fn or_else_try<F, U>(self, f: F) -> Result<Option<T>, U> + where + F: FnOnce() -> Result<Option<T>, U>, + { + if self.is_none() { + f() + } else { + Ok(self) + } + } +} + +trait GenericsExt { + /// Get index of `GenericParam::Type` ignoring other generic param types. + fn get_generic_type_param_index(&self, type_tree: &TypeTree) -> Option<usize>; +} + +impl<'g> GenericsExt for &'g syn::Generics { + fn get_generic_type_param_index(&self, type_tree: &TypeTree) -> Option<usize> { + let ident = &type_tree + .path + .as_ref() + .expect("TypeTree of generic object must have a path") + .segments + .last() + .expect("Generic object path must have at least one segment") + .ident; + + self.params + .iter() + .filter(|generic| matches!(generic, GenericParam::Type(_))) + .enumerate() + .find_map(|(index, generic)| { + if matches!(generic, GenericParam::Type(ty) if ty.ident == *ident) { + Some(index) + } else { + None + } + }) + } +} + +trait ToTokensDiagnostics { + fn to_tokens(&self, tokens: &mut TokenStream2) -> Result<(), Diagnostics>; + + #[allow(unused)] + fn into_token_stream(self) -> TokenStream2 + where + Self: std::marker::Sized, + { + ToTokensDiagnostics::to_token_stream(&self) + } + + fn to_token_stream(&self) -> TokenStream2 { + let mut tokens = TokenStream2::new(); + match ToTokensDiagnostics::to_tokens(self, &mut tokens) { + Ok(_) => tokens, + Err(error_stream) => Into::<Diagnostics>::into(error_stream).into_token_stream(), + } + } + + fn try_to_token_stream(&self) -> Result<TokenStream2, Diagnostics> { + let mut tokens = TokenStream2::new(); + match ToTokensDiagnostics::to_tokens(self, &mut tokens) { + Ok(_) => Ok(tokens), + Err(diagnostics) => Err(diagnostics), + } + } +} + +macro_rules! as_tokens_or_diagnostics { + ( $type:expr ) => {{ + let mut _tokens = proc_macro2::TokenStream::new(); + match crate::ToTokensDiagnostics::to_tokens($type, &mut _tokens) { + Ok(_) => _tokens, + Err(diagnostics) => return Err(diagnostics), + } + }}; +} + +use as_tokens_or_diagnostics; + +#[derive(Debug)] +struct Diagnostics { + diagnostics: Vec<DiangosticsInner>, +} + +#[derive(Debug)] +struct DiangosticsInner { + span: Span, + message: Cow<'static, str>, + suggestions: Vec<Suggestion>, +} + +#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)] +enum Suggestion { + Help(Cow<'static, str>), + Note(Cow<'static, str>), +} + +impl Display for Diagnostics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.message()) + } +} + +impl Display for Suggestion { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Help(help) => { + let s: &str = help.borrow(); + write!(f, "help = {}", s) + } + Self::Note(note) => { + let s: &str = note.borrow(); + write!(f, "note = {}", s) + } + } + } +} + +impl Diagnostics { + fn message(&self) -> Cow<'static, str> { + self.diagnostics + .first() + .as_ref() + .map(|diagnostics| diagnostics.message.clone()) + .unwrap_or_else(|| Cow::Borrowed("")) + } + + pub fn new<S: Into<Cow<'static, str>>>(message: S) -> Self { + Self::with_span(Span::call_site(), message) + } + + pub fn with_span<S: Into<Cow<'static, str>>>(span: Span, message: S) -> Self { + Self { + diagnostics: vec![DiangosticsInner { + span, + message: message.into(), + suggestions: Vec::new(), + }], + } + } + + pub fn help<S: Into<Cow<'static, str>>>(mut self, help: S) -> Self { + if let Some(diagnostics) = self.diagnostics.first_mut() { + diagnostics.suggestions.push(Suggestion::Help(help.into())); + diagnostics.suggestions.sort(); + } + + self + } + + pub fn note<S: Into<Cow<'static, str>>>(mut self, note: S) -> Self { + if let Some(diagnostics) = self.diagnostics.first_mut() { + diagnostics.suggestions.push(Suggestion::Note(note.into())); + diagnostics.suggestions.sort(); + } + + self + } +} + +impl From<syn::Error> for Diagnostics { + fn from(value: syn::Error) -> Self { + Self::with_span(value.span(), value.to_string()) + } +} + +impl ToTokens for Diagnostics { + fn to_tokens(&self, tokens: &mut TokenStream2) { + for diagnostics in &self.diagnostics { + let span = diagnostics.span; + let message: &str = diagnostics.message.borrow(); + + let suggestions = diagnostics + .suggestions + .iter() + .map(Suggestion::to_string) + .collect::<Vec<_>>() + .join("\n"); + + let diagnostics = if !suggestions.is_empty() { + Cow::Owned(format!("{message}\n\n{suggestions}")) + } else { + Cow::Borrowed(message) + }; + + tokens.extend(quote_spanned! {span=> + ::core::compile_error!(#diagnostics); + }) + } + } +} + +impl Error for Diagnostics {} + +impl FromIterator<Diagnostics> for Option<Diagnostics> { + fn from_iter<T: IntoIterator<Item = Diagnostics>>(iter: T) -> Self { + iter.into_iter().reduce(|mut acc, diagnostics| { + acc.diagnostics.extend(diagnostics.diagnostics); + acc + }) + } +} + +trait AttributesExt { + fn has_deprecated(&self) -> bool; +} + +impl AttributesExt for Vec<syn::Attribute> { + fn has_deprecated(&self) -> bool { + let this = &**self; + this.has_deprecated() + } +} + +impl<'a> AttributesExt for &'a [syn::Attribute] { + fn has_deprecated(&self) -> bool { + self.iter().any(|attr| { + matches!(attr.path().get_ident(), Some(ident) if &*ident.to_string() == "deprecated") + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn diagnostics_ordering_help_comes_before_note() { + let diagnostics = Diagnostics::new("this an error") + .note("you could do this to solve the error") + .help("try this thing"); + + let tokens = diagnostics.into_token_stream(); + + let expected_tokens = quote::quote!(::core::compile_error!( + "this an error\n\nhelp = try this thing\nnote = you could do this to solve the error" + );); + + assert_eq!(tokens.to_string(), expected_tokens.to_string()); + } +} + +/// Parsing utils +mod parse_utils { + use std::fmt::Display; + + use proc_macro2::{Group, Ident, TokenStream}; + use quote::{quote, ToTokens}; + use syn::{ + parenthesized, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, + token::Comma, + Error, Expr, ExprPath, LitBool, LitStr, Token, + }; + + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub enum LitStrOrExpr { + LitStr(LitStr), + Expr(Expr), + } + + impl From<String> for LitStrOrExpr { + fn from(value: String) -> Self { + Self::LitStr(LitStr::new(&value, proc_macro2::Span::call_site())) + } + } + + impl LitStrOrExpr { + pub(crate) fn is_empty_litstr(&self) -> bool { + matches!(self, Self::LitStr(s) if s.value().is_empty()) + } + } + + impl Default for LitStrOrExpr { + fn default() -> Self { + Self::LitStr(LitStr::new("", proc_macro2::Span::call_site())) + } + } + + impl Parse for LitStrOrExpr { + fn parse(input: ParseStream) -> syn::Result<Self> { + if input.peek(LitStr) { + Ok::<LitStrOrExpr, Error>(LitStrOrExpr::LitStr(input.parse::<LitStr>()?)) + } else { + Ok(LitStrOrExpr::Expr(input.parse::<Expr>()?)) + } + } + } + + impl ToTokens for LitStrOrExpr { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::LitStr(str) => str.to_tokens(tokens), + Self::Expr(expr) => expr.to_tokens(tokens), + } + } + } + + impl Display for LitStrOrExpr { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::LitStr(str) => write!(f, "{str}", str = str.value()), + Self::Expr(expr) => write!(f, "{expr}", expr = expr.into_token_stream()), + } + } + } + + pub fn parse_next<T: FnOnce() -> Result<R, syn::Error>, R: Sized>( + input: ParseStream, + next: T, + ) -> Result<R, syn::Error> { + input.parse::<Token![=]>()?; + next() + } + + pub fn parse_next_literal_str(input: ParseStream) -> syn::Result<String> { + Ok(parse_next(input, || input.parse::<LitStr>())?.value()) + } + + pub fn parse_next_literal_str_or_expr(input: ParseStream) -> syn::Result<LitStrOrExpr> { + parse_next(input, || LitStrOrExpr::parse(input)).map_err(|error| { + syn::Error::new( + error.span(), + format!("expected literal string or expression argument: {error}"), + ) + }) + } + + pub fn parse_groups_collect<T, R>(input: ParseStream) -> syn::Result<R> + where + T: Sized, + T: Parse, + R: FromIterator<T>, + { + Punctuated::<Group, Comma>::parse_terminated(input).and_then(|groups| { + groups + .into_iter() + .map(|group| syn::parse2::<T>(group.stream())) + .collect::<syn::Result<R>>() + }) + } + + pub fn parse_parethesized_terminated<T: Parse, S: Parse>( + input: ParseStream, + ) -> syn::Result<Punctuated<T, S>> { + let group; + syn::parenthesized!(group in input); + Punctuated::parse_terminated(&group) + } + + pub fn parse_comma_separated_within_parethesis_with<T>( + input: ParseStream, + with: fn(ParseStream) -> syn::Result<T>, + ) -> syn::Result<Punctuated<T, Comma>> + where + T: Parse, + { + let content; + parenthesized!(content in input); + Punctuated::<T, Comma>::parse_terminated_with(&content, with) + } + + pub fn parse_comma_separated_within_parenthesis<T>( + input: ParseStream, + ) -> syn::Result<Punctuated<T, Comma>> + where + T: Parse, + { + let content; + parenthesized!(content in input); + Punctuated::<T, Comma>::parse_terminated(&content) + } + + pub fn parse_bool_or_true(input: ParseStream) -> syn::Result<bool> { + if input.peek(Token![=]) && input.peek2(LitBool) { + input.parse::<Token![=]>()?; + + Ok(input.parse::<LitBool>()?.value()) + } else { + Ok(true) + } + } + + /// Parse `json!(...)` as a [`TokenStream`]. + pub fn parse_json_token_stream(input: ParseStream) -> syn::Result<TokenStream> { + if input.peek(syn::Ident) && input.peek2(Token![!]) { + input.parse::<Ident>().and_then(|ident| { + if ident != "json" { + return Err(Error::new( + ident.span(), + format!("unexpected token {ident}, expected: json!(...)"), + )); + } + + Ok(ident) + })?; + input.parse::<Token![!]>()?; + + Ok(input.parse::<Group>()?.stream()) + } else { + Err(Error::new( + input.span(), + "unexpected token, expected json!(...)", + )) + } + } + + #[cfg_attr(feature = "debug", derive(Debug))] + #[derive(Clone)] + pub enum LitBoolOrExprPath { + LitBool(LitBool), + ExprPath(ExprPath), + } + + impl From<bool> for LitBoolOrExprPath { + fn from(value: bool) -> Self { + Self::LitBool(LitBool::new(value, proc_macro2::Span::call_site())) + } + } + + impl Default for LitBoolOrExprPath { + fn default() -> Self { + Self::LitBool(LitBool::new(false, proc_macro2::Span::call_site())) + } + } + + impl Parse for LitBoolOrExprPath { + fn parse(input: ParseStream) -> syn::Result<Self> { + if input.peek(LitBool) { + Ok(LitBoolOrExprPath::LitBool(input.parse::<LitBool>()?)) + } else { + let expr = input.parse::<Expr>()?; + + match expr { + Expr::Path(expr_path) => Ok(LitBoolOrExprPath::ExprPath(expr_path)), + _ => Err(syn::Error::new( + expr.span(), + format!( + "expected literal bool or path to a function that returns bool, found: {}", + quote! {#expr} + ), + )), + } + } + } + } + + impl ToTokens for LitBoolOrExprPath { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + Self::LitBool(bool) => bool.to_tokens(tokens), + Self::ExprPath(call) => call.to_tokens(tokens), + } + } + } + + pub fn parse_next_literal_bool_or_call(input: ParseStream) -> syn::Result<LitBoolOrExprPath> { + if input.peek(Token![=]) { + parse_next(input, || LitBoolOrExprPath::parse(input)) + } else { + Ok(LitBoolOrExprPath::from(true)) + } + } +} diff --git a/fastapi-gen/src/openapi.rs b/fastapi-gen/src/openapi.rs new file mode 100644 index 0000000..976e26f --- /dev/null +++ b/fastapi-gen/src/openapi.rs @@ -0,0 +1,826 @@ +use std::borrow::Cow; + +use proc_macro2::Ident; +use syn::{ + bracketed, parenthesized, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, + token::{And, Comma}, + Attribute, Error, ExprPath, LitStr, Token, TypePath, +}; + +use proc_macro2::TokenStream; +use quote::{format_ident, quote, quote_spanned, ToTokens}; + +use crate::{ + component::{features::Feature, ComponentSchema, Container, TypeTree}, + parse_utils, + security_requirement::SecurityRequirementsAttr, + Array, Diagnostics, ExternalDocs, ToTokensDiagnostics, +}; +use crate::{path, OptionExt}; + +use self::info::Info; + +mod info; + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct OpenApiAttr<'o> { + info: Option<Info<'o>>, + paths: Punctuated<ExprPath, Comma>, + components: Components, + modifiers: Punctuated<Modifier, Comma>, + security: Option<Array<'static, SecurityRequirementsAttr>>, + tags: Option<Array<'static, Tag>>, + external_docs: Option<ExternalDocs>, + servers: Punctuated<Server, Comma>, + nested: Vec<NestOpenApi>, +} + +impl<'o> OpenApiAttr<'o> { + fn merge(mut self, other: OpenApiAttr<'o>) -> Self { + if other.info.is_some() { + self.info = other.info; + } + if !other.paths.is_empty() { + self.paths = other.paths; + } + if !other.components.schemas.is_empty() { + self.components.schemas = other.components.schemas; + } + if !other.components.responses.is_empty() { + self.components.responses = other.components.responses; + } + if other.security.is_some() { + self.security = other.security; + } + if other.tags.is_some() { + self.tags = other.tags; + } + if other.external_docs.is_some() { + self.external_docs = other.external_docs; + } + if !other.servers.is_empty() { + self.servers = other.servers; + } + + self + } +} + +pub fn parse_openapi_attrs(attrs: &[Attribute]) -> Result<Option<OpenApiAttr>, Error> { + attrs + .iter() + .filter(|attribute| attribute.path().is_ident("openapi")) + .map(|attribute| attribute.parse_args::<OpenApiAttr>()) + .collect::<Result<Vec<_>, _>>() + .map(|attrs| attrs.into_iter().reduce(|acc, item| acc.merge(item))) +} + +impl Parse for OpenApiAttr<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + const EXPECTED_ATTRIBUTE: &str = + "unexpected attribute, expected any of: handlers, components, modifiers, security, tags, external_docs, servers, nest"; + let mut openapi = OpenApiAttr::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>().map_err(|error| { + Error::new(error.span(), format!("{EXPECTED_ATTRIBUTE}, {error}")) + })?; + let attribute = &*ident.to_string(); + + match attribute { + "info" => { + let info_stream; + parenthesized!(info_stream in input); + openapi.info = Some(info_stream.parse()?) + } + "paths" => { + openapi.paths = parse_utils::parse_comma_separated_within_parenthesis(input)?; + } + "components" => { + openapi.components = input.parse()?; + } + "modifiers" => { + openapi.modifiers = + parse_utils::parse_comma_separated_within_parenthesis(input)?; + } + "security" => { + let security; + parenthesized!(security in input); + openapi.security = Some(parse_utils::parse_groups_collect(&security)?) + } + "tags" => { + let tags; + parenthesized!(tags in input); + openapi.tags = Some(parse_utils::parse_groups_collect(&tags)?); + } + "external_docs" => { + let external_docs; + parenthesized!(external_docs in input); + openapi.external_docs = Some(external_docs.parse()?); + } + "servers" => { + openapi.servers = parse_utils::parse_comma_separated_within_parenthesis(input)?; + } + "nest" => { + let nest; + parenthesized!(nest in input); + openapi.nested = parse_utils::parse_groups_collect(&nest)?; + } + _ => { + return Err(Error::new(ident.span(), EXPECTED_ATTRIBUTE)); + } + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(openapi) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct Schema(TypePath); + +impl Schema { + fn get_component(&self) -> Result<ComponentSchema, Diagnostics> { + let ty = syn::Type::Path(self.0.clone()); + let type_tree = TypeTree::from_type(&ty)?; + let generics = type_tree.get_path_generics()?; + + let container = Container { + generics: &generics, + }; + let component_schema = ComponentSchema::new(crate::component::ComponentSchemaProps { + container: &container, + type_tree: &type_tree, + features: vec![Feature::Inline(true.into())], + description: None, + })?; + + Ok(component_schema) + } +} + +impl Parse for Schema { + fn parse(input: ParseStream) -> syn::Result<Self> { + input.parse().map(Self) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct Response(TypePath); + +impl Parse for Response { + fn parse(input: ParseStream) -> syn::Result<Self> { + input.parse().map(Self) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct Modifier { + and: And, + ident: Ident, +} + +impl ToTokens for Modifier { + fn to_tokens(&self, tokens: &mut TokenStream) { + let and = &self.and; + let ident = &self.ident; + tokens.extend(quote! { + #and #ident + }) + } +} + +impl Parse for Modifier { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(Self { + and: input.parse()?, + ident: input.parse()?, + }) + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct Tag { + name: parse_utils::LitStrOrExpr, + description: Option<parse_utils::LitStrOrExpr>, + external_docs: Option<ExternalDocs>, +} + +impl Parse for Tag { + fn parse(input: ParseStream) -> syn::Result<Self> { + const EXPECTED_ATTRIBUTE: &str = + "unexpected token, expected any of: name, description, external_docs"; + + let mut tag = Tag::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>().map_err(|error| { + syn::Error::new(error.span(), format!("{EXPECTED_ATTRIBUTE}, {error}")) + })?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "name" => tag.name = parse_utils::parse_next_literal_str_or_expr(input)?, + "description" => { + tag.description = Some(parse_utils::parse_next_literal_str_or_expr(input)?) + } + "external_docs" => { + let content; + parenthesized!(content in input); + tag.external_docs = Some(content.parse::<ExternalDocs>()?); + } + _ => return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE)), + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(tag) + } +} + +impl ToTokens for Tag { + fn to_tokens(&self, tokens: &mut TokenStream) { + let name = &self.name; + tokens.extend(quote! { + fastapi::openapi::tag::TagBuilder::new().name(#name) + }); + + if let Some(ref description) = self.description { + tokens.extend(quote! { + .description(Some(#description)) + }); + } + + if let Some(ref external_docs) = self.external_docs { + tokens.extend(quote! { + .external_docs(Some(#external_docs)) + }); + } + + tokens.extend(quote! { .build() }) + } +} + +// (url = "http:://url", description = "description", variables(...)) +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Server { + url: String, + description: Option<String>, + variables: Punctuated<ServerVariable, Comma>, +} + +impl Parse for Server { + fn parse(input: ParseStream) -> syn::Result<Self> { + let server_stream; + parenthesized!(server_stream in input); + let mut server = Server::default(); + while !server_stream.is_empty() { + let ident = server_stream.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "url" => { + server.url = parse_utils::parse_next(&server_stream, || server_stream.parse::<LitStr>())?.value() + } + "description" => { + server.description = + Some(parse_utils::parse_next(&server_stream, || server_stream.parse::<LitStr>())?.value()) + } + "variables" => { + server.variables = parse_utils::parse_comma_separated_within_parenthesis(&server_stream)? + } + _ => { + return Err(Error::new(ident.span(), format!("unexpected attribute: {attribute_name}, expected one of: url, description, variables"))) + } + } + + if !server_stream.is_empty() { + server_stream.parse::<Comma>()?; + } + } + + Ok(server) + } +} + +impl ToTokens for Server { + fn to_tokens(&self, tokens: &mut TokenStream) { + let url = &self.url; + let description = &self + .description + .as_ref() + .map(|description| quote! { .description(Some(#description)) }); + + let parameters = self + .variables + .iter() + .map(|variable| { + let name = &variable.name; + let default_value = &variable.default; + let description = &variable + .description + .as_ref() + .map(|description| quote! { .description(Some(#description)) }); + let enum_values = &variable.enum_values.as_ref().map(|enum_values| { + let enum_values = enum_values.iter().collect::<Array<&LitStr>>(); + + quote! { .enum_values(Some(#enum_values)) } + }); + + quote! { + .parameter(#name, fastapi::openapi::server::ServerVariableBuilder::new() + .default_value(#default_value) + #description + #enum_values + ) + } + }) + .collect::<TokenStream>(); + + tokens.extend(quote! { + fastapi::openapi::server::ServerBuilder::new() + .url(#url) + #description + #parameters + .build() + }) + } +} + +// ("username" = (default = "demo", description = "This is default username for the API")), +// ("port" = (enum_values = (8080, 5000, 4545))) +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct ServerVariable { + name: String, + default: String, + description: Option<String>, + enum_values: Option<Punctuated<LitStr, Comma>>, +} + +impl Parse for ServerVariable { + fn parse(input: ParseStream) -> syn::Result<Self> { + let variable_stream; + parenthesized!(variable_stream in input); + let mut server_variable = ServerVariable { + name: variable_stream.parse::<LitStr>()?.value(), + ..ServerVariable::default() + }; + + variable_stream.parse::<Token![=]>()?; + let content; + parenthesized!(content in variable_stream); + + while !content.is_empty() { + let ident = content.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "default" => { + server_variable.default = + parse_utils::parse_next(&content, || content.parse::<LitStr>())?.value() + } + "description" => { + server_variable.description = + Some(parse_utils::parse_next(&content, || content.parse::<LitStr>())?.value()) + } + "enum_values" => { + server_variable.enum_values = + Some(parse_utils::parse_comma_separated_within_parenthesis(&content)?) + } + _ => { + return Err(Error::new(ident.span(), format!( "unexpected attribute: {attribute_name}, expected one of: default, description, enum_values"))) + } + } + + if !content.is_empty() { + content.parse::<Comma>()?; + } + } + + Ok(server_variable) + } +} + +pub(crate) struct OpenApi<'o>(pub Option<OpenApiAttr<'o>>, pub Ident); + +impl OpenApi<'_> { + fn nested_tokens(&self) -> Option<TokenStream> { + let nested = self.0.as_ref().map(|openapi| &openapi.nested)?; + let nest_tokens = nested.iter() + .map(|item| { + let path = &item.path; + let nest_api = &item + .open_api + .as_ref() + .expect("type path of nested api is mandatory"); + let nest_api_ident = &nest_api + .path + .segments + .last() + .expect("nest api must have at least one segment") + .ident; + let nest_api_config = format_ident!("{}Config", nest_api_ident.to_string()); + + let module_path = nest_api + .path + .segments + .iter() + .take(nest_api.path.segments.len() - 1) + .map(|segment| segment.ident.to_string()) + .collect::<Vec<_>>() + .join("::"); + let tags = &item.tags.iter().collect::<Array<_>>(); + + let span = nest_api.span(); + quote_spanned! {span=> + .nest(#path, { + #[allow(non_camel_case_types)] + struct #nest_api_config; + impl fastapi::__dev::NestedApiConfig for #nest_api_config { + fn config() -> (fastapi::openapi::OpenApi, Vec<&'static str>, &'static str) { + let api = <#nest_api as fastapi::OpenApi>::openapi(); + + (api, #tags.into(), #module_path) + } + } + <#nest_api_config as fastapi::OpenApi>::openapi() + }) + } + }) + .collect::<TokenStream>(); + + if nest_tokens.is_empty() { + None + } else { + Some(nest_tokens) + } + } +} + +impl ToTokensDiagnostics for OpenApi<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let OpenApi(attributes, ident) = self; + + let info = Info::merge_with_env_args( + attributes + .as_ref() + .and_then(|attributes| attributes.info.clone()), + ); + + let components = attributes + .as_ref() + .map_try(|attributes| attributes.components.try_to_token_stream())? + .and_then(|tokens| { + if !tokens.is_empty() { + Some(quote! { .components(Some(#tokens)) }) + } else { + None + } + }); + + let Paths(path_items, handlers) = + impl_paths(attributes.as_ref().map(|attributes| &attributes.paths)); + + let handler_schemas = handlers.iter().fold( + quote! { + let components = openapi.components.get_or_insert(fastapi::openapi::Components::new()); + let mut schemas = Vec::<(String, fastapi::openapi::RefOr<fastapi::openapi::schema::Schema>)>::new(); + }, + |mut handler_schemas, (usage, ..)| { + handler_schemas.extend(quote! { + <#usage as fastapi::__dev::SchemaReferences>::schemas(&mut schemas); + }); + + handler_schemas + }, + ); + + let securities = attributes + .as_ref() + .and_then(|openapi_attributes| openapi_attributes.security.as_ref()) + .map(|securities| { + quote! { + .security(Some(#securities)) + } + }); + let tags = attributes + .as_ref() + .and_then(|attributes| attributes.tags.as_ref()) + .map(|tags| { + quote! { + .tags(Some(#tags)) + } + }); + let external_docs = attributes + .as_ref() + .and_then(|attributes| attributes.external_docs.as_ref()) + .map(|external_docs| { + quote! { + .external_docs(Some(#external_docs)) + } + }); + + let servers = match attributes.as_ref().map(|attributes| &attributes.servers) { + Some(servers) if !servers.is_empty() => { + let servers = servers.iter().collect::<Array<&Server>>(); + Some(quote! { .servers(Some(#servers)) }) + } + _ => None, + }; + + let modifiers_tokens = attributes + .as_ref() + .map(|attributes| &attributes.modifiers) + .map(|modifiers| { + let modifiers_len = modifiers.len(); + + quote! { + let _mods: [&dyn fastapi::Modify; #modifiers_len] = [#modifiers]; + _mods.iter().for_each(|modifier| modifier.modify(&mut openapi)); + } + }); + + let nested_tokens = self + .nested_tokens() + .map(|tokens| quote! {openapi = openapi #tokens;}); + tokens.extend(quote! { + impl fastapi::OpenApi for #ident { + fn openapi() -> fastapi::openapi::OpenApi { + use fastapi::{ToSchema, Path}; + let mut openapi = fastapi::openapi::OpenApiBuilder::new() + .info(#info) + .paths({ + #path_items + }) + #components + #securities + #tags + #servers + #external_docs + .build(); + #handler_schemas + components.schemas.extend(schemas); + #nested_tokens + + #modifiers_tokens + + openapi + } + } + }); + + Ok(()) + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct Components { + schemas: Vec<Schema>, + responses: Vec<Response>, +} + +impl Parse for Components { + fn parse(input: ParseStream) -> syn::Result<Self> { + let content; + parenthesized!(content in input); + const EXPECTED_ATTRIBUTE: &str = + "unexpected attribute. expected one of: schemas, responses"; + + let mut schemas: Vec<Schema> = Vec::new(); + let mut responses: Vec<Response> = Vec::new(); + + while !content.is_empty() { + let ident = content.parse::<Ident>().map_err(|error| { + Error::new(error.span(), format!("{EXPECTED_ATTRIBUTE}, {error}")) + })?; + let attribute = &*ident.to_string(); + + match attribute { + "schemas" => schemas.append( + &mut parse_utils::parse_comma_separated_within_parenthesis(&content)? + .into_iter() + .collect(), + ), + "responses" => responses.append( + &mut parse_utils::parse_comma_separated_within_parenthesis(&content)? + .into_iter() + .collect(), + ), + _ => return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE)), + } + + if !content.is_empty() { + content.parse::<Token![,]>()?; + } + } + + Ok(Self { schemas, responses }) + } +} + +impl crate::ToTokensDiagnostics for Components { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + if self.schemas.is_empty() && self.responses.is_empty() { + return Ok(()); + } + + let builder_tokens = self + .schemas + .iter() + .map(|schema| match schema.get_component() { + Ok(component_schema) => Ok((component_schema, &schema.0)), + Err(diagnostics) => Err(diagnostics), + }) + .collect::<Result<Vec<(ComponentSchema, &TypePath)>, Diagnostics>>()? + .into_iter() + .fold( + quote! { fastapi::openapi::ComponentsBuilder::new() }, + |mut components, (component_schema, type_path)| { + let schema = component_schema.to_token_stream(); + let name = &component_schema.name_tokens; + + components.extend(quote! { .schemas_from_iter( { + let mut schemas = Vec::<(String, fastapi::openapi::RefOr<fastapi::openapi::schema::Schema>)>::new(); + <#type_path as fastapi::ToSchema>::schemas(&mut schemas); + schemas + } )}); + components.extend(quote! { .schema(#name, #schema) }); + + components + }, + ); + + let builder_tokens = + self.responses + .iter() + .fold(builder_tokens, |mut builder_tokens, responses| { + let Response(path) = responses; + + builder_tokens.extend(quote_spanned! {path.span() => + .response_from::<#path>() + }); + builder_tokens + }); + + tokens.extend(quote! { #builder_tokens.build() }); + + Ok(()) + } +} + +struct Paths(TokenStream, Vec<(ExprPath, String, Ident)>); + +fn impl_paths(handler_paths: Option<&Punctuated<ExprPath, Comma>>) -> Paths { + let handlers = handler_paths + .into_iter() + .flatten() + .map(|handler| { + let segments = handler.path.segments.iter().collect::<Vec<_>>(); + let handler_config_name = segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::<Vec<_>>() + .join("_"); + let handler_fn = &segments.last().unwrap().ident; + let handler_ident = path::format_path_ident(Cow::Borrowed(handler_fn)); + let handler_ident_config = format_ident!("{}_config", handler_config_name); + + let tag = segments + .iter() + .take(segments.len() - 1) + .map(|part| part.ident.to_string()) + .collect::<Vec<_>>() + .join("::"); + + let usage = syn::parse_str::<ExprPath>( + &vec![ + if tag.is_empty() { None } else { Some(&*tag) }, + Some(&*handler_ident.as_ref().to_string()), + ] + .into_iter() + .flatten() + .collect::<Vec<_>>() + .join("::"), + ) + .unwrap(); + (usage, tag, handler_ident_config) + }) + .collect::<Vec<_>>(); + + let handlers_impls = handlers + .iter() + .map(|(usage, tag, handler_ident_nested)| { + quote! { + #[allow(non_camel_case_types)] + struct #handler_ident_nested; + #[allow(non_camel_case_types)] + impl fastapi::__dev::PathConfig for #handler_ident_nested { + fn path() -> String { + #usage::path() + } + fn methods() -> Vec<fastapi::openapi::path::HttpMethod> { + #usage::methods() + } + fn tags_and_operation() -> (Vec<&'static str>, fastapi::openapi::path::Operation) { + let item = #usage::operation(); + let mut tags = <#usage as fastapi::__dev::Tags>::tags(); + if !#tag.is_empty() && tags.is_empty() { + tags.push(#tag); + } + + (tags, item) + } + } + } + }) + .collect::<TokenStream>(); + + let tokens = handler_paths.into_iter().flatten().fold( + quote! { #handlers_impls fastapi::openapi::path::PathsBuilder::new() }, + |mut paths, handler| { + let segments = handler.path.segments.iter().collect::<Vec<_>>(); + let handler_config_name = segments + .iter() + .map(|segment| segment.ident.to_string()) + .collect::<Vec<_>>() + .join("_"); + let handler_ident_config = format_ident!("{}_config", handler_config_name); + + paths.extend(quote! { + .path_from::<#handler_ident_config>() + }); + + paths + }, + ); + + Paths(tokens, handlers) +} + +/// (path = "/nest/path", api = NestApi, tags = ["tag1", "tag2"]) +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Default)] +struct NestOpenApi { + path: parse_utils::LitStrOrExpr, + open_api: Option<TypePath>, + tags: Punctuated<parse_utils::LitStrOrExpr, Comma>, +} + +impl Parse for NestOpenApi { + fn parse(input: ParseStream) -> syn::Result<Self> { + const ERROR_MESSAGE: &str = "unexpected identifier, expected any of: path, api, tags"; + let mut nest = NestOpenApi::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>().map_err(|error| { + syn::Error::new(error.span(), format!("{ERROR_MESSAGE}: {error}")) + })?; + + match &*ident.to_string() { + "path" => nest.path = parse_utils::parse_next_literal_str_or_expr(input)?, + "api" => nest.open_api = Some(parse_utils::parse_next(input, || input.parse())?), + "tags" => { + nest.tags = parse_utils::parse_next(input, || { + let tags; + bracketed!(tags in input); + Punctuated::parse_terminated(&tags) + })?; + } + _ => return Err(syn::Error::new(ident.span(), ERROR_MESSAGE)), + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + if nest.path.is_empty_litstr() { + return Err(syn::Error::new( + input.span(), + "`path = ...` argument is mandatory for nest(...) statement", + )); + } + if nest.open_api.is_none() { + return Err(syn::Error::new( + input.span(), + "`api = ...` argument is mandatory for nest(...) statement", + )); + } + + Ok(nest) + } +} diff --git a/fastapi-gen/src/openapi/info.rs b/fastapi-gen/src/openapi/info.rs new file mode 100644 index 0000000..3c84545 --- /dev/null +++ b/fastapi-gen/src/openapi/info.rs @@ -0,0 +1,430 @@ +use std::borrow::Cow; +use std::io; + +use proc_macro2::{Ident, TokenStream as TokenStream2}; +use quote::{quote, ToTokens}; +use syn::parse::Parse; +use syn::token::Comma; +use syn::{parenthesized, Error, LitStr}; + +use crate::parse_utils::{self, LitStrOrExpr}; + +#[derive(Default, Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub(super) struct Info<'i> { + title: Option<LitStrOrExpr>, + version: Option<LitStrOrExpr>, + description: Option<LitStrOrExpr>, + terms_of_service: Option<LitStrOrExpr>, + license: Option<License<'i>>, + contact: Option<Contact<'i>>, +} + +impl Info<'_> { + /// Construct new [`Info`] from _`cargo`_ env variables such as + /// * `CARGO_PGK_NAME` + /// * `CARGO_PGK_VERSION` + /// * `CARGO_PGK_DESCRIPTION` + /// * `CARGO_PGK_AUTHORS` + /// * `CARGO_PGK_LICENSE` + pub fn from_env() -> Self { + let name = std::env::var("CARGO_PKG_NAME").ok(); + let version = std::env::var("CARGO_PKG_VERSION").ok(); + let description = std::env::var("CARGO_PKG_DESCRIPTION").ok(); + let contact = std::env::var("CARGO_PKG_AUTHORS") + .ok() + .and_then(|authors| Contact::try_from(authors).ok()) + .and_then(|contact| { + if contact.name.is_none() && contact.email.is_none() && contact.url.is_none() { + None + } else { + Some(contact) + } + }); + let license = std::env::var("CARGO_PKG_LICENSE").ok().map(License::from); + + Info { + title: name.map(|name| name.into()), + version: version.map(|version| version.into()), + description: description.map(|description| description.into()), + contact, + license, + ..Default::default() + } + } + + /// Merge given info arguments to [`Info`] created from `CARGO_*` env arguments. + pub fn merge_with_env_args(info: Option<Info>) -> Info { + let mut from_env = Info::from_env(); + if let Some(info) = info { + if info.title.is_some() { + from_env.title = info.title; + } + + if info.terms_of_service.is_some() { + from_env.terms_of_service = info.terms_of_service; + } + + if info.description.is_some() { + from_env.description = info.description; + } + + if info.license.is_some() { + from_env.license = info.license; + } + + if info.contact.is_some() { + from_env.contact = info.contact; + } + + if info.version.is_some() { + from_env.version = info.version; + } + } + + from_env + } +} + +impl Parse for Info<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let mut info = Info::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "title" => { + info.title = Some(parse_utils::parse_next(input, || { + input.parse::<LitStrOrExpr>() + })?) + } + "version" => { + info.version = Some(parse_utils::parse_next(input, || { + input.parse::<LitStrOrExpr>() + })?) + } + "description" => { + info.description = Some(parse_utils::parse_next(input, || { + input.parse::<LitStrOrExpr>() + })?) + } + "terms_of_service" => { + info.terms_of_service = Some(parse_utils::parse_next(input, || { + input.parse::<LitStrOrExpr>() + })?) + } + "license" => { + let license_stream; + parenthesized!(license_stream in input); + info.license = Some(license_stream.parse()?) + } + "contact" => { + let contact_stream; + parenthesized!(contact_stream in input); + info.contact = Some(contact_stream.parse()?) + } + _ => { + return Err(Error::new(ident.span(), format!("unexpected attribute: {attribute_name}, expected one of: title, terms_of_service, version, description, license, contact"))); + } + } + if !input.is_empty() { + input.parse::<Comma>()?; + } + } + + Ok(info) + } +} + +impl ToTokens for Info<'_> { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let title = self.title.as_ref().map(|title| quote! { .title(#title) }); + let version = self + .version + .as_ref() + .map(|version| quote! { .version(#version) }); + let terms_of_service = self + .terms_of_service + .as_ref() + .map(|terms_of_service| quote! {.terms_of_service(Some(#terms_of_service))}); + let description = self + .description + .as_ref() + .map(|description| quote! { .description(Some(#description)) }); + let license = self + .license + .as_ref() + .map(|license| quote! { .license(Some(#license)) }); + let contact = self + .contact + .as_ref() + .map(|contact| quote! { .contact(Some(#contact)) }); + + tokens.extend(quote! { + fastapi::openapi::InfoBuilder::new() + #title + #version + #terms_of_service + #description + #license + #contact + }) + } +} + +#[derive(Default, Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub(super) struct License<'l> { + name: Cow<'l, str>, + url: Option<Cow<'l, str>>, + identifier: Cow<'l, str>, +} + +impl Parse for License<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let mut license = License::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "name" => { + license.name = Cow::Owned( + parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(), + ) + } + "url" => { + license.url = Some(Cow::Owned( + parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(), + )) + } + "identifier" => { + license.identifier = Cow::Owned( + parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(), + ) + } + _ => { + return Err(Error::new( + ident.span(), + format!( + "unexpected attribute: {attribute_name}, expected one of: name, url" + ), + )); + } + } + if !input.is_empty() { + input.parse::<Comma>()?; + } + } + + Ok(license) + } +} + +impl ToTokens for License<'_> { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let name = &self.name; + let url = self.url.as_ref().map(|url| quote! { .url(Some(#url))}); + let identifier = if !self.identifier.is_empty() { + let identifier = self.identifier.as_ref(); + quote! { .identifier(Some(#identifier))} + } else { + TokenStream2::new() + }; + + tokens.extend(quote! { + fastapi::openapi::info::LicenseBuilder::new() + .name(#name) + #url + #identifier + .build() + }) + } +} + +impl From<String> for License<'_> { + fn from(string: String) -> Self { + License { + name: Cow::Owned(string), + ..Default::default() + } + } +} + +#[derive(Default, Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub(super) struct Contact<'c> { + name: Option<Cow<'c, str>>, + email: Option<Cow<'c, str>>, + url: Option<Cow<'c, str>>, +} + +impl Parse for Contact<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let mut contact = Contact::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "name" => { + contact.name = Some(Cow::Owned( + parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(), + )) + } + "email" => { + contact.email = Some(Cow::Owned( + parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(), + )) + } + "url" => { + contact.url = Some(Cow::Owned( + parse_utils::parse_next(input, || input.parse::<LitStr>())?.value(), + )) + } + _ => { + return Err(Error::new( + ident.span(), + format!("unexpected attribute: {attribute_name}, expected one of: name, email, url"), + )); + } + } + if !input.is_empty() { + input.parse::<Comma>()?; + } + } + + Ok(contact) + } +} + +impl ToTokens for Contact<'_> { + fn to_tokens(&self, tokens: &mut TokenStream2) { + let name = self.name.as_ref().map(|name| quote! { .name(Some(#name)) }); + let email = self + .email + .as_ref() + .map(|email| quote! { .email(Some(#email)) }); + let url = self.url.as_ref().map(|url| quote! { .url(Some(#url)) }); + + tokens.extend(quote! { + fastapi::openapi::info::ContactBuilder::new() + #name + #email + #url + .build() + }) + } +} + +impl TryFrom<String> for Contact<'_> { + type Error = io::Error; + + fn try_from(value: String) -> Result<Self, Self::Error> { + if let Some((name, email)) = get_parsed_author(value.split(':').next()) { + let non_empty = |value: &str| -> Option<Cow<'static, str>> { + if !value.is_empty() { + Some(Cow::Owned(value.to_string())) + } else { + None + } + }; + Ok(Contact { + name: non_empty(name), + email: non_empty(email), + ..Default::default() + }) + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!("invalid contact: {value}"), + )) + } + } +} + +fn get_parsed_author(author: Option<&str>) -> Option<(&str, &str)> { + author.map(|author| { + let mut author_iter = author.split('<'); + + let name = author_iter.next().unwrap_or_default(); + let mut email = author_iter.next().unwrap_or_default(); + if !email.is_empty() { + email = &email[..email.len() - 1]; + } + + (name.trim_end(), email) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_author_with_email_success() { + let author = "Tessu Tester <tessu@steps.com>"; + + if let Some((name, email)) = get_parsed_author(Some(author)) { + assert_eq!( + name, "Tessu Tester", + "expected name {} != {}", + "Tessu Tester", name + ); + assert_eq!( + email, "tessu@steps.com", + "expected email {} != {}", + "tessu@steps.com", email + ); + } else { + panic!("Expected Some(Tessu Tester, tessu@steps.com), but was none") + } + } + + #[test] + fn parse_author_only_name() { + let author = "Tessu Tester"; + + if let Some((name, email)) = get_parsed_author(Some(author)) { + assert_eq!( + name, "Tessu Tester", + "expected name {} != {}", + "Tessu Tester", name + ); + assert_eq!(email, "", "expected email {} != {}", "", email); + } else { + panic!("Expected Some(Tessu Tester, ), but was none") + } + } + + #[test] + fn contact_from_only_name() { + let author = "Suzy Lin"; + let contanct = Contact::try_from(author.to_string()).unwrap(); + + assert!(contanct.name.is_some(), "Suzy should have name"); + assert!(contanct.email.is_none(), "Suzy should not have email"); + } + + #[test] + fn contact_from_name_and_email() { + let author = "Suzy Lin <suzy@lin.com>"; + let contanct = Contact::try_from(author.to_string()).unwrap(); + + assert!(contanct.name.is_some(), "Suzy should have name"); + assert!(contanct.email.is_some(), "Suzy should have email"); + } + + #[test] + fn contact_from_empty() { + let author = ""; + let contact = Contact::try_from(author.to_string()).unwrap(); + + assert!(contact.name.is_none(), "Contact name should be empty"); + assert!(contact.email.is_none(), "Contact email should be empty"); + } +} diff --git a/fastapi-gen/src/path.rs b/fastapi-gen/src/path.rs new file mode 100644 index 0000000..eb0a62e --- /dev/null +++ b/fastapi-gen/src/path.rs @@ -0,0 +1,806 @@ +use std::borrow::Cow; +use std::ops::Deref; +use std::{io::Error, str::FromStr}; + +use proc_macro2::{Ident, Span, TokenStream as TokenStream2}; +use quote::{quote, quote_spanned, ToTokens}; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::token::Comma; +use syn::{parenthesized, parse::Parse, Token}; +use syn::{Expr, ExprLit, Lit, LitStr}; + +use crate::component::{ComponentSchema, GenericType, TypeTree}; +use crate::{ + as_tokens_or_diagnostics, parse_utils, Deprecated, Diagnostics, OptionExt, ToTokensDiagnostics, +}; +use crate::{schema_type::SchemaType, security_requirement::SecurityRequirementsAttr, Array}; + +use self::response::Response; +use self::{parameter::Parameter, request_body::RequestBodyAttr, response::Responses}; + +pub mod example; +pub mod handler; +pub mod media_type; +pub mod parameter; +mod request_body; +pub mod response; +mod status; + +const PATH_STRUCT_PREFIX: &str = "__path_"; + +#[inline] +pub fn format_path_ident(fn_name: Cow<'_, Ident>) -> Cow<'_, Ident> { + Cow::Owned(quote::format_ident!( + "{PATH_STRUCT_PREFIX}{}", + fn_name.as_ref() + )) +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct PathAttr<'p> { + methods: Vec<HttpMethod>, + request_body: Option<RequestBodyAttr<'p>>, + responses: Vec<Response<'p>>, + pub(super) path: Option<parse_utils::LitStrOrExpr>, + operation_id: Option<Expr>, + tag: Option<parse_utils::LitStrOrExpr>, + tags: Vec<parse_utils::LitStrOrExpr>, + params: Vec<Parameter<'p>>, + security: Option<Array<'p, SecurityRequirementsAttr>>, + context_path: Option<parse_utils::LitStrOrExpr>, + impl_for: Option<Ident>, + description: Option<parse_utils::LitStrOrExpr>, + summary: Option<parse_utils::LitStrOrExpr>, +} + +impl<'p> PathAttr<'p> { + #[cfg(feature = "auto_into_responses")] + pub fn responses_from_into_responses(&mut self, ty: &'p syn::TypePath) { + self.responses + .push(Response::IntoResponses(Cow::Borrowed(ty))) + } + + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + pub fn update_request_body(&mut self, schema: Option<crate::ext::ExtSchema<'p>>) { + use self::media_type::Schema; + if self.request_body.is_none() { + if let Some(schema) = schema { + self.request_body = Some(RequestBodyAttr::from_schema(Schema::Ext(schema))); + } + } + } + + /// Update path with external parameters from extensions. + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + pub fn update_parameters_ext<I: IntoIterator<Item = Parameter<'p>>>( + &mut self, + ext_parameters: I, + ) { + let ext_params = ext_parameters.into_iter(); + + let (existing_incoming_params, new_params): (Vec<Parameter>, Vec<Parameter>) = + ext_params.partition(|param| self.params.iter().any(|p| p == param)); + + for existing_incoming in existing_incoming_params { + if let Some(param) = self.params.iter_mut().find(|p| **p == existing_incoming) { + param.merge(existing_incoming); + } + } + + self.params.extend( + new_params + .into_iter() + .filter(|param| !matches!(param, Parameter::IntoParamsIdent(_))), + ); + } +} + +impl Parse for PathAttr<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + const EXPECTED_ATTRIBUTE_MESSAGE: &str = "unexpected identifier, expected any of: method, get, post, put, delete, options, head, patch, trace, operation_id, path, request_body, responses, params, tag, security, context_path, description, summary"; + let mut path_attr = PathAttr::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>().map_err(|error| { + syn::Error::new( + error.span(), + format!("{EXPECTED_ATTRIBUTE_MESSAGE}, {error}"), + ) + })?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "method" => { + path_attr.methods = + parse_utils::parse_parethesized_terminated::<HttpMethod, Comma>(input)? + .into_iter() + .collect() + } + "operation_id" => { + path_attr.operation_id = + Some(parse_utils::parse_next(input, || Expr::parse(input))?); + } + "path" => { + path_attr.path = Some(parse_utils::parse_next_literal_str_or_expr(input)?); + } + "request_body" => { + path_attr.request_body = Some(input.parse::<RequestBodyAttr>()?); + } + "responses" => { + let responses; + parenthesized!(responses in input); + path_attr.responses = + Punctuated::<Response, Token![,]>::parse_terminated(&responses) + .map(|punctuated| punctuated.into_iter().collect::<Vec<Response>>())?; + } + "params" => { + let params; + parenthesized!(params in input); + path_attr.params = + Punctuated::<Parameter, Token![,]>::parse_terminated(¶ms) + .map(|punctuated| punctuated.into_iter().collect::<Vec<Parameter>>())?; + } + "tag" => { + path_attr.tag = Some(parse_utils::parse_next_literal_str_or_expr(input)?); + } + "tags" => { + path_attr.tags = parse_utils::parse_next(input, || { + let tags; + syn::bracketed!(tags in input); + Punctuated::<parse_utils::LitStrOrExpr, Token![,]>::parse_terminated(&tags) + })? + .into_iter() + .collect::<Vec<_>>(); + } + "security" => { + let security; + parenthesized!(security in input); + path_attr.security = Some(parse_utils::parse_groups_collect(&security)?) + } + "context_path" => { + path_attr.context_path = + Some(parse_utils::parse_next_literal_str_or_expr(input)?) + } + "impl_for" => { + path_attr.impl_for = + Some(parse_utils::parse_next(input, || input.parse::<Ident>())?); + } + "description" => { + path_attr.description = + Some(parse_utils::parse_next_literal_str_or_expr(input)?) + } + "summary" => { + path_attr.summary = Some(parse_utils::parse_next_literal_str_or_expr(input)?) + } + _ => { + if let Some(path_operation) = + attribute_name.parse::<HttpMethod>().into_iter().next() + { + path_attr.methods = vec![path_operation] + } else { + return Err(syn::Error::new(ident.span(), EXPECTED_ATTRIBUTE_MESSAGE)); + } + } + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(path_attr) + } +} + +/// Path operation HTTP method +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum HttpMethod { + Get, + Post, + Put, + Delete, + Options, + Head, + Patch, + Trace, +} + +impl Parse for HttpMethod { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let method = input + .parse::<Ident>() + .map_err(|error| syn::Error::new(error.span(), HttpMethod::ERROR_MESSAGE))?; + + method + .to_string() + .parse::<HttpMethod>() + .map_err(|_| syn::Error::new(method.span(), HttpMethod::ERROR_MESSAGE)) + } +} + +impl HttpMethod { + const ERROR_MESSAGE: &'static str = "unexpected http method, expected one of: get, post, put, delete, options, head, patch, trace"; + /// Create path operation from ident + /// + /// Ident must have value of http request type as lower case string such as `get`. + #[cfg(any(feature = "actix_extras", feature = "rocket_extras"))] + pub fn from_ident(ident: &Ident) -> Result<Self, Diagnostics> { + let name = &*ident.to_string(); + name + .parse::<HttpMethod>() + .map_err(|error| { + let mut diagnostics = Diagnostics::with_span(ident.span(), error.to_string()); + if name == "connect" { + diagnostics = diagnostics.note("HTTP method `CONNET` is not supported by OpenAPI spec <https://spec.openapis.org/oas/latest.html#path-item-object>"); + } + + diagnostics + }) + } +} + +impl FromStr for HttpMethod { + type Err = Error; + + fn from_str(s: &str) -> Result<Self, Self::Err> { + match s { + "get" => Ok(Self::Get), + "post" => Ok(Self::Post), + "put" => Ok(Self::Put), + "delete" => Ok(Self::Delete), + "options" => Ok(Self::Options), + "head" => Ok(Self::Head), + "patch" => Ok(Self::Patch), + "trace" => Ok(Self::Trace), + _ => Err(Error::new( + std::io::ErrorKind::Other, + HttpMethod::ERROR_MESSAGE, + )), + } + } +} + +impl ToTokens for HttpMethod { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let path_item_type = match self { + Self::Get => quote! { fastapi::openapi::HttpMethod::Get }, + Self::Post => quote! { fastapi::openapi::HttpMethod::Post }, + Self::Put => quote! { fastapi::openapi::HttpMethod::Put }, + Self::Delete => quote! { fastapi::openapi::HttpMethod::Delete }, + Self::Options => quote! { fastapi::openapi::HttpMethod::Options }, + Self::Head => quote! { fastapi::openapi::HttpMethod::Head }, + Self::Patch => quote! { fastapi::openapi::HttpMethod::Patch }, + Self::Trace => quote! { fastapi::openapi::HttpMethod::Trace }, + }; + + tokens.extend(path_item_type); + } +} +pub struct Path<'p> { + path_attr: PathAttr<'p>, + fn_ident: &'p Ident, + ext_methods: Vec<HttpMethod>, + path: Option<String>, + doc_comments: Option<Vec<String>>, + deprecated: bool, +} + +impl<'p> Path<'p> { + pub fn new(path_attr: PathAttr<'p>, fn_ident: &'p Ident) -> Self { + Self { + path_attr, + fn_ident, + ext_methods: Vec::new(), + path: None, + doc_comments: None, + deprecated: false, + } + } + + pub fn ext_methods(mut self, methods: Option<Vec<HttpMethod>>) -> Self { + self.ext_methods = methods.unwrap_or_default(); + + self + } + + pub fn path(mut self, path: Option<String>) -> Self { + self.path = path; + + self + } + + pub fn doc_comments(mut self, doc_comments: Vec<String>) -> Self { + self.doc_comments = Some(doc_comments); + + self + } + + pub fn deprecated(mut self, deprecated: bool) -> Self { + self.deprecated = deprecated; + + self + } +} + +impl<'p> ToTokensDiagnostics for Path<'p> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let fn_name = &*self.fn_ident.to_string(); + let operation_id = self + .path_attr + .operation_id + .clone() + .or(Some( + ExprLit { + attrs: vec![], + lit: Lit::Str(LitStr::new(fn_name, Span::call_site())), + } + .into(), + )) + .ok_or_else(|| { + Diagnostics::new("operation id is not defined for path") + .help(format!( + "Try to define it in #[fastapi::path(operation_id = {})]", + &fn_name + )) + .help("Did you define the #[fastapi::path(...)] over function?") + })?; + + let methods = if !self.path_attr.methods.is_empty() { + &self.path_attr.methods + } else { + &self.ext_methods + }; + if methods.is_empty() { + let diagnostics = || { + Diagnostics::new("path operation(s) is not defined for path") + .help("Did you forget to define it, e.g. #[fastapi::path(get, ...)]") + .help("Or perhaps #[fastapi::path(method(head, get), ...)]") + }; + + #[cfg(any(feature = "actix_extras", feature = "rocket_extras"))] + { + return Err(diagnostics().help( + "Did you forget to define operation path attribute macro e.g #[get(...)]", + )); + } + + #[cfg(not(any(feature = "actix_extras", feature = "rocket_extras")))] + return Err(diagnostics()); + } + + let method_operations = methods.iter().collect::<Array<_>>(); + + let path = self + .path_attr + .path + .as_ref() + .map(|path| path.to_token_stream()) + .or(self.path.as_ref().map(|path| path.to_token_stream())) + .ok_or_else(|| { + let diagnostics = || { + Diagnostics::new("path is not defined for #[fastapi::path(...)]").help( + r#"Did you forget to define it in #[fastapi::path(..., path = "...")]"#, + ) + }; + + #[cfg(any(feature = "actix_extras", feature = "rocket_extras"))] + { + diagnostics().help( + "Did you forget to define operation path attribute macro e.g #[get(...)]", + ) + } + + #[cfg(not(any(feature = "actix_extras", feature = "rocket_extras")))] + diagnostics() + })?; + + let path_with_context_path = self + .path_attr + .context_path + .as_ref() + .map(|context_path| { + let context_path = context_path.to_token_stream(); + let context_path_tokens = quote! { + format!("{}{}", + #context_path, + #path + ) + }; + context_path_tokens + }) + .unwrap_or_else(|| { + quote! { + String::from(#path) + } + }); + + let split_comment = self.doc_comments.as_ref().map(|comments| { + let mut split = comments.split(|comment| comment.trim().is_empty()); + let summary = split + .by_ref() + .next() + .map(|summary| summary.join("\n")) + .unwrap_or_default(); + let description = split.map(|lines| lines.join("\n")).collect::<Vec<_>>(); + + (summary, description) + }); + + let summary = self + .path_attr + .summary + .as_ref() + .map(Summary::Value) + .or_else(|| { + split_comment + .as_ref() + .map(|(summary, _)| Summary::Str(summary)) + }); + + let description = self + .path_attr + .description + .as_ref() + .map(Description::Value) + .or_else(|| { + split_comment + .as_ref() + .map(|(_, description)| Description::Vec(description)) + }); + + let operation: Operation = Operation { + deprecated: self.deprecated, + operation_id, + summary, + description, + parameters: self.path_attr.params.as_ref(), + request_body: self.path_attr.request_body.as_ref(), + responses: self.path_attr.responses.as_ref(), + security: self.path_attr.security.as_ref(), + }; + let operation = as_tokens_or_diagnostics!(&operation); + + fn to_schema_references( + mut schemas: TokenStream2, + (is_inline, component_schema): (bool, ComponentSchema), + ) -> TokenStream2 { + for reference in component_schema.schema_references { + let name = &reference.name; + let tokens = &reference.tokens; + let references = &reference.references; + + #[cfg(feature = "config")] + let should_collect_schema = (matches!( + crate::CONFIG.schema_collect, + fastapi_config::SchemaCollect::NonInlined + ) && !is_inline) + || matches!( + crate::CONFIG.schema_collect, + fastapi_config::SchemaCollect::All + ); + #[cfg(not(feature = "config"))] + let should_collect_schema = !is_inline; + if should_collect_schema { + schemas.extend(quote!( schemas.push((#name, #tokens)); )); + } + schemas.extend(quote!( #references; )); + } + + schemas + } + + let response_schemas = self + .path_attr + .responses + .iter() + .map(|response| response.get_component_schemas()) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .flatten() + .fold(TokenStream2::new(), to_schema_references); + + let schemas = self + .path_attr + .request_body + .as_ref() + .map_try(|request_body| request_body.get_component_schemas())? + .into_iter() + .flatten() + .fold(TokenStream2::new(), to_schema_references); + + let mut tags = self.path_attr.tags.clone(); + if let Some(tag) = self.path_attr.tag.as_ref() { + // if defined tag is the first before the additional tags + tags.insert(0, tag.clone()); + } + let tags_list = tags.into_iter().collect::<Array<_>>(); + + let impl_for = if let Some(impl_for) = &self.path_attr.impl_for { + Cow::Borrowed(impl_for) + } else { + let path_struct = format_path_ident(Cow::Borrowed(self.fn_ident)); + + tokens.extend(quote! { + #[allow(non_camel_case_types)] + #[doc(hidden)] + #[derive(Clone)] + pub struct #path_struct; + }); + + #[cfg(feature = "actix_extras")] + { + // Add supporting passthrough implementations only if actix-web service config + // is implemented and no impl_for has been defined + if self.path_attr.impl_for.is_none() && !self.ext_methods.is_empty() { + let fn_ident = self.fn_ident; + tokens.extend(quote! { + impl ::actix_web::dev::HttpServiceFactory for #path_struct { + fn register(self, __config: &mut actix_web::dev::AppService) { + ::actix_web::dev::HttpServiceFactory::register(#fn_ident, __config); + } + } + impl<'t> fastapi::__dev::Tags<'t> for #fn_ident { + fn tags() -> Vec<&'t str> { + #path_struct::tags() + } + } + impl fastapi::Path for #fn_ident { + fn path() -> String { + #path_struct::path() + } + + fn methods() -> Vec<fastapi::openapi::path::HttpMethod> { + #path_struct::methods() + } + + fn operation() -> fastapi::openapi::path::Operation { + #path_struct::operation() + } + } + + impl fastapi::__dev::SchemaReferences for #fn_ident { + fn schemas(schemas: &mut Vec<(String, fastapi::openapi::RefOr<fastapi::openapi::schema::Schema>)>) { + <#path_struct as fastapi::__dev::SchemaReferences>::schemas(schemas); + } + } + }) + } + } + + path_struct + }; + + tokens.extend(quote! { + impl<'t> fastapi::__dev::Tags<'t> for #impl_for { + fn tags() -> Vec<&'t str> { + #tags_list.into() + } + } + impl fastapi::Path for #impl_for { + fn path() -> String { + #path_with_context_path + } + + fn methods() -> Vec<fastapi::openapi::path::HttpMethod> { + #method_operations.into() + } + + fn operation() -> fastapi::openapi::path::Operation { + use fastapi::openapi::ToArray; + use std::iter::FromIterator; + #operation.into() + } + } + + impl fastapi::__dev::SchemaReferences for #impl_for { + fn schemas(schemas: &mut Vec<(String, fastapi::openapi::RefOr<fastapi::openapi::schema::Schema>)>) { + #schemas + #response_schemas + } + } + + }); + + Ok(()) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct Operation<'a> { + operation_id: Expr, + summary: Option<Summary<'a>>, + description: Option<Description<'a>>, + deprecated: bool, + parameters: &'a Vec<Parameter<'a>>, + request_body: Option<&'a RequestBodyAttr<'a>>, + responses: &'a Vec<Response<'a>>, + security: Option<&'a Array<'a, SecurityRequirementsAttr>>, +} + +impl ToTokensDiagnostics for Operation<'_> { + fn to_tokens(&self, tokens: &mut TokenStream2) -> Result<(), Diagnostics> { + tokens.extend(quote! { fastapi::openapi::path::OperationBuilder::new() }); + + if let Some(request_body) = self.request_body { + let request_body = as_tokens_or_diagnostics!(request_body); + tokens.extend(quote! { + .request_body(Some(#request_body)) + }) + } + + let responses = Responses(self.responses); + let responses = as_tokens_or_diagnostics!(&responses); + tokens.extend(quote! { + .responses(#responses) + }); + if let Some(security_requirements) = self.security { + tokens.extend(quote! { + .securities(Some(#security_requirements)) + }) + } + let operation_id = &self.operation_id; + tokens.extend(quote_spanned! { operation_id.span() => + .operation_id(Some(#operation_id)) + }); + + if self.deprecated { + let deprecated: Deprecated = self.deprecated.into(); + tokens.extend(quote!( .deprecated(Some(#deprecated)))) + } + + if let Some(summary) = &self.summary { + summary.to_tokens(tokens); + } + + if let Some(description) = &self.description { + description.to_tokens(tokens); + } + + for parameter in self.parameters { + parameter.to_tokens(tokens)?; + } + + Ok(()) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum Description<'a> { + Value(&'a parse_utils::LitStrOrExpr), + Vec(&'a [String]), +} + +impl ToTokens for Description<'_> { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + Self::Value(value) => tokens.extend(quote! { + .description(Some(#value)) + }), + Self::Vec(vec) => { + let description = vec.join("\n\n"); + + if !description.is_empty() { + tokens.extend(quote! { + .description(Some(#description)) + }) + } + } + } + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum Summary<'a> { + Value(&'a parse_utils::LitStrOrExpr), + Str(&'a str), +} + +impl ToTokens for Summary<'_> { + fn to_tokens(&self, tokens: &mut TokenStream2) { + match self { + Self::Value(value) => tokens.extend(quote! { + .summary(Some(#value)) + }), + Self::Str(str) if !str.is_empty() => tokens.extend(quote! { + .summary(Some(#str)) + }), + _ => (), + } + } +} + +pub trait PathTypeTree { + /// Resolve default content type based on current [`Type`]. + fn get_default_content_type(&self) -> Cow<'static, str>; + + /// Check whether [`TypeTree`] is a Vec, slice, array or other supported array type + fn is_array(&self) -> bool; +} + +impl<'p> PathTypeTree for TypeTree<'p> { + /// Resolve default content type based on current [`Type`]. + fn get_default_content_type(&self) -> Cow<'static, str> { + if self.is_array() + && self + .children + .as_ref() + .map(|children| { + children + .iter() + .flat_map(|child| child.path.as_ref().zip(Some(child.is_option()))) + .any(|(path, nullable)| { + SchemaType { + path: Cow::Borrowed(path), + nullable, + } + .is_byte() + }) + }) + .unwrap_or(false) + { + Cow::Borrowed("application/octet-stream") + } else if self + .path + .as_ref() + .map(|path| SchemaType { + path: Cow::Borrowed(path.deref()), + nullable: self.is_option(), + }) + .map(|schema_type| schema_type.is_primitive()) + .unwrap_or(false) + { + Cow::Borrowed("text/plain") + } else { + Cow::Borrowed("application/json") + } + } + + /// Check whether [`TypeTree`] is a Vec, slice, array or other supported array type + fn is_array(&self) -> bool { + match self.generic_type { + Some(GenericType::Vec | GenericType::Set) => true, + Some(_) => self + .children + .as_ref() + .unwrap() + .iter() + .any(|child| child.is_array()), + None => false, + } + } +} + +mod parse { + use syn::parse::ParseStream; + use syn::punctuated::Punctuated; + use syn::token::Comma; + use syn::Result; + + use crate::path::example::Example; + use crate::{parse_utils, AnyValue}; + + #[inline] + pub(super) fn description(input: ParseStream) -> Result<parse_utils::LitStrOrExpr> { + parse_utils::parse_next_literal_str_or_expr(input) + } + + #[inline] + pub(super) fn example(input: ParseStream) -> Result<AnyValue> { + parse_utils::parse_next(input, || AnyValue::parse_lit_str_or_json(input)) + } + + #[inline] + pub(super) fn examples(input: ParseStream) -> Result<Punctuated<Example, Comma>> { + parse_utils::parse_comma_separated_within_parenthesis(input) + } +} diff --git a/fastapi-gen/src/path/example.rs b/fastapi-gen/src/path/example.rs new file mode 100644 index 0000000..a9d3891 --- /dev/null +++ b/fastapi-gen/src/path/example.rs @@ -0,0 +1,106 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, ToTokens}; +use syn::parse::{Parse, ParseStream}; +use syn::token::Comma; +use syn::{parenthesized, Error, LitStr, Token}; + +use crate::{parse_utils, AnyValue}; + +// (name = (summary = "...", description = "...", value = "..", external_value = "...")) +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Example { + pub(super) name: String, + pub(super) summary: Option<String>, + pub(super) description: Option<String>, + pub(super) value: Option<AnyValue>, + pub(super) external_value: Option<String>, +} + +impl Parse for Example { + fn parse(input: ParseStream) -> syn::Result<Self> { + let example_stream; + parenthesized!(example_stream in input); + let mut example = Example { + name: example_stream.parse::<LitStr>()?.value(), + ..Default::default() + }; + example_stream.parse::<Token![=]>()?; + + let content; + parenthesized!(content in example_stream); + + while !content.is_empty() { + let ident = content.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + match attribute_name { + "summary" => { + example.summary = Some( + parse_utils::parse_next(&content, || content.parse::<LitStr>())? + .value(), + ) + } + "description" => { + example.description = Some( + parse_utils::parse_next(&content, || content.parse::<LitStr>())? + .value(), + ) + } + "value" => { + example.value = Some(parse_utils::parse_next(&content, || { + AnyValue::parse_json(&content) + })?) + } + "external_value" => { + example.external_value = Some( + parse_utils::parse_next(&content, || content.parse::<LitStr>())? + .value(), + ) + } + _ => { + return Err( + Error::new( + ident.span(), + format!("unexpected attribute: {attribute_name}, expected one of: summary, description, value, external_value") + ) + ) + } + } + + if !content.is_empty() { + content.parse::<Comma>()?; + } + } + + Ok(example) + } +} + +impl ToTokens for Example { + fn to_tokens(&self, tokens: &mut TokenStream) { + let summary = self + .summary + .as_ref() + .map(|summary| quote!(.summary(#summary))); + let description = self + .description + .as_ref() + .map(|description| quote!(.description(#description))); + let value = self + .value + .as_ref() + .map(|value| quote!(.value(Some(#value)))); + let external_value = self + .external_value + .as_ref() + .map(|external_value| quote!(.external_value(#external_value))); + + tokens.extend(quote! { + fastapi::openapi::example::ExampleBuilder::new() + #summary + #description + #value + #external_value + }) + } +} diff --git a/fastapi-gen/src/path/handler.rs b/fastapi-gen/src/path/handler.rs new file mode 100644 index 0000000..30fbd1a --- /dev/null +++ b/fastapi-gen/src/path/handler.rs @@ -0,0 +1,24 @@ +use quote::quote; +use syn::ItemFn; + +use crate::{as_tokens_or_diagnostics, ToTokensDiagnostics}; + +use super::Path; + +pub struct Handler<'p> { + pub path: Path<'p>, + pub handler_fn: &'p ItemFn, +} + +impl<'p> ToTokensDiagnostics for Handler<'p> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), crate::Diagnostics> { + let ast_fn = &self.handler_fn; + let path = as_tokens_or_diagnostics!(&self.path); + tokens.extend(quote! { + #path + #ast_fn + }); + + Ok(()) + } +} diff --git a/fastapi-gen/src/path/media_type.rs b/fastapi-gen/src/path/media_type.rs new file mode 100644 index 0000000..d913775 --- /dev/null +++ b/fastapi-gen/src/path/media_type.rs @@ -0,0 +1,402 @@ +use std::borrow::Cow; + +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::parse::{Parse, ParseStream}; +use syn::punctuated::Punctuated; +use syn::token::{Comma, Paren}; +use syn::{Error, Generics, Ident, Token, Type}; + +use crate::component::features::attributes::Inline; +use crate::component::features::Feature; +use crate::component::{ComponentSchema, ComponentSchemaProps, Container, TypeTree, ValueType}; +use crate::ext::ExtSchema; +use crate::{parse_utils, AnyValue, Array, Diagnostics, ToTokensDiagnostics}; + +use super::example::Example; +use super::PathTypeTree; + +/// Parse OpenAPI Media Type object params +/// ( Schema ) +/// ( Schema = "content/type" ) +/// ( "content/type", ), +/// ( "content/type", example = ..., examples(..., ...), encoding(...) ) +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct MediaTypeAttr<'m> { + pub content_type: Option<parse_utils::LitStrOrExpr>, // if none, true guess + pub schema: Schema<'m>, + pub example: Option<AnyValue>, + pub examples: Punctuated<Example, Comma>, + // econding: String, // TODO parse encoding +} + +impl Parse for MediaTypeAttr<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let mut media_type = MediaTypeAttr::default(); + + let fork = input.fork(); + let is_schema = fork.parse::<DefaultSchema>().is_ok(); + if is_schema { + let schema = input.parse::<DefaultSchema>()?; + + let content_type = if input.parse::<Option<Token![=]>>()?.is_some() { + Some( + input + .parse::<parse_utils::LitStrOrExpr>() + .map_err(|error| { + Error::new( + error.span(), + format!( + r#"missing content type e.g. `"application/json"`, {error}"# + ), + ) + })?, + ) + } else { + None + }; + media_type.schema = Schema::Default(schema); + media_type.content_type = content_type; + } else { + // if schema, the content type is required + let content_type = input + .parse::<parse_utils::LitStrOrExpr>() + .map_err(|error| { + Error::new( + error.span(), + format!("unexpected content, should be `schema`, `schema = content_type` or `content_type`, {error}"), + ) + })?; + media_type.content_type = Some(content_type); + } + + if !input.is_empty() { + input.parse::<Comma>()?; + } + + while !input.is_empty() { + let attribute = input.parse::<Ident>()?; + MediaTypeAttr::parse_named_attributes(&mut media_type, input, &attribute)?; + } + + Ok(media_type) + } +} + +impl<'m> MediaTypeAttr<'m> { + pub fn parse_schema(input: ParseStream) -> syn::Result<DefaultSchema<'m>> { + input.parse() + } + + pub fn parse_named_attributes( + media_type: &mut MediaTypeAttr, + input: ParseStream, + attribute: &Ident, + ) -> syn::Result<()> { + let name = &*attribute.to_string(); + + match name { + "example" => { + media_type.example = Some(parse_utils::parse_next(input, || { + AnyValue::parse_any(input) + })?) + } + "examples" => { + media_type.examples = parse_utils::parse_comma_separated_within_parenthesis(input)? + } + // // TODO implement encoding support + // "encoding" => (), + unexpected => { + return Err(syn::Error::new( + attribute.span(), + format!( + "unexpected attribute: {unexpected}, expected any of: example, examples" + ), + )) + } + } + + if !input.is_empty() { + input.parse::<Comma>()?; + } + + Ok(()) + } +} + +impl ToTokensDiagnostics for MediaTypeAttr<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let schema = &self.schema.try_to_token_stream()?; + let schema_tokens = if schema.is_empty() { + None + } else { + Some(quote! { .schema(Some(#schema)) }) + }; + let example = self + .example + .as_ref() + .map(|example| quote!( .example(Some(#example)) )); + + let examples = self + .examples + .iter() + .map(|example| { + let name = &example.name; + quote!( (#name, #example) ) + }) + .collect::<Array<TokenStream>>(); + let examples = if !examples.is_empty() { + Some(quote!( .examples_from_iter(#examples) )) + } else { + None + }; + + tokens.extend(quote! { + fastapi::openapi::content::ContentBuilder::new() + #schema_tokens + #example + #examples + .into() + }); + + Ok(()) + } +} + +pub trait MediaTypePathExt<'a> { + fn get_component_schema(&self) -> Result<Option<ComponentSchema>, Diagnostics>; +} + +#[cfg_attr(feature = "debug", derive(Debug))] +#[allow(unused)] +pub enum Schema<'a> { + Default(DefaultSchema<'a>), + Ext(ExtSchema<'a>), +} + +impl Default for Schema<'_> { + fn default() -> Self { + Self::Default(DefaultSchema::None) + } +} + +impl Schema<'_> { + pub fn get_type_tree(&self) -> Result<Option<Cow<TypeTree<'_>>>, Diagnostics> { + match self { + Self::Default(def) => def.get_type_tree(), + Self::Ext(ext) => ext.get_type_tree(), + } + } + + pub fn get_default_content_type(&self) -> Result<Cow<'static, str>, Diagnostics> { + match self { + Self::Default(def) => def.get_default_content_type(), + Self::Ext(ext) => ext.get_default_content_type(), + } + } + + pub fn get_component_schema(&self) -> Result<Option<ComponentSchema>, Diagnostics> { + match self { + Self::Default(def) => def.get_component_schema(), + Self::Ext(ext) => ext.get_component_schema(), + } + } + + pub fn is_inline(&self) -> bool { + match self { + Self::Default(def) => match def { + DefaultSchema::TypePath(parsed) => parsed.is_inline, + _ => false, + }, + Self::Ext(_) => false, + } + } +} + +impl ToTokensDiagnostics for Schema<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + match self { + Self::Default(def) => def.to_tokens(tokens)?, + Self::Ext(ext) => ext.to_tokens(tokens)?, + } + + Ok(()) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Default)] +pub enum DefaultSchema<'d> { + Ref(parse_utils::LitStrOrExpr), + TypePath(ParsedType<'d>), + /// for cases where the schema is irrelevant but we just want to return generic + /// `content_type` without actual schema. + #[default] + None, + /// Support for raw tokens as Schema. Used in response derive. + Raw { + tokens: TokenStream, + ty: Cow<'d, Type>, + }, +} + +impl ToTokensDiagnostics for DefaultSchema<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + match self { + Self::Ref(reference) => tokens.extend(quote! { + fastapi::openapi::schema::Ref::new(#reference) + }), + Self::TypePath(parsed) => { + let is_inline = parsed.is_inline; + let type_tree = &parsed.to_type_tree()?; + + let component_tokens = ComponentSchema::new(ComponentSchemaProps { + type_tree, + features: vec![Inline::from(is_inline).into()], + description: None, + container: &Container { + generics: &Generics::default(), + }, + })? + .to_token_stream(); + + component_tokens.to_tokens(tokens); + } + Self::Raw { + tokens: raw_tokens, .. + } => { + raw_tokens.to_tokens(tokens); + } + // nada + Self::None => (), + } + + Ok(()) + } +} + +impl<'a> MediaTypePathExt<'a> for TypeTree<'a> { + fn get_component_schema(&self) -> Result<Option<ComponentSchema>, Diagnostics> { + let generics = &if matches!(self.value_type, ValueType::Tuple) { + Generics::default() + } else { + self.get_path_generics()? + }; + + let component_schema = ComponentSchema::new(ComponentSchemaProps { + container: &Container { generics }, + type_tree: self, + description: None, + // get the actual schema, not the reference + features: vec![Feature::Inline(true.into())], + })?; + + Ok(Some(component_schema)) + } +} + +impl DefaultSchema<'_> { + pub fn get_default_content_type(&self) -> Result<Cow<'static, str>, Diagnostics> { + match self { + Self::TypePath(path) => { + let type_tree = path.to_type_tree()?; + Ok(type_tree.get_default_content_type()) + } + Self::Ref(_) => Ok(Cow::Borrowed("application/json")), + Self::Raw { ty, .. } => { + let type_tree = TypeTree::from_type(ty.as_ref())?; + Ok(type_tree.get_default_content_type()) + } + Self::None => Ok(Cow::Borrowed("")), + } + } + + pub fn get_component_schema(&self) -> Result<Option<ComponentSchema>, Diagnostics> { + match self { + Self::TypePath(path) => { + let type_tree = path.to_type_tree()?; + let v = type_tree.get_component_schema()?; + + Ok(v) + } + _ => Ok(None), + } + } + + pub fn get_type_tree(&self) -> Result<Option<Cow<'_, TypeTree<'_>>>, Diagnostics> { + match self { + Self::TypePath(path) => path + .to_type_tree() + .map(|type_tree| Some(Cow::Owned(type_tree))), + _ => Ok(None), + } + } +} + +impl Parse for DefaultSchema<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let fork = input.fork(); + let is_ref = if (fork.parse::<Option<Token![ref]>>()?).is_some() { + fork.peek(Paren) + } else { + false + }; + + if is_ref { + input.parse::<Token![ref]>()?; + let ref_stream; + syn::parenthesized!(ref_stream in input); + + ref_stream.parse().map(Self::Ref) + } else { + input.parse().map(Self::TypePath) + } + } +} + +impl<'r> From<ParsedType<'r>> for Schema<'r> { + fn from(value: ParsedType<'r>) -> Self { + Self::Default(DefaultSchema::TypePath(value)) + } +} + +// inline(syn::TypePath) | syn::TypePath +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ParsedType<'i> { + pub ty: Cow<'i, Type>, + pub is_inline: bool, +} + +impl ParsedType<'_> { + /// Get's the underlying [`syn::Type`] as [`TypeTree`]. + fn to_type_tree(&self) -> Result<TypeTree, Diagnostics> { + TypeTree::from_type(&self.ty) + } +} + +impl Parse for ParsedType<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let fork = input.fork(); + let is_inline = if let Some(ident) = fork.parse::<Option<syn::Ident>>()? { + ident == "inline" && fork.peek(Paren) + } else { + false + }; + + let ty = if is_inline { + input.parse::<syn::Ident>()?; + let inlined; + syn::parenthesized!(inlined in input); + + inlined.parse::<Type>()? + } else { + input.parse::<Type>()? + }; + + Ok(ParsedType { + ty: Cow::Owned(ty), + is_inline, + }) + } +} diff --git a/fastapi-gen/src/path/parameter.rs b/fastapi-gen/src/path/parameter.rs new file mode 100644 index 0000000..329021d --- /dev/null +++ b/fastapi-gen/src/path/parameter.rs @@ -0,0 +1,546 @@ +use std::{borrow::Cow, fmt::Display}; + +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, quote_spanned, ToTokens}; +use syn::{ + parenthesized, + parse::{Parse, ParseBuffer, ParseStream}, + Error, Generics, LitStr, Token, TypePath, +}; + +use crate::{ + as_tokens_or_diagnostics, + component::{ + self, + features::{ + attributes::{ + AllowReserved, Description, Example, Explode, Format, Nullable, ReadOnly, Style, + WriteOnly, XmlAttr, + }, + impl_into_inner, parse_features, + validation::{ + ExclusiveMaximum, ExclusiveMinimum, MaxItems, MaxLength, Maximum, MinItems, + MinLength, Minimum, MultipleOf, Pattern, + }, + Feature, ToTokensExt, + }, + ComponentSchema, Container, TypeTree, + }, + parse_utils, Diagnostics, Required, ToTokensDiagnostics, +}; + +use super::media_type::ParsedType; + +/// Parameter of request such as in path, header, query or cookie +/// +/// For example path `/users/{id}` the path parameter is used to define +/// type, format and other details of the `{id}` parameter within the path +/// +/// Parse is executed for following formats: +/// +/// * ("id" = String, path, deprecated, description = "Users database id"), +/// * ("id", path, deprecated, description = "Users database id"), +/// +/// The `= String` type statement is optional if automatic resolution is supported. +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(PartialEq, Eq)] +pub enum Parameter<'a> { + Value(ValueParameter<'a>), + /// Identifier for a struct that implements `IntoParams` trait. + IntoParamsIdent(IntoParamsIdentParameter<'a>), +} + +#[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" +))] +impl<'p> Parameter<'p> { + pub fn merge(&mut self, other: Parameter<'p>) { + match (self, other) { + (Self::Value(value), Parameter::Value(other)) => { + let (schema_features, _) = &value.features; + // if value parameter schema has not been defined use the external one + if value.parameter_schema.is_none() { + value.parameter_schema = other.parameter_schema; + } + + if let Some(parameter_schema) = &mut value.parameter_schema { + parameter_schema.features.clone_from(schema_features); + } + } + (Self::IntoParamsIdent(into_params), Parameter::IntoParamsIdent(other)) => { + *into_params = other; + } + _ => (), + } + } +} + +impl Parse for Parameter<'_> { + fn parse(input: ParseStream) -> syn::Result<Self> { + if input.fork().parse::<TypePath>().is_ok() { + Ok(Self::IntoParamsIdent(IntoParamsIdentParameter { + path: Cow::Owned(input.parse::<TypePath>()?.path), + parameter_in_fn: None, + })) + } else { + Ok(Self::Value(input.parse()?)) + } + } +} + +impl ToTokensDiagnostics for Parameter<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + match self { + Parameter::Value(parameter) => { + let parameter = as_tokens_or_diagnostics!(parameter); + tokens.extend(quote! { .parameter(#parameter) }); + } + Parameter::IntoParamsIdent(IntoParamsIdentParameter { + path, + parameter_in_fn, + }) => { + let last_ident = &path.segments.last().unwrap().ident; + + let default_parameter_in_provider = "e! { || None }; + let parameter_in_provider = parameter_in_fn + .as_ref() + .unwrap_or(default_parameter_in_provider); + tokens.extend(quote_spanned! {last_ident.span()=> + .parameters( + Some(<#path as fastapi::IntoParams>::into_params(#parameter_in_provider)) + ) + }) + } + } + + Ok(()) + } +} + +#[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" +))] +impl<'a> From<crate::ext::ValueArgument<'a>> for Parameter<'a> { + fn from(argument: crate::ext::ValueArgument<'a>) -> Self { + Self::Value(ValueParameter { + name: argument.name.unwrap_or_else(|| Cow::Owned(String::new())), + parameter_in: if argument.argument_in == crate::ext::ArgumentIn::Path { + ParameterIn::Path + } else { + ParameterIn::Query + }, + parameter_schema: argument.type_tree.map(|type_tree| ParameterSchema { + parameter_type: ParameterType::External(type_tree), + features: Vec::new(), + }), + ..Default::default() + }) + } +} + +#[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" +))] +impl<'a> From<crate::ext::IntoParamsType<'a>> for Parameter<'a> { + fn from(value: crate::ext::IntoParamsType<'a>) -> Self { + Self::IntoParamsIdent(IntoParamsIdentParameter { + path: value.type_path.expect("IntoParams type must have a path"), + parameter_in_fn: Some(value.parameter_in_provider), + }) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct ParameterSchema<'p> { + parameter_type: ParameterType<'p>, + features: Vec<Feature>, +} + +impl ToTokensDiagnostics for ParameterSchema<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + let mut to_tokens = |param_schema, required| { + tokens.extend(quote! { .schema(Some(#param_schema)).required(#required) }); + }; + + match &self.parameter_type { + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + ParameterType::External(type_tree) => { + let required: Required = (!type_tree.is_option()).into(); + + to_tokens( + ComponentSchema::new(component::ComponentSchemaProps { + type_tree, + features: self.features.clone(), + description: None, + container: &Container { + generics: &Generics::default(), + }, + })? + .to_token_stream(), + required, + ); + Ok(()) + } + ParameterType::Parsed(inline_type) => { + let type_tree = TypeTree::from_type(inline_type.ty.as_ref())?; + let required: Required = (!type_tree.is_option()).into(); + let mut schema_features = Vec::<Feature>::new(); + schema_features.clone_from(&self.features); + schema_features.push(Feature::Inline(inline_type.is_inline.into())); + + to_tokens( + ComponentSchema::new(component::ComponentSchemaProps { + type_tree: &type_tree, + features: schema_features, + description: None, + container: &Container { + generics: &Generics::default(), + }, + })? + .to_token_stream(), + required, + ); + Ok(()) + } + } + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum ParameterType<'p> { + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + External(crate::component::TypeTree<'p>), + Parsed(ParsedType<'p>), +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ValueParameter<'a> { + pub name: Cow<'a, str>, + parameter_in: ParameterIn, + parameter_schema: Option<ParameterSchema<'a>>, + features: (Vec<Feature>, Vec<Feature>), +} + +impl PartialEq for ValueParameter<'_> { + fn eq(&self, other: &Self) -> bool { + self.name == other.name && self.parameter_in == other.parameter_in + } +} + +impl Eq for ValueParameter<'_> {} + +impl Parse for ValueParameter<'_> { + fn parse(input_with_parens: ParseStream) -> syn::Result<Self> { + let input: ParseBuffer; + parenthesized!(input in input_with_parens); + + let mut parameter = ValueParameter::default(); + + if input.peek(LitStr) { + // parse name + let name = input.parse::<LitStr>()?.value(); + parameter.name = Cow::Owned(name); + + if input.peek(Token![=]) { + parameter.parameter_schema = Some(ParameterSchema { + parameter_type: ParameterType::Parsed(parse_utils::parse_next(&input, || { + input.parse().map_err(|error| { + Error::new( + error.span(), + format!("unexpected token, expected type such as String, {error}"), + ) + }) + })?), + features: Vec::new(), + }); + } + } else { + return Err(input.error("unparsable parameter name, expected literal string")); + } + + input.parse::<Token![,]>()?; + + if input.fork().parse::<ParameterIn>().is_ok() { + parameter.parameter_in = input.parse()?; + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + let (schema_features, parameter_features) = input + .parse::<ParameterFeatures>()? + .split_for_parameter_type(); + + parameter.features = (schema_features.clone(), parameter_features); + if let Some(parameter_schema) = &mut parameter.parameter_schema { + parameter_schema.features = schema_features; + } + + Ok(parameter) + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct ParameterFeatures(Vec<Feature>); + +impl Parse for ParameterFeatures { + fn parse(input: ParseStream) -> syn::Result<Self> { + Ok(Self(parse_features!( + // param features + input as Style, + Explode, + AllowReserved, + Example, + crate::component::features::attributes::Deprecated, + Description, + // param schema features + Format, + WriteOnly, + ReadOnly, + Nullable, + XmlAttr, + MultipleOf, + Maximum, + Minimum, + ExclusiveMaximum, + ExclusiveMinimum, + MaxLength, + MinLength, + Pattern, + MaxItems, + MinItems + ))) + } +} + +impl ParameterFeatures { + /// Split parsed features to two `Vec`s of [`Feature`]s. + /// + /// * First vec contains parameter type schema features. + /// * Second vec contains generic parameter features. + fn split_for_parameter_type(self) -> (Vec<Feature>, Vec<Feature>) { + self.0.into_iter().fold( + (Vec::new(), Vec::new()), + |(mut schema_features, mut param_features), feature| { + match feature { + Feature::Format(_) + | Feature::WriteOnly(_) + | Feature::ReadOnly(_) + | Feature::Nullable(_) + | Feature::XmlAttr(_) + | Feature::MultipleOf(_) + | Feature::Maximum(_) + | Feature::Minimum(_) + | Feature::ExclusiveMaximum(_) + | Feature::ExclusiveMinimum(_) + | Feature::MaxLength(_) + | Feature::MinLength(_) + | Feature::Pattern(_) + | Feature::MaxItems(_) + | Feature::MinItems(_) => { + schema_features.push(feature); + } + _ => { + param_features.push(feature); + } + }; + + (schema_features, param_features) + }, + ) + } +} + +impl_into_inner!(ParameterFeatures); + +impl ToTokensDiagnostics for ValueParameter<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + let name = &*self.name; + tokens.extend(quote! { + fastapi::openapi::path::ParameterBuilder::from(fastapi::openapi::path::Parameter::new(#name)) + }); + let parameter_in = &self.parameter_in; + tokens.extend(quote! { .parameter_in(#parameter_in) }); + + let (schema_features, param_features) = &self.features; + + tokens.extend(param_features.to_token_stream()?); + + if !schema_features.is_empty() && self.parameter_schema.is_none() { + return Err( + Diagnostics::new("Missing `parameter_type` attribute, cannot define schema features without it.") + .help("See docs for more details <https://docs.rs/fastapi/latest/fastapi/attr.path.html#parameter-type-attributes>") + ); + } + + if let Some(parameter_schema) = &self.parameter_schema { + parameter_schema.to_tokens(tokens)?; + } + + Ok(()) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct IntoParamsIdentParameter<'i> { + pub path: Cow<'i, syn::Path>, + /// quote!{ ... } of function which should implement `parameter_in_provider` for [`fastapi::IntoParams::into_param`] + parameter_in_fn: Option<TokenStream>, +} + +// Compare paths loosely only by segment idents ignoring possible generics +impl PartialEq for IntoParamsIdentParameter<'_> { + fn eq(&self, other: &Self) -> bool { + self.path + .segments + .iter() + .map(|segment| &segment.ident) + .collect::<Vec<_>>() + == other + .path + .segments + .iter() + .map(|segment| &segment.ident) + .collect::<Vec<_>>() + } +} + +impl Eq for IntoParamsIdentParameter<'_> {} + +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum ParameterIn { + Query, + Path, + Header, + Cookie, +} + +impl ParameterIn { + pub const VARIANTS: &'static [Self] = &[Self::Query, Self::Path, Self::Header, Self::Cookie]; +} + +impl Display for ParameterIn { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ParameterIn::Query => write!(f, "Query"), + ParameterIn::Path => write!(f, "Path"), + ParameterIn::Header => write!(f, "Header"), + ParameterIn::Cookie => write!(f, "Cookie"), + } + } +} + +impl Default for ParameterIn { + fn default() -> Self { + Self::Path + } +} + +impl Parse for ParameterIn { + fn parse(input: ParseStream) -> syn::Result<Self> { + fn expected_style() -> String { + let variants: String = ParameterIn::VARIANTS + .iter() + .map(ToString::to_string) + .collect::<Vec<_>>() + .join(", "); + format!("unexpected in, expected one of: {variants}") + } + let style = input.parse::<Ident>()?; + + match &*style.to_string() { + "Path" => Ok(Self::Path), + "Query" => Ok(Self::Query), + "Header" => Ok(Self::Header), + "Cookie" => Ok(Self::Cookie), + _ => Err(Error::new(style.span(), expected_style())), + } + } +} + +impl ToTokens for ParameterIn { + fn to_tokens(&self, tokens: &mut TokenStream) { + tokens.extend(match self { + Self::Path => quote! { fastapi::openapi::path::ParameterIn::Path }, + Self::Query => quote! { fastapi::openapi::path::ParameterIn::Query }, + Self::Header => quote! { fastapi::openapi::path::ParameterIn::Header }, + Self::Cookie => quote! { fastapi::openapi::path::ParameterIn::Cookie }, + }) + } +} + +/// See definitions from `fastapi` crate path.rs +#[derive(Copy, Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum ParameterStyle { + Matrix, + Label, + Form, + Simple, + SpaceDelimited, + PipeDelimited, + DeepObject, +} + +impl Parse for ParameterStyle { + fn parse(input: ParseStream) -> syn::Result<Self> { + const EXPECTED_STYLE: &str = "unexpected style, expected one of: Matrix, Label, Form, Simple, SpaceDelimited, PipeDelimited, DeepObject"; + let style = input.parse::<Ident>()?; + + match &*style.to_string() { + "Matrix" => Ok(ParameterStyle::Matrix), + "Label" => Ok(ParameterStyle::Label), + "Form" => Ok(ParameterStyle::Form), + "Simple" => Ok(ParameterStyle::Simple), + "SpaceDelimited" => Ok(ParameterStyle::SpaceDelimited), + "PipeDelimited" => Ok(ParameterStyle::PipeDelimited), + "DeepObject" => Ok(ParameterStyle::DeepObject), + _ => Err(Error::new(style.span(), EXPECTED_STYLE)), + } + } +} + +impl ToTokens for ParameterStyle { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + ParameterStyle::Matrix => { + tokens.extend(quote! { fastapi::openapi::path::ParameterStyle::Matrix }) + } + ParameterStyle::Label => { + tokens.extend(quote! { fastapi::openapi::path::ParameterStyle::Label }) + } + ParameterStyle::Form => { + tokens.extend(quote! { fastapi::openapi::path::ParameterStyle::Form }) + } + ParameterStyle::Simple => { + tokens.extend(quote! { fastapi::openapi::path::ParameterStyle::Simple }) + } + ParameterStyle::SpaceDelimited => { + tokens.extend(quote! { fastapi::openapi::path::ParameterStyle::SpaceDelimited }) + } + ParameterStyle::PipeDelimited => { + tokens.extend(quote! { fastapi::openapi::path::ParameterStyle::PipeDelimited }) + } + ParameterStyle::DeepObject => { + tokens.extend(quote! { fastapi::openapi::path::ParameterStyle::DeepObject }) + } + } + } +} diff --git a/fastapi-gen/src/path/request_body.rs b/fastapi-gen/src/path/request_body.rs new file mode 100644 index 0000000..603f93f --- /dev/null +++ b/fastapi-gen/src/path/request_body.rs @@ -0,0 +1,270 @@ +use proc_macro2::{Ident, TokenStream}; +use quote::{quote, ToTokens}; +use syn::parse::ParseStream; +use syn::punctuated::Punctuated; +use syn::token::Paren; +use syn::{parse::Parse, Error, Token}; + +use crate::component::ComponentSchema; +use crate::{parse_utils, Diagnostics, Required, ToTokensDiagnostics}; + +use super::media_type::{MediaTypeAttr, Schema}; +use super::parse; + +/// Parsed information related to request body of path. +/// +/// Supported configuration options: +/// * **content** Request body content object type. Can also be array e.g. `content = [String]`. +/// * **content_type** Defines the actual content mime type of a request body such as `application/json`. +/// If not provided really rough guess logic is used. Basically all primitive types are treated as `text/plain` +/// and Object types are expected to be `application/json` by default. +/// * **description** Additional description for request body content type. +/// # Examples +/// +/// Request body in path with all supported info. Where content type is treated as a String and expected +/// to be xml. +/// ```text +/// #[fastapi::path( +/// request_body(content = String, description = "foobar", content_type = "text/xml"), +/// )] +/// +/// It is also possible to provide the request body type simply by providing only the content object type. +/// ```text +/// #[fastapi::path( +/// request_body = Foo, +/// )] +/// ``` +/// +/// Or the request body content can also be an array as well by surrounding it with brackets `[..]`. +/// ```text +/// #[fastapi::path( +/// request_body = [Foo], +/// )] +/// ``` +/// +/// To define optional request body just wrap the type in `Option<type>`. +/// ```text +/// #[fastapi::path( +/// request_body = Option<[Foo]>, +/// )] +/// ``` +/// +/// request_body( +/// description = "This is request body", +/// content_type = "content/type", +/// content = Schema, +/// example = ..., +/// examples(..., ...), +/// encoding(...) +/// ) +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct RequestBodyAttr<'r> { + description: Option<parse_utils::LitStrOrExpr>, + content: Vec<MediaTypeAttr<'r>>, +} + +impl<'r> RequestBodyAttr<'r> { + fn new() -> Self { + Self { + description: Default::default(), + content: vec![MediaTypeAttr::default()], + } + } + + #[cfg(any( + feature = "actix_extras", + feature = "rocket_extras", + feature = "axum_extras" + ))] + pub fn from_schema(schema: Schema<'r>) -> RequestBodyAttr<'r> { + Self { + content: vec![MediaTypeAttr { + schema, + ..Default::default() + }], + ..Self::new() + } + } + + pub fn get_component_schemas( + &self, + ) -> Result<impl Iterator<Item = (bool, ComponentSchema)>, Diagnostics> { + Ok(self + .content + .iter() + .map( + |media_type| match media_type.schema.get_component_schema() { + Ok(component_schema) => { + Ok(Some(media_type.schema.is_inline()).zip(component_schema)) + } + Err(error) => Err(error), + }, + ) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .flatten()) + } +} + +impl Parse for RequestBodyAttr<'_> { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + const EXPECTED_ATTRIBUTE_MESSAGE: &str = + "unexpected attribute, expected any of: content, content_type, description, examples, example"; + let lookahead = input.lookahead1(); + + if lookahead.peek(Paren) { + let group; + syn::parenthesized!(group in input); + + let mut is_content_group = false; + let mut request_body_attr = RequestBodyAttr::new(); + while !group.is_empty() { + let ident = group + .parse::<Ident>() + .map_err(|error| Error::new(error.span(), EXPECTED_ATTRIBUTE_MESSAGE))?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "content" => { + if group.peek(Token![=]) { + group.parse::<Token![=]>()?; + let schema = MediaTypeAttr::parse_schema(&group)?; + if let Some(media_type) = request_body_attr.content.get_mut(0) { + media_type.schema = Schema::Default(schema); + } + } else if group.peek(Paren) { + is_content_group = true; + fn group_parser<'a>( + input: ParseStream, + ) -> syn::Result<MediaTypeAttr<'a>> { + let buf; + syn::parenthesized!(buf in input); + buf.call(MediaTypeAttr::parse) + } + + let media_type = + parse_utils::parse_comma_separated_within_parethesis_with( + &group, + group_parser, + )? + .into_iter() + .collect::<Vec<_>>(); + + request_body_attr.content = media_type; + } else { + return Err(Error::new(ident.span(), "unexpected content format, expected either `content = schema` or `content(...)`")); + } + } + "content_type" => { + if is_content_group { + return Err(Error::new(ident.span(), "cannot set `content_type` when content(...) is defined in group form")); + } + let content_type = parse_utils::parse_next(&group, || { + parse_utils::LitStrOrExpr::parse(&group) + }).map_err(|error| Error::new(error.span(), + format!(r#"invalid content_type, must be literal string or expression, e.g. "application/json", {error} "#) + ))?; + + if let Some(media_type) = request_body_attr.content.get_mut(0) { + media_type.content_type = Some(content_type); + } + } + "description" => { + request_body_attr.description = Some(parse::description(&group)?); + } + _ => { + MediaTypeAttr::parse_named_attributes( + request_body_attr + .content + .get_mut(0) + .expect("parse request body named attributes must have media type"), + &group, + &ident, + )?; + } + } + + if !group.is_empty() { + group.parse::<Token![,]>()?; + } + } + + Ok(request_body_attr) + } else if lookahead.peek(Token![=]) { + input.parse::<Token![=]>()?; + + let media_type = MediaTypeAttr { + schema: Schema::Default(MediaTypeAttr::parse_schema(input)?), + content_type: None, + example: None, + examples: Punctuated::default(), + }; + + Ok(RequestBodyAttr { + content: vec![media_type], + description: None, + }) + } else { + Err(lookahead.error()) + } + } +} + +impl ToTokensDiagnostics for RequestBodyAttr<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + let media_types = self + .content + .iter() + .map(|media_type| { + let default_content_type_result = media_type.schema.get_default_content_type(); + let type_tree = media_type.schema.get_type_tree(); + + match (default_content_type_result, type_tree) { + (Ok(content_type), Ok(type_tree)) => Ok((content_type, media_type, type_tree)), + (Err(diagnostics), _) => Err(diagnostics), + (_, Err(diagnostics)) => Err(diagnostics), + } + }) + .collect::<Result<Vec<_>, Diagnostics>>()?; + + let any_required = media_types.iter().any(|(_, _, type_tree)| { + type_tree + .as_ref() + .map(|type_tree| !type_tree.is_option()) + .unwrap_or(false) + }); + + tokens.extend(quote! { + fastapi::openapi::request_body::RequestBodyBuilder::new() + }); + for (content_type, media_type, _) in media_types { + let content_type_tokens = media_type + .content_type + .as_ref() + .map(|content_type| content_type.to_token_stream()) + .unwrap_or_else(|| content_type.to_token_stream()); + let content_tokens = media_type.try_to_token_stream()?; + + tokens.extend(quote! { + .content(#content_type_tokens, #content_tokens) + }); + } + + if any_required { + let required: Required = any_required.into(); + tokens.extend(quote! { + .required(Some(#required)) + }) + } + if let Some(ref description) = self.description { + tokens.extend(quote! { + .description(Some(#description)) + }) + } + + tokens.extend(quote! { .build() }); + + Ok(()) + } +} diff --git a/fastapi-gen/src/path/response.rs b/fastapi-gen/src/path/response.rs new file mode 100644 index 0000000..02c2cc5 --- /dev/null +++ b/fastapi-gen/src/path/response.rs @@ -0,0 +1,773 @@ +use proc_macro2::{Ident, Span, TokenStream as TokenStream2}; +use quote::{quote, quote_spanned, ToTokens}; +use std::borrow::Cow; +use syn::{ + parenthesized, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + spanned::Spanned, + token::Comma, + Attribute, Error, ExprPath, LitInt, LitStr, Token, TypePath, +}; + +use crate::{ + component::ComponentSchema, parse_utils, path::media_type::Schema, AnyValue, Diagnostics, + ToTokensDiagnostics, +}; + +use self::{header::Header, link::LinkTuple}; + +use super::{ + example::Example, + media_type::{DefaultSchema, MediaTypeAttr, ParsedType}, + parse, + status::STATUS_CODES, +}; + +pub mod derive; +mod header; +pub mod link; + +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum Response<'r> { + /// A type that implements `fastapi::IntoResponses`. + IntoResponses(Cow<'r, TypePath>), + /// The tuple definition of a response. + Tuple(ResponseTuple<'r>), +} + +impl Parse for Response<'_> { + fn parse(input: ParseStream) -> syn::Result<Self> { + if input.fork().parse::<ExprPath>().is_ok() { + Ok(Self::IntoResponses(Cow::Owned(input.parse::<TypePath>()?))) + } else { + let response; + parenthesized!(response in input); + Ok(Self::Tuple(response.parse()?)) + } + } +} + +impl Response<'_> { + pub fn get_component_schemas( + &self, + ) -> Result<impl Iterator<Item = (bool, ComponentSchema)>, Diagnostics> { + match self { + Self::Tuple(tuple) => match &tuple.inner { + // Only tuple type will have `ComponentSchema`s as of now + Some(ResponseTupleInner::Value(value)) => { + Ok(ResponseComponentSchemaIter::Iter(Box::new( + value + .content + .iter() + .map( + |media_type| match media_type.schema.get_component_schema() { + Ok(component_schema) => { + Ok(Some(media_type.schema.is_inline()) + .zip(component_schema)) + } + Err(error) => Err(error), + }, + ) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .flatten(), + ))) + } + _ => Ok(ResponseComponentSchemaIter::Empty), + }, + Self::IntoResponses(_) => Ok(ResponseComponentSchemaIter::Empty), + } + } +} + +pub enum ResponseComponentSchemaIter<'a, T> { + Iter(Box<dyn std::iter::Iterator<Item = T> + 'a>), + Empty, +} + +impl<'a, T> Iterator for ResponseComponentSchemaIter<'a, T> { + type Item = T; + + fn next(&mut self) -> Option<Self::Item> { + match self { + Self::Iter(iter) => iter.next(), + Self::Empty => None, + } + } + + fn size_hint(&self) -> (usize, Option<usize>) { + match self { + Self::Iter(iter) => iter.size_hint(), + Self::Empty => (0, None), + } + } +} + +/// Parsed representation of response attributes from `#[fastapi::path]` attribute. +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ResponseTuple<'r> { + status_code: ResponseStatus, + inner: Option<ResponseTupleInner<'r>>, +} + +const RESPONSE_INCOMPATIBLE_ATTRIBUTES_MSG: &str = + "The `response` attribute may only be used in conjunction with the `status` attribute"; + +impl<'r> ResponseTuple<'r> { + /// Set as `ResponseValue` the content. This will fail if `response` attribute is already + /// defined. + fn set_as_value<F: FnOnce(&mut ResponseValue) -> syn::Result<()>>( + &mut self, + ident: &Ident, + attribute: &str, + op: F, + ) -> syn::Result<()> { + match &mut self.inner { + Some(ResponseTupleInner::Value(value)) => { + op(value)?; + } + Some(ResponseTupleInner::Ref(_)) => { + return Err(Error::new(ident.span(), format!("Cannot use `{attribute}` in conjunction with `response`. The `response` attribute can only be used in conjunction with `status` attribute."))); + } + None => { + let mut value = ResponseValue { + content: vec![MediaTypeAttr::default()], + ..Default::default() + }; + op(&mut value)?; + self.inner = Some(ResponseTupleInner::Value(value)) + } + }; + + Ok(()) + } + + // Use with the `response` attribute, this will fail if an incompatible attribute has already been set + fn set_ref_type(&mut self, span: Span, ty: ParsedType<'r>) -> syn::Result<()> { + match &mut self.inner { + None => self.inner = Some(ResponseTupleInner::Ref(ty)), + Some(ResponseTupleInner::Ref(r)) => *r = ty, + Some(ResponseTupleInner::Value(_)) => { + return Err(Error::new(span, RESPONSE_INCOMPATIBLE_ATTRIBUTES_MSG)) + } + } + Ok(()) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +enum ResponseTupleInner<'r> { + Value(ResponseValue<'r>), + Ref(ParsedType<'r>), +} + +impl Parse for ResponseTuple<'_> { + fn parse(input: ParseStream) -> syn::Result<Self> { + const EXPECTED_ATTRIBUTES: &str = + "status, description, body, content_type, headers, example, examples, response"; + + let mut response = ResponseTuple::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>().map_err(|error| { + Error::new( + error.span(), + format!( + "unexpected attribute, expected any of: {EXPECTED_ATTRIBUTES}, {error}" + ), + ) + })?; + let name = &*ident.to_string(); + match name { + "status" => { + response.status_code = + parse_utils::parse_next(input, || input.parse::<ResponseStatus>())?; + } + "response" => { + response.set_ref_type( + input.span(), + parse_utils::parse_next(input, || input.parse())?, + )?; + } + _ => { + response.set_as_value(&ident, name, |value| { + value.parse_named_attributes(input, &ident) + })?; + } + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(response) + } +} + +impl<'r> From<ResponseValue<'r>> for ResponseTuple<'r> { + fn from(value: ResponseValue<'r>) -> Self { + ResponseTuple { + inner: Some(ResponseTupleInner::Value(value)), + ..Default::default() + } + } +} + +impl<'r> From<(ResponseStatus, ResponseValue<'r>)> for ResponseTuple<'r> { + fn from((status_code, response_value): (ResponseStatus, ResponseValue<'r>)) -> Self { + ResponseTuple { + inner: Some(ResponseTupleInner::Value(response_value)), + status_code, + } + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ResponseValue<'r> { + description: parse_utils::LitStrOrExpr, + headers: Vec<Header>, + links: Punctuated<LinkTuple, Comma>, + content: Vec<MediaTypeAttr<'r>>, + is_content_group: bool, +} + +impl Parse for ResponseValue<'_> { + fn parse(input: ParseStream) -> syn::Result<Self> { + let mut response_value = ResponseValue::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>().map_err(|error| { + Error::new( + error.span(), + format!( + "unexpected attribute, expected any of: {expected_attributes}, {error}", + expected_attributes = ResponseValue::EXPECTED_ATTRIBUTES + ), + ) + })?; + response_value.parse_named_attributes(input, &ident)?; + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(response_value) + } +} + +impl<'r> ResponseValue<'r> { + const EXPECTED_ATTRIBUTES: &'static str = + "description, body, content_type, headers, example, examples"; + + fn parse_named_attributes(&mut self, input: ParseStream, attribute: &Ident) -> syn::Result<()> { + let attribute_name = &*attribute.to_string(); + + match attribute_name { + "description" => { + self.description = parse::description(input)?; + } + "body" => { + if self.is_content_group { + return Err(Error::new( + attribute.span(), + "cannot set `body` when content(...) is defined in group form", + )); + } + + let schema = parse_utils::parse_next(input, || MediaTypeAttr::parse_schema(input))?; + if let Some(media_type) = self.content.get_mut(0) { + media_type.schema = Schema::Default(schema); + } + } + "content_type" => { + if self.is_content_group { + return Err(Error::new( + attribute.span(), + "cannot set `content_type` when content(...) is defined in group form", + )); + } + let content_type = parse_utils::parse_next(input, || { + parse_utils::LitStrOrExpr::parse(input) + }).map_err(|error| Error::new(error.span(), + format!(r#"invalid content_type, must be literal string or expression, e.g. "application/json", {error} "#) + ))?; + + if let Some(media_type) = self.content.get_mut(0) { + media_type.content_type = Some(content_type); + } + } + "headers" => { + self.headers = header::headers(input)?; + } + "content" => { + self.is_content_group = true; + fn group_parser<'a>(input: ParseStream) -> syn::Result<MediaTypeAttr<'a>> { + let buf; + syn::parenthesized!(buf in input); + buf.call(MediaTypeAttr::parse) + } + + let content = + parse_utils::parse_comma_separated_within_parethesis_with(input, group_parser)? + .into_iter() + .collect::<Vec<_>>(); + + self.content = content; + } + "links" => { + self.links = parse_utils::parse_comma_separated_within_parenthesis(input)?; + } + _ => { + MediaTypeAttr::parse_named_attributes( + self.content.get_mut(0).expect( + "parse named attributes response value must have one media type by default", + ), + input, + attribute, + )?; + } + } + Ok(()) + } + + fn from_schema<S: Into<Schema<'r>>>(schema: S, description: parse_utils::LitStrOrExpr) -> Self { + let media_type = MediaTypeAttr { + schema: schema.into(), + ..Default::default() + }; + + Self { + description, + content: vec![media_type], + ..Default::default() + } + } + + fn from_derive_to_response_value<S: Into<Schema<'r>>>( + derive_value: DeriveToResponseValue, + schema: S, + description: parse_utils::LitStrOrExpr, + ) -> Self { + let media_type = MediaTypeAttr { + content_type: derive_value.content_type, + schema: schema.into(), + example: derive_value.example.map(|(example, _)| example), + examples: derive_value + .examples + .map(|(examples, _)| examples) + .unwrap_or_default(), + }; + + Self { + description: if derive_value.description.is_empty_litstr() + && !description.is_empty_litstr() + { + description + } else { + derive_value.description + }, + headers: derive_value.headers, + content: vec![media_type], + ..Default::default() + } + } + + fn from_derive_into_responses_value<S: Into<Schema<'r>>>( + response_value: DeriveIntoResponsesValue, + schema: S, + description: parse_utils::LitStrOrExpr, + ) -> Self { + let media_type = MediaTypeAttr { + content_type: response_value.content_type, + schema: schema.into(), + example: response_value.example.map(|(example, _)| example), + examples: response_value + .examples + .map(|(examples, _)| examples) + .unwrap_or_default(), + }; + + ResponseValue { + description: if response_value.description.is_empty_litstr() + && !description.is_empty_litstr() + { + description + } else { + response_value.description + }, + headers: response_value.headers, + content: vec![media_type], + ..Default::default() + } + } +} + +impl ToTokensDiagnostics for ResponseTuple<'_> { + fn to_tokens(&self, tokens: &mut TokenStream2) -> Result<(), Diagnostics> { + match self.inner.as_ref() { + Some(ResponseTupleInner::Ref(res)) => { + let path = &res.ty; + if res.is_inline { + tokens.extend(quote_spanned! {path.span()=> + <#path as fastapi::ToResponse>::response().1 + }); + } else { + tokens.extend(quote! { + fastapi::openapi::Ref::from_response_name(<#path as fastapi::ToResponse>::response().0) + }); + } + } + Some(ResponseTupleInner::Value(value)) => { + let description = &value.description; + tokens.extend(quote! { + fastapi::openapi::ResponseBuilder::new().description(#description) + }); + + for media_type in value.content.iter().filter(|media_type| { + !(matches!(media_type.schema, Schema::Default(DefaultSchema::None)) + && media_type.content_type.is_none()) + }) { + let default_content_type = media_type.schema.get_default_content_type()?; + + let content_type_tokens = media_type + .content_type + .as_ref() + .map(|content_type| content_type.to_token_stream()) + .unwrap_or_else(|| default_content_type.to_token_stream()); + let content_tokens = media_type.try_to_token_stream()?; + + tokens.extend(quote! { + .content(#content_type_tokens, #content_tokens) + }); + } + + for header in &value.headers { + let name = &header.name; + let header = crate::as_tokens_or_diagnostics!(header); + tokens.extend(quote! { + .header(#name, #header) + }) + } + + for LinkTuple(name, link) in &value.links { + tokens.extend(quote! { + .link(#name, #link) + }) + } + + tokens.extend(quote! { .build() }); + } + None => tokens.extend(quote! { + fastapi::openapi::ResponseBuilder::new().description("") + }), + } + + Ok(()) + } +} + +trait DeriveResponseValue: Parse { + fn merge_from(self, other: Self) -> Self; + + fn from_attributes(attributes: &[Attribute]) -> Result<Option<Self>, Diagnostics> { + Ok(attributes + .iter() + .filter(|attribute| attribute.path().get_ident().unwrap() == "response") + .map(|attribute| attribute.parse_args::<Self>().map_err(Diagnostics::from)) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .reduce(|acc, item| acc.merge_from(item))) + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct DeriveToResponseValue { + content_type: Option<parse_utils::LitStrOrExpr>, + headers: Vec<Header>, + description: parse_utils::LitStrOrExpr, + example: Option<(AnyValue, Ident)>, + examples: Option<(Punctuated<Example, Comma>, Ident)>, +} + +impl DeriveResponseValue for DeriveToResponseValue { + fn merge_from(mut self, other: Self) -> Self { + if other.content_type.is_some() { + self.content_type = other.content_type; + } + if !other.headers.is_empty() { + self.headers = other.headers; + } + if !other.description.is_empty_litstr() { + self.description = other.description; + } + if other.example.is_some() { + self.example = other.example; + } + if other.examples.is_some() { + self.examples = other.examples; + } + + self + } +} + +impl Parse for DeriveToResponseValue { + fn parse(input: ParseStream) -> syn::Result<Self> { + let mut response = DeriveToResponseValue::default(); + + while !input.is_empty() { + let ident = input.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "description" => { + response.description = parse::description(input)?; + } + "content_type" => { + response.content_type = + Some(parse_utils::parse_next_literal_str_or_expr(input)?); + } + "headers" => { + response.headers = header::headers(input)?; + } + "example" => { + response.example = Some((parse::example(input)?, ident)); + } + "examples" => { + response.examples = Some((parse::examples(input)?, ident)); + } + _ => { + return Err(Error::new( + ident.span(), + format!("unexpected attribute: {attribute_name}, expected any of: inline, description, content_type, headers, example"), + )); + } + } + + if !input.is_empty() { + input.parse::<Comma>()?; + } + } + + Ok(response) + } +} + +#[derive(Default)] +struct DeriveIntoResponsesValue { + status: ResponseStatus, + content_type: Option<parse_utils::LitStrOrExpr>, + headers: Vec<Header>, + description: parse_utils::LitStrOrExpr, + example: Option<(AnyValue, Ident)>, + examples: Option<(Punctuated<Example, Comma>, Ident)>, +} + +impl DeriveResponseValue for DeriveIntoResponsesValue { + fn merge_from(mut self, other: Self) -> Self { + self.status = other.status; + + if other.content_type.is_some() { + self.content_type = other.content_type; + } + if !other.headers.is_empty() { + self.headers = other.headers; + } + if !other.description.is_empty_litstr() { + self.description = other.description; + } + if other.example.is_some() { + self.example = other.example; + } + if other.examples.is_some() { + self.examples = other.examples; + } + + self + } +} + +impl Parse for DeriveIntoResponsesValue { + fn parse(input: ParseStream) -> syn::Result<Self> { + let mut response = DeriveIntoResponsesValue::default(); + const MISSING_STATUS_ERROR: &str = "missing expected `status` attribute"; + let first_span = input.span(); + + let status_ident = input + .parse::<Ident>() + .map_err(|error| Error::new(error.span(), MISSING_STATUS_ERROR))?; + + if status_ident == "status" { + response.status = parse_utils::parse_next(input, || input.parse::<ResponseStatus>())?; + } else { + return Err(Error::new(status_ident.span(), MISSING_STATUS_ERROR)); + } + + if response.status.to_token_stream().is_empty() { + return Err(Error::new(first_span, MISSING_STATUS_ERROR)); + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + + while !input.is_empty() { + let ident = input.parse::<Ident>()?; + let attribute_name = &*ident.to_string(); + + match attribute_name { + "description" => { + response.description = parse::description(input)?; + } + "content_type" => { + response.content_type = + Some(parse_utils::parse_next_literal_str_or_expr(input)?); + } + "headers" => { + response.headers = header::headers(input)?; + } + "example" => { + response.example = Some((parse::example(input)?, ident)); + } + "examples" => { + response.examples = Some((parse::examples(input)?, ident)); + } + _ => { + return Err(Error::new( + ident.span(), + format!("unexpected attribute: {attribute_name}, expected any of: description, content_type, headers, example, examples"), + )); + } + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + } + + Ok(response) + } +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +struct ResponseStatus(TokenStream2); + +impl Parse for ResponseStatus { + fn parse(input: ParseStream) -> syn::Result<Self> { + fn parse_lit_int(input: ParseStream) -> syn::Result<Cow<'_, str>> { + input.parse::<LitInt>()?.base10_parse().map(Cow::Owned) + } + + fn parse_lit_str_status_range(input: ParseStream) -> syn::Result<Cow<'_, str>> { + const VALID_STATUS_RANGES: [&str; 6] = ["default", "1XX", "2XX", "3XX", "4XX", "5XX"]; + + input + .parse::<LitStr>() + .and_then(|lit_str| { + let value = lit_str.value(); + if !VALID_STATUS_RANGES.contains(&value.as_str()) { + Err(Error::new( + value.span(), + format!( + "Invalid status range, expected one of: {}", + VALID_STATUS_RANGES.join(", "), + ), + )) + } else { + Ok(value) + } + }) + .map(Cow::Owned) + } + + fn parse_http_status_code(input: ParseStream) -> syn::Result<TokenStream2> { + let http_status_path = input.parse::<ExprPath>()?; + let last_segment = http_status_path + .path + .segments + .last() + .expect("Expected at least one segment in http StatusCode"); + + STATUS_CODES + .iter() + .find_map(|(code, name)| { + if last_segment.ident == name { + Some(code.to_string().to_token_stream()) + } else { + None + } + }) + .ok_or_else(|| { + Error::new( + last_segment.span(), + format!( + "No associate item `{}` found for struct `http::StatusCode`", + last_segment.ident + ), + ) + }) + } + + let lookahead = input.lookahead1(); + if lookahead.peek(LitInt) { + parse_lit_int(input).map(|status| Self(status.to_token_stream())) + } else if lookahead.peek(LitStr) { + parse_lit_str_status_range(input).map(|status| Self(status.to_token_stream())) + } else if lookahead.peek(syn::Ident) { + parse_http_status_code(input).map(Self) + } else { + Err(lookahead.error()) + } + } +} + +impl ToTokens for ResponseStatus { + fn to_tokens(&self, tokens: &mut TokenStream2) { + self.0.to_tokens(tokens); + } +} + +pub struct Responses<'a>(pub &'a [Response<'a>]); + +impl ToTokensDiagnostics for Responses<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + tokens.extend( + self.0 + .iter() + .map(|response| match response { + Response::IntoResponses(path) => { + let span = path.span(); + Ok(quote_spanned! {span => + .responses_from_into_responses::<#path>() + }) + } + Response::Tuple(response) => { + let code = &response.status_code; + let response = crate::as_tokens_or_diagnostics!(response); + Ok(quote! { .response(#code, #response) }) + } + }) + .collect::<Result<Vec<_>, Diagnostics>>()? + .into_iter() + .fold( + quote! { fastapi::openapi::ResponsesBuilder::new() }, + |mut acc, response| { + response.to_tokens(&mut acc); + + acc + }, + ), + ); + + tokens.extend(quote! { .build() }); + + Ok(()) + } +} diff --git a/fastapi-gen/src/path/response/derive.rs b/fastapi-gen/src/path/response/derive.rs new file mode 100644 index 0000000..9420202 --- /dev/null +++ b/fastapi-gen/src/path/response/derive.rs @@ -0,0 +1,761 @@ +use std::borrow::Cow; +use std::{iter, mem}; + +use proc_macro2::{Ident, Span, TokenStream}; +use quote::{quote, ToTokens}; +use syn::parse::ParseStream; +use syn::punctuated::Punctuated; +use syn::spanned::Spanned; +use syn::token::Comma; +use syn::{ + Attribute, Data, Field, Fields, Generics, Lifetime, LifetimeParam, LitStr, Path, Type, + TypePath, Variant, +}; + +use crate::component::schema::{EnumSchema, NamedStructSchema, Root}; +use crate::doc_comment::CommentAttributes; +use crate::path::media_type::{DefaultSchema, MediaTypeAttr, ParsedType, Schema}; +use crate::{ + as_tokens_or_diagnostics, parse_utils, Array, Diagnostics, OptionExt, ToTokensDiagnostics, +}; + +use super::{ + DeriveIntoResponsesValue, DeriveResponseValue, DeriveToResponseValue, ResponseTuple, + ResponseTupleInner, ResponseValue, +}; + +pub struct ToResponse<'r> { + ident: Ident, + lifetime: Lifetime, + generics: Generics, + response: ResponseTuple<'r>, +} + +impl<'r> ToResponse<'r> { + const LIFETIME: &'static str = "'__r"; + + pub fn new( + attributes: Vec<Attribute>, + data: &'r Data, + generics: Generics, + ident: Ident, + ) -> Result<ToResponse<'r>, Diagnostics> { + let response = match &data { + Data::Struct(struct_value) => match &struct_value.fields { + Fields::Named(fields) => { + ToResponseNamedStructResponse::new(&attributes, &ident, &fields.named)?.0 + } + Fields::Unnamed(fields) => { + let field = fields + .unnamed + .iter() + .next() + .expect("Unnamed struct must have 1 field"); + + ToResponseUnnamedStructResponse::new(&attributes, &field.ty, &field.attrs)?.0 + } + Fields::Unit => ToResponseUnitStructResponse::new(&attributes)?.0, + }, + Data::Enum(enum_value) => { + EnumResponse::new(&ident, &enum_value.variants, &attributes)?.0 + } + Data::Union(_) => { + return Err(Diagnostics::with_span( + ident.span(), + "`ToResponse` does not support `Union` type", + )) + } + }; + + let lifetime = Lifetime::new(ToResponse::LIFETIME, Span::call_site()); + + Ok(Self { + ident, + lifetime, + generics, + response, + }) + } +} + +impl ToTokensDiagnostics for ToResponse<'_> { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + let (_, ty_generics, where_clause) = self.generics.split_for_impl(); + + let lifetime = &self.lifetime; + let ident = &self.ident; + let name = ident.to_string(); + let response = as_tokens_or_diagnostics!(&self.response); + + let mut to_response_generics = self.generics.clone(); + to_response_generics + .params + .push(syn::GenericParam::Lifetime(LifetimeParam::new( + lifetime.clone(), + ))); + let (to_response_impl_generics, _, _) = to_response_generics.split_for_impl(); + + tokens.extend(quote! { + impl #to_response_impl_generics fastapi::ToResponse <#lifetime> for #ident #ty_generics #where_clause { + fn response() -> (& #lifetime str, fastapi::openapi::RefOr<fastapi::openapi::response::Response>) { + (#name, #response.into()) + } + } + }); + + Ok(()) + } +} + +pub struct IntoResponses { + pub attributes: Vec<Attribute>, + pub data: Data, + pub generics: Generics, + pub ident: Ident, +} + +impl ToTokensDiagnostics for IntoResponses { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + let responses = match &self.data { + Data::Struct(struct_value) => match &struct_value.fields { + Fields::Named(fields) => { + let response = + NamedStructResponse::new(&self.attributes, &self.ident, &fields.named)?.0; + let status = &response.status_code; + let response_tokens = as_tokens_or_diagnostics!(&response); + + Array::from_iter(iter::once(quote!((#status, #response_tokens)))) + } + Fields::Unnamed(fields) => { + let field = fields + .unnamed + .iter() + .next() + .expect("Unnamed struct must have 1 field"); + + let response = + UnnamedStructResponse::new(&self.attributes, &field.ty, &field.attrs)?.0; + let status = &response.status_code; + let response_tokens = as_tokens_or_diagnostics!(&response); + + Array::from_iter(iter::once(quote!((#status, #response_tokens)))) + } + Fields::Unit => { + let response = UnitStructResponse::new(&self.attributes)?.0; + let status = &response.status_code; + let response_tokens = as_tokens_or_diagnostics!(&response); + + Array::from_iter(iter::once(quote!((#status, #response_tokens)))) + } + }, + Data::Enum(enum_value) => enum_value + .variants + .iter() + .map(|variant| match &variant.fields { + Fields::Named(fields) => Ok(NamedStructResponse::new( + &variant.attrs, + &variant.ident, + &fields.named, + )? + .0), + Fields::Unnamed(fields) => { + let field = fields + .unnamed + .iter() + .next() + .expect("Unnamed enum variant must have 1 field"); + match UnnamedStructResponse::new(&variant.attrs, &field.ty, &field.attrs) { + Ok(response) => Ok(response.0), + Err(diagnostics) => Err(diagnostics), + } + } + Fields::Unit => Ok(UnitStructResponse::new(&variant.attrs)?.0), + }) + .collect::<Result<Vec<ResponseTuple>, Diagnostics>>()? + .iter() + .map(|response| { + let status = &response.status_code; + let response_tokens = as_tokens_or_diagnostics!(response); + Ok(quote!((#status, fastapi::openapi::RefOr::from(#response_tokens)))) + }) + .collect::<Result<Array<TokenStream>, Diagnostics>>()?, + Data::Union(_) => { + return Err(Diagnostics::with_span( + self.ident.span(), + "`IntoResponses` does not support `Union` type", + )) + } + }; + + let ident = &self.ident; + let (impl_generics, ty_generics, where_clause) = self.generics.split_for_impl(); + + let responses = if responses.len() > 0 { + Some(quote!( .responses_from_iter(#responses))) + } else { + None + }; + tokens.extend(quote!{ + impl #impl_generics fastapi::IntoResponses for #ident #ty_generics #where_clause { + fn responses() -> std::collections::BTreeMap<String, fastapi::openapi::RefOr<fastapi::openapi::response::Response>> { + fastapi::openapi::response::ResponsesBuilder::new() + #responses + .build() + .into() + } + } + }); + + Ok(()) + } +} + +trait Response { + fn to_type(ident: &Ident) -> Type { + let path = Path::from(ident.clone()); + let type_path = TypePath { path, qself: None }; + Type::Path(type_path) + } + + fn has_no_field_attributes(attribute: &Attribute) -> (bool, &'static str) { + const ERROR: &str = + "Unexpected field attribute, field attributes are only supported at unnamed fields"; + + let ident = attribute.path().get_ident().unwrap(); + match &*ident.to_string() { + "to_schema" => (false, ERROR), + "ref_response" => (false, ERROR), + "content" => (false, ERROR), + "to_response" => (false, ERROR), + _ => (true, ERROR), + } + } + + fn validate_attributes<'a, I: IntoIterator<Item = &'a Attribute>>( + attributes: I, + validate: impl Fn(&Attribute) -> (bool, &'static str) + 'a, + ) -> impl Iterator<Item = Diagnostics> { + attributes.into_iter().filter_map(move |attribute| { + let (valid, error_message) = validate(attribute); + if !valid { + Some(Diagnostics::with_span(attribute.span(), error_message)) + } else { + None + } + }) + } +} + +struct UnnamedStructResponse<'u>(ResponseTuple<'u>); + +impl Response for UnnamedStructResponse<'_> {} + +impl<'u> UnnamedStructResponse<'u> { + fn new( + attributes: &[Attribute], + ty: &'u Type, + inner_attributes: &[Attribute], + ) -> Result<Self, Diagnostics> { + let is_inline = inner_attributes + .iter() + .any(|attribute| attribute.path().get_ident().unwrap() == "to_schema"); + let ref_response = inner_attributes + .iter() + .any(|attribute| attribute.path().get_ident().unwrap() == "ref_response"); + let to_response = inner_attributes + .iter() + .any(|attribute| attribute.path().get_ident().unwrap() == "to_response"); + + if is_inline && (ref_response || to_response) { + return Err(Diagnostics::with_span(ty.span(), "Attribute `to_schema` cannot be used with `ref_response` and `to_response` attribute")); + } + let mut derive_value = DeriveIntoResponsesValue::from_attributes(attributes)? + .expect("`IntoResponses` must have `#[response(...)]` attribute"); + let description = { + let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); + parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) + }; + let status_code = mem::take(&mut derive_value.status); + + let response = match (ref_response, to_response) { + (false, false) => Self( + ( + status_code, + ResponseValue::from_derive_into_responses_value( + derive_value, + ParsedType { + ty: Cow::Borrowed(ty), + is_inline, + }, + description, + ), + ) + .into(), + ), + (true, false) => Self(ResponseTuple { + inner: Some(ResponseTupleInner::Ref(ParsedType { + ty: Cow::Borrowed(ty), + is_inline: false, + })), + status_code, + }), + (false, true) => Self(ResponseTuple { + inner: Some(ResponseTupleInner::Ref(ParsedType { + ty: Cow::Borrowed(ty), + is_inline: true, + })), + status_code, + }), + (true, true) => { + return Err(Diagnostics::with_span( + ty.span(), + "Cannot define `ref_response` and `to_response` attribute simultaneously", + )) + } + }; + + Ok(response) + } +} + +struct NamedStructResponse<'n>(ResponseTuple<'n>); + +impl Response for NamedStructResponse<'_> {} + +impl NamedStructResponse<'_> { + fn new( + attributes: &[Attribute], + ident: &Ident, + fields: &Punctuated<Field, Comma>, + ) -> Result<Self, Diagnostics> { + if let Some(diagnostics) = + Self::validate_attributes(attributes, Self::has_no_field_attributes) + .chain(Self::validate_attributes( + fields.iter().flat_map(|field| &field.attrs), + Self::has_no_field_attributes, + )) + .collect::<Option<Diagnostics>>() + { + return Err(diagnostics); + } + + let mut derive_value = DeriveIntoResponsesValue::from_attributes(attributes)? + .expect("`IntoResponses` must have `#[response(...)]` attribute"); + let description = { + let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); + parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) + }; + let status_code = mem::take(&mut derive_value.status); + let inline_schema = NamedStructSchema::new( + &Root { + ident, + attributes, + generics: &Generics::default(), + }, + fields, + Vec::new(), + )?; + + let ty = Self::to_type(ident); + + Ok(Self( + ( + status_code, + ResponseValue::from_derive_into_responses_value( + derive_value, + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ), + ) + .into(), + )) + } +} + +struct UnitStructResponse<'u>(ResponseTuple<'u>); + +impl Response for UnitStructResponse<'_> {} + +impl UnitStructResponse<'_> { + fn new(attributes: &[Attribute]) -> Result<Self, Diagnostics> { + if let Some(diagnostics) = + Self::validate_attributes(attributes, Self::has_no_field_attributes) + .collect::<Option<Diagnostics>>() + { + return Err(diagnostics); + } + + let mut derive_value = DeriveIntoResponsesValue::from_attributes(attributes)? + .expect("`IntoResponses` must have `#[response(...)]` attribute"); + let status_code = mem::take(&mut derive_value.status); + let description = { + let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); + parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) + }; + + Ok(Self( + ( + status_code, + ResponseValue::from_derive_into_responses_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ), + ) + .into(), + )) + } +} + +struct ToResponseNamedStructResponse<'p>(ResponseTuple<'p>); + +impl Response for ToResponseNamedStructResponse<'_> {} + +impl<'p> ToResponseNamedStructResponse<'p> { + fn new( + attributes: &[Attribute], + ident: &Ident, + fields: &Punctuated<Field, Comma>, + ) -> Result<Self, Diagnostics> { + if let Some(diagnostics) = + Self::validate_attributes(attributes, Self::has_no_field_attributes) + .chain(Self::validate_attributes( + fields.iter().flat_map(|field| &field.attrs), + Self::has_no_field_attributes, + )) + .collect::<Option<Diagnostics>>() + { + return Err(diagnostics); + } + + let derive_value = DeriveToResponseValue::from_attributes(attributes)?; + let description = { + let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); + parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) + }; + let ty = Self::to_type(ident); + + let inline_schema = NamedStructSchema::new( + &Root { + ident, + attributes, + generics: &Generics::default(), + }, + fields, + Vec::new(), + )?; + + let response_value = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) + } else { + ResponseValue::from_schema( + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) + }; + // response_value.response_type = Some(response_type); + + Ok(Self(response_value.into())) + } +} + +struct ToResponseUnnamedStructResponse<'c>(ResponseTuple<'c>); + +impl Response for ToResponseUnnamedStructResponse<'_> {} + +impl<'u> ToResponseUnnamedStructResponse<'u> { + fn new( + attributes: &[Attribute], + ty: &'u Type, + inner_attributes: &[Attribute], + ) -> Result<Self, Diagnostics> { + if let Some(diagnostics) = + Self::validate_attributes(attributes, Self::has_no_field_attributes) + .chain(Self::validate_attributes(inner_attributes, |attribute| { + const ERROR: &str = + "Unexpected attribute, `content` is only supported on unnamed field enum variant"; + if attribute.path().get_ident().unwrap() == "content" { + (false, ERROR) + } else { + (true, ERROR) + } + })) + .collect::<Option<Diagnostics>>() + { + return Err(diagnostics); + } + let derive_value = DeriveToResponseValue::from_attributes(attributes)?; + let description = { + let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); + parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) + }; + + let is_inline = inner_attributes + .iter() + .any(|attribute| attribute.path().get_ident().unwrap() == "to_schema"); + + let response_value = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + ParsedType { + ty: Cow::Borrowed(ty), + is_inline, + }, + description, + ) + } else { + ResponseValue::from_schema( + ParsedType { + ty: Cow::Borrowed(ty), + is_inline, + }, + description, + ) + }; + + Ok(Self(response_value.into())) + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +struct VariantAttributes<'r> { + type_and_content: Option<(&'r Type, String)>, + derive_value: Option<DeriveToResponseValue>, + is_inline: bool, +} + +struct EnumResponse<'r>(ResponseTuple<'r>); + +impl Response for EnumResponse<'_> {} + +impl<'r> EnumResponse<'r> { + fn new( + ident: &Ident, + variants: &'r Punctuated<Variant, Comma>, + attributes: &[Attribute], + ) -> Result<Self, Diagnostics> { + if let Some(diagnostics) = + Self::validate_attributes(attributes, Self::has_no_field_attributes) + .chain(Self::validate_attributes( + variants.iter().flat_map(|variant| &variant.attrs), + Self::has_no_field_attributes, + )) + .collect::<Option<Diagnostics>>() + { + return Err(diagnostics); + } + + let ty = Self::to_type(ident); + let description = { + let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); + parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) + }; + + let content = variants + .into_iter() + .map(Self::parse_variant_attributes) + .collect::<Result<Vec<VariantAttributes>, Diagnostics>>()? + .into_iter() + .filter(|variant| variant.type_and_content.is_some()) + .collect::<Vec<_>>(); + + let derive_value = DeriveToResponseValue::from_attributes(attributes)?; + if let Some(derive_value) = &derive_value { + if (!content.is_empty() && derive_value.example.is_some()) + || (!content.is_empty() && derive_value.examples.is_some()) + { + let ident = derive_value + .example + .as_ref() + .map(|(_, ident)| ident) + .or_else(|| derive_value.examples.as_ref().map(|(_, ident)| ident)) + .expect("Expected `example` or `examples` to be present"); + return Err( + Diagnostics::with_span(ident.span(), + "Enum with `#[content]` attribute in variant cannot have enum level `example` or `examples` defined") + .help(format!("Try defining `{}` on the enum variant", ident)) + ); + } + } + + let generics = Generics::default(); + let root = &Root { + ident, + attributes, + generics: &generics, + }; + let inline_schema = EnumSchema::new(root, variants)?; + + let response_value = if content.is_empty() { + if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ) + } else { + ResponseValue::from_schema( + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) + } + } else { + let content = content + .into_iter() + .map( + |VariantAttributes { + type_and_content, + derive_value, + is_inline, + }| { + let (content_type, schema) = if let Some((ty, content)) = type_and_content { + ( + Some(content.into()), + Some(Schema::Default(DefaultSchema::TypePath(ParsedType { + ty: Cow::Borrowed(ty), + is_inline, + }))), + ) + } else { + (None, None) + }; + let (example, examples) = if let Some(derive_value) = derive_value { + ( + derive_value.example.map(|(example, _)| example), + derive_value.examples.map(|(examples, _)| examples), + ) + } else { + (None, None) + }; + + MediaTypeAttr { + content_type, + schema: schema.unwrap_or_else(|| Schema::Default(DefaultSchema::None)), + example, + examples: examples.unwrap_or_default(), + } + }, + ) + .collect::<Vec<_>>(); + + let mut response = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ) + } else { + ResponseValue::from_schema( + Schema::Default(DefaultSchema::Raw { + tokens: inline_schema.to_token_stream(), + ty: Cow::Owned(ty), + }), + description, + ) + }; + response.content = content; + + response + }; + + Ok(Self(response_value.into())) + } + + fn parse_variant_attributes(variant: &Variant) -> Result<VariantAttributes, Diagnostics> { + let variant_derive_response_value = + DeriveToResponseValue::from_attributes(variant.attrs.as_slice())?; + // named enum variant should not have field attributes + if let Fields::Named(named_fields) = &variant.fields { + if let Some(diagnostics) = Self::validate_attributes( + named_fields.named.iter().flat_map(|field| &field.attrs), + Self::has_no_field_attributes, + ) + .collect::<Option<Diagnostics>>() + { + return Err(diagnostics); + } + }; + + let field = variant.fields.iter().next(); + + let content_type = field.and_then_try(|field| { + field + .attrs + .iter() + .find(|attribute| attribute.path().get_ident().unwrap() == "content") + .map_try(|attribute| { + attribute + .parse_args_with(|input: ParseStream| input.parse::<LitStr>()) + .map(|content| content.value()) + .map_err(Diagnostics::from) + }) + })?; + + let is_inline = field + .map(|field| { + field + .attrs + .iter() + .any(|attribute| attribute.path().get_ident().unwrap() == "to_schema") + }) + .unwrap_or(false); + + Ok(VariantAttributes { + type_and_content: field.map(|field| &field.ty).zip(content_type), + derive_value: variant_derive_response_value, + is_inline, + }) + } +} + +struct ToResponseUnitStructResponse<'u>(ResponseTuple<'u>); + +impl Response for ToResponseUnitStructResponse<'_> {} + +impl ToResponseUnitStructResponse<'_> { + fn new(attributes: &[Attribute]) -> Result<Self, Diagnostics> { + if let Some(diagnostics) = + Self::validate_attributes(attributes, Self::has_no_field_attributes) + .collect::<Option<Diagnostics>>() + { + return Err(diagnostics); + } + + let derive_value = DeriveToResponseValue::from_attributes(attributes)?; + let description = { + let s = CommentAttributes::from_attributes(attributes).as_formatted_string(); + parse_utils::LitStrOrExpr::LitStr(LitStr::new(&s, Span::call_site())) + }; + + let response_value = if let Some(derive_value) = derive_value { + ResponseValue::from_derive_to_response_value( + derive_value, + Schema::Default(DefaultSchema::None), + description, + ) + } else { + ResponseValue { + description, + ..Default::default() + } + }; + + Ok(Self(response_value.into())) + } +} diff --git a/fastapi-gen/src/path/response/header.rs b/fastapi-gen/src/path/response/header.rs new file mode 100644 index 0000000..0e434a1 --- /dev/null +++ b/fastapi-gen/src/path/response/header.rs @@ -0,0 +1,165 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::parse::{Parse, ParseStream}; +use syn::{Error, Generics, Ident, LitStr, Token}; + +use crate::component::features::attributes::Inline; +use crate::component::{ComponentSchema, Container, TypeTree}; +use crate::path::media_type::ParsedType; +use crate::{parse_utils, Diagnostics, ToTokensDiagnostics}; + +/// Parsed representation of response header defined in `#[fastapi::path(..)]` attribute. +/// +/// Supported configuration format is `("x-my-header-name" = type, description = "optional description of header")`. +/// The `= type` and the `description = ".."` are optional configurations thus so the same configuration +/// could be written as follows: `("x-my-header-name")`. +/// +/// The `type` can be any typical type supported as a header argument such as `String, i32, u64, bool` etc. +/// and if not provided it will default to `String`. +/// +/// # Examples +/// +/// Example of 200 success response which does return nothing back in response body, but returns a +/// new csrf token in response headers. +/// ```text +/// #[fastapi::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", +/// headers = [ +/// ("xrfs-token" = String, description = "New csrf token sent back in response header") +/// ] +/// ), +/// ] +/// )] +/// ``` +/// +/// Example with default values. +/// ```text +/// #[fastapi::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", +/// headers = [ +/// ("xrfs-token") +/// ] +/// ), +/// ] +/// )] +/// ``` +/// +/// Example with multiple headers with default values. +/// ```text +/// #[fastapi::path( +/// ... +/// responses = [ +/// (status = 200, description = "success response", +/// headers = [ +/// ("xrfs-token"), +/// ("another-header"), +/// ] +/// ), +/// ] +/// )] +/// ``` +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Header { + pub name: String, + value_type: Option<ParsedType<'static>>, + description: Option<String>, +} + +impl Parse for Header { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let mut header = Header { + name: input.parse::<LitStr>()?.value(), + ..Default::default() + }; + + if input.peek(Token![=]) { + input.parse::<Token![=]>()?; + + header.value_type = Some(input.parse().map_err(|error| { + Error::new( + error.span(), + format!("unexpected token, expected type such as String, {error}"), + ) + })?); + } + + if !input.is_empty() { + input.parse::<Token![,]>()?; + } + + if input.peek(syn::Ident) { + input + .parse::<Ident>() + .map_err(|error| { + Error::new( + error.span(), + format!("unexpected attribute, expected: description, {error}"), + ) + }) + .and_then(|ident| { + if ident != "description" { + return Err(Error::new( + ident.span(), + "unexpected attribute, expected: description", + )); + } + Ok(ident) + })?; + input.parse::<Token![=]>()?; + header.description = Some(input.parse::<LitStr>()?.value()); + } + + Ok(header) + } +} + +impl ToTokensDiagnostics for Header { + fn to_tokens(&self, tokens: &mut TokenStream) -> Result<(), Diagnostics> { + if let Some(header_type) = &self.value_type { + // header property with custom type + let type_tree = TypeTree::from_type(header_type.ty.as_ref())?; + + let media_type_schema = ComponentSchema::new(crate::component::ComponentSchemaProps { + type_tree: &type_tree, + features: vec![Inline::from(header_type.is_inline).into()], + description: None, + container: &Container { + generics: &Generics::default(), + }, + })? + .to_token_stream(); + + tokens.extend(quote! { + fastapi::openapi::HeaderBuilder::new().schema(#media_type_schema) + }) + } else { + // default header (string type) + tokens.extend(quote! { + Into::<fastapi::openapi::HeaderBuilder>::into(fastapi::openapi::Header::default()) + }) + }; + + if let Some(ref description) = self.description { + tokens.extend(quote! { + .description(Some(#description)) + }) + } + + tokens.extend(quote! { .build() }); + + Ok(()) + } +} + +#[inline] +pub fn headers(input: ParseStream) -> syn::Result<Vec<Header>> { + let headers; + syn::parenthesized!(headers in input); + + parse_utils::parse_groups_collect(&headers) +} diff --git a/fastapi-gen/src/path/response/link.rs b/fastapi-gen/src/path/response/link.rs new file mode 100644 index 0000000..61c61be --- /dev/null +++ b/fastapi-gen/src/path/response/link.rs @@ -0,0 +1,149 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::parse::Parse; +use syn::punctuated::Punctuated; +use syn::token::Comma; +use syn::{Ident, Token}; + +use crate::openapi::Server; +use crate::{parse_utils, AnyValue}; + +/// ("name" = (link)) +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct LinkTuple(pub parse_utils::LitStrOrExpr, pub Link); + +impl Parse for LinkTuple { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let inner; + syn::parenthesized!(inner in input); + + let name = inner.parse::<parse_utils::LitStrOrExpr>()?; + inner.parse::<Token![=]>()?; + let value = inner.parse::<Link>()?; + + Ok(LinkTuple(name, value)) + } +} + +/// (operation_ref = "", operation_id = "", +/// parameters( +/// ("name" = value), +/// ("name" = value) +/// ), +/// request_body = value, +/// description = "", +/// server(...) +/// ) +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Link { + operation_ref: Option<parse_utils::LitStrOrExpr>, + operation_id: Option<parse_utils::LitStrOrExpr>, + parameters: Punctuated<LinkParameter, Comma>, + request_body: Option<AnyValue>, + description: Option<parse_utils::LitStrOrExpr>, + server: Option<Server>, +} + +impl Parse for Link { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let inner; + syn::parenthesized!(inner in input); + let mut link = Link::default(); + + while !inner.is_empty() { + let ident = inner.parse::<Ident>()?; + let attribute = &*ident.to_string(); + + match attribute { + "operation_ref" => link.operation_ref = Some(parse_utils::parse_next_literal_str_or_expr(&inner)?), + "operation_id" => link.operation_id = Some(parse_utils::parse_next_literal_str_or_expr(&inner)?), + "parameters" => { + link.parameters = parse_utils::parse_comma_separated_within_parenthesis(&inner)?; + }, + "request_body" => link.request_body = Some(parse_utils::parse_next(&inner, || { AnyValue::parse_any(&inner)})?), + "description" => link.description = Some(parse_utils::parse_next_literal_str_or_expr(&inner)?), + "server" => link.server = Some(inner.call(Server::parse)?), + _ => return Err(syn::Error::new(ident.span(), format!("unexpected attribute: {attribute}, expected any of: operation_ref, operation_id, parameters, request_body, description, server"))) + } + + if !inner.is_empty() { + inner.parse::<Token![,]>()?; + } + } + + Ok(link) + } +} + +impl ToTokens for Link { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) { + let operation_ref = self + .operation_ref + .as_ref() + .map(|operation_ref| quote! { .operation_ref(#operation_ref)}); + + let operation_id = self + .operation_id + .as_ref() + .map(|operation_id| quote! { .operation_id(#operation_id)}); + + let parameters = + self.parameters + .iter() + .fold(TokenStream::new(), |mut params, parameter| { + let name = ¶meter.name; + let value = ¶meter.value; + params.extend(quote! { .parameter(#name, #value) }); + + params + }); + + let request_body = self + .request_body + .as_ref() + .map(|request_body| quote! { .request_body(Some(#request_body)) }); + + let description = self + .description + .as_ref() + .map(|description| quote! { .description(#description) }); + + let server = self + .server + .as_ref() + .map(|server| quote! { .server(Some(#server)) }); + + tokens.extend(quote! { + fastapi::openapi::link::Link::builder() + #operation_ref + #operation_id + #parameters + #request_body + #description + #server + .build() + }) + } +} + +/// ("foobar" = json!(...)) +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct LinkParameter { + name: parse_utils::LitStrOrExpr, + value: parse_utils::LitStrOrExpr, +} + +impl Parse for LinkParameter { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let inner; + syn::parenthesized!(inner in input); + let name = inner.parse::<parse_utils::LitStrOrExpr>()?; + + inner.parse::<Token![=]>()?; + + let value = inner.parse::<parse_utils::LitStrOrExpr>()?; + + Ok(LinkParameter { name, value }) + } +} diff --git a/fastapi-gen/src/path/status.rs b/fastapi-gen/src/path/status.rs new file mode 100644 index 0000000..5a353a4 --- /dev/null +++ b/fastapi-gen/src/path/status.rs @@ -0,0 +1,63 @@ +/// Known http `StatusCode`s available in `http::status::StatusCode` struct in `http` crate. +pub const STATUS_CODES: [(i16, &str); 60] = [ + (100, "CONTINUE"), + (101, "SWITCHING_PROTOCOLS"), + (102, "PROCESSING"), + (200, "OK"), + (201, "CREATED"), + (202, "ACCEPTED"), + (203, "NON_AUTHORITATIVE_INFORMATION"), + (204, "NO_CONTENT"), + (205, "RESET_CONTENT"), + (206, "PARTIAL_CONTENT"), + (207, "MULTI_STATUS"), + (208, "ALREADY_REPORTED"), + (226, "IM_USED"), + (300, "MULTIPLE_CHOICES"), + (301, "MOVED_PERMANENTLY"), + (302, "FOUND"), + (303, "SEE_OTHER"), + (304, "NOT_MODIFIED"), + (305, "USE_PROXY"), + (307, "TEMPORARY_REDIRECT"), + (308, "PERMANENT_REDIRECT"), + (400, "BAD_REQUEST"), + (401, "UNAUTHORIZED"), + (402, "PAYMENT_REQUIRED"), + (403, "FORBIDDEN"), + (404, "NOT_FOUND"), + (405, "METHOD_NOT_ALLOWED"), + (406, "NOT_ACCEPTABLE"), + (407, "PROXY_AUTHENTICATION_REQUIRED"), + (408, "REQUEST_TIMEOUT"), + (409, "CONFLICT"), + (410, "GONE"), + (411, "LENGTH_REQUIRED"), + (412, "PRECONDITION_FAILED"), + (413, "PAYLOAD_TOO_LARGE"), + (414, "URI_TOO_LONG"), + (415, "UNSUPPORTED_MEDIA_TYPE"), + (416, "RANGE_NOT_SATISFIABLE"), + (417, "EXPECTATION_FAILED"), + (418, "IM_A_TEAPOT"), + (421, "MISDIRECTED_REQUEST"), + (422, "UNPROCESSABLE_ENTITY"), + (423, "LOCKED"), + (424, "FAILED_DEPENDENCY"), + (426, "UPGRADE_REQUIRED"), + (428, "PRECONDITION_REQUIRED"), + (429, "TOO_MANY_REQUESTS"), + (431, "REQUEST_HEADER_FIELDS_TOO_LARGE"), + (451, "UNAVAILABLE_FOR_LEGAL_REASONS"), + (500, "INTERNAL_SERVER_ERROR"), + (501, "NOT_IMPLEMENTED"), + (502, "BAD_GATEWAY"), + (503, "SERVICE_UNAVAILABLE"), + (504, "GATEWAY_TIMEOUT"), + (505, "HTTP_VERSION_NOT_SUPPORTED"), + (506, "VARIANT_ALSO_NEGOTIATES"), + (507, "INSUFFICIENT_STORAGE"), + (508, "LOOP_DETECTED"), + (510, "NOT_EXTENDED"), + (511, "NETWORK_AUTHENTICATION_REQUIRED"), +]; diff --git a/fastapi-gen/src/schema_type.rs b/fastapi-gen/src/schema_type.rs new file mode 100644 index 0000000..91b0989 --- /dev/null +++ b/fastapi-gen/src/schema_type.rs @@ -0,0 +1,744 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::spanned::Spanned; +use syn::{parse::Parse, Error, Ident, LitStr, Path}; + +use crate::{Diagnostics, ToTokensDiagnostics}; + +/// Represents data type of [`Schema`]. +#[cfg_attr(feature = "debug", derive(Debug))] +#[allow(dead_code)] +pub enum SchemaTypeInner { + /// Generic schema type allows "properties" with custom types + Object, + /// Indicates string type of content. + String, + /// Indicates integer type of content. + Integer, + /// Indicates floating point number type of content. + Number, + /// Indicates boolean type of content. + Boolean, + /// Indicates array type of content. + Array, + /// Null type. Used together with other type to indicate nullable values. + Null, +} + +impl ToTokens for SchemaTypeInner { + fn to_tokens(&self, tokens: &mut TokenStream) { + let ty = match self { + Self::Object => quote! { fastapi::openapi::schema::Type::Object }, + Self::String => quote! { fastapi::openapi::schema::Type::String }, + Self::Integer => quote! { fastapi::openapi::schema::Type::Integer }, + Self::Number => quote! { fastapi::openapi::schema::Type::Number }, + Self::Boolean => quote! { fastapi::openapi::schema::Type::Boolean }, + Self::Array => quote! { fastapi::openapi::schema::Type::Array }, + Self::Null => quote! { fastapi::openapi::schema::Type::Null }, + }; + tokens.extend(ty) + } +} + +/// Tokenizes OpenAPI data type correctly according to the Rust type +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct SchemaType<'a> { + pub path: std::borrow::Cow<'a, syn::Path>, + pub nullable: bool, +} + +impl SchemaType<'_> { + fn last_segment_to_string(&self) -> String { + self.path + .segments + .last() + .expect("Expected at least one segment is_integer") + .ident + .to_string() + } + + pub fn is_value(&self) -> bool { + matches!(&*self.last_segment_to_string(), "Value") + } + + /// Check whether type is known to be primitive in which case returns true. + pub fn is_primitive(&self) -> bool { + let SchemaType { path, .. } = self; + let last_segment = match path.segments.last() { + Some(segment) => segment, + None => return false, + }; + let name = &*last_segment.ident.to_string(); + + #[cfg(not(any( + feature = "chrono", + feature = "decimal", + feature = "decimal_float", + feature = "rocket_extras", + feature = "uuid", + feature = "ulid", + feature = "url", + feature = "time", + )))] + { + is_primitive(name) + } + + #[cfg(any( + feature = "chrono", + feature = "decimal", + feature = "decimal_float", + feature = "rocket_extras", + feature = "uuid", + feature = "ulid", + feature = "url", + feature = "time", + ))] + { + let mut primitive = is_primitive(name); + + #[cfg(feature = "chrono")] + if !primitive { + primitive = is_primitive_chrono(name); + } + + #[cfg(any(feature = "decimal", feature = "decimal_float"))] + if !primitive { + primitive = is_primitive_rust_decimal(name); + } + + #[cfg(feature = "rocket_extras")] + if !primitive { + primitive = matches!(name, "PathBuf"); + } + + #[cfg(feature = "uuid")] + if !primitive { + primitive = matches!(name, "Uuid"); + } + + #[cfg(feature = "ulid")] + if !primitive { + primitive = matches!(name, "Ulid"); + } + + #[cfg(feature = "url")] + if !primitive { + primitive = matches!(name, "Url"); + } + + #[cfg(feature = "time")] + if !primitive { + primitive = matches!( + name, + "Date" | "PrimitiveDateTime" | "OffsetDateTime" | "Duration" + ); + } + + primitive + } + } + + pub fn is_integer(&self) -> bool { + matches!( + &*self.last_segment_to_string(), + "i8" | "i16" + | "i32" + | "i64" + | "i128" + | "isize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "usize" + ) + } + + pub fn is_unsigned_integer(&self) -> bool { + matches!( + &*self.last_segment_to_string(), + "u8" | "u16" | "u32" | "u64" | "u128" | "usize" + ) + } + + pub fn is_number(&self) -> bool { + match &*self.last_segment_to_string() { + "f32" | "f64" => true, + _ if self.is_integer() => true, + _ => false, + } + } + + pub fn is_string(&self) -> bool { + matches!(&*self.last_segment_to_string(), "str" | "String") + } + + pub fn is_byte(&self) -> bool { + matches!(&*self.last_segment_to_string(), "u8") + } +} + +#[inline] +fn is_primitive(name: &str) -> bool { + matches!( + name, + "String" + | "str" + | "char" + | "bool" + | "usize" + | "u8" + | "u16" + | "u32" + | "u64" + | "u128" + | "isize" + | "i8" + | "i16" + | "i32" + | "i64" + | "i128" + | "f32" + | "f64" + ) +} + +#[inline] +#[cfg(feature = "chrono")] +fn is_primitive_chrono(name: &str) -> bool { + matches!( + name, + "DateTime" | "Date" | "NaiveDate" | "NaiveTime" | "Duration" | "NaiveDateTime" + ) +} + +#[inline] +#[cfg(any(feature = "decimal", feature = "decimal_float"))] +fn is_primitive_rust_decimal(name: &str) -> bool { + matches!(name, "Decimal") +} + +impl ToTokensDiagnostics for SchemaType<'_> { + fn to_tokens(&self, tokens: &mut proc_macro2::TokenStream) -> Result<(), Diagnostics> { + let last_segment = self.path.segments.last().ok_or_else(|| { + Diagnostics::with_span( + self.path.span(), + "schema type should have at least one segment in the path", + ) + })?; + let name = &*last_segment.ident.to_string(); + + fn schema_type_tokens( + tokens: &mut TokenStream, + schema_type: SchemaTypeInner, + nullable: bool, + ) { + if nullable { + tokens.extend(quote! { + { + use std::iter::FromIterator; + fastapi::openapi::schema::SchemaType::from_iter([ + #schema_type, + fastapi::openapi::schema::Type::Null + ]) + } + }) + } else { + tokens.extend(quote! { fastapi::openapi::schema::SchemaType::new(#schema_type)}); + } + } + + match name { + "String" | "str" | "char" => { + schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable) + } + + "bool" => schema_type_tokens(tokens, SchemaTypeInner::Boolean, self.nullable), + + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" + | "u128" | "usize" => { + schema_type_tokens(tokens, SchemaTypeInner::Integer, self.nullable) + } + "f32" | "f64" => schema_type_tokens(tokens, SchemaTypeInner::Number, self.nullable), + + #[cfg(feature = "chrono")] + "DateTime" | "NaiveDateTime" | "NaiveDate" | "NaiveTime" => { + schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable) + } + + #[cfg(any(feature = "chrono", feature = "time"))] + "Date" | "Duration" => { + schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable) + } + + #[cfg(feature = "decimal")] + "Decimal" => schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable), + + #[cfg(feature = "decimal_float")] + "Decimal" => schema_type_tokens(tokens, SchemaTypeInner::Number, self.nullable), + + #[cfg(feature = "rocket_extras")] + "PathBuf" => schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable), + + #[cfg(feature = "uuid")] + "Uuid" => schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable), + + #[cfg(feature = "ulid")] + "Ulid" => schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable), + + #[cfg(feature = "url")] + "Url" => schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable), + + #[cfg(feature = "time")] + "PrimitiveDateTime" | "OffsetDateTime" => { + schema_type_tokens(tokens, SchemaTypeInner::String, self.nullable) + } + _ => schema_type_tokens(tokens, SchemaTypeInner::Object, self.nullable), + }; + + Ok(()) + } +} + +/// [`Parse`] and [`ToTokens`] implementation for [`fastapi::openapi::schema::SchemaFormat`]. +#[derive(Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum KnownFormat { + #[cfg(feature = "non_strict_integers")] + Int8, + #[cfg(feature = "non_strict_integers")] + Int16, + Int32, + Int64, + #[cfg(feature = "non_strict_integers")] + UInt8, + #[cfg(feature = "non_strict_integers")] + UInt16, + #[cfg(feature = "non_strict_integers")] + UInt32, + #[cfg(feature = "non_strict_integers")] + UInt64, + Float, + Double, + Byte, + Binary, + Date, + DateTime, + Duration, + Password, + #[cfg(feature = "uuid")] + Uuid, + #[cfg(feature = "ulid")] + Ulid, + #[cfg(feature = "url")] + Uri, + #[cfg(feature = "url")] + UriReference, + #[cfg(feature = "url")] + Iri, + #[cfg(feature = "url")] + IriReference, + Email, + IdnEmail, + Hostname, + IdnHostname, + Ipv4, + Ipv6, + UriTemplate, + JsonPointer, + RelativeJsonPointer, + Regex, + /// Custom format is reserved only for manual entry. + Custom(String), + /// This is not tokenized, but is present for purpose of having some format in + /// case we do not know the format. E.g. We cannot determine the format based on type path. + #[allow(unused)] + Unknown, +} + +impl KnownFormat { + pub fn from_path(path: &syn::Path) -> Result<Self, Diagnostics> { + let last_segment = path.segments.last().ok_or_else(|| { + Diagnostics::with_span( + path.span(), + "type should have at least one segment in the path", + ) + })?; + let name = &*last_segment.ident.to_string(); + + let variant = match name { + #[cfg(feature = "non_strict_integers")] + "i8" => Self::Int8, + #[cfg(feature = "non_strict_integers")] + "u8" => Self::UInt8, + #[cfg(feature = "non_strict_integers")] + "i16" => Self::Int16, + #[cfg(feature = "non_strict_integers")] + "u16" => Self::UInt16, + #[cfg(feature = "non_strict_integers")] + "u32" => Self::UInt32, + #[cfg(feature = "non_strict_integers")] + "u64" => Self::UInt64, + + #[cfg(not(feature = "non_strict_integers"))] + "i8" | "i16" | "u8" | "u16" | "u32" => Self::Int32, + + #[cfg(not(feature = "non_strict_integers"))] + "u64" => Self::Int64, + + "i32" => Self::Int32, + "i64" => Self::Int64, + "f32" => Self::Float, + "f64" => Self::Double, + + #[cfg(feature = "chrono")] + "NaiveDate" => Self::Date, + + #[cfg(feature = "chrono")] + "DateTime" | "NaiveDateTime" => Self::DateTime, + + #[cfg(any(feature = "chrono", feature = "time"))] + "Date" => Self::Date, + + #[cfg(feature = "decimal_float")] + "Decimal" => Self::Double, + + #[cfg(feature = "uuid")] + "Uuid" => Self::Uuid, + + #[cfg(feature = "ulid")] + "Ulid" => Self::Ulid, + + #[cfg(feature = "url")] + "Url" => Self::Uri, + + #[cfg(feature = "time")] + "PrimitiveDateTime" | "OffsetDateTime" => Self::DateTime, + _ => Self::Unknown, + }; + + Ok(variant) + } + + pub fn is_known_format(&self) -> bool { + !matches!(self, Self::Unknown) + } + + fn get_allowed_formats() -> String { + let default_formats = [ + "Int32", + "Int64", + "Float", + "Double", + "Byte", + "Binary", + "Date", + "DateTime", + "Duration", + "Password", + #[cfg(feature = "uuid")] + "Uuid", + #[cfg(feature = "ulid")] + "Ulid", + #[cfg(feature = "url")] + "Uri", + #[cfg(feature = "url")] + "UriReference", + #[cfg(feature = "url")] + "Iri", + #[cfg(feature = "url")] + "IriReference", + "Email", + "IdnEmail", + "Hostname", + "IdnHostname", + "Ipv4", + "Ipv6", + "UriTemplate", + "JsonPointer", + "RelativeJsonPointer", + "Regex", + ]; + #[cfg(feature = "non_strict_integers")] + let non_strict_integer_formats = [ + "Int8", "Int16", "Int32", "Int64", "UInt8", "UInt16", "UInt32", "UInt64", + ]; + + #[cfg(feature = "non_strict_integers")] + let formats = { + let mut formats = default_formats + .into_iter() + .chain(non_strict_integer_formats) + .collect::<Vec<_>>(); + formats.sort_unstable(); + formats.join(", ") + }; + #[cfg(not(feature = "non_strict_integers"))] + let formats = { + let formats = default_formats.into_iter().collect::<Vec<_>>(); + formats.join(", ") + }; + + formats + } +} + +impl Parse for KnownFormat { + fn parse(input: syn::parse::ParseStream) -> syn::Result<Self> { + let formats = KnownFormat::get_allowed_formats(); + + let lookahead = input.lookahead1(); + if lookahead.peek(Ident) { + let format = input.parse::<Ident>()?; + let name = &*format.to_string(); + + match name { + #[cfg(feature = "non_strict_integers")] + "Int8" => Ok(Self::Int8), + #[cfg(feature = "non_strict_integers")] + "Int16" => Ok(Self::Int16), + "Int32" => Ok(Self::Int32), + "Int64" => Ok(Self::Int64), + #[cfg(feature = "non_strict_integers")] + "UInt8" => Ok(Self::UInt8), + #[cfg(feature = "non_strict_integers")] + "UInt16" => Ok(Self::UInt16), + #[cfg(feature = "non_strict_integers")] + "UInt32" => Ok(Self::UInt32), + #[cfg(feature = "non_strict_integers")] + "UInt64" => Ok(Self::UInt64), + "Float" => Ok(Self::Float), + "Double" => Ok(Self::Double), + "Byte" => Ok(Self::Byte), + "Binary" => Ok(Self::Binary), + "Date" => Ok(Self::Date), + "DateTime" => Ok(Self::DateTime), + "Duration" => Ok(Self::Duration), + "Password" => Ok(Self::Password), + #[cfg(feature = "uuid")] + "Uuid" => Ok(Self::Uuid), + #[cfg(feature = "ulid")] + "Ulid" => Ok(Self::Ulid), + #[cfg(feature = "url")] + "Uri" => Ok(Self::Uri), + #[cfg(feature = "url")] + "UriReference" => Ok(Self::UriReference), + #[cfg(feature = "url")] + "Iri" => Ok(Self::Iri), + #[cfg(feature = "url")] + "IriReference" => Ok(Self::IriReference), + "Email" => Ok(Self::Email), + "IdnEmail" => Ok(Self::IdnEmail), + "Hostname" => Ok(Self::Hostname), + "IdnHostname" => Ok(Self::IdnHostname), + "Ipv4" => Ok(Self::Ipv4), + "Ipv6" => Ok(Self::Ipv6), + "UriTemplate" => Ok(Self::UriTemplate), + "JsonPointer" => Ok(Self::JsonPointer), + "RelativeJsonPointer" => Ok(Self::RelativeJsonPointer), + "Regex" => Ok(Self::Regex), + _ => Err(Error::new( + format.span(), + format!("unexpected format: {name}, expected one of: {formats}"), + )), + } + } else if lookahead.peek(LitStr) { + let value = input.parse::<LitStr>()?.value(); + Ok(Self::Custom(value)) + } else { + Err(lookahead.error()) + } + } +} + +impl ToTokens for KnownFormat { + fn to_tokens(&self, tokens: &mut TokenStream) { + match self { + #[cfg(feature = "non_strict_integers")] + Self::Int8 => tokens.extend(quote! {fastapi::openapi::schema::SchemaFormat::KnownFormat(fastapi::openapi::schema::KnownFormat::Int8)}), + #[cfg(feature = "non_strict_integers")] + Self::Int16 => tokens.extend(quote! {fastapi::openapi::schema::SchemaFormat::KnownFormat(fastapi::openapi::schema::KnownFormat::Int16)}), + Self::Int32 => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Int32 + ))), + Self::Int64 => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Int64 + ))), + #[cfg(feature = "non_strict_integers")] + Self::UInt8 => tokens.extend(quote! {fastapi::openapi::schema::SchemaFormat::KnownFormat(fastapi::openapi::schema::KnownFormat::UInt8)}), + #[cfg(feature = "non_strict_integers")] + Self::UInt16 => tokens.extend(quote! {fastapi::openapi::schema::SchemaFormat::KnownFormat(fastapi::openapi::schema::KnownFormat::UInt16)}), + #[cfg(feature = "non_strict_integers")] + Self::UInt32 => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::UInt32 + ))), + #[cfg(feature = "non_strict_integers")] + Self::UInt64 => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::UInt64 + ))), + Self::Float => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Float + ))), + Self::Double => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Double + ))), + Self::Byte => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Byte + ))), + Self::Binary => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Binary + ))), + Self::Date => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Date + ))), + Self::DateTime => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::DateTime + ))), + Self::Duration => tokens.extend(quote! {fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Duration + ) }), + Self::Password => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Password + ))), + #[cfg(feature = "uuid")] + Self::Uuid => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Uuid + ))), + #[cfg(feature = "ulid")] + Self::Ulid => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Ulid + ))), + #[cfg(feature = "url")] + Self::Uri => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Uri + ))), + #[cfg(feature = "url")] + Self::UriReference => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::UriReference + ))), + #[cfg(feature = "url")] + Self::Iri => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Iri + ))), + #[cfg(feature = "url")] + Self::IriReference => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::IriReference + ))), + Self::Email => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Email + ))), + Self::IdnEmail => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::IdnEmail + ))), + Self::Hostname => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Hostname + ))), + Self::IdnHostname => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::IdnHostname + ))), + Self::Ipv4 => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Ipv4 + ))), + Self::Ipv6 => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Ipv6 + ))), + Self::UriTemplate => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::UriTemplate + ))), + Self::JsonPointer => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::JsonPointer + ))), + Self::RelativeJsonPointer => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::RelativeJsonPointer + ))), + Self::Regex => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::KnownFormat( + fastapi::openapi::schema::KnownFormat::Regex + ))), + Self::Custom(value) => tokens.extend(quote!(fastapi::openapi::schema::SchemaFormat::Custom( + String::from(#value) + ))), + Self::Unknown => (), // unknown we just skip it + }; + } +} + +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct PrimitiveType { + pub ty: syn::Type, +} + +impl PrimitiveType { + pub fn new(path: &Path) -> Option<PrimitiveType> { + let last_segment = path.segments.last().unwrap_or_else(|| { + panic!( + "Path for DefaultType must have at least one segment: `{path}`", + path = path.to_token_stream() + ) + }); + + let name = &*last_segment.ident.to_string(); + + let ty: syn::Type = match name { + "String" | "str" | "char" => syn::parse_quote!(#path), + + "bool" => syn::parse_quote!(#path), + + "i8" | "i16" | "i32" | "i64" | "i128" | "isize" | "u8" | "u16" | "u32" | "u64" + | "u128" | "usize" => syn::parse_quote!(#path), + "f32" | "f64" => syn::parse_quote!(#path), + + #[cfg(feature = "chrono")] + "DateTime" | "NaiveDateTime" | "NaiveDate" | "NaiveTime" => { + syn::parse_quote!(String) + } + + #[cfg(any(feature = "chrono", feature = "time"))] + "Date" | "Duration" => { + syn::parse_quote!(String) + } + + #[cfg(feature = "decimal")] + "Decimal" => { + syn::parse_quote!(String) + } + + #[cfg(feature = "decimal_float")] + "Decimal" => { + syn::parse_quote!(f64) + } + + #[cfg(feature = "rocket_extras")] + "PathBuf" => { + syn::parse_quote!(String) + } + + #[cfg(feature = "uuid")] + "Uuid" => { + syn::parse_quote!(String) + } + + #[cfg(feature = "ulid")] + "Ulid" => { + syn::parse_quote!(String) + } + + #[cfg(feature = "url")] + "Url" => { + syn::parse_quote!(String) + } + + #[cfg(feature = "time")] + "PrimitiveDateTime" | "OffsetDateTime" => { + syn::parse_quote!(String) + } + _ => { + // not a primitive type + return None; + } + }; + + Some(Self { ty }) + } +} diff --git a/fastapi-gen/src/security_requirement.rs b/fastapi-gen/src/security_requirement.rs new file mode 100644 index 0000000..03e15eb --- /dev/null +++ b/fastapi-gen/src/security_requirement.rs @@ -0,0 +1,69 @@ +use proc_macro2::TokenStream; +use quote::{quote, ToTokens}; +use syn::{ + bracketed, + parse::{Parse, ParseStream}, + punctuated::Punctuated, + token::Comma, + LitStr, Token, +}; + +use crate::Array; + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct SecurityRequirementsAttrItem { + pub name: Option<String>, + pub scopes: Option<Vec<String>>, +} + +#[derive(Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct SecurityRequirementsAttr(Punctuated<SecurityRequirementsAttrItem, Comma>); + +impl Parse for SecurityRequirementsAttr { + fn parse(input: ParseStream) -> syn::Result<Self> { + Punctuated::<SecurityRequirementsAttrItem, Comma>::parse_terminated(input) + .map(|o| Self(o.into_iter().collect())) + } +} + +impl Parse for SecurityRequirementsAttrItem { + fn parse(input: ParseStream) -> syn::Result<Self> { + let name = input.parse::<LitStr>()?.value(); + + input.parse::<Token![=]>()?; + + let scopes_stream; + bracketed!(scopes_stream in input); + + let scopes = Punctuated::<LitStr, Comma>::parse_terminated(&scopes_stream)? + .iter() + .map(LitStr::value) + .collect::<Vec<_>>(); + + Ok(Self { + name: Some(name), + scopes: Some(scopes), + }) + } +} + +impl ToTokens for SecurityRequirementsAttr { + fn to_tokens(&self, tokens: &mut TokenStream) { + tokens.extend(quote! { + fastapi::openapi::security::SecurityRequirement::default() + }); + + for requirement in &self.0 { + if let (Some(name), Some(scopes)) = (&requirement.name, &requirement.scopes) { + let scopes = scopes.iter().collect::<Array<&String>>(); + let scopes_len = scopes.len(); + + tokens.extend(quote! { + .add::<&str, [&str; #scopes_len], &str>(#name, #scopes) + }); + } + } + } +} diff --git a/fastapi-gen/tests/common.rs b/fastapi-gen/tests/common.rs new file mode 100644 index 0000000..4fbbb6a --- /dev/null +++ b/fastapi-gen/tests/common.rs @@ -0,0 +1,40 @@ +use serde_json::Value; + +pub fn value_as_string(value: Option<&'_ Value>) -> String { + value.unwrap_or(&Value::Null).to_string() +} + +#[allow(unused)] +pub fn assert_json_array_len(value: &Value, len: usize) { + match value { + Value::Array(array) => assert_eq!( + len, + array.len(), + "wrong amount of parameters {} != {}", + len, + array.len() + ), + _ => unreachable!(), + } +} + +#[macro_export] +macro_rules! assert_value { + ($value:expr=> $( $path:literal = $expected:literal, $error:literal)* ) => {{ + $( + let p = &*format!("/{}", $path.replace(".", "/").replace("[", "").replace("]", "")); + let actual = $crate::common::value_as_string(Some($value.pointer(p).unwrap_or(&serde_json::Value::Null))); + assert_eq!(actual, $expected, "{}: {} expected to be: {} but was: {}", $error, $path, $expected, actual); + )* + }}; + + ($value:expr=> $( $path:literal = $expected:expr, $error:literal)*) => { + { + $( + let p = &*format!("/{}", $path.replace(".", "/").replace("[", "").replace("]", "")); + let actual = $value.pointer(p).unwrap_or(&serde_json::Value::Null); + assert!(actual == &$expected, "{}: {} expected to be: {:?} but was: {:?}", $error, $path, $expected, actual); + )* + } + } +} diff --git a/fastapi-gen/tests/fastapi_gen_test.rs b/fastapi-gen/tests/fastapi_gen_test.rs new file mode 100644 index 0000000..fe4fc29 --- /dev/null +++ b/fastapi-gen/tests/fastapi_gen_test.rs @@ -0,0 +1,163 @@ +#![allow(dead_code)] + +use fastapi::{ + openapi::{ + self, + security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, + server::{ServerBuilder, ServerVariableBuilder}, + }, + Modify, OpenApi, ToSchema, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(example = json!({"name": "bob the cat", "id": 1}))] +struct Pet { + id: u64, + name: String, + age: Option<i32>, +} + +// #[derive(ToSchema)] +// struct Status<StatusType> { +// status: StatusType, +// } + +// #[derive(ToSchema)] +// enum StatusType { +// Ok, +// NotOk, +// } + +// #[derive(ToSchema)] +// enum Random { +// Response { id: String }, +// PetResponse(Pet), +// Ids(Vec<String>), +// UnitValue, +// } + +// #[derive(Serialize, Deserialize, ToSchema)] +// struct Simple { +// greeting: &'static str, +// cow: Cow<'static, str>, +// } + +mod pet_api { + use super::*; + + const ID: &str = "get_pet"; + + /// Get pet by id + /// + /// Get pet from database by pet database id + #[fastapi::path( + get, + operation_id = ID, + path = "/pets/{id}", + responses( + (status = 200, description = "Pet found successfully", body = Pet), + (status = 404, description = "Pet was not found") + ), + params( + ("id" = u64, Path, description = "Pet database id to get Pet for"), + ), + security( + (), + ("my_auth" = ["read:items", "edit:items"]), + ("token_jwt" = []) + ) + )] + #[allow(unused)] + async fn get_pet_by_id(pet_id: u64) -> Pet { + Pet { + id: pet_id, + age: None, + name: "lightning".to_string(), + } + } +} + +#[derive(Default, OpenApi)] +#[openapi( + paths(pet_api::get_pet_by_id), + components(schemas(Pet, C<A, B>, C<B, A>)), + modifiers(&Foo), + security( + (), + ("my_auth" = ["read:items", "edit:items"]), + ("token_jwt" = []) + ) +)] +struct ApiDoc; + +macro_rules! build_foo { + ($typ: ident, $d: ty, $r: ty) => { + #[derive(Debug, Serialize, ToSchema)] + struct $typ { + data: $d, + resources: $r, + } + }; +} + +#[derive(Deserialize, Serialize, ToSchema)] +struct A { + a: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +struct B { + b: i64, +} + +#[derive(Deserialize, Serialize, ToSchema)] +struct C<T, R> { + field_1: R, + field_2: T, +} + +impl Modify for Foo { + fn modify(&self, openapi: &mut openapi::OpenApi) { + if let Some(schema) = openapi.components.as_mut() { + schema.add_security_scheme( + "token_jwt", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ) + } + + openapi.servers = Some(vec![ServerBuilder::new() + .url("/api/bar/{username}") + .description(Some("this is description of the server")) + .parameter( + "username", + ServerVariableBuilder::new() + .default_value("the_user") + .description(Some("this is user")), + ) + .build()]); + } +} + +#[derive(Debug, Serialize, ToSchema)] +struct Foo; + +#[derive(Debug, Serialize, ToSchema)] +struct FooResources; + +#[test] +#[ignore = "this is just a test bed to run macros"] +fn derive_openapi() { + fastapi::openapi::OpenApi::new( + fastapi::openapi::Info::new("my application", "0.1.0"), + fastapi::openapi::Paths::new(), + ); + println!("{}", ApiDoc::openapi().to_pretty_json().unwrap()); + + build_foo!(GetFooBody, Foo, FooResources); +} diff --git a/fastapi-gen/tests/modify_test.rs b/fastapi-gen/tests/modify_test.rs new file mode 100644 index 0000000..bd697c0 --- /dev/null +++ b/fastapi-gen/tests/modify_test.rs @@ -0,0 +1,44 @@ +use fastapi::{ + openapi::{ + self, + security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, + }, + Modify, OpenApi, +}; + +mod common; + +#[test] +fn modify_openapi_add_security_scheme() { + #[derive(Default, OpenApi)] + #[openapi(modifiers(&SecurityAddon))] + struct ApiDoc; + + struct SecurityAddon; + + impl Modify for SecurityAddon { + fn modify(&self, openapi: &mut openapi::OpenApi) { + openapi.components = Some( + fastapi::openapi::ComponentsBuilder::new() + .security_scheme( + "api_jwt_token", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ) + .build(), + ) + } + } + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "components.securitySchemes.api_jwt_token.scheme" = r###""bearer""###, "api_jwt_token scheme" + "components.securitySchemes.api_jwt_token.type" = r###""http""###, "api_jwt_token type" + "components.securitySchemes.api_jwt_token.bearerFormat" = r###""JWT""###, "api_jwt_token bearerFormat" + } +} diff --git a/fastapi-gen/tests/openapi_derive.rs b/fastapi-gen/tests/openapi_derive.rs new file mode 100644 index 0000000..1e3f15a --- /dev/null +++ b/fastapi-gen/tests/openapi_derive.rs @@ -0,0 +1,771 @@ +use std::{borrow::Cow, marker::PhantomData}; + +use assert_json_diff::{assert_json_eq, assert_json_include}; +use fastapi::{ + openapi::{RefOr, Response, ResponseBuilder}, + OpenApi, ToResponse, +}; +use fastapi_gen::ToSchema; +use serde::Serialize; +use serde_json::{json, Value}; + +mod common; + +#[test] +fn derive_openapi_with_security_requirement() { + #[derive(Default, OpenApi)] + #[openapi(security( + (), + ("my_auth" = ["read:items", "edit:items"]), + ("token_jwt" = []), + ("api_key1" = [], "api_key2" = []), + ))] + struct ApiDoc; + + let doc_value = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc_value=> + "security.[0]" = "{}", "Optional security requirement" + "security.[1].my_auth.[0]" = r###""read:items""###, "api_oauth first scope" + "security.[1].my_auth.[1]" = r###""edit:items""###, "api_oauth second scope" + "security.[2].token_jwt" = "[]", "jwt_token auth scopes" + "security.[3].api_key1" = "[]", "api_key1 auth scopes" + "security.[3].api_key2" = "[]", "api_key2 auth scopes" + } +} + +#[test] +fn derive_logical_or_security_requirement() { + #[derive(Default, OpenApi)] + #[openapi(security( + ("oauth" = ["a"]), + ("oauth" = ["b"]), + ))] + struct ApiDoc; + + let doc_value = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let security = doc_value + .pointer("/security") + .expect("should have security requirements"); + + assert_json_eq!( + security, + json!([ + {"oauth": ["a"]}, + {"oauth": ["b"]}, + ]) + ); +} + +#[test] +fn derive_openapi_tags() { + #[derive(OpenApi)] + #[openapi(tags( + (name = "random::api", description = "this is random api description"), + (name = "pets::api", description = "api all about pets", external_docs( + url = "http://localhost", description = "Find more about pets") + ) + ))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "tags.[0].name" = r###""random::api""###, "Tags random_api name" + "tags.[0].description" = r###""this is random api description""###, "Tags random_api description" + "tags.[0].externalDocs" = r###"null"###, "Tags random_api external docs" + "tags.[1].name" = r###""pets::api""###, "Tags pets_api name" + "tags.[1].description" = r###""api all about pets""###, "Tags pets_api description" + "tags.[1].externalDocs.url" = r###""http://localhost""###, "Tags pets_api external docs url" + "tags.[1].externalDocs.description" = r###""Find more about pets""###, "Tags pets_api external docs description" + } +} + +#[test] +fn derive_openapi_tags_include_str() { + #[derive(OpenApi)] + #[openapi(tags( + (name = "random::api", description = include_str!("testdata/openapi-derive-info-description")), + ))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "tags.[0].name" = r###""random::api""###, "Tags random_api name" + "tags.[0].description" = r###""this is include description\n""###, "Tags random_api description" + } +} + +#[test] +fn derive_openapi_tags_with_const_name() { + const TAG: &str = "random::api"; + #[derive(OpenApi)] + #[openapi(tags( + (name = TAG), + ))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "tags.[0].name" = r###""random::api""###, "Tags random_api name" + "tags.[0].description" = r###"null"###, "Tags random_api description" + } +} + +#[test] +fn derive_openapi_with_external_docs() { + #[derive(OpenApi)] + #[openapi(external_docs( + url = "http://localhost.more.about.api", + description = "Find out more" + ))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "externalDocs.url" = r###""http://localhost.more.about.api""###, "External docs url" + "externalDocs.description" = r###""Find out more""###, "External docs description" + } +} + +#[test] +fn derive_openapi_with_external_docs_only_url() { + #[derive(OpenApi)] + #[openapi(external_docs(url = "http://localhost.more.about.api"))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "externalDocs.url" = r###""http://localhost.more.about.api""###, "External docs url" + "externalDocs.description" = r###"null"###, "External docs description" + } +} + +#[test] +fn derive_openapi_with_components_in_different_module() { + mod custom { + use fastapi::ToSchema; + + #[derive(ToSchema)] + #[allow(unused)] + pub(super) struct Todo { + name: String, + } + } + + #[derive(OpenApi)] + #[openapi(components(schemas(custom::Todo)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let todo = doc.pointer("/components/schemas/Todo").unwrap(); + + assert_ne!( + todo, + &Value::Null, + "Expected components.schemas.Todo not to be null" + ); +} + +#[test] +fn derive_openapi_with_responses() { + #[allow(unused)] + struct MyResponse; + + impl<'r> ToResponse<'r> for MyResponse { + fn response() -> (&'r str, RefOr<Response>) { + ( + "MyResponse", + ResponseBuilder::new().description("Ok").build().into(), + ) + } + } + + #[derive(OpenApi)] + #[openapi(components(responses(MyResponse)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let responses = doc.pointer("/components/responses").unwrap(); + + assert_json_eq!( + responses, + json!({ + "MyResponse": { + "description": "Ok" + }, + }) + ) +} + +#[test] +fn derive_openapi_with_servers() { + #[derive(OpenApi)] + #[openapi( + servers( + (url = "http://localhost:8989", description = "this is description"), + (url = "http://api.{username}:{port}", description = "remote api", + variables( + ("username" = (default = "demo", description = "Default username for API")), + ("port" = (default = "8080", enum_values("8080", "5000", "3030"), description = "Supported ports for the API")) + ) + ) + ) + )] + struct ApiDoc; + + let value = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let servers = value.pointer("/servers"); + + assert_json_eq!( + servers, + json!([ + { + "description": "this is description", + "url": "http://localhost:8989" + }, + { + "description": "remote api", + "url": "http://api.{username}:{port}", + "variables": { + "port": { + "default": "8080", + "enum": [ + "8080", + "5000", + "3030" + ], + "description": "Supported ports for the API" + }, + "username": { + "default": "demo", + "description": "Default username for API" + } + } + } + ]) + ) +} + +#[test] +fn derive_openapi_with_licence() { + #[derive(OpenApi)] + #[openapi(info(license(name = "licence_name", identifier = "MIT"), version = "1.0.0",))] + struct ApiDoc; + + let value = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let info = value.pointer("/info/license"); + + assert_json_include!( + actual: info, + expected: + json!({ + "name": "licence_name", + "identifier": "MIT", + }) + ) +} + +#[test] +fn derive_openapi_with_custom_info() { + #[derive(OpenApi)] + #[openapi(info( + terms_of_service = "http://localhost/terms", + title = "title override", + description = "description override", + version = "1.0.0", + contact(name = "Test") + ))] + struct ApiDoc; + + let value = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let info = value.pointer("/info"); + + assert_json_include!( + actual: info, + expected: + json!( + { + "title": "title override", + "termsOfService": "http://localhost/terms", + "description": "description override", + "license": { + "name": "MIT OR Apache-2.0", + }, + "contact": { + "name": "Test" + }, + "version": "1.0.0", + } + ) + ) +} + +#[test] +fn derive_openapi_with_include_str_description() { + #[derive(OpenApi)] + #[openapi(info( + title = "title override", + description = include_str!("./testdata/openapi-derive-info-description"), + contact(name = "Test") + ))] + struct ApiDoc; + + let value = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let info = value.pointer("/info"); + + assert_json_include!( + actual: info, + expected: + json!( + { + "title": "title override", + "description": "this is include description\n", + "license": { + "name": "MIT OR Apache-2.0", + }, + "contact": { + "name": "Test" + } + } + ) + ) +} + +#[test] +fn derive_openapi_with_generic_response() { + struct Resp; + + #[derive(Serialize, ToResponse)] + struct Response<'a, Resp> { + #[serde(skip)] + _p: PhantomData<Resp>, + value: Cow<'a, str>, + } + + #[derive(OpenApi)] + #[openapi(components(responses(Response<Resp>)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let response = doc.pointer("/components/responses/Response"); + + assert_json_eq!( + response, + json!({ + "content": { + "application/json": { + "schema": { + "properties": { + "value": { + "type": "string" + } + }, + "required": ["value"], + "type": "object" + } + } + }, + "description": "" + }) + ) +} + +#[test] +fn derive_openapi_with_generic_schema() { + #[derive(ToSchema)] + struct Value; + + #[derive(Serialize, ToSchema)] + struct Pet<'a, Resp> { + #[serde(skip)] + _p: PhantomData<Resp>, + value: Cow<'a, str>, + } + + #[derive(OpenApi)] + #[openapi(components(schemas(Pet<Value>)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schema = doc.pointer("/components/schemas/Pet_Value"); + + assert_json_eq!( + schema, + json!({ + "properties": { + "value": { + "type": "string" + } + }, + "required": ["value"], + "type": "object" + }) + ) +} + +#[test] +fn derive_openapi_with_generic_schema_with_as() { + #[derive(ToSchema)] + struct Value; + + #[derive(Serialize, ToSchema)] + #[schema(as = api::models::Pet)] + struct Pet<'a, Resp> { + #[serde(skip)] + _p: PhantomData<Resp>, + value: Cow<'a, str>, + } + + #[derive(OpenApi)] + #[openapi(components(schemas(Pet<Value>)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schema = doc.pointer("/components/schemas/api.models.Pet_Value"); + + assert_json_eq!( + schema, + json!({ + "properties": { + "value": { + "type": "string" + } + }, + "required": ["value"], + "type": "object" + }) + ) +} + +#[test] +fn derive_nest_openapi_with_tags() { + #[fastapi::path(get, path = "/api/v1/status")] + #[allow(dead_code)] + fn test_path_status() {} + + mod random { + #[fastapi::path(get, path = "/random")] + #[allow(dead_code)] + fn random() {} + } + + mod user_api { + #[fastapi::path(get, path = "/test")] + #[allow(dead_code)] + fn user_test_path() {} + + #[derive(super::OpenApi)] + #[openapi(paths(user_test_path))] + pub(super) struct UserApi; + } + + #[fastapi::path(get, path = "/", tag = "mytag", tags = ["yeah", "wowow"])] + #[allow(dead_code)] + fn foobar() {} + + #[fastapi::path(get, path = "/another", tag = "mytaganother")] + #[allow(dead_code)] + fn foobaranother() {} + + #[fastapi::path(get, path = "/", tags = ["yeah", "wowow"])] + #[allow(dead_code)] + fn foobar2() {} + + #[derive(OpenApi)] + #[openapi(paths(foobar, foobaranother), nest( + (path = "/nest2", api = FooBarNestedApi) + ))] + struct FooBarApi; + + #[derive(OpenApi)] + #[openapi(paths(foobar2))] + struct FooBarNestedApi; + + const TAG: &str = "tag1"; + + #[derive(OpenApi)] + #[openapi( + paths( + test_path_status, + random::random + ), + nest( + (path = "/api/v1/user", api = user_api::UserApi, tags = ["user", TAG]), + (path = "/api/v1/foobar", api = FooBarApi, tags = ["foobarapi"]) + ) + )] + struct ApiDoc; + + let api = serde_json::to_value(ApiDoc::openapi()).expect("should serialize to value"); + let paths = api.pointer("/paths"); + + assert_json_eq!( + paths, + json!({ + "/api/v1/foobar/": { + "get": { + "operationId": "foobar", + "responses": {}, + "tags": [ "mytag", "yeah", "wowow", "foobarapi" ] + } + }, + "/api/v1/foobar/another": { + "get": { + "operationId": "foobaranother", + "responses": {}, + "tags": [ "mytaganother", "foobarapi" ] + } + }, + "/api/v1/foobar/nest2/": { + "get": { + "operationId": "foobar2", + "responses": {}, + "tags": [ "yeah", "wowow", "foobarapi" ] + } + }, + "/api/v1/status": { + "get": { + "operationId": "test_path_status", + "responses": {}, + "tags": [] + } + }, + "/api/v1/user/test": { + "get": { + "operationId": "user_test_path", + "responses": {}, + "tags": [ "user", TAG ] + } + }, + "/random": { + "get": { + "operationId": "random", + "responses": {}, + "tags": [ "random" ] + } + } + }) + ) +} + +#[test] +fn openapi_schemas_resolve_generic_enum_schema() { + #![allow(dead_code)] + use fastapi::ToSchema; + + #[derive(ToSchema)] + enum Element<T> { + One(T), + Many(Vec<T>), + } + + #[derive(OpenApi)] + #[openapi(components(schemas(Element<String>)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + + let value = serde_json::to_value(&doc).expect("OpenAPI is JSON serializable"); + let schemas = value.pointer("/components/schemas").unwrap(); + let json = serde_json::to_string_pretty(&schemas).expect("OpenAPI is json serializable"); + println!("{json}"); + + assert_json_eq!( + schemas, + json!({ + "Element_String": { + "oneOf": [ + { + "properties": { + "One": { + "type": "string" + } + }, + "required": [ + "One" + ], + "type": "object" + }, + { + "properties": { + "Many": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "Many" + ], + "type": "object" + } + ] + } + }) + ) +} + +#[test] +fn openapi_schemas_resolve_schema_references() { + #![allow(dead_code)] + use fastapi::ToSchema; + + #[derive(ToSchema)] + enum Element<T> { + One(T), + Many(Vec<T>), + } + + #[derive(ToSchema)] + struct Foobar; + + #[derive(ToSchema)] + struct Account { + id: i32, + } + + #[derive(ToSchema)] + struct Person { + name: String, + foo_bar: Foobar, + accounts: Vec<Option<Account>>, + } + + #[derive(ToSchema)] + struct Yeah { + name: String, + foo_bar: Foobar, + accounts: Vec<Option<Account>>, + } + + #[derive(ToSchema)] + struct Boo { + boo: bool, + } + + #[derive(ToSchema)] + struct OneOfOne(Person); + + #[derive(ToSchema)] + struct OneOfYeah(Yeah); + + #[derive(ToSchema)] + struct ThisIsNone; + + #[derive(ToSchema)] + enum EnumMixedContent { + ContentZero, + One(Foobar), + NamedSchema { + value: Account, + value2: Boo, + foo: ThisIsNone, + int: i32, + f: bool, + }, + Many(Vec<Person>), + } + + #[derive(ToSchema)] + struct Foob { + item: Element<String>, + item2: Element<Yeah>, + } + + #[derive(OpenApi)] + #[openapi(components(schemas(Person, Foob, OneOfYeah, OneOfOne, EnumMixedContent, Element<String>)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + + let value = serde_json::to_value(&doc).expect("OpenAPI is JSON serializable"); + let schemas = value.pointer("/components").unwrap(); + let json = serde_json::to_string_pretty(&schemas).expect("OpenAPI is json serializable"); + println!("{json}"); + + let expected = + include_str!("./testdata/openapi_schemas_resolve_inner_schema_references").trim(); + assert_eq!(expected, json.trim()); +} + +#[test] +fn openapi_resolvle_recursive_references() { + #![allow(dead_code)] + use fastapi::ToSchema; + + #[derive(ToSchema)] + struct Foobar; + + #[derive(ToSchema)] + struct Account { + id: i32, + foobar: Foobar, + } + + #[derive(ToSchema)] + struct Person { + name: String, + accounts: Vec<Option<Account>>, + } + + #[derive(OpenApi)] + #[openapi(components(schemas(Person)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + + let value = serde_json::to_value(doc).expect("OpenAPI is serde serializable"); + let schemas = value + .pointer("/components/schemas") + .expect("OpenAPI must have schemas"); + + assert_json_eq!( + schemas, + json!({ + "Account": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + }, + "foobar": { + "$ref": "#/components/schemas/Foobar" + } + }, + "type": "object", + "required": [ "id" , "foobar" ], + }, + "Foobar": { + "default": null, + }, + "Person": { + "properties": { + "accounts": { + "items": { + "oneOf": [ + { + "type": "null", + }, + { + "$ref": "#/components/schemas/Account", + } + ] + }, + "type": "array", + }, + "name": { + "type": "string" + }, + }, + "type": "object", + "required": [ "name" , "accounts" ], + } + }) + ) +} diff --git a/fastapi-gen/tests/openapi_derive_test.rs b/fastapi-gen/tests/openapi_derive_test.rs new file mode 100644 index 0000000..6eff5da --- /dev/null +++ b/fastapi-gen/tests/openapi_derive_test.rs @@ -0,0 +1,128 @@ +#![allow(dead_code)] + +use fastapi::{ + openapi::{ + self, + security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, + server::{ServerBuilder, ServerVariableBuilder}, + }, + Modify, OpenApi, ToSchema, +}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, ToSchema)] +#[schema(example = json!({"name": "bob the cat", "id": 1}))] +struct Pet { + id: u64, + name: String, + age: Option<i32>, +} + +mod pet_api { + use super::*; + + /// Get pet by id + /// + /// Get pet from database by pet database id + #[fastapi::path( + get, + path = "/pets/{id}", + responses( + (status = 200, description = "Pet found successfully", body = Pet), + (status = 404, description = "Pet was not found") + ), + params( + ("id" = u64, Path, description = "Pet database id to get Pet for"), + ), + security( + (), + ("my_auth1" = ["read:items", "edit:items"], "my_auth2" = ["read:items"]), + ("token_jwt" = []) + ) + )] + #[allow(unused)] + async fn get_pet_by_id(pet_id: u64) -> Pet { + Pet { + id: pet_id, + age: None, + name: "lightning".to_string(), + } + } +} + +#[derive(Default, OpenApi)] +#[openapi( + paths(pet_api::get_pet_by_id), + components(schemas(Pet, C<A, B>, C<B, A>)), + modifiers(&Foo), + security( + (), + ("my_auth1" = ["read:items", "edit:items"], "my_auth2" = ["read:items"]), + ("token_jwt" = []) + ) +)] +struct ApiDoc; + +#[derive(Deserialize, Serialize, ToSchema)] +struct A { + a: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +struct B { + b: i64, +} + +#[derive(Deserialize, Serialize, ToSchema)] +struct C<T, R> { + field_1: R, + field_2: T, +} + +#[derive(Debug, Serialize)] +struct Foo; + +#[derive(Debug, Serialize)] +struct FooResources; + +impl Modify for Foo { + fn modify(&self, openapi: &mut openapi::OpenApi) { + if let Some(schema) = openapi.components.as_mut() { + schema.add_security_scheme( + "token_jwt", + SecurityScheme::Http( + HttpBuilder::new() + .scheme(HttpAuthScheme::Bearer) + .bearer_format("JWT") + .build(), + ), + ) + } + + openapi.servers = Some(vec![ServerBuilder::new() + .url("/api/bar/{username}") + .description(Some("this is description of the server")) + .parameter( + "username", + ServerVariableBuilder::new() + .default_value("the_user") + .description(Some("this is user")), + ) + .build()]); + } +} + +#[test] +#[cfg(feature = "yaml")] +fn stable_yaml() { + let left = ApiDoc::openapi().to_yaml().unwrap(); + let right = ApiDoc::openapi().to_yaml().unwrap(); + assert_eq!(left, right); +} + +#[test] +fn stable_json() { + let left = ApiDoc::openapi().to_json().unwrap(); + let right = ApiDoc::openapi().to_json().unwrap(); + assert_eq!(left, right); +} diff --git a/fastapi-gen/tests/path_derive.rs b/fastapi-gen/tests/path_derive.rs new file mode 100644 index 0000000..b0b1f4a --- /dev/null +++ b/fastapi-gen/tests/path_derive.rs @@ -0,0 +1,3169 @@ +use std::collections::BTreeMap; + +use assert_json_diff::{assert_json_eq, assert_json_matches, CompareMode, Config, NumericMode}; +use fastapi::openapi::RefOr; +use fastapi::openapi::{Object, ObjectBuilder}; +use fastapi::Path; +use fastapi::{ + openapi::{Response, ResponseBuilder, ResponsesBuilder}, + IntoParams, IntoResponses, OpenApi, ToSchema, +}; +use paste::paste; +use serde::Serialize; +use serde_json::{json, Value}; +use std::collections::HashMap; + +mod common; + +macro_rules! test_api_fn_doc { + ( $handler:path, operation: $operation:expr, path: $path:literal ) => {{ + use fastapi::OpenApi; + #[derive(OpenApi, Default)] + #[openapi(paths($handler))] + struct ApiDoc; + + let doc = &serde_json::to_value(ApiDoc::openapi()).unwrap(); + let operation = doc + .pointer(&format!( + "/paths/{}/{}", + $path.replace("/", "~1"), + stringify!($operation) + )) + .unwrap_or(&serde_json::Value::Null); + operation.clone() + }}; +} + +macro_rules! test_api_fn { + (name: $name:ident, module: $module:ident, + operation: $operation:ident, + path: $path:expr + $(, params: $params:expr )? + $(, operation_id: $operation_id:expr )? + $(, tag: $tag:expr )? + $(; $( #[$meta:meta] )* )? ) => { + mod $module { + $( $(#[$meta])* )* + #[fastapi::path( + $operation, + $( operation_id = $operation_id, )* + path = $path, + responses( + (status = 200, description = "success response") + ), + $( params $params, )* + $( tag = $tag, )* + )] + #[allow(unused)] + async fn $name() -> String { + "foo".to_string() + } + } + }; +} +macro_rules! test_path_operation { + ( $($name:ident: $operation:ident)* ) => { + $(paste! { + test_api_fn! { + name: test_operation, + module: [<mod_ $name>], + operation: $operation, + path: "/foo" + } + } + #[test] + fn $name() { + paste!{ + use fastapi::OpenApi; + #[derive(OpenApi, Default)] + #[openapi(paths( + [<mod_ $name>]::test_operation + ))] + struct ApiDoc; + } + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let operation_value = doc.pointer(&*format!("/paths/~1foo/{}", stringify!($operation))).unwrap_or(&serde_json::Value::Null); + assert!(operation_value != &serde_json::Value::Null, + "expected to find operation with: {}", &format!("paths./foo.{}", stringify!($operation))); + })* + }; +} + +test_path_operation! { + derive_path_post: post + derive_path_get: get + derive_path_delete: delete + derive_path_put: put + derive_path_options: options + derive_path_head: head + derive_path_patch: patch + derive_path_trace: trace +} + +macro_rules! api_fn_doc_with_params { + ( $method:ident: $path:literal => $( #[$attr:meta] )* $key:ident $name:ident $body:tt ) => {{ + #[allow(dead_code)] + #[derive(serde::Deserialize, fastapi::IntoParams)] + $(#[$attr])* + $key $name $body + + #[fastapi::path( + $method, + path = $path, + responses( + (status = 200, description = "success response") + ), + params( + $name, + ) + )] + #[allow(unused)] + async fn my_operation(params: MyParams) -> String { + "".to_string() + } + + let operation: Value = test_api_fn_doc! { + my_operation, + operation: $method, + path: $path + }; + + operation + }}; +} + +test_api_fn! { + name: test_operation2, + module: derive_path_with_all_info, + operation: post, + path: "/foo/bar/{id}", + params: (("id", description = "Foo bar id")), + operation_id: "foo_bar_id", + tag: "custom_tag"; + /// This is test operation long multiline + /// summary. That need to be correctly split. + /// + /// Additional info in long description + /// + /// With more info on separate lines + /// containing markdown: + /// - A + /// Indented. + /// - B + #[deprecated] +} + +#[test] +fn derive_path_with_all_info_success() { + let operation = test_api_fn_doc! { + derive_path_with_all_info::test_operation2, + operation: post, + path: "/foo/bar/{id}" + }; + + common::assert_json_array_len(operation.pointer("/parameters").unwrap(), 1); + assert_value! {operation=> + "deprecated" = r#"true"#, "Api fn deprecated status" + "description" = r#""Additional info in long description\n\nWith more info on separate lines\ncontaining markdown:\n- A\n Indented.\n- B""#, "Api fn description" + "summary" = r#""This is test operation long multiline\nsummary. That need to be correctly split.""#, "Api fn summary" + "operationId" = r#""foo_bar_id""#, "Api fn operation_id" + "tags.[0]" = r#""custom_tag""#, "Api fn tag" + + "parameters.[0].deprecated" = r#"null"#, "Path parameter deprecated" + "parameters.[0].description" = r#""Foo bar id""#, "Path parameter description" + "parameters.[0].in" = r#""path""#, "Path parameter in" + "parameters.[0].name" = r#""id""#, "Path parameter name" + "parameters.[0].required" = r#"true"#, "Path parameter required" + } +} + +#[test] +fn derive_path_with_defaults_success() { + test_api_fn! { + name: test_operation3, + module: derive_path_with_defaults, + operation: post, + path: "/foo/bar"; + } + let operation = test_api_fn_doc! { + derive_path_with_defaults::test_operation3, + operation: post, + path: "/foo/bar" + }; + + assert_value! {operation=> + "deprecated" = r#"null"#, "Api fn deprecated status" + "operationId" = r#""test_operation3""#, "Api fn operation_id" + "tags.[0]" = r#""derive_path_with_defaults""#, "Api fn tag" + "parameters" = r#"null"#, "Api parameters" + } +} + +#[test] +fn derive_path_with_extra_attributes_without_nested_module() { + /// This is test operation + /// + /// This is long description for test operation + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + ( + status = 200, description = "success response") + ), + params( + ("id" = i64, deprecated = false, description = "Foo database id"), + ("since" = Option<String>, Query, deprecated = false, description = "Datetime since foo is updated") + ) + )] + #[allow(unused)] + async fn get_foos_by_id_since() -> String { + "".to_string() + } + + let operation = test_api_fn_doc! { + get_foos_by_id_since, + operation: get, + path: "/foo/{id}" + }; + + common::assert_json_array_len(operation.pointer("/parameters").unwrap(), 2); + assert_value! {operation=> + "deprecated" = r#"null"#, "Api operation deprecated" + "description" = r#""This is long description for test operation""#, "Api operation description" + "operationId" = r#""get_foos_by_id_since""#, "Api operation operation_id" + "summary" = r#""This is test operation""#, "Api operation summary" + "tags.[0]" = r#"null"#, "Api operation tag" + + "parameters.[0].deprecated" = r#"false"#, "Parameter 0 deprecated" + "parameters.[0].description" = r#""Foo database id""#, "Parameter 0 description" + "parameters.[0].in" = r#""path""#, "Parameter 0 in" + "parameters.[0].name" = r#""id""#, "Parameter 0 name" + "parameters.[0].required" = r#"true"#, "Parameter 0 required" + "parameters.[0].schema.format" = r#""int64""#, "Parameter 0 schema format" + "parameters.[0].schema.type" = r#""integer""#, "Parameter 0 schema type" + + "parameters.[1].deprecated" = r#"false"#, "Parameter 1 deprecated" + "parameters.[1].description" = r#""Datetime since foo is updated""#, "Parameter 1 description" + "parameters.[1].in" = r#""query""#, "Parameter 1 in" + "parameters.[1].name" = r#""since""#, "Parameter 1 name" + "parameters.[1].required" = r#"false"#, "Parameter 1 required" + "parameters.[1].schema.allOf.[0].format" = r#"null"#, "Parameter 1 schema format" + "parameters.[1].schema.allOf.[0].type" = r#"null"#, "Parameter 1 schema type" + "parameters.[1].schema.allOf.nullable" = r#"null"#, "Parameter 1 schema type" + } +} + +#[test] +fn derive_path_with_security_requirements() { + #[fastapi::path( + get, + path = "/items", + responses( + (status = 200, description = "success response") + ), + security( + (), + ("api_oauth" = ["read:items", "edit:items"]), + ("jwt_token" = []) + ) + )] + #[allow(unused)] + fn get_items() -> String { + "".to_string() + } + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/items" + }; + + assert_value! {operation=> + "security.[0]" = "{}", "Optional security requirement" + "security.[1].api_oauth.[0]" = r###""read:items""###, "api_oauth first scope" + "security.[1].api_oauth.[1]" = r###""edit:items""###, "api_oauth second scope" + "security.[2].jwt_token" = "[]", "jwt_token auth scopes" + } +} + +#[test] +fn derive_path_with_datetime_format_query_parameter() { + #[derive(serde::Deserialize, fastapi::ToSchema)] + struct Since { + /// Some date + #[allow(dead_code)] + date: String, + /// Some time + #[allow(dead_code)] + time: String, + } + + /// This is test operation + /// + /// This is long description for test operation + #[fastapi::path( + get, + path = "/foo/{id}/{start}", + responses( + (status = 200, description = "success response") + ), + params( + ("id" = i64, Path, description = "Foo database id"), + ("start" = String, Path, description = "Datetime since foo is updated", format = DateTime) + ) + )] + #[allow(unused)] + async fn get_foos_by_id_date() -> String { + "".to_string() + } + + let operation: Value = test_api_fn_doc! { + get_foos_by_id_date, + operation: get, + path: "/foo/{id}/{start}" + }; + + let parameters: &Value = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "description": "Foo database id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + } + }, + { + "description": "Datetime since foo is updated", + "in": "path", + "name": "start", + "required": true, + "schema": { + "format": "date-time", + "type": "string", + } + } + ]) + ); +} + +#[test] +fn derive_path_with_datetime_format_path_parameter() { + #[derive(serde::Deserialize, fastapi::ToSchema)] + struct Since { + /// Some date + #[allow(dead_code)] + date: String, + /// Some time + #[allow(dead_code)] + time: String, + } + + /// This is test operation + /// + /// This is long description for test operation + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + (status = 200, description = "success response") + ), + params( + ("id" = i64, description = "Foo database id"), + ("start" = String, Query, description = "Datetime since foo is updated", format = DateTime) + ) + )] + #[allow(unused)] + async fn get_foos_by_id_date() -> String { + "".to_string() + } + + let operation: Value = test_api_fn_doc! { + get_foos_by_id_date, + operation: get, + path: "/foo/{id}" + }; + + let parameters: &Value = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "description": "Foo database id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + } + }, + { + "description": "Datetime since foo is updated", + "in": "query", + "name": "start", + "required": true, + "schema": { + "format": "date-time", + "type": "string", + } + } + ]) + ); +} + +#[test] +fn derive_path_with_parameter_schema() { + #[derive(serde::Deserialize, fastapi::ToSchema)] + struct Since { + /// Some date + #[allow(dead_code)] + date: String, + /// Some time + #[allow(dead_code)] + time: String, + } + + /// This is test operation + /// + /// This is long description for test operation + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + (status = 200, description = "success response") + ), + params( + ("id" = i64, description = "Foo database id"), + ("since" = Option<Since>, Query, description = "Datetime since foo is updated") + ) + )] + #[allow(unused)] + async fn get_foos_by_id_since() -> String { + "".to_string() + } + + let operation: Value = test_api_fn_doc! { + get_foos_by_id_since, + operation: get, + path: "/foo/{id}" + }; + + let parameters: &Value = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "description": "Foo database id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + } + }, + { + "description": "Datetime since foo is updated", + "in": "query", + "name": "since", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Since" + } + ], + } + } + ]) + ); +} + +#[test] +fn derive_path_with_parameter_inline_schema() { + #[derive(serde::Deserialize, fastapi::ToSchema)] + struct Since { + /// Some date + #[allow(dead_code)] + date: String, + /// Some time + #[allow(dead_code)] + time: String, + } + + /// This is test operation + /// + /// This is long description for test operation + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + (status = 200, description = "success response") + ), + params( + ("id" = i64, description = "Foo database id"), + ("since" = inline(Option<Since>), Query, description = "Datetime since foo is updated") + ) + )] + #[allow(unused)] + async fn get_foos_by_id_since() -> String { + "".to_string() + } + + let operation: Value = test_api_fn_doc! { + get_foos_by_id_since, + operation: get, + path: "/foo/{id}" + }; + + let parameters: &Value = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "description": "Foo database id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + } + }, + { + "description": "Datetime since foo is updated", + "in": "query", + "name": "since", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "date": { + "description": "Some date", + "type": "string" + }, + "time": { + "description": "Some time", + "type": "string" + } + }, + "required": [ + "date", + "time" + ], + "type": "object" + } + ], + } + } + ]) + ); +} + +#[test] +fn derive_path_params_map() { + #[derive(serde::Deserialize, ToSchema)] + enum Foo { + Bar, + Baz, + } + + #[derive(serde::Deserialize, IntoParams)] + #[allow(unused)] + struct MyParams { + with_ref: HashMap<String, Foo>, + with_type: HashMap<String, String>, + } + + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, description = "success response") + ), + params( + MyParams, + ) + )] + #[allow(unused)] + fn use_maps(params: MyParams) -> String { + "".to_string() + } + + let operation: Value = test_api_fn_doc! { + use_maps, + operation: get, + path: "/foo" + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq! { + parameters, + json!{[ + { + "in": "path", + "name": "with_ref", + "required": true, + "schema": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Foo" + }, + "type": "object" + } + }, + { + "in": "path", + "name": "with_type", + "required": true, + "schema": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + } + ]} + } +} + +#[test] +fn derive_path_params_with_examples() { + let operation = api_fn_doc_with_params! {get: "/foo" => + struct MyParams { + #[param(example = json!({"key": "value"}))] + map: HashMap<String, String>, + #[param(example = json!(["value1", "value2"]))] + vec: Vec<String>, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq! { + parameters, + json!{[ + { + "in": "path", + "name": "map", + "required": true, + "example": { + "key": "value" + }, + "schema": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "type": "object" + } + }, + { + "in": "path", + "name": "vec", + "required": true, + "example": ["value1", "value2"], + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + ]} + } +} + +#[test] +fn path_parameters_with_free_form_properties() { + let operation = api_fn_doc_with_params! {get: "/foo" => + struct MyParams { + #[param(additional_properties)] + map: HashMap<String, String>, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq! { + parameters, + json!{[ + { + "in": "path", + "name": "map", + "required": true, + "schema": { + "additionalProperties": true, + "type": "object" + } + } + ]} + } +} + +#[test] +fn derive_path_query_params_with_schema_features() { + let operation = api_fn_doc_with_params! {get: "/foo" => + #[into_params(parameter_in = Query)] + struct MyParams { + #[serde(default)] + #[param(write_only, read_only, default = "value", nullable, xml(name = "xml_value"))] + value: String, + #[param(value_type = String, format = Binary)] + int: i64, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq! { + parameters, + json!{[ + { + "in": "query", + "name": "value", + "required": false, + "schema": { + "default": "value", + "type": ["string", "null"], + "readOnly": true, + "writeOnly": true, + "xml": { + "name": "xml_value" + } + } + }, + { + "in": "query", + "name": "int", + "required": true, + "schema": { + "type": "string", + "format": "binary" + } + } + ]} + } +} + +#[test] +fn derive_path_params_always_required() { + let operation = api_fn_doc_with_params! {get: "/foo" => + #[into_params(parameter_in = Path)] + struct MyParams { + #[serde(default)] + value: String, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq! { + parameters, + json!{[ + { + "in": "path", + "name": "value", + "required": true, + "schema": { + "type": "string", + } + } + ]} + } +} + +#[test] +fn derive_required_path_params() { + let operation = api_fn_doc_with_params! {get: "/list/{id}" => + #[into_params(parameter_in = Query)] + struct MyParams { + #[serde(default)] + vec_default: Option<Vec<String>>, + + #[serde(default)] + string_default: Option<String>, + + #[serde(default)] + vec_default_required: Vec<String>, + + #[serde(default)] + string_default_required: String, + + vec_option: Option<Vec<String>>, + + string_option: Option<String>, + + vec: Vec<String>, + + string: String, + } + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vec_default", + "required": false, + "schema": { + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string_default", + "required": false, + "schema": { + "type": ["string", "null"] + } + }, + { + "in": "query", + "name": "vec_default_required", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string_default_required", + "required": false, + "schema": { + "type": "string" + }, + }, + { + "in": "query", + "name": "vec_option", + "required": false, + "schema": { + "items": { + "type": "string" + }, + "type": ["array", "null"], + }, + }, + { + "in": "query", + "name": "string_option", + "required": false, + "schema": { + "type": ["string", "null"] + } + }, + { + "in": "query", + "name": "vec", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "query", + "name": "string", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} + +#[test] +fn derive_path_params_with_serde_and_custom_rename() { + let operation = api_fn_doc_with_params! {get: "/list/{id}" => + #[into_params(parameter_in = Query)] + #[serde(rename_all = "camelCase")] + struct MyParams { + vec_default: Option<Vec<String>>, + + #[serde(default, rename = "STRING")] + string_default: Option<String>, + + #[serde(default, rename = "VEC")] + #[param(rename = "vec2")] + vec_default_required: Vec<String>, + + #[serde(default)] + #[param(rename = "string_r2")] + string_default_required: String, + + string: String, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vecDefault", + "required": false, + "schema": { + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "STRING", + "required": false, + "schema": { + "type": ["string", "null"] + } + }, + { + "in": "query", + "name": "VEC", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string_r2", + "required": false, + "schema": { + "type": "string" + }, + }, + { + "in": "query", + "name": "string", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} + +#[test] +fn derive_path_params_custom_rename_all() { + let operation = api_fn_doc_with_params! {get: "/list/{id}" => + #[into_params(rename_all = "camelCase", parameter_in = Query)] + struct MyParams { + vec_default: Option<Vec<String>>, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vecDefault", + "required": false, + "schema": { + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + }, + ]) + ) +} + +#[test] +fn derive_path_params_custom_rename_all_serde_will_override() { + let operation = api_fn_doc_with_params! {get: "/list/{id}" => + #[into_params(rename_all = "camelCase", parameter_in = Query)] + #[serde(rename_all = "UPPERCASE")] + struct MyParams { + vec_default: Option<Vec<String>>, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "VEC_DEFAULT", + "required": false, + "schema": { + "type": ["array", "null"], + "items": { + "type": "string" + } + }, + }, + ]) + ) +} + +#[test] +fn derive_path_parameters_container_level_default() { + let operation = api_fn_doc_with_params! {get: "/list/{id}" => + #[derive(Default)] + #[into_params(parameter_in = Query)] + #[serde(default)] + struct MyParams { + vec_default: Vec<String>, + string: String, + } + }; + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "vec_default", + "required": false, + "schema": { + "type": "array", + "items": { + "type": "string" + } + }, + }, + { + "in": "query", + "name": "string", + "required": false, + "schema": { + "type": "string" + }, + } + ]) + ) +} + +#[test] +fn derive_path_params_intoparams() { + #[derive(serde::Deserialize, ToSchema)] + #[schema(default = "foo1", example = "foo1")] + #[serde(rename_all = "snake_case")] + enum Foo { + Foo1, + Foo2, + } + + #[derive(serde::Deserialize, IntoParams)] + #[into_params(style = Form, parameter_in = Query)] + struct MyParams { + /// Foo database id. + #[param(example = 1)] + #[allow(unused)] + id: i64, + /// Datetime since foo is updated. + #[param(example = "2020-04-12T10:23:00Z")] + #[allow(unused)] + since: Option<String>, + /// A Foo item ref. + #[allow(unused)] + foo_ref: Foo, + /// A Foo item inline. + #[param(inline)] + #[allow(unused)] + foo_inline: Foo, + /// An optional Foo item inline. + #[param(inline)] + #[allow(unused)] + foo_inline_option: Option<Foo>, + /// A vector of Foo item inline. + #[param(inline)] + #[allow(unused)] + foo_inline_vec: Vec<Foo>, + } + + #[fastapi::path( + get, + path = "/list/{id}", + responses( + (status = 200, description = "success response") + ), + params( + MyParams, + ("id" = i64, Path, description = "Id of some items to list") + ) + )] + #[allow(unused)] + fn list(id: i64, params: MyParams) -> String { + "".to_string() + } + + let operation: Value = test_api_fn_doc! { + list, + operation: get, + path: "/list/{id}" + }; + + let parameters = operation.get("parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "description": "Foo database id.", + "example": 1, + "in": "query", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + }, + "style": "form" + }, + { + "description": "Datetime since foo is updated.", + "example": "2020-04-12T10:23:00Z", + "in": "query", + "name": "since", + "required": false, + "schema": { + "type": ["string", "null"] + }, + "style": "form" + }, + { + "description": "A Foo item ref.", + "in": "query", + "name": "foo_ref", + "required": true, + "schema": { + "$ref": "#/components/schemas/Foo" + }, + "style": "form" + }, + { + "description": "A Foo item inline.", + "in": "query", + "name": "foo_inline", + "required": true, + "schema": { + "default": "foo1", + "example": "foo1", + "enum": ["foo1", "foo2"], + "type": "string", + }, + "style": "form" + }, + { + "description": "An optional Foo item inline.", + "in": "query", + "name": "foo_inline_option", + "required": false, + "schema": { + "oneOf": [ + { + "type": "null", + }, + { + "default": "foo1", + "example": "foo1", + "enum": ["foo1", "foo2"], + "type": "string", + } + ], + }, + "style": "form" + }, + { + "description": "A vector of Foo item inline.", + "in": "query", + "name": "foo_inline_vec", + "required": true, + "schema": { + "items": { + "default": "foo1", + "example": "foo1", + "enum": ["foo1", "foo2"], + "type": "string", + }, + "type": "array", + }, + "style": "form", + }, + { + "description": "Id of some items to list", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ]) + ) +} + +#[test] +fn derive_path_params_into_params_with_value_type() { + use fastapi::OpenApi; + + #[derive(ToSchema)] + #[allow(dead_code)] + struct Foo { + #[allow(unused)] + value: String, + } + + #[derive(IntoParams)] + #[into_params(parameter_in = Query)] + #[allow(unused)] + struct Filter { + #[param(value_type = i64, style = Simple)] + id: String, + #[param(value_type = Object)] + another_id: String, + #[param(value_type = Vec<Vec<String>>)] + value1: Vec<i64>, + #[param(value_type = Vec<String>)] + value2: Vec<i64>, + #[param(value_type = Option<String>)] + value3: i64, + #[param(value_type = Option<Object>)] + value4: i64, + #[param(value_type = Vec<Object>)] + value5: i64, + #[param(value_type = Vec<Foo>)] + value6: i64, + } + + #[fastapi::path( + get, + path = "foo", + responses( + (status = 200, description = "success response") + ), + params( + Filter + ) + )] + #[allow(unused)] + fn get_foo(query: Filter) {} + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/foo/get/parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([{ + "in": "query", + "name": "id", + "required": true, + "style": "simple", + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "in": "query", + "name": "another_id", + "required": true, + "schema": { + "type": "object" + } + }, + { + "in": "query", + "name": "value1", + "required": true, + "schema": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + } + }, + { + "in": "query", + "name": "value2", + "required": true, + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + { + "in": "query", + "name": "value3", + "required": false, + "schema": { + "type": ["string", "null"] + } + }, + { + "in": "query", + "name": "value4", + "required": false, + "schema": { + "type": ["object", "null"] + } + }, + { + "in": "query", + "name": "value5", + "required": true, + "schema": { + "items": { + "type": "object" + }, + "type": "array" + } + }, + { + "in": "query", + "name": "value6", + "required": true, + "schema": { + "items": { + "$ref": "#/components/schemas/Foo" + }, + "type": "array" + } + }]) + ) +} + +#[test] +fn derive_path_params_into_params_with_raw_identifier() { + #[derive(IntoParams)] + #[into_params(parameter_in = Path)] + struct Filter { + #[allow(unused)] + r#in: String, + } + + #[fastapi::path( + get, + path = "foo", + responses( + (status = 200, description = "success response") + ), + params( + Filter + ) + )] + #[allow(unused)] + fn get_foo(query: Filter) {} + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/foo/get/parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([{ + "in": "path", + "name": "in", + "required": true, + "schema": { + "type": "string" + } + }]) + ) +} + +#[test] +fn derive_path_params_into_params_with_unit_type() { + #[derive(IntoParams)] + #[into_params(parameter_in = Path)] + struct Filter { + #[allow(unused)] + r#in: (), + } + + #[fastapi::path( + get, + path = "foo", + responses( + (status = 200, description = "success response") + ), + params( + Filter + ) + )] + #[allow(unused)] + fn get_foo(query: Filter) {} + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/foo/get/parameters").unwrap(); + + assert_json_eq!( + parameters, + json!([{ + "in": "path", + "name": "in", + "required": true, + "schema": { + "default": null, + } + }]) + ) +} + +#[test] +fn arbitrary_expr_in_operation_id() { + #[fastapi::path( + get, + path = "foo", + operation_id=format!("{}", 3+5), + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + fn get_foo() {} + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let operation_id = doc.pointer("/paths/foo/get/operationId").unwrap(); + + assert_json_eq!(operation_id, json!("8")) +} + +#[test] +fn derive_path_with_validation_attributes() { + #[derive(IntoParams)] + #[allow(dead_code)] + struct Query { + #[param(maximum = 10, minimum = 5, multiple_of = 2.5)] + id: i32, + + #[param(max_length = 10, min_length = 5, pattern = "[a-z]*")] + value: String, + + #[param(max_items = 5, min_items = 1)] + items: Vec<String>, + } + + #[fastapi::path( + get, + path = "foo", + responses( + (status = 200, description = "success response") + ), + params( + Query + ) + )] + #[allow(unused)] + fn get_foo(query: Query) {} + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/foo/get/parameters").unwrap(); + + let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat); + + assert_json_matches!( + parameters, + json!([ + { + "schema": { + "format": "int32", + "type": "integer", + "maximum": 10.0, + "minimum": 5.0, + "multipleOf": 2.5, + }, + "required": true, + "name": "id", + "in": "path" + }, + { + "schema": { + "type": "string", + "maxLength": 10, + "minLength": 5, + "pattern": "[a-z]*" + }, + "required": true, + "name": "value", + "in": "path" + }, + { + "schema": { + "type": "array", + "items": { + "type": "string", + }, + "maxItems": 5, + "minItems": 1, + }, + "required": true, + "name": "items", + "in": "path" + } + ]), + config + ); +} + +#[test] +fn derive_path_with_into_responses() { + #[allow(unused)] + enum MyResponse { + Ok, + NotFound, + } + + impl IntoResponses for MyResponse { + fn responses() -> BTreeMap<String, RefOr<Response>> { + let responses = ResponsesBuilder::new() + .response("200", ResponseBuilder::new().description("Ok")) + .response("404", ResponseBuilder::new().description("Not Found")) + .build(); + + responses.responses + } + } + + #[fastapi::path(get, path = "foo", responses(MyResponse))] + #[allow(unused)] + fn get_foo() {} + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/foo/get/responses").unwrap(); + + assert_json_eq!( + parameters, + json!({ + "200": { + "description": "Ok" + }, + "404": { + "description": "Not Found" + } + }) + ) +} + +#[cfg(feature = "uuid")] +#[test] +fn derive_path_with_uuid() { + use uuid::Uuid; + + #[fastapi::path( + get, + path = "/items/{id}", + responses( + (status = 200, description = "success response") + ), + params( + ("id" = Uuid, description = "Foo uuid"), + ) + )] + #[allow(unused)] + fn get_items(id: Uuid) -> String { + "".to_string() + } + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/items/{id}" + }; + + assert_value! {operation=> + "parameters.[0].schema.type" = r#""string""#, "Parameter id type" + "parameters.[0].schema.format" = r#""uuid""#, "Parameter id format" + "parameters.[0].description" = r#""Foo uuid""#, "Parameter id description" + "parameters.[0].name" = r#""id""#, "Parameter id id" + "parameters.[0].in" = r#""path""#, "Parameter in" + } +} + +#[cfg(feature = "ulid")] +#[test] +fn derive_path_with_ulid() { + use ulid::Ulid; + + #[fastapi::path( + get, + path = "/items/{id}", + responses( + (status = 200, description = "success response") + ), + params( + ("id" = Ulid, description = "Foo ulid"), + ) + )] + #[allow(unused)] + fn get_items(id: Ulid) -> String { + "".to_string() + } + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/items/{id}" + }; + + assert_value! {operation=> + "parameters.[0].schema.type" = r#""string""#, "Parameter id type" + "parameters.[0].schema.format" = r#""ulid""#, "Parameter id format" + "parameters.[0].description" = r#""Foo ulid""#, "Parameter id description" + "parameters.[0].name" = r#""id""#, "Parameter id id" + "parameters.[0].in" = r#""path""#, "Parameter in" + } +} + +#[test] +fn derive_path_with_into_params_custom_schema() { + fn custom_type() -> Object { + ObjectBuilder::new() + .schema_type(fastapi::openapi::Type::String) + .format(Some(fastapi::openapi::SchemaFormat::Custom( + "email".to_string(), + ))) + .description(Some("this is the description")) + .build() + } + + #[derive(IntoParams)] + #[into_params(parameter_in = Query)] + #[allow(unused)] + struct Query { + #[param(schema_with = custom_type)] + email: String, + } + + #[fastapi::path( + get, + path = "/items", + responses( + (status = 200, description = "success response") + ), + params( + Query + ) + )] + #[allow(unused)] + fn get_items(query: Query) -> String { + "".to_string() + } + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/items" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "email", + "required": false, + "schema": { + "description": "this is the description", + "type": "string", + "format": "email" + } + } + ]) + ) +} + +#[test] +fn derive_into_params_required() { + #[derive(IntoParams)] + #[into_params(parameter_in = Query)] + #[allow(unused)] + struct Params { + name: String, + name2: Option<String>, + #[param(required)] + name3: Option<String>, + } + + #[fastapi::path(get, path = "/params", params(Params))] + #[allow(unused)] + fn get_params() {} + let operation = test_api_fn_doc! { + get_params, + operation: get, + path: "/params" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "name2", + "required": false, + "schema": { + "type": ["string", "null"], + }, + }, + { + "in": "query", + "name": "name3", + "required": true, + "schema": { + "type": ["string", "null"], + }, + }, + ]) + ) +} + +#[test] +fn derive_into_params_with_serde_skip() { + #[derive(IntoParams, Serialize)] + #[into_params(parameter_in = Query)] + #[allow(unused)] + struct Params { + name: String, + name2: Option<String>, + #[serde(skip)] + name3: Option<String>, + } + + #[fastapi::path(get, path = "/params", params(Params))] + #[allow(unused)] + fn get_params() {} + let operation = test_api_fn_doc! { + get_params, + operation: get, + path: "/params" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "name2", + "required": false, + "schema": { + "type": ["string", "null"], + }, + }, + ]) + ) +} + +// TODO: IntoParams seems not to follow Option<T> is automatically nullable rule! + +#[test] +fn derive_into_params_with_serde_skip_deserializing() { + #[derive(IntoParams, Serialize)] + #[into_params(parameter_in = Query)] + #[allow(unused)] + struct Params { + name: String, + name2: Option<String>, + #[serde(skip_deserializing)] + name3: Option<String>, + } + + #[fastapi::path(get, path = "/params", params(Params))] + #[allow(unused)] + fn get_params() {} + let operation = test_api_fn_doc! { + get_params, + operation: get, + path: "/params" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "name2", + "required": false, + "schema": { + "type": ["string", "null"], + }, + }, + ]) + ) +} + +#[test] +fn derive_into_params_with_serde_skip_serializing() { + #[derive(IntoParams, Serialize)] + #[into_params(parameter_in = Query)] + #[allow(unused)] + struct Params { + name: String, + name2: Option<String>, + #[serde(skip_serializing)] + name3: Option<String>, + } + + #[fastapi::path(get, path = "/params", params(Params))] + #[allow(unused)] + fn get_params() {} + let operation = test_api_fn_doc! { + get_params, + operation: get, + path: "/params" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "in": "query", + "name": "name2", + "required": false, + "schema": { + "type": ["string", "null"], + }, + }, + ]) + ) +} + +#[test] +fn derive_path_with_const_expression_context_path() { + const FOOBAR: &str = "/api/v1/prefix"; + + #[fastapi::path( + get, + context_path = FOOBAR, + path = "/items", + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + fn get_items() -> String { + "".to_string() + } + + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/api/v1/prefix/items" + }; + + assert_ne!(operation, Value::Null); +} + +#[test] +fn derive_path_with_const_expression_reference_context_path() { + const FOOBAR: &str = "/api/v1/prefix"; + + #[fastapi::path( + get, + context_path = &FOOBAR, + path = "/items", + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + fn get_items() -> String { + "".to_string() + } + + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/api/v1/prefix/items" + }; + + assert_ne!(operation, Value::Null); +} + +#[test] +fn derive_path_with_const_expression() { + const FOOBAR: &str = "/items"; + + #[fastapi::path( + get, + path = FOOBAR, + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + fn get_items() -> String { + "".to_string() + } + + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/items" + }; + + assert_ne!(operation, Value::Null); +} + +#[test] +fn derive_path_with_tag_constant() { + const TAG: &str = "mytag"; + + #[fastapi::path( + get, + tag = TAG, + path = "/items", + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + fn get_items() -> String { + "".to_string() + } + + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/items" + }; + + assert_ne!(operation, Value::Null); + assert_json_eq!( + &operation, + json!({ + "operationId": "get_items", + "responses": { + "200": { + "description": "success response", + }, + }, + "tags": ["mytag"] + }) + ); +} + +#[test] +fn derive_path_with_multiple_tags() { + #[allow(dead_code)] + const TAG: &str = "mytag"; + const ANOTHER: &str = "another"; + + #[fastapi::path( + get, + tag = TAG, + tags = ["one", "two", ANOTHER], + path = "/items", + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + async fn get_items() -> String { + "".to_string() + } + + let operation = test_api_fn_doc! { + get_items, + operation: get, + path: "/items" + }; + + assert_ne!(operation, Value::Null); + assert_json_eq!( + &operation, + json!({ + "operationId": "get_items", + "responses": { + "200": { + "description": "success response", + }, + }, + "tags": ["mytag", "one", "two","another"] + }) + ); +} + +#[test] +fn derive_path_with_description_and_summary_override() { + const SUMMARY: &str = "This is summary override that is +split to multiple lines"; + /// This is long summary + /// split to multiple lines + /// + /// This is description + /// split to multiple lines + #[allow(dead_code)] + #[fastapi::path( + get, + path = "/test-description", + summary = SUMMARY, + description = "This is description override", + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + async fn test_description_summary() -> &'static str { + "" + } + + let operation = test_api_fn_doc! { + test_description_summary, + operation: get, + path: "/test-description" + }; + + assert_json_eq!( + &operation, + json!({ + "description": "This is description override", + "operationId": "test_description_summary", + "responses": { + "200": { + "description": "success response", + }, + }, + "summary": "This is summary override that is\nsplit to multiple lines", + "tags": [] + }) + ); +} + +#[test] +fn derive_path_include_str_description() { + #[allow(dead_code)] + #[fastapi::path( + get, + path = "/test-description", + description = include_str!("./testdata/description_override"), + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + async fn test_description_summary() -> &'static str { + "" + } + + let operation = test_api_fn_doc! { + test_description_summary, + operation: get, + path: "/test-description" + }; + + assert_json_eq!( + &operation, + json!({ + "description": "This is description from include_str!\n", + "operationId": "test_description_summary", + "responses": { + "200": { + "description": "success response", + }, + }, + "tags": [] + }) + ); +} + +#[test] +fn path_and_nest_with_default_tags_from_path() { + mod test_path { + #[allow(dead_code)] + #[fastapi::path(get, path = "/test")] + #[allow(unused)] + fn test_path() -> &'static str { + "" + } + } + + mod test_nest { + #[derive(fastapi::OpenApi)] + #[openapi(paths(test_path_nested))] + pub struct NestApi; + + #[allow(dead_code)] + #[fastapi::path(get, path = "/test")] + #[allow(unused)] + fn test_path_nested() -> &'static str { + "" + } + } + + #[derive(fastapi::OpenApi)] + #[openapi( + paths(test_path::test_path), + nest( + (path = "/api/nest", api = test_nest::NestApi) + ) + )] + struct ApiDoc; + let value = serde_json::to_value(ApiDoc::openapi()).expect("should be able to serialize json"); + let paths = value + .pointer("/paths") + .expect("should find /paths from the OpenAPI spec"); + + assert_json_eq!( + &paths, + json!({ + "/api/nest/test": { + "get": { + "operationId": "test_path_nested", + "responses": {}, + "tags": ["test_nest"] + } + }, + "/test": { + "get": { + "operationId": "test_path", + "responses": {}, + "tags": ["test_path"] + } + } + }) + ); +} + +#[test] +fn path_and_nest_with_additional_tags() { + mod test_path { + #[allow(dead_code)] + #[fastapi::path(get, path = "/test", tag = "this_is_tag", tags = ["additional"])] + #[allow(unused)] + fn test_path() -> &'static str { + "" + } + } + + mod test_nest { + #[derive(fastapi::OpenApi)] + #[openapi(paths(test_path_nested))] + pub struct NestApi; + + #[allow(dead_code)] + #[fastapi::path(get, path = "/test", tag = "this_is_tag:nest", tags = ["additional:nest"])] + #[allow(unused)] + fn test_path_nested() -> &'static str { + "" + } + } + + #[derive(fastapi::OpenApi)] + #[openapi( + paths(test_path::test_path), + nest( + (path = "/api/nest", api = test_nest::NestApi) + ) + )] + struct ApiDoc; + let value = serde_json::to_value(ApiDoc::openapi()).expect("should be able to serialize json"); + let paths = value + .pointer("/paths") + .expect("should find /paths from the OpenAPI spec"); + + assert_json_eq!( + &paths, + json!({ + "/api/nest/test": { + "get": { + "operationId": "test_path_nested", + "responses": {}, + "tags": ["this_is_tag:nest", "additional:nest"] + }, + }, + "/test": { + "get": { + "operationId": "test_path", + "responses": {}, + "tags": ["this_is_tag", "additional"] + }, + } + }) + ); +} + +#[test] +fn path_nest_without_any_tags() { + mod test_path { + #[allow(dead_code)] + #[fastapi::path(get, path = "/test")] + #[allow(unused)] + pub fn test_path() -> &'static str { + "" + } + } + + mod test_nest { + #[derive(fastapi::OpenApi)] + #[openapi(paths(test_path_nested))] + pub struct NestApi; + + #[allow(dead_code)] + #[fastapi::path(get, path = "/test")] + #[allow(unused)] + fn test_path_nested() -> &'static str { + "" + } + } + + use test_nest::NestApi; + use test_path::__path_test_path; + #[derive(fastapi::OpenApi)] + #[openapi( + paths(test_path), + nest( + (path = "/api/nest", api = NestApi) + ) + )] + struct ApiDoc; + let value = serde_json::to_value(ApiDoc::openapi()).expect("should be able to serialize json"); + let paths = value + .pointer("/paths") + .expect("should find /paths from the OpenAPI spec"); + + assert_json_eq!( + &paths, + json!({ + "/api/nest/test": { + "get": { + "operationId": "test_path_nested", + "responses": {}, + "tags": [] + }, + }, + "/test": { + "get": { + "operationId": "test_path", + "responses": {}, + "tags": [] + }, + } + }) + ); +} + +#[test] +fn derive_path_with_multiple_methods() { + #[allow(dead_code)] + #[fastapi::path( + method(head, get), + path = "/test-multiple", + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + async fn test_multiple() -> &'static str { + "" + } + use fastapi::OpenApi; + #[derive(OpenApi, Default)] + #[openapi(paths(test_multiple))] + struct ApiDoc; + + let doc = &serde_json::to_value(ApiDoc::openapi()).unwrap(); + let paths = doc.pointer("/paths").expect("OpenApi must have paths"); + + assert_json_eq!( + &paths, + json!({ + "/test-multiple": { + "get": { + "operationId": "test_multiple", + "responses": { + "200": { + "description": "success response", + }, + }, + "tags": [] + }, + "head": { + "operationId": "test_multiple", + "responses": { + "200": { + "description": "success response", + }, + }, + "tags": [] + } + } + }) + ); +} + +#[test] +fn derive_path_with_response_links() { + #![allow(dead_code)] + + #[fastapi::path( + get, + path = "/test-links", + responses( + (status = 200, description = "success response", + links( + ("getFoo" = ( + operation_id = "test_links", + parameters(("key" = "value"), ("json_value" = json!(1))), + request_body = "this is body", + server(url = "http://localhost") + )), + ("getBar" = ( + operation_ref = "this is ref" + )) + ) + ) + ), + )] + #[allow(unused)] + async fn test_links() -> &'static str { + "" + } + use fastapi::OpenApi; + #[derive(OpenApi, Default)] + #[openapi(paths(test_links))] + struct ApiDoc; + + let doc = &serde_json::to_value(ApiDoc::openapi()).unwrap(); + let paths = doc.pointer("/paths").expect("OpenApi must have paths"); + + assert_json_eq!( + &paths, + json!({ + "/test-links": { + "get": { + "operationId": "test_links", + "responses": { + "200": { + "description": "success response", + "links": { + "getFoo": { + "operation_id": "test_links", + "parameters": { + "json_value": 1, + "key": "value" + }, + "request_body": "this is body", + "server": { + "url": "http://localhost" + } + }, + "getBar": { + "operation_ref": "this is ref" + } + }, + }, + }, + "tags": [] + }, + } + }) + ); +} + +#[test] +fn derive_path_test_collect_request_body() { + #![allow(dead_code)] + + #[derive(ToSchema)] + struct Account { + id: i32, + } + + #[derive(ToSchema)] + struct Person { + name: String, + account: Account, + } + + #[fastapi::path( + post, + request_body = Person, + path = "/test-collect-schemas", + responses( + (status = 200, description = "success response") + ), + )] + async fn test_collect_schemas(_body: Person) -> &'static str { + "" + } + + use fastapi::OpenApi; + #[derive(OpenApi)] + #[openapi(paths(test_collect_schemas))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("OpenApi must have schemas"); + + assert_json_eq!( + &schemas, + json!({ + "Person": { + "properties": { + "name": { + "type": "string", + }, + "account": { + "$ref": "#/components/schemas/Account" + } + }, + "required": ["name", "account"], + "type": "object" + }, + "Account": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + }, + }, + "required": ["id"], + "type": "object" + } + }) + ); +} + +#[test] +fn derive_path_test_do_not_collect_inlined_schema() { + #![allow(dead_code)] + + #[derive(ToSchema)] + struct Account { + id: i32, + } + + #[derive(ToSchema)] + struct Person { + name: String, + account: Account, + } + + #[fastapi::path( + post, + request_body = inline(Person), + path = "/test-collect-schemas", + )] + async fn test_collect_schemas(_body: Person) {} + + use fastapi::OpenApi; + #[derive(OpenApi)] + #[openapi(paths(test_collect_schemas))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("OpenApi must have schemas"); + + assert_json_eq!( + &schemas, + json!({ + "Account": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + }, + }, + "required": ["id"], + "type": "object" + } + }) + ); +} + +#[test] +fn derive_path_test_do_not_collect_recursive_inlined() { + #![allow(dead_code)] + + #[derive(ToSchema)] + struct Account { + id: i32, + } + + #[derive(ToSchema)] + struct Person { + name: String, + #[schema(inline)] + account: Account, + } + + #[fastapi::path( + post, + request_body = inline(Person), + path = "/test-collect-schemas", + )] + async fn test_collect_schemas(_body: Person) {} + + use fastapi::OpenApi; + #[derive(OpenApi)] + #[openapi(paths(test_collect_schemas))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schemas = doc.pointer("/components/schemas"); + let body = doc + .pointer("/paths/~1test-collect-schemas/post/requestBody/content/application~1json/schema") + .expect("request body must have schema"); + + assert_eq!(None, schemas); + assert_json_eq!( + body, + json!({ + "properties": { + "name": { + "type": "string", + }, + "account": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + }, + }, + "required": ["id"], + "type": "object" + } + }, + "required": ["name", "account"], + "type": "object" + }) + ) +} + +#[test] +fn derive_path_test_collect_generic_array_request_body() { + #![allow(dead_code)] + + #[derive(ToSchema)] + struct Account { + id: i32, + } + + #[derive(ToSchema)] + struct Person { + name: String, + account: Account, + } + + #[derive(ToSchema)] + struct CreateRequest<T> { + value: T, + } + + #[fastapi::path( + post, + request_body = [ CreateRequest<Person> ], + path = "/test-collect-schemas", + responses( + (status = 200, description = "success response") + ), + )] + async fn test_collect_schemas(_body: Person) -> &'static str { + "" + } + + use fastapi::OpenApi; + #[derive(OpenApi)] + #[openapi(paths(test_collect_schemas))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("OpenApi must have schemas"); + + assert_json_eq!( + &schemas, + json!({ + "CreateRequest_Person": { + "properties": { + "value": { + "properties": { + "name": { + "type": "string", + }, + "account": { + "$ref": "#/components/schemas/Account" + } + }, + "required": ["name", "account"], + "type": "object" + } + }, + "required": ["value"], + "type": "object" + }, + "Person": { + "properties": { + "name": { + "type": "string", + }, + "account": { + "$ref": "#/components/schemas/Account" + } + }, + "required": ["name", "account"], + "type": "object" + }, + "Account": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + }, + }, + "required": ["id"], + "type": "object" + } + }) + ); +} + +#[test] +fn derive_path_test_collect_generic_request_body() { + #![allow(dead_code)] + + #[derive(ToSchema)] + struct Account { + id: i32, + } + + #[derive(ToSchema)] + struct Person { + name: String, + account: Account, + } + + #[derive(ToSchema)] + struct CreateRequest<T> { + value: T, + } + + #[fastapi::path( + post, + request_body = CreateRequest<Person>, + path = "/test-collect-schemas", + responses( + (status = 200, description = "success response") + ), + )] + async fn test_collect_schemas(_body: Person) -> &'static str { + "" + } + + use fastapi::OpenApi; + #[derive(OpenApi)] + #[openapi(paths(test_collect_schemas))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("OpenApi must have schemas"); + + assert_json_eq!( + &schemas, + json!({ + "CreateRequest_Person": { + "properties": { + "value": { + "properties": { + "name": { + "type": "string", + }, + "account": { + "$ref": "#/components/schemas/Account" + } + }, + "required": ["name", "account"], + "type": "object" + } + }, + "required": ["value"], + "type": "object" + }, + "Person": { + "properties": { + "name": { + "type": "string", + }, + "account": { + "$ref": "#/components/schemas/Account" + } + }, + "required": ["name", "account"], + "type": "object" + }, + "Account": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + }, + }, + "required": ["id"], + "type": "object" + } + }) + ); +} + +#[test] +fn path_derive_with_body_ref_using_as_attribute_schema() { + #![allow(unused)] + + #[derive(Serialize, serde::Deserialize, Debug, Clone, ToSchema)] + #[schema(as = types::calculation::calculation_assembly_cost::v1::CalculationAssemblyCostResponse)] + pub struct CalculationAssemblyCostResponse { + #[schema(value_type = uuid::Uuid)] + pub id: String, + } + + #[fastapi::path( + get, + path = "/calculations/assembly-costs", + responses( + (status = 200, description = "Get calculated cost of an assembly.", + body = CalculationAssemblyCostResponse) + ), + )] + async fn handler() {} + + let operation = __path_handler::operation(); + let operation = serde_json::to_value(&operation).expect("operation is JSON serializable"); + + assert_json_eq!( + operation, + json!({ + "operationId": "handler", + "responses": { + "200": { + "description": "Get calculated cost of an assembly.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/types.calculation.calculation_assembly_cost.v1.CalculationAssemblyCostResponse" + }, + } + } + } + } + }) + ); +} + +#[test] +fn derive_into_params_with_ignored_field() { + #![allow(unused)] + + #[derive(IntoParams)] + #[into_params(parameter_in = Query)] + struct Params { + name: String, + #[param(ignore)] + __this_is_private: String, + } + + #[fastapi::path(get, path = "/params", params(Params))] + #[allow(unused)] + fn get_params() {} + let operation = test_api_fn_doc! { + get_params, + operation: get, + path: "/params" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} + +#[test] +fn derive_into_params_with_ignored_eq_false_field() { + #![allow(unused)] + + #[derive(IntoParams)] + #[into_params(parameter_in = Query)] + struct Params { + name: String, + #[param(ignore = false)] + __this_is_private: String, + } + + #[fastapi::path(get, path = "/params", params(Params))] + #[allow(unused)] + fn get_params() {} + let operation = test_api_fn_doc! { + get_params, + operation: get, + path: "/params" + }; + + let value = operation.pointer("/parameters"); + + assert_json_eq!( + value, + json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "__this_is_private", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ) +} + +#[test] +fn derive_octet_stream_request_body() { + #![allow(dead_code)] + + #[fastapi::path( + post, + request_body = Vec<u8>, + path = "/test-octet-stream", + responses( + (status = 200, description = "success response") + ), + )] + async fn test_octet_stream(_body: Vec<u8>) {} + + let operation = serde_json::to_value(__path_test_octet_stream::operation()) + .expect("Operation is JSON serializable"); + let request_body = operation + .pointer("/requestBody") + .expect("must have request body"); + + assert_json_eq!( + &request_body, + json!({ + "content": { + "application/octet-stream": { + "schema": { + "items": { + "type": "integer", + "format": "int32", + "minimum": 0, + }, + "type": "array", + }, + }, + }, + "required": true, + }) + ); +} + +#[test] +fn derive_img_png_request_body() { + #![allow(dead_code)] + + #[derive(fastapi::ToSchema)] + #[schema(content_encoding = "base64")] + struct MyPng(String); + + #[fastapi::path( + post, + request_body(content = inline(MyPng), content_type = "image/png"), + path = "/test_png", + responses( + (status = 200, description = "success response") + ), + )] + async fn test_png(_body: MyPng) {} + + let operation = + serde_json::to_value(__path_test_png::operation()).expect("Operation is JSON serializable"); + let request_body = operation + .pointer("/requestBody") + .expect("must have request body"); + + assert_json_eq!( + &request_body, + json!({ + "content": { + "image/png": { + "schema": { + "type": "string", + "contentEncoding": "base64" + }, + }, + }, + "required": true, + }) + ); +} + +#[test] +fn derive_multipart_form_data() { + #![allow(dead_code)] + + #[derive(fastapi::ToSchema)] + struct MyForm { + order_id: i32, + #[schema(content_media_type = "application/octet-stream")] + file_bytes: Vec<u8>, + } + + #[fastapi::path( + post, + request_body(content = inline(MyForm), content_type = "multipart/form-data"), + path = "/test_multipart", + responses( + (status = 200, description = "success response") + ), + )] + async fn test_multipart(_body: MyForm) {} + + let operation = serde_json::to_value(__path_test_multipart::operation()) + .expect("Operation is JSON serializable"); + let request_body = operation + .pointer("/requestBody") + .expect("must have request body"); + + assert_json_eq!( + &request_body, + json!({ + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "properties": { + "order_id": { + "type": "integer", + "format": "int32" + }, + "file_bytes": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + "minimum": 0, + }, + "contentMediaType": "application/octet-stream" + }, + }, + "required": ["order_id", "file_bytes"] + }, + }, + }, + "required": true, + }) + ); +} + +#[test] +fn derive_images_as_application_octet_stream() { + #![allow(dead_code)] + + #[fastapi::path( + post, + request_body( + content( + ("image/png"), + ("image/jpg"), + ), + ), + path = "/test_images", + responses( + (status = 200, description = "success response") + ), + )] + async fn test_multipart(_body: Vec<u8>) {} + + let operation = serde_json::to_value(__path_test_multipart::operation()) + .expect("Operation is JSON serializable"); + let request_body = operation + .pointer("/requestBody") + .expect("must have request body"); + + assert_json_eq!( + &request_body, + json!({ + "content": { + "image/jpg": {}, + "image/png": {}, + }, + }) + ); +} + +#[test] +fn derive_const_generic_request_body_compiles() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct ArrayResponse<T: ToSchema, const N: usize> { + array: [T; N], + } + + #[derive(ToSchema)] + struct CombinedResponse<T: ToSchema, const N: usize> { + pub array_response: ArrayResponse<T, N>, + } + + #[fastapi::path( + post, + request_body = CombinedResponse<String, 3>, + path = "/test_const_generic", + )] + async fn test_const_generic(_body: Vec<u8>) {} + + let _ = serde_json::to_value(__path_test_const_generic::operation()) + .expect("Operation is JSON serializable"); +} + +#[test] +fn derive_lifetime_generic_request_body_compiles() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct ArrayResponse<'a, T: ToSchema, const N: usize> { + array: &'a [T; N], + } + + #[derive(ToSchema)] + struct CombinedResponse<'a, T: ToSchema, const N: usize> { + pub array_response: ArrayResponse<'a, T, N>, + } + + #[fastapi::path( + post, + request_body = CombinedResponse<String, 3>, + path = "/test_const_generic", + )] + async fn test_const_generic(_body: Vec<u8>) {} + + let _ = serde_json::to_value(__path_test_const_generic::operation()) + .expect("Operation is JSON serializable"); +} diff --git a/fastapi-gen/tests/path_derive_actix.rs b/fastapi-gen/tests/path_derive_actix.rs new file mode 100644 index 0000000..4422be5 --- /dev/null +++ b/fastapi-gen/tests/path_derive_actix.rs @@ -0,0 +1,1161 @@ +#![cfg(feature = "actix_extras")] + +use actix_web::{ + get, post, route, + web::{Json, Path, Query}, + FromRequest, Responder, ResponseError, +}; +use assert_json_diff::assert_json_eq; +use fastapi::{ + openapi::{ + path::{Parameter, ParameterBuilder, ParameterIn}, + Array, KnownFormat, ObjectBuilder, SchemaFormat, + }, + IntoParams, OpenApi, ToSchema, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use std::{fmt::Display, future::Ready, todo}; + +mod common; + +mod mod_derive_path_actix { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + params( + ("id", description = "Foo id"), + ) + )] + #[get("/foo/{id}")] + #[allow(unused)] + async fn get_foo_by_id(id: web::Path<i32>) -> impl Responder { + HttpResponse::Ok().json(json!({ "foo": format!("{:?}", &id.into_inner()) })) + } +} + +#[test] +fn derive_path_one_value_actix_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_path_actix::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo~1{id}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Foo id""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + }; +} + +mod mod_derive_path_unnamed_regex_actix { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + responses( + (status = 200, description = "success"), + ), + params( + ("arg0", description = "Foo path unnamed regex tail") + ) + )] + #[get("/foo/{_:.*}")] + #[allow(unused)] + async fn get_foo_by_id(arg0: web::Path<String>) -> impl Responder { + HttpResponse::Ok().json(json!({ "foo": &format!("{:?}", arg0.into_inner()) })) + } +} + +#[test] +fn derive_path_with_unnamed_regex_actix_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_path_unnamed_regex_actix::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo~1{arg0}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""arg0""#, "Parameter name" + "[0].description" = r#""Foo path unnamed regex tail""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""string""#, "Parameter schema type" + "[0].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +mod mod_derive_path_named_regex_actix { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + params( + ("tail", description = "Foo path named regex tail") + ) + )] + #[get("/foo/{tail:.*}")] + #[allow(unused)] + async fn get_foo_by_id(tail: web::Path<String>) -> impl Responder { + HttpResponse::Ok().json(json!({ "foo": &format!("{:?}", tail.into_inner()) })) + } +} + +#[test] +fn derive_path_with_named_regex_actix_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_path_named_regex_actix::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let parameters = doc.pointer("/paths/~1foo~1{tail}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""tail""#, "Parameter name" + "[0].description" = r#""Foo path named regex tail""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""string""#, "Parameter schema type" + "[0].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +#[test] +fn derive_path_with_multiple_args() { + mod mod_derive_path_multiple_args { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + )] + #[get("/foo/{id}/bar/{digest}")] + #[allow(unused)] + async fn get_foo_by_id(path: web::Path<(i64, String)>) -> impl Responder { + let (id, digest) = path.into_inner(); + HttpResponse::Ok().json(json!({ "id": &format!("{:?} {:?}", id, digest) })) + } + } + + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_path_multiple_args::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1bar~1{digest}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#"null"#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""digest""#, "Parameter name" + "[1].description" = r#"null"#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +#[test] +fn derive_path_with_dyn_trait_compiles() { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + + trait Store {} + + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + )] + #[get("/foo/{id}/bar/{digest}")] + #[allow(unused)] + async fn get_foo_by_id( + path: web::Path<(i64, String)>, + data: web::Data<&dyn Store>, + ) -> impl Responder { + let (id, digest) = path.into_inner(); + HttpResponse::Ok().json(json!({ "id": &format!("{:?} {:?}", id, digest) })) + } +} + +#[test] +fn derive_complex_actix_web_path() { + mod mod_derive_complex_actix_path { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + )] + #[get("/foo/{id}", name = "api_name")] + #[allow(unused)] + async fn get_foo_by_id(path: web::Path<i64>) -> impl Responder { + let id = path.into_inner(); + HttpResponse::Ok().json(json!({ "id": &format!("{}", id) })) + } + } + + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_complex_actix_path::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo~1{id}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#"null"#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + }; +} + +#[test] +fn derive_path_with_multiple_args_with_descriptions() { + mod mod_derive_path_multiple_args { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + params( + ("id", description = "Foo id"), + ("digest", description = "Foo digest") + ) + )] + #[get("/foo/{id}/bar/{digest}")] + #[allow(unused)] + async fn get_foo_by_id(path: web::Path<(i64, String)>) -> impl Responder { + let (id, digest) = path.into_inner(); + HttpResponse::Ok().json(json!({ "id": &format!("{:?} {:?}", id, digest) })) + } + } + + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_path_multiple_args::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1bar~1{digest}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Foo id""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""digest""#, "Parameter name" + "[1].description" = r#""Foo digest""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +#[test] +fn derive_path_with_context_path() { + use actix_web::{get, HttpResponse, Responder}; + use serde_json::json; + + #[fastapi::path( + context_path = "/api", + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo")] + #[allow(unused)] + async fn get_foo() -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let path = doc.pointer("/paths/~1api~1foo/get").unwrap(); + + assert_ne!(path, &Value::Null, "expected path with context path /api"); +} + +#[test] +fn derive_path_with_context_path_from_const() { + use actix_web::{get, HttpResponse, Responder}; + use serde_json::json; + const CONTEXT: &str = "/api"; + + #[fastapi::path( + context_path = CONTEXT, + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo")] + #[allow(unused)] + async fn get_foo() -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let path = doc.pointer("/paths/~1api~1foo/get").unwrap(); + + assert_ne!(path, &Value::Null, "expected path with context path /api"); +} + +#[test] +fn path_with_struct_variables_with_into_params() { + use actix_web::{get, HttpResponse, Responder}; + use serde_json::json; + + #[derive(Deserialize)] + #[allow(unused)] + struct Person { + id: i64, + name: String, + } + + impl IntoParams for Person { + fn into_params( + _: impl Fn() -> Option<fastapi::openapi::path::ParameterIn>, + ) -> Vec<Parameter> { + vec![ + ParameterBuilder::new() + .name("name") + .schema(Some( + ObjectBuilder::new().schema_type(fastapi::openapi::schema::Type::String), + )) + .parameter_in(ParameterIn::Path) + .build(), + ParameterBuilder::new() + .name("id") + .schema(Some( + ObjectBuilder::new() + .schema_type(fastapi::openapi::schema::Type::Integer) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))), + )) + .parameter_in(ParameterIn::Path) + .build(), + ] + } + } + + #[derive(Deserialize)] + #[allow(unused)] + struct Filter { + age: Vec<String>, + } + + impl IntoParams for Filter { + fn into_params( + _: impl Fn() -> Option<fastapi::openapi::path::ParameterIn>, + ) -> Vec<Parameter> { + vec![ParameterBuilder::new() + .name("age") + .schema(Some(Array::new( + ObjectBuilder::new().schema_type(fastapi::openapi::schema::Type::String), + ))) + .parameter_in(ParameterIn::Query) + .build()] + } + } + + #[fastapi::path( + params( + Person, + Filter + ), + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo/{id}/{name}")] + #[allow(unused)] + async fn get_foo(person: Path<Person>, query: Query<Filter>) -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{name}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 3); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""name""#, "Parameter name" + "[0].required" = r#"false"#, "Parameter required" + "[0].schema.type" = r#""string""#, "Parameter schema type" + "[0].schema.format" = r#"null"#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""id""#, "Parameter name" + "[1].required" = r#"false"#, "Parameter required" + "[1].schema.type" = r#""integer""#, "Parameter schema type" + "[1].schema.format" = r#""int64""#, "Parameter schema format" + + "[2].in" = r#""query""#, "Parameter in" + "[2].name" = r#""age""#, "Parameter name" + "[2].required" = r#"false"#, "Parameter required" + "[2].schema.type" = r#""array""#, "Parameter schema type" + "[2].schema.items.type" = r#""string""#, "Parameter items schema type" + } +} + +#[test] +fn derive_path_with_struct_variables_with_into_params() { + use actix_web::{get, HttpResponse, Responder}; + use serde_json::json; + + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + struct Person { + /// Id of person + id: i64, + /// Name of person + name: String, + } + + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + struct Filter { + /// Age filter for user + #[deprecated] + age: Option<Vec<String>>, + } + + #[fastapi::path( + params( + Person, + Filter + ), + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo/{id}/{name}")] + #[allow(unused)] + async fn get_foo(person: Path<Person>, query: Query<Filter>) -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{name}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 3); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Id of person""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""name""#, "Parameter name" + "[1].description" = r#""Name of person""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + + "[2].in" = r#""query""#, "Parameter in" + "[2].name" = r#""age""#, "Parameter name" + "[2].description" = r#""Age filter for user""#, "Parameter description" + "[2].required" = r#"false"#, "Parameter required" + "[2].deprecated" = r#"true"#, "Parameter deprecated" + "[2].schema.type" = r#"["array","null"]"#, "Parameter schema type" + "[2].schema.items.type" = r#""string""#, "Parameter items schema type" + } +} + +#[test] +fn derive_path_with_multiple_instances_same_path_params() { + use actix_web::{delete, get, HttpResponse, Responder}; + use serde_json::json; + + #[derive(Deserialize, Serialize, ToSchema, IntoParams)] + #[into_params(names("id"))] + struct Id(u64); + + #[fastapi::path( + params( + Id + ), + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo/{id}")] + #[allow(unused)] + async fn get_foo(id: Path<Id>) -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[fastapi::path( + params( + Id + ), + responses( + (status = 200, description = "success response") + ) + )] + #[delete("/foo/{id}")] + #[allow(unused)] + async fn delete_foo(id: Path<Id>) -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo, delete_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + for operation in ["get", "delete"] { + let parameters = doc + .pointer(&format!("/paths/~1foo~1{{id}}/{operation}/parameters")) + .unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + } + } +} + +#[test] +fn derive_path_with_multiple_into_params_names() { + use actix_web::{get, HttpResponse, Responder}; + + #[derive(Deserialize, Serialize, IntoParams)] + #[into_params(names("id", "name"))] + struct IdAndName(u64, String); + + #[fastapi::path( + params(IdAndName), + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo/{id}/{name}")] + #[allow(unused)] + async fn get_foo(path: Path<IdAndName>) -> impl Responder { + HttpResponse::Ok() + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{name}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""name""#, "Parameter name" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + } +} + +#[test] +fn derive_into_params_with_custom_attributes() { + use actix_web::{get, HttpResponse, Responder}; + use serde_json::json; + + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + struct Person { + /// Id of person + id: i64, + /// Name of person + #[param(style = Simple, example = "John")] + name: String, + } + + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + struct Filter { + /// Age filter for user + #[param(style = Form, explode, allow_reserved, example = json!(["10"]))] + age: Option<Vec<String>>, + sort: Sort, + } + + #[derive(Deserialize, ToSchema)] + enum Sort { + Asc, + Desc, + } + + #[fastapi::path( + params( + Person, + Filter + ), + responses( + (status = 200, description = "success response") + ) + )] + #[get("/foo/{id}/{name}")] + #[allow(unused)] + async fn get_foo(person: Path<Person>, query: Query<Filter>) -> impl Responder { + HttpResponse::Ok().json(json!({ "id": "foo" })) + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo), components(schemas(Sort)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{name}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 4); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Id of person""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].style" = r#"null"#, "Parameter style" + "[0].example" = r#"null"#, "Parameter example" + "[0].allowReserved" = r#"null"#, "Parameter allowReserved" + "[0].explode" = r#"null"#, "Parameter explode" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int64""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""name""#, "Parameter name" + "[1].description" = r#""Name of person""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].style" = r#""simple""#, "Parameter style" + "[1].allowReserved" = r#"null"#, "Parameter allowReserved" + "[1].explode" = r#"null"#, "Parameter explode" + "[1].example" = r#""John""#, "Parameter example" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + + "[2].in" = r#""query""#, "Parameter in" + "[2].name" = r#""age""#, "Parameter name" + "[2].description" = r#""Age filter for user""#, "Parameter description" + "[2].required" = r#"false"#, "Parameter required" + "[2].deprecated" = r#"null"#, "Parameter deprecated" + "[2].style" = r#""form""#, "Parameter style" + "[2].example" = r#"["10"]"#, "Parameter example" + "[2].allowReserved" = r#"true"#, "Parameter allowReserved" + "[2].explode" = r#"true"#, "Parameter explode" + "[2].schema.type" = r#"["array","null"]"#, "Parameter schema type" + "[2].schema.items.type" = r#""string""#, "Parameter items schema type" + + "[3].in" = r#""query""#, "Parameter in" + "[3].name" = r#""sort""#, "Parameter name" + "[3].description" = r#"null"#, "Parameter description" + "[3].required" = r#"true"#, "Parameter required" + "[3].deprecated" = r#"null"#, "Parameter deprecated" + "[3].schema.$ref" = r###""#/components/schemas/Sort""###, "Parameter schema type" + } +} + +#[test] +fn derive_into_params_in_another_module() { + use actix_web::{get, HttpResponse, Responder}; + use fastapi::OpenApi; + pub mod params { + use fastapi::IntoParams; + use serde::Deserialize; + + #[derive(Deserialize, IntoParams)] + pub struct FooParams { + #[allow(unused)] + pub id: String, + } + } + + /// Foo test + #[fastapi::path( + params( + params::FooParams, + ), + responses( + (status = 200, description = "Todo foo operation success"), + ) + )] + #[get("/todo/foo/{id}")] + pub async fn foo_todos(_path: Path<params::FooParams>) -> impl Responder { + HttpResponse::Ok() + } + + #[derive(OpenApi, Default)] + #[openapi(paths(foo_todos))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1todo~1foo~1{id}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + } +} + +#[test] +fn path_with_all_args() { + #![allow(unused)] + #[derive(fastapi::ToSchema, serde::Serialize, serde::Deserialize)] + struct Item(String); + + /// Error + #[derive(Debug)] + struct Error; + + impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error") + } + } + + impl ResponseError for Error {} + + #[derive(serde::Serialize, serde::Deserialize, IntoParams)] + struct Filter { + age: i32, + status: String, + } + + // NOTE! temporarily disable automatic parameter recognition + #[fastapi::path(params(Filter))] + #[post("/item/{id}/{name}")] + async fn post_item( + _path: Path<(i32, String)>, + _query: Query<Filter>, + _body: Json<Item>, + ) -> Result<Json<Item>, Error> { + Ok(Json(Item(String::new()))) + } + + #[derive(fastapi::OpenApi)] + #[openapi(paths(post_item))] + struct Doc; + + let doc = serde_json::to_value(Doc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1item~1{id}~1{name}/post").unwrap(); + + assert_json_eq!( + &operation.pointer("/parameters").unwrap(), + json!([ + { + "in": "query", + "name": "age", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ); + assert_json_eq!( + &operation.pointer("/requestBody"), + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "required": true, + }) + ) +} + +#[test] +#[cfg(feature = "uuid")] +fn path_with_all_args_using_uuid() { + #![allow(unused)] + + #[derive(fastapi::ToSchema, serde::Serialize, serde::Deserialize)] + struct Item(String); + + /// Error + #[derive(Debug)] + struct Error; + + impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error") + } + } + + impl ResponseError for Error {} + + #[fastapi::path] + #[post("/item/{uuid}")] + async fn post_item(_path: Path<uuid::Uuid>, _body: Json<Item>) -> Result<Json<Item>, Error> { + Ok(Json(Item(String::new()))) + } + + #[derive(fastapi::OpenApi)] + #[openapi(paths(post_item))] + struct Doc; + + let doc = serde_json::to_value(Doc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1item~1{uuid}/post").unwrap(); + + assert_json_eq!( + &operation.pointer("/parameters").unwrap(), + json!([ + { + "in": "path", + "name": "uuid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + ]) + ); + assert_json_eq!( + &operation.pointer("/requestBody"), + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "required": true, + }) + ) +} + +#[test] +#[cfg(feature = "uuid")] +fn path_with_all_args_using_custom_uuid() { + #[derive(fastapi::ToSchema, serde::Serialize, serde::Deserialize)] + struct Item(String); + + /// Error + #[derive(Debug)] + struct Error; + + impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error") + } + } + + impl ResponseError for Error {} + + #[derive(Serialize, Deserialize, IntoParams)] + #[into_params(names("custom_uuid"))] + struct Id(uuid::Uuid); + + impl FromRequest for Id { + type Error = Error; + + type Future = Ready<Result<Self, Self::Error>>; + + fn from_request( + _: &actix_web::HttpRequest, + _: &mut actix_web::dev::Payload, + ) -> Self::Future { + todo!() + } + } + + // NOTE! temporarily disable automatic parameter recognition + #[fastapi::path(params(Id))] + #[post("/item/{custom_uuid}")] + async fn post_item(_path: Path<Id>, _body: Json<Item>) -> Result<Json<Item>, Error> { + Ok(Json(Item(String::new()))) + } + + #[derive(fastapi::OpenApi)] + #[openapi(paths(post_item))] + struct Doc; + + let doc = serde_json::to_value(Doc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1item~1{custom_uuid}/post").unwrap(); + + assert_json_eq!( + &operation.pointer("/parameters").unwrap(), + json!([ + { + "in": "path", + "name": "custom_uuid", + "required": true, + "schema": { + "format": "uuid", + "type": "string" + } + }, + ]) + ); + assert_json_eq!( + &operation.pointer("/requestBody"), + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "required": true, + }) + ) +} + +macro_rules! test_derive_path_operations { + ( $( $name:ident, $mod:ident: $operation:ident)* ) => { + $( + mod $mod { + use actix_web::{$operation, HttpResponse, Responder}; + use serde_json::json; + + #[fastapi::path( + responses( + (status = 200, description = "success response") + ) + )] + #[$operation("/foo")] + #[allow(unused)] + async fn test_operation() -> impl Responder { + HttpResponse::Ok().json(json!({ "foo": "".to_string() })) + } + } + + #[test] + fn $name() { + #[derive(OpenApi, Default)] + #[openapi(paths($mod::test_operation))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let op_str = stringify!($operation); + let path = format!("/paths/~1foo/{}", op_str); + let value = doc.pointer(&path).unwrap_or(&serde_json::Value::Null); + assert!(value != &Value::Null, "expected to find operation with: {}", path); + } + )* + }; +} + +#[test] +fn path_derive_custom_generic_wrapper() { + #[derive(serde::Serialize, serde::Deserialize)] + struct Validated<T>(T); + + impl<T> FromRequest for Validated<T> { + type Error = actix_web::Error; + + type Future = Ready<Result<Self, Self::Error>>; + + fn from_request( + _req: &actix_web::HttpRequest, + _payload: &mut actix_web::dev::Payload, + ) -> Self::Future { + todo!() + } + } + + #[derive(fastapi::ToSchema, serde::Serialize, serde::Deserialize)] + struct Item(String); + + #[fastapi::path()] + #[post("/item")] + async fn post_item(_body: Validated<Json<Item>>) -> Json<Item> { + Json(Item(String::new())) + } + + #[derive(fastapi::OpenApi)] + #[openapi(paths(post_item))] + struct Doc; + + let doc = serde_json::to_value(Doc::openapi()).unwrap(); + let schemas = doc.pointer("/components/schemas").unwrap(); + let operation = doc.pointer("/paths/~1item/post").unwrap(); + + assert_json_eq!( + &schemas, + json!({ + "Item": { + "type": "string" + } + }) + ); + assert_json_eq!( + &operation.pointer("/requestBody"), + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "required": true, + }) + ) +} + +test_derive_path_operations! { + derive_path_operation_post, mod_test_post: post + derive_path_operation_get, mod_test_get: get + derive_path_operation_delete, mod_test_delete: delete + derive_path_operation_put, mod_test_put: put + derive_path_operation_head, mod_test_head: head + derive_path_operation_options, mod_test_options: options + derive_path_operation_trace, mod_test_trace: trace + derive_path_operation_patch, mod_test_patch: patch +} + +#[test] +fn derive_path_with_multiple_methods_skip_connect() { + #[fastapi::path( + responses( + (status = 200, description = "success response") + ) + )] + #[route("/route foo", method = "GET", method = "HEAD", method = "CONNECT")] + #[allow(unused)] + async fn multiple_methods() -> impl Responder { + String::new() + } + + use fastapi::Path; + assert_eq!( + vec![ + fastapi::openapi::path::HttpMethod::Get, + fastapi::openapi::path::HttpMethod::Head + ], + __path_multiple_methods::methods() + ) +} diff --git a/fastapi-gen/tests/path_derive_auto_into_responses.rs b/fastapi-gen/tests/path_derive_auto_into_responses.rs new file mode 100644 index 0000000..3dc2787 --- /dev/null +++ b/fastapi-gen/tests/path_derive_auto_into_responses.rs @@ -0,0 +1,74 @@ +#![cfg(feature = "auto_into_responses")] + +use assert_json_diff::assert_json_eq; +use fastapi::OpenApi; + +#[test] +fn path_operation_auto_types_responses() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + /// No item found + #[response(status = NOT_FOUND)] + NotFound, + } + + #[fastapi::path(get, path = "/item")] + #[allow(unused)] + async fn get_item() -> ItemResponse<'static> { + ItemResponse::Success(Item { value: "super" }) + } + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/get").unwrap(); + + assert_json_eq!( + &path.pointer("/responses").unwrap(), + serde_json::json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Item found", + }, + "404": { + "description": "No item found" + } + }) + ) +} + +#[test] +fn path_operation_auto_types_default_response_type() { + #[fastapi::path(get, path = "/item")] + #[allow(unused)] + async fn post_item() {} + + #[derive(OpenApi)] + #[openapi(paths(post_item))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/get").unwrap(); + + assert_json_eq!(&path.pointer("/responses").unwrap(), serde_json::json!({})) +} diff --git a/fastapi-gen/tests/path_derive_auto_into_responses_actix.rs b/fastapi-gen/tests/path_derive_auto_into_responses_actix.rs new file mode 100644 index 0000000..7168533 --- /dev/null +++ b/fastapi-gen/tests/path_derive_auto_into_responses_actix.rs @@ -0,0 +1,474 @@ +#![cfg(all(feature = "auto_into_responses", feature = "actix_extras"))] + +use actix_web::web::{Form, Json}; +use fastapi::OpenApi; +use std::fmt::Display; + +use actix_web::body::BoxBody; +use actix_web::http::header::ContentType; +use actix_web::{get, post, HttpResponse, Responder, ResponseError}; +use assert_json_diff::assert_json_eq; + +#[test] +fn path_operation_auto_types_responses() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + /// No item found + #[response(status = NOT_FOUND)] + NotFound, + } + + /// Error + #[derive(Debug, fastapi::IntoResponses)] + #[response(status = 500)] + struct Error; + + impl Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Error") + } + } + + impl ResponseError for Error {} + + impl Responder for ItemResponse<'static> { + type Body = BoxBody; + + fn respond_to(self, _: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { + match self { + Self::Success(item) => HttpResponse::Ok() + .content_type(ContentType::json()) + .body(serde_json::to_string(&item).expect("Item must serialize to json")), + Self::NotFound => HttpResponse::NotFound().finish(), + } + } + } + + #[fastapi::path] + #[get("/item")] + async fn get_item() -> Result<ItemResponse<'static>, Error> { + Ok(ItemResponse::Success(Item { value: "super" })) + } + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/get").unwrap(); + + assert_json_eq!( + &path.pointer("/responses").unwrap(), + serde_json::json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Item found", + }, + "404": { + "description": "No item found" + }, + "500": { + "description": "Error" + } + }) + ) +} + +#[test] +fn path_operation_auto_types_fn_parameters() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + /// No item found + #[response(status = NOT_FOUND)] + NotFound, + } + + impl Responder for ItemResponse<'static> { + type Body = BoxBody; + + fn respond_to(self, _: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { + match self { + Self::Success(item) => HttpResponse::Ok() + .content_type(ContentType::json()) + .body(serde_json::to_string(&item).expect("Item must serialize to json")), + Self::NotFound => HttpResponse::NotFound().finish(), + } + } + } + + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct ItemBody { + value: String, + } + + #[fastapi::path] + #[post("/item")] + #[allow(unused)] + async fn post_item(item: Json<ItemBody>) -> ItemResponse<'static> { + ItemResponse::Success(Item { value: "super" }) + } + + #[derive(OpenApi)] + #[openapi(paths(post_item), components(schemas(ItemBody)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/post").unwrap(); + + assert_json_eq!( + &path.pointer("/responses").unwrap(), + serde_json::json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Item found", + }, + "404": { + "description": "No item found" + }, + }) + ); + + assert_json_eq!( + &path.pointer("/requestBody"), + serde_json::json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ItemBody" + } + } + }, + "description": "", + "required": true, + }) + ) +} + +#[test] +fn path_operation_optional_json_body() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + /// No item found + #[response(status = NOT_FOUND)] + NotFound, + } + + impl Responder for ItemResponse<'static> { + type Body = BoxBody; + + fn respond_to(self, _: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { + match self { + Self::Success(item) => HttpResponse::Ok() + .content_type(ContentType::json()) + .body(serde_json::to_string(&item).expect("Item must serialize to json")), + Self::NotFound => HttpResponse::NotFound().finish(), + } + } + } + + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct ItemBody { + value: String, + } + + #[fastapi::path] + #[post("/item")] + #[allow(unused)] + async fn post_item(item: Option<Json<ItemBody>>) -> ItemResponse<'static> { + ItemResponse::Success(Item { value: "super" }) + } + + #[derive(OpenApi)] + #[openapi(paths(post_item), components(schemas(ItemBody)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/post").unwrap(); + + assert_json_eq!( + &path.pointer("/responses").unwrap(), + serde_json::json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Item found", + }, + "404": { + "description": "No item found" + }, + }) + ); + + assert_json_eq!( + &path.pointer("/requestBody"), + serde_json::json!({ + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "$ref": "#/components/schemas/ItemBody" + } + ], + "nullable": true, + } + } + }, + "description": "", + "required": false, + }) + ) +} + +#[test] +fn path_operation_auto_types_tuple() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + } + + impl Responder for ItemResponse<'static> { + type Body = BoxBody; + + fn respond_to(self, _: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { + match self { + Self::Success(item) => HttpResponse::Ok() + .content_type(ContentType::json()) + .body(serde_json::to_string(&item).expect("Item must serialize to json")), + } + } + } + + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct ItemBody { + value: String, + } + + #[fastapi::path] + #[post("/item")] + #[allow(unused)] + async fn post_item(item: Json<(ItemBody, String)>) -> ItemResponse<'static> { + ItemResponse::Success(Item { value: "super" }) + } + + #[derive(OpenApi)] + #[openapi(paths(post_item), components(schemas(ItemBody)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/post").unwrap(); + + assert_json_eq!( + &path.pointer("/requestBody"), + serde_json::json!({ + "content": { + "application/json": { + "schema": { + "items": { + "allOf": [ + { + "$ref": "#/components/schemas/ItemBody" + }, + { + "type": "string" + } + ] + }, + "type": "array", + } + } + }, + "description": "", + "required": true, + }) + ) +} + +#[test] +fn path_operation_request_body_bytes() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + } + + impl Responder for ItemResponse<'static> { + type Body = BoxBody; + + fn respond_to(self, _: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { + match self { + Self::Success(item) => HttpResponse::Ok() + .content_type(ContentType::json()) + .body(serde_json::to_string(&item).expect("Item must serialize to json")), + } + } + } + + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct ItemBody { + value: String, + } + + #[fastapi::path] + #[post("/item")] + #[allow(unused)] + async fn post_item(item: actix_web::web::Bytes) -> ItemResponse<'static> { + ItemResponse::Success(Item { value: "super" }) + } + + #[derive(OpenApi)] + #[openapi(paths(post_item), components(schemas(ItemBody)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/post").unwrap(); + + assert_json_eq!( + &path.pointer("/requestBody"), + serde_json::json!({ + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "binary", + } + } + }, + "description": "", + "required": true, + }) + ) +} + +#[test] +fn path_operation_request_body_form() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + } + + impl Responder for ItemResponse<'static> { + type Body = BoxBody; + + fn respond_to(self, _: &actix_web::HttpRequest) -> actix_web::HttpResponse<Self::Body> { + match self { + Self::Success(item) => HttpResponse::Ok() + .content_type(ContentType::json()) + .body(serde_json::to_string(&item).expect("Item must serialize to json")), + } + } + } + + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct ItemBody { + value: String, + } + + #[fastapi::path] + #[post("/item")] + #[allow(unused)] + async fn post_item(item: Form<ItemBody>) -> ItemResponse<'static> { + ItemResponse::Success(Item { value: "super" }) + } + + #[derive(OpenApi)] + #[openapi(paths(post_item), components(schemas(ItemBody)))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/post").unwrap(); + + assert_json_eq!( + &path.pointer("/requestBody"), + serde_json::json!({ + "content": { + "application/x-www-form-urlencoded": { + "schema": { + "$ref": "#/components/schemas/ItemBody" + } + } + }, + "description": "", + "required": true, + }) + ) +} diff --git a/fastapi-gen/tests/path_derive_auto_into_responses_axum.rs b/fastapi-gen/tests/path_derive_auto_into_responses_axum.rs new file mode 100644 index 0000000..a801a73 --- /dev/null +++ b/fastapi-gen/tests/path_derive_auto_into_responses_axum.rs @@ -0,0 +1,57 @@ +#![cfg(all(feature = "auto_into_responses", feature = "axum_extras"))] + +use assert_json_diff::assert_json_eq; +use fastapi::OpenApi; + +#[test] +fn path_operation_auto_types_responses() { + /// Test item to to return + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Item<'s> { + value: &'s str, + } + + #[derive(fastapi::IntoResponses)] + #[allow(unused)] + enum ItemResponse<'s> { + /// Item found + #[response(status = 200)] + Success(Item<'s>), + /// No item found + #[response(status = NOT_FOUND)] + NotFound, + } + + #[fastapi::path(get, path = "/item")] + #[allow(unused)] + async fn get_item() -> ItemResponse<'static> { + ItemResponse::Success(Item { value: "super" }) + } + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = ApiDoc::openapi(); + let value = serde_json::to_value(&doc).unwrap(); + let path = value.pointer("/paths/~1item/get").unwrap(); + + assert_json_eq!( + &path.pointer("/responses").unwrap(), + serde_json::json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "description": "Item found", + }, + "404": { + "description": "No item found" + } + }) + ) +} diff --git a/fastapi-gen/tests/path_derive_axum_test.rs b/fastapi-gen/tests/path_derive_axum_test.rs new file mode 100644 index 0000000..19a7e03 --- /dev/null +++ b/fastapi-gen/tests/path_derive_axum_test.rs @@ -0,0 +1,809 @@ +#![cfg(feature = "axum_extras")] + +use std::sync::{Arc, Mutex}; + +use assert_json_diff::{assert_json_eq, assert_json_matches, CompareMode, Config, NumericMode}; +use axum::{ + extract::{Path, Query}, + Extension, Json, +}; +use fastapi::{IntoParams, OpenApi}; +use serde::Deserialize; +use serde_json::json; + +#[test] +fn derive_path_params_into_params_axum() { + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + struct Person { + /// Id of person + id: i64, + /// Name of person + name: String, + } + + pub mod custom { + use fastapi::IntoParams; + use serde::Deserialize; + #[derive(Deserialize, IntoParams)] + #[allow(unused)] + pub(super) struct Filter { + /// Age filter for user + #[deprecated] + age: Option<Vec<String>>, + } + } + + #[fastapi::path( + get, + path = "/person/{id}/{name}", + params(Person, custom::Filter), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_person(person: Path<Person>, query: Query<custom::Filter>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_person))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1person~1{id}~1{name}/get/parameters") + .unwrap(); + + assert_json_eq!( + parameters, + &json!([ + { + "description": "Id of person", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + }, + }, + { + "description": "Name of person", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + { + "deprecated": true, + "description": "Age filter for user", + "in": "query", + "name": "age", + "required": false, + "schema": { + "items": { + "type": "string", + }, + "type": ["array", "null"], + } + }, + ]) + ) +} + +#[test] +fn get_todo_with_path_tuple() { + #[fastapi::path( + get, + path = "/person/{id}/{name}", + params( + ("id", description = "Person id"), + ("name", description = "Person name") + ), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_person(Path((id, name)): Path<(String, String)>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_person))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1person~1{id}~1{name}/get/parameters") + .unwrap(); + + assert_json_eq!( + parameters, + &json!([ + { + "description": "Person id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + }, + { + "description": "Person name", + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + ]) + ) +} + +#[test] +fn get_todo_with_extension() { + #[derive(fastapi::ToSchema)] + struct Todo { + #[allow(unused)] + id: i32, + } + /// In-memory todo store + type Store = Mutex<Vec<Todo>>; + + /// List all Todo items + /// + /// List all Todo items from in-memory storage. + #[fastapi::path( + get, + path = "/todo", + responses( + (status = 200, description = "List all todos successfully", body = [Todo]) + ) + )] + #[allow(unused)] + async fn list_todos(Extension(store): Extension<Arc<Store>>) {} + + #[derive(OpenApi)] + #[openapi(paths(list_todos))] + struct ApiDoc; + + serde_json::to_value(ApiDoc::openapi()) + .unwrap() + .pointer("/paths/~1todo/get") + .expect("Expected to find /paths/todo/get"); +} + +#[test] +fn derive_path_params_into_params_unnamed() { + #[derive(Deserialize, IntoParams)] + #[into_params(names("id", "name"))] + #[allow(dead_code)] + struct IdAndName(u64, String); + + #[fastapi::path( + get, + path = "/person/{id}/{name}", + params(IdAndName), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_person(person: Path<IdAndName>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_person))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1person~1{id}~1{name}/get/parameters") + .unwrap(); + + let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat); + + assert_json_matches!( + parameters, + &json!([ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + "minimum": 0.0, + }, + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + ]), + config + ) +} + +#[test] +fn derive_path_params_with_ignored_parameter() { + struct Auth; + #[derive(Deserialize, IntoParams)] + #[into_params(names("id", "name"))] + #[allow(dead_code)] + struct IdAndName(u64, String); + + #[fastapi::path( + get, + path = "/person/{id}/{name}", + params(IdAndName), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_person(_: Auth, person: Path<IdAndName>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_person))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1person~1{id}~1{name}/get/parameters") + .unwrap(); + + let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat); + + assert_json_matches!( + parameters, + &json!([ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + "minimum": 0.0, + }, + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + ]), + config + ) +} + +#[test] +fn derive_path_params_with_unnamed_struct_destructed() { + #[derive(Deserialize, IntoParams)] + #[into_params(names("id", "name"))] + struct IdAndName(u64, String); + + #[fastapi::path( + get, + path = "/person/{id}/{name}", + params(IdAndName), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_person(Path(IdAndName(id, name)): Path<IdAndName>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_person))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1person~1{id}~1{name}/get/parameters") + .unwrap(); + + let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat); + assert_json_matches!( + parameters, + &json!([ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int64", + "type": "integer", + "minimum": 0.0, + }, + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + ]), + config + ) +} + +#[test] +fn derive_path_query_params_with_named_struct_destructed() { + #[derive(IntoParams)] + #[allow(unused)] + struct QueryParmas<'q> { + name: &'q str, + } + + #[fastapi::path(get, path = "/item", params(QueryParmas))] + #[allow(unused)] + async fn get_item(Query(QueryParmas { name }): Query<QueryParmas<'static>>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1item/get/parameters").unwrap(); + + assert_json_eq!( + parameters, + &json!([ + { + "in": "query", + "name": "name", + "required": true, + "schema": { + "type": "string", + }, + }, + ]) + ) +} + +#[test] +fn path_with_path_query_body_resolved() { + #[derive(fastapi::ToSchema, serde::Serialize, serde::Deserialize)] + struct Item(String); + + #[allow(unused)] + struct Error; + + #[derive(serde::Serialize, serde::Deserialize, IntoParams)] + struct Filter { + age: i32, + status: String, + } + + #[fastapi::path(path = "/item/{id}/{name}", params(Filter), post)] + #[allow(unused)] + async fn post_item( + _path: Path<(i32, String)>, + _query: Query<Filter>, + _body: Json<Item>, + ) -> Result<Json<Item>, Error> { + Ok(Json(Item(String::new()))) + } + + #[derive(fastapi::OpenApi)] + #[openapi(paths(post_item))] + struct Doc; + + let doc = serde_json::to_value(Doc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1item~1{id}~1{name}/post").unwrap(); + + assert_json_eq!( + &operation.pointer("/parameters").unwrap(), + json!([ + { + "in": "query", + "name": "age", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "query", + "name": "status", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + ]) + ); + assert_json_eq!( + &operation.pointer("/requestBody"), + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Item" + } + } + }, + "required": true, + }) + ) +} + +#[test] +fn test_into_params_for_option_query_type() { + #[fastapi::path( + get, + path = "/items", + params(("id" = u32, Query, description = "")), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_item(id: Option<Query<u32>>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1items/get").unwrap(); + + assert_json_eq!( + operation.pointer("/parameters"), + json!([ + { + "description": "", + "in": "query", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer", + "minimum": 0 + } + } + ]) + ) +} + +#[test] +fn path_param_single_arg_primitive_type() { + #[fastapi::path( + get, + path = "/items/{id}", + params(("id" = u32, Path, description = "")), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_item(id: Path<u32>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1items~1{id}/get").unwrap(); + + assert_json_eq!( + operation.pointer("/parameters"), + json!([ + { + "description": "", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer", + "minimum": 0 + } + } + ]) + ) +} + +#[test] +fn path_param_single_arg_non_primitive_type() { + #[derive(fastapi::ToSchema)] + #[allow(dead_code)] + struct Id(String); + + #[fastapi::path( + get, + path = "/items/{id}", + params(("id" = inline(Id), Path, description = "")), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_item(id: Path<Id>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1items~1{id}/get").unwrap(); + + assert_json_eq!( + operation.pointer("/parameters"), + json!([ + { + "description": "", + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + } + } + ]) + ) +} + +#[test] +fn path_param_single_arg_non_primitive_type_into_params() { + #[derive(fastapi::ToSchema, fastapi::IntoParams)] + #[into_params(names("id"))] + #[allow(dead_code)] + struct Id(String); + + #[fastapi::path( + get, + path = "/items/{id}", + params(Id), + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + async fn get_item(id: Path<Id>) {} + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let operation = doc.pointer("/paths/~1items~1{id}/get").unwrap(); + + assert_json_eq!( + operation.pointer("/parameters"), + json!([ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string", + } + } + ]) + ) +} + +#[test] +fn derive_path_with_validation_attributes_axum() { + #[derive(IntoParams)] + #[allow(dead_code)] + struct Params { + #[param(maximum = 10, minimum = 5, multiple_of = 2.5)] + id: i32, + + #[param(max_length = 10, min_length = 5, pattern = "[a-z]*")] + value: String, + + #[param(max_items = 5, min_items = 1)] + items: Vec<String>, + } + + #[fastapi::path( + get, + path = "foo/{foo_id}", + responses( + (status = 200, description = "success response") + ), + params( + ("foo_id" = String, min_length = 1, description = "Id of Foo to get"), + Params, + ("name" = Option<String>, description = "Foo name", min_length = 3), + ("nonnullable" = String, description = "Foo nonnullable", min_length = 3, max_length = 10), + ("namequery" = Option<String>, Query, description = "Foo name", min_length = 3), + ("nonnullablequery" = String, Query, description = "Foo nonnullable", min_length = 3, max_length = 10), + ) + )] + #[allow(unused)] + async fn get_foo(path: Path<String>, query: Query<Params>) {} + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/foo~1{foo_id}/get/parameters").unwrap(); + + let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat); + + assert_json_matches!( + parameters, + json!([ + { + "schema": { + "type": "string", + "minLength": 1, + }, + "required": true, + "name": "foo_id", + "in": "path", + "description": "Id of Foo to get" + }, + { + "schema": { + "format": "int32", + "type": "integer", + "maximum": 10.0, + "minimum": 5.0, + "multipleOf": 2.5, + }, + "required": true, + "name": "id", + "in": "query" + }, + { + "schema": { + "type": "string", + "maxLength": 10, + "minLength": 5, + "pattern": "[a-z]*" + }, + "required": true, + "name": "value", + "in": "query" + }, + { + "schema": { + "type": "array", + "items": { + "type": "string", + }, + "maxItems": 5, + "minItems": 1, + }, + "required": true, + "name": "items", + "in": "query" + }, + { + "schema": { + "type": ["string", "null"], + "minLength": 3, + }, + "required": true, + "name": "name", + "in": "path", + "description": "Foo name" + }, + { + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 10, + }, + "required": true, + "name": "nonnullable", + "in": "path", + "description": "Foo nonnullable" + }, + { + "schema": { + "type": ["string", "null"], + "minLength": 3, + }, + "required": false, + "name": "namequery", + "in": "query", + "description": "Foo name" + }, + { + "schema": { + "type": "string", + "minLength": 3, + "maxLength": 10, + }, + "required": true, + "name": "nonnullablequery", + "in": "query", + "description": "Foo nonnullable" + } + ]), + config + ); +} + +#[test] +fn path_derive_inline_with_tuple() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + pub enum ResourceType { + Type1, + Type2, + } + + #[fastapi::path( + get, + path = "/test_2params_separated/{resource_type}/{id}", + params( + ("resource_type" = inline(ResourceType), Path), + ("id" = String, Path) + ) + )] + #[allow(unused)] + pub async fn inline_tuple( + Path((resource_type, id)): axum::extract::Path<(ResourceType, String)>, + ) { + } + + use fastapi::Path; + let value = __path_inline_tuple::operation(); + let value = serde_json::to_value(value).expect("operation should serialize to json"); + + assert_json_eq!( + value, + json!({ + "operationId": "inline_tuple", + "parameters": [ + { + "in": "path", + "name": "resource_type", + "required": true, + "schema": { + "enum": ["Type1", "Type2"], + "type": "string" + }, + }, + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "type": "string" + }, + } + ], + "responses": {} + }) + ) +} diff --git a/fastapi-gen/tests/path_derive_rocket.rs b/fastapi-gen/tests/path_derive_rocket.rs new file mode 100644 index 0000000..fa304f0 --- /dev/null +++ b/fastapi-gen/tests/path_derive_rocket.rs @@ -0,0 +1,744 @@ +#![cfg(feature = "rocket_extras")] + +use std::io::Error; + +use assert_json_diff::assert_json_eq; +use fastapi::openapi::path::ParameterBuilder; +use fastapi::{IntoParams, OpenApi, Path, ToSchema}; +use fastapi_gen::schema; +use rocket::request::FromParam; +use rocket::serde::json::Json; +use rocket::{get, post, FromForm}; +use serde_json::{json, Value}; + +mod common; + +#[test] +fn resolve_route_with_simple_url() { + mod rocket_route_operation { + use rocket::route; + + #[fastapi::path(responses( + (status = 200, description = "Hello from server") + ))] + #[route(GET, uri = "/hello")] + #[allow(unused)] + fn hello() -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_route_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let operation = value.pointer("/paths/~1hello/get").unwrap(); + + assert_ne!(operation, &Value::Null, "expected paths.hello.get not null"); +} + +#[test] +fn resolve_get_with_multiple_args() { + mod rocket_get_operation { + use rocket::get; + + #[fastapi::path(responses( + (status = 200, description = "Hello from server") + ))] + #[get("/hello/<id>/<name>?<colors>")] + #[allow(unused)] + fn hello(id: i32, name: &str, colors: Vec<&str>) -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_get_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let parameters = value + .pointer("/paths/~1hello~1{id}~1{name}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 3); + assert_ne!( + parameters, + &Value::Null, + "expected paths.hello.{{id}}.name.get.parameters not null" + ); + assert_value! {parameters=> + "[0].schema.type" = r#""array""#, "Query parameter type" + "[0].schema.format" = r#"null"#, "Query parameter format" + "[0].schema.items.type" = r#""string""#, "Query items parameter type" + "[0].schema.items.format" = r#"null"#, "Query items parameter format" + "[0].name" = r#""colors""#, "Query parameter name" + "[0].required" = r#"true"#, "Query parameter required" + "[0].deprecated" = r#"null"#, "Query parameter required" + "[0].in" = r#""query""#, "Query parameter in" + + "[1].schema.type" = r#""integer""#, "Id parameter type" + "[1].schema.format" = r#""int32""#, "Id parameter format" + "[1].name" = r#""id""#, "Id parameter name" + "[1].required" = r#"true"#, "Id parameter required" + "[1].deprecated" = r#"null"#, "Id parameter required" + "[1].in" = r#""path""#, "Id parameter in" + + "[2].schema.type" = r#""string""#, "Name parameter type" + "[2].schema.format" = r#"null"#, "Name parameter format" + "[2].name" = r#""name""#, "Name parameter name" + "[2].required" = r#"true"#, "Name parameter required" + "[2].deprecated" = r#"null"#, "Name parameter required" + "[2].in" = r#""path""#, "Name parameter in" + } +} + +#[test] +fn resolve_get_with_optional_query_args() { + mod rocket_get_operation { + use rocket::get; + + #[fastapi::path(responses( + (status = 200, description = "Hello from server") + ))] + #[get("/hello?<colors>")] + #[allow(unused)] + fn hello(colors: Option<Vec<&str>>) -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_get_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let parameters = value.pointer("/paths/~1hello/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_ne!( + parameters, + &Value::Null, + "expected paths.hello.get.parameters not null" + ); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "colors", + "required": false, + "schema": { + "items": { + "type": "string", + }, + "type": ["array", "null"], + } + } + ]) + ); +} + +#[test] +fn resolve_path_arguments_not_same_order() { + mod rocket_get_operation { + use rocket::get; + + #[fastapi::path(responses( + (status = 200, description = "Hello from server") + ))] + #[get("/hello/<id>/<name>")] + #[allow(unused)] + fn hello(name: &str, id: i64) -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_get_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let parameters = value + .pointer("/paths/~1hello~1{id}~1{name}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_ne!( + parameters, + &Value::Null, + r"expected paths.hello/{{id}}/{{name}}.get.parameters not null" + ); + + assert_value! {parameters=> + "[0].schema.type" = r#""integer""#, "Id parameter type" + "[0].schema.format" = r#""int64""#, "Id parameter format" + "[0].name" = r#""id""#, "Id parameter name" + "[0].required" = r#"true"#, "Id parameter required" + "[0].deprecated" = r#"null"#, "Id parameter required" + "[0].in" = r#""path""#, "Id parameter in" + + "[1].schema.type" = r#""string""#, "Name parameter type" + "[1].schema.format" = r#"null"#, "Name parameter format" + "[1].name" = r#""name""#, "Name parameter name" + "[1].required" = r#"true"#, "Name parameter required" + "[1].deprecated" = r#"null"#, "Name parameter required" + "[1].in" = r#""path""#, "Name parameter in" + } +} + +#[test] +fn resolve_get_path_with_anonymous_parts() { + mod rocket_get_operation { + use rocket::get; + + #[fastapi::path(responses( + (status = 200, description = "Hello from server") + ))] + #[get("/hello/<_>/<_>/<id>")] + #[allow(unused)] + fn hello(id: i64) -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_get_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let parameters = value + .pointer("/paths/~1hello~1{arg0}~1{arg1}~1{id}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 3); + assert_ne!( + parameters, + &Value::Null, + r"expected paths.hello/{{arg0}}/{{arg1}}/{{id}}.get.parameters not null" + ); + + assert_value! {parameters=> + "[0].schema.type" = r#""integer""#, "Id parameter type" + "[0].schema.format" = r#""int64""#, "Id parameter format" + "[0].name" = r#""id""#, "Id parameter name" + "[0].required" = r#"true"#, "Id parameter required" + "[0].deprecated" = r#"null"#, "Id parameter required" + "[0].in" = r#""path""#, "Id parameter in" + + "[1].schema.type" = r#"null"#, "Arg0 parameter type" + "[1].schema.format" = r#"null"#, "Arg0 parameter format" + "[1].name" = r#""arg0""#, "Arg0 parameter name" + "[1].required" = r#"true"#, "Arg0 parameter required" + "[1].deprecated" = r#"null"#, "Arg0 parameter required" + "[1].in" = r#""path""#, "Arg0 parameter in" + + "[2].schema.type" = r#"null"#, "Arg1 parameter type" + "[2].schema.format" = r#"null"#, "Arg1 parameter format" + "[2].name" = r#""arg1""#, "Arg1 parameter name" + "[2].required" = r#"true"#, "Arg1 parameter required" + "[2].deprecated" = r#"null"#, "Arg1 parameter required" + "[2].in" = r#""path""#, "Arg1 parameter in" + } +} + +#[test] +fn resolve_get_path_with_tail() { + mod rocket_get_operation { + use std::path::PathBuf; + + use rocket::get; + + #[fastapi::path(responses( + (status = 200, description = "Hello from server") + ))] + #[get("/hello/<tail..>")] + #[allow(unused)] + fn hello(tail: PathBuf) -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_get_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let parameters = value + .pointer("/paths/~1hello~1{tail}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_ne!( + parameters, + &Value::Null, + r"expected paths.hello/{{tail}}.get.parameters not null" + ); + + assert_value! {parameters=> + "[0].schema.type" = r#""string""#, "Tail parameter type" + "[0].schema.format" = r#"null"#, "Tail parameter format" + "[0].name" = r#""tail""#, "Tail parameter name" + "[0].required" = r#"true"#, "Tail parameter required" + "[0].deprecated" = r#"null"#, "Tail parameter required" + "[0].in" = r#""path""#, "Tail parameter in" + } +} + +#[test] +fn resolve_get_path_and_update_params() { + mod rocket_get_operation { + use rocket::get; + + #[fastapi::path( + responses( + (status = 200, description = "Hello from server") + ), + params( + ("id", description = "Hello id") + ) + )] + #[get("/hello/<id>/<name>")] + #[allow(unused)] + fn hello(id: i32, name: String) -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_get_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let parameters = value + .pointer("/paths/~1hello~1{id}~1{name}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_ne!( + parameters, + &Value::Null, + r"expected paths.hello/{{id}}/{{name}}.get.parameters not null" + ); + + assert_value! {parameters=> + "[0].schema.type" = r#""integer""#, "Id parameter type" + "[0].schema.format" = r#""int32""#, "Id parameter format" + "[0].description" = r#""Hello id""#, "Id parameter format" + "[0].name" = r#""id""#, "Id parameter name" + "[0].required" = r#"true"#, "Id parameter required" + "[0].deprecated" = r#"null"#, "Id parameter required" + "[0].in" = r#""path""#, "Id parameter in" + + "[1].schema.type" = r#""string""#, "Name parameter type" + "[1].schema.format" = r#"null"#, "Name parameter format" + "[1].description" = r#"null"#, "Name parameter format" + "[1].name" = r#""name""#, "Name parameter name" + "[1].required" = r#"true"#, "Name parameter required" + "[1].deprecated" = r#"null"#, "Name parameter required" + "[1].in" = r#""path""#, "Name parameter in" + } +} + +#[test] +fn resolve_path_query_params_from_form() { + mod rocket_get_operation { + use fastapi::IntoParams; + use rocket::{get, FromForm}; + + #[derive(serde::Deserialize, FromForm, IntoParams)] + #[allow(unused)] + struct QueryParams { + foo: String, + bar: i64, + } + + #[fastapi::path( + responses( + (status = 200, description = "Hello from server") + ), + params( + ("id", description = "Hello id"), + QueryParams + ) + )] + #[get("/hello/<id>?<rest..>")] + #[allow(unused)] + fn hello(id: i32, rest: QueryParams) -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_get_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let parameters = value + .pointer("/paths/~1hello~1{id}/get/parameters") + .unwrap(); + + assert_json_eq!( + parameters, + json!([ + { + "description": "Hello id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "query", + "name": "foo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "bar", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + } + ]) + ) +} + +#[test] +fn path_with_all_args_and_body() { + use fastapi::IntoParams; + use rocket::FromForm; + + #[derive(serde::Serialize, serde::Deserialize, fastapi::ToSchema)] + struct Hello<'a> { + message: &'a str, + } + + #[derive(serde::Deserialize, FromForm, IntoParams)] + #[allow(unused)] + struct QueryParams { + foo: String, + bar: i64, + } + + // NOTE! temporarily disable automatic parameter recognition + #[fastapi::path( + responses( + (status = 200, description = "Hello from server")), + params( + ("id", description = "Hello id"), + QueryParams + ) + )] + #[post("/hello/<id>/<name>?<colors>&<rest..>", data = "<hello>")] + #[allow(unused)] + fn post_hello( + id: i32, + name: &str, + colors: Vec<&str>, + rest: QueryParams, + hello: Json<Hello>, + ) -> String { + "Hello".to_string() + } + + #[derive(OpenApi)] + #[openapi(paths(post_hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let operation = value.pointer("/paths/~1hello~1{id}~1{name}/post").unwrap(); + + assert_json_eq!( + operation.pointer("/parameters"), + json!([ + { + "description": "Hello id", + "in": "path", + "name": "id", + "required": true, + "schema": { + "format": "int32", + "type": "integer" + } + }, + { + "in": "query", + "name": "foo", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "query", + "name": "bar", + "required": true, + "schema": { + "format": "int64", + "type": "integer" + } + }, + { + "in": "query", + "name": "colors", + "required": true, + "schema": { + "type": "array", + "items": { + "type": "string" + } + } + }, + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "type": "string" + } + } + + ]) + ); + assert_json_eq!( + &operation.pointer("/requestBody"), + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Hello" + } + } + }, + "required": true + }) + ); +} + +#[test] +fn path_with_enum_path_param() { + #[derive(ToSchema)] + #[allow(unused)] + enum ApiVersion { + V1, + } + + impl IntoParams for ApiVersion { + fn into_params( + _: impl Fn() -> Option<fastapi::openapi::path::ParameterIn>, + ) -> Vec<fastapi::openapi::path::Parameter> { + vec![ParameterBuilder::new() + .description(Some("")) + .name("api_version") + .required(fastapi::openapi::Required::True) + .parameter_in(fastapi::openapi::path::ParameterIn::Path) + .schema(Some(schema!( + #[inline] + ApiVersion + ))) + .build()] + } + } + + impl<'a> FromParam<'a> for ApiVersion { + type Error = Error; + + fn from_param(_param: &'a str) -> Result<Self, Self::Error> { + todo!() + } + } + + // NOTE! temporarily disable automatic parameter recognition + #[fastapi::path( + post, + path = "/item", + params( + ApiVersion + ), + responses( + (status = 201, description = "Item created successfully"), + ), + )] + #[post("/<api_version>/item", format = "json")] + #[allow(unused)] + async fn create_item(api_version: ApiVersion) -> String { + todo!() + } + + #[derive(OpenApi)] + #[openapi(paths(create_item))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let operation = value.pointer("/paths/~1item/post").unwrap(); + + assert_json_eq!( + operation.pointer("/parameters"), + json!([ + { + "description": "", + "in": "path", + "name": "api_version", + "required": true, + "schema": { + "type": "string", + "enum": [ + "V1" + ] + } + } + ]) + ) +} + +macro_rules! test_derive_path_operations { + ( $($name:ident: $operation:ident)* ) => { + $( + #[test] + fn $name() { + mod rocket_operation { + use rocket::$operation; + + #[fastapi::path( + responses( + (status = 200, description = "Hello from server") + ) + )] + #[$operation("/hello")] + #[allow(unused)] + fn hello() -> String { + "Hello".to_string() + } + } + + #[derive(OpenApi)] + #[openapi(paths(rocket_operation::hello))] + struct ApiDoc; + + let openapi = ApiDoc::openapi(); + let value = &serde_json::to_value(&openapi).unwrap(); + let op = value + .pointer(&*format!("/paths/~1hello/{}", stringify!($operation))) + .unwrap(); + + assert_ne!( + op, + &Value::Null, + "expected paths./hello.{}", stringify!($operation) + ); + } + )* + }; +} + +test_derive_path_operations! { + derive_path_get: get + derive_path_post: post + derive_path_put: put + derive_path_delete: delete + derive_path_head: head + derive_path_options: options + derive_path_patch: patch +} + +#[test] +fn derive_rocket_path_with_query_params_in_option() { + #![allow(unused)] + + #[derive(FromForm, IntoParams)] + #[into_params(parameter_in = Query, style = Form)] + pub struct PageParams { + pub page: u64, + pub per_page: u64, + } + + #[fastapi::path( + context_path = "/user/api_keys", + params( + PageParams, + ), + responses( + (status = 200, body = ()), + (status = 400, body = ()), + ), + )] + #[get("/list?<page..>")] + async fn list_items(page: Option<PageParams>) {} + + let operation = __path_list_items::operation(); + let value = serde_json::to_value(&operation).expect("operation is JSON serializable"); + + assert_json_eq!( + value, + json!({ + "operationId": "list_items", + "parameters": [ + { + "in": "query", + "name": "page", + "required": true, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "style": "form" + }, + { + "in": "query", + "name": "per_page", + "required": true, + "schema": { + "format": "int64", + "minimum": 0, + "type": "integer" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "default": null, + } + } + }, + "description": "" + }, + "400": { + "content": { + "application/json": { + "schema": { + "default": null, + } + } + }, + "description": "" + } + } + }) + ) +} diff --git a/fastapi-gen/tests/path_parameter_derive_actix.rs b/fastapi-gen/tests/path_parameter_derive_actix.rs new file mode 100644 index 0000000..979a667 --- /dev/null +++ b/fastapi-gen/tests/path_parameter_derive_actix.rs @@ -0,0 +1,277 @@ +#![cfg(feature = "actix_extras")] + +use fastapi::OpenApi; + +mod common; + +mod derive_params_multiple_actix { + use actix_web::{web, HttpResponse, Responder}; + use serde_json::json; + + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + get, + path = "/foo/{id}/{digest}", + responses( + (status = 200, description = "success response") + ), + params( + ("id", description = "Foo id"), + ("digest", description = "Digest of foo"), + ) + )] + #[allow(unused)] + async fn get_foo_by_id(path: web::Path<(i32, String)>) -> impl Responder { + let (id, digest) = path.into_inner(); + HttpResponse::Ok().json(json!({ "foo": format!("{:?}{:?}", &id, &digest) })) + } +} + +#[test] +fn derive_path_parameter_multiple_with_matching_names_and_types_actix_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_params_multiple_actix::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{digest}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Foo id""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""digest""#, "Parameter name" + "[1].description" = r#""Digest of foo""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +mod derive_parameters_multiple_no_matching_names_actix { + use actix_web::{web, HttpResponse, Responder}; + use serde_json::json; + + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + get, + path = "/foo/{id}/{digest}", + responses( + (status = 200, description = "success response") + ), + params( + ("id" = i32, description = "Foo id"), + ("digest" = String, description = "Digest of foo"), + ) + )] + #[allow(unused)] + async fn get_foo_by_id(info: web::Path<(i32, String)>) -> impl Responder { + // is no matching names since the parameter name does not match to amount of types + HttpResponse::Ok().json(json!({ "foo": format!("{:?}{:?}", &info.0, &info.1) })) + } +} + +#[test] +fn derive_path_parameter_multiple_no_matching_names_actix_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_parameters_multiple_no_matching_names_actix::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{digest}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Foo id""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""digest""#, "Parameter name" + "[1].description" = r#""Digest of foo""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +mod derive_params_from_method_args_actix { + use actix_web::{web, HttpResponse, Responder}; + use serde_json::json; + + #[fastapi::path( + get, + path = "/foo/{id}/{digest}", + responses( + (status = 200, description = "success response") + ), + )] + #[allow(unused)] + async fn get_foo_by_id(path: web::Path<(i32, String)>) -> impl Responder { + let (id, digest) = path.into_inner(); + HttpResponse::Ok().json(json!({ "foo": format!("{:?}{:?}", &id, &digest) })) + } +} + +#[test] +fn derive_params_from_method_args_actix_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_params_from_method_args_actix::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{digest}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#"null"#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""digest""#, "Parameter name" + "[1].description" = r#"null"#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +#[test] +#[cfg(feature = "chrono")] +fn derive_path_with_date_params_chrono_implicit() { + mod mod_derive_path_with_date_params { + use actix_web::{get, web, HttpResponse, Responder}; + use chrono::{DateTime, Utc}; + use serde_json::json; + use time::Date; + + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + params( + ("start_date", description = "Start date filter"), + ("end_date", description = "End date filter"), + ) + )] + #[get("/visitors/v1/{start_date}/{end_date}")] + #[allow(unused)] + async fn get_foo_by_date(path: web::Path<(Date, DateTime<Utc>)>) -> impl Responder { + let (start_date, end_date) = path.into_inner(); + HttpResponse::Ok() + .json(json!({ "params": &format!("{:?} {:?}", start_date, end_date) })) + } + } + + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_path_with_date_params::get_foo_by_date))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1visitors~1v1~1{start_date}~1{end_date}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""start_date""#, "Parameter name" + "[0].description" = r#""Start date filter""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""string""#, "Parameter schema type" + "[0].schema.format" = r#""date""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""end_date""#, "Parameter name" + "[1].description" = r#""End date filter""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#""date-time""#, "Parameter schema format" + }; +} + +#[test] +#[cfg(feature = "time")] +fn derive_path_with_date_params_explicit_ignored_time() { + mod mod_derive_path_with_date_params { + use actix_web::{get, web, HttpResponse, Responder}; + use serde_json::json; + use time::Date; + + #[fastapi::path( + responses( + (status = 200, description = "success response") + ), + params( + ("start_date", description = "Start date filter", format = Date), + ("end_date", description = "End date filter", format = DateTime), + ) + )] + #[get("/visitors/v1/{start_date}/{end_date}")] + #[allow(unused)] + async fn get_foo_by_date(path: web::Path<(Date, String)>) -> impl Responder { + let (start_date, end_date) = path.into_inner(); + HttpResponse::Ok() + .json(json!({ "params": &format!("{:?} {:?}", start_date, end_date) })) + } + } + + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_path_with_date_params::get_foo_by_date))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1visitors~1v1~1{start_date}~1{end_date}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""start_date""#, "Parameter name" + "[0].description" = r#""Start date filter""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""string""#, "Parameter schema type" + "[0].schema.format" = r#""date""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""end_date""#, "Parameter name" + "[1].description" = r#""End date filter""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#""date-time""#, "Parameter schema format" + }; +} diff --git a/fastapi-gen/tests/path_parameter_derive_test.rs b/fastapi-gen/tests/path_parameter_derive_test.rs new file mode 100644 index 0000000..9ec865e --- /dev/null +++ b/fastapi-gen/tests/path_parameter_derive_test.rs @@ -0,0 +1,450 @@ +use assert_json_diff::assert_json_eq; +use fastapi::{OpenApi, Path}; +use serde_json::json; + +mod common; + +mod derive_params_all_options { + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + (status = 200, description = "success"), + ), + params( + ("id" = i32, Path, deprecated, description = "Search foos by ids"), + ) + )] + #[allow(unused)] + async fn get_foo_by_id(id: i32) -> i32 { + id + } +} + +#[test] +fn derive_path_parameters_with_all_options_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_params_all_options::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo~1{id}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Search foos by ids""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"true"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + }; +} + +mod derive_params_minimal { + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + (status = 200, description = "success"), + ), + params( + ("id" = i32, description = "Search foos by ids"), + ) + )] + #[allow(unused)] + async fn get_foo_by_id(id: i32) -> i32 { + id + } +} + +#[test] +fn derive_path_parameters_minimal_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_params_minimal::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo~1{id}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Search foos by ids""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + }; +} + +mod derive_params_multiple { + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + get, + path = "/foo/{id}/{digest}", + responses( + (status = 200, description = "success"), + ), + params( + ("id" = i32, description = "Foo id"), + ("digest" = String, description = "Digest of foo"), + ) + )] + #[allow(unused)] + async fn get_foo_by_id(id: i32, digest: String) -> String { + format!("{:?}{:?}", &id, &digest) + } +} + +#[test] +fn derive_path_parameter_multiple_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_params_multiple::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc + .pointer("/paths/~1foo~1{id}~1{digest}/get/parameters") + .unwrap(); + + common::assert_json_array_len(parameters, 2); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Foo id""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + + "[1].in" = r#""path""#, "Parameter in" + "[1].name" = r#""digest""#, "Parameter name" + "[1].description" = r#""Digest of foo""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"null"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +mod mod_derive_parameters_all_types { + /// Get foo by id + /// + /// Get foo by id long description + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + (status = 200, description = "success"), + ), + params( + ("id" = i32, Path, description = "Foo id"), + ("since" = String, Query, deprecated, description = "Datetime since"), + ("numbers" = Option<[i64]>, Query, description = "Foo numbers list"), + ("token" = String, Header, deprecated, description = "Token of foo"), + ("cookieval" = String, Cookie, deprecated, description = "Foo cookie"), + ) + )] + #[allow(unused)] + async fn get_foo_by_id(id: i32) -> i32 { + id + } +} + +#[test] +fn derive_parameters_with_all_types() { + #[derive(OpenApi, Default)] + #[openapi(paths(mod_derive_parameters_all_types::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo~1{id}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 5); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Foo id""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + + "[1].in" = r#""query""#, "Parameter in" + "[1].name" = r#""since""#, "Parameter name" + "[1].description" = r#""Datetime since""#, "Parameter description" + "[1].required" = r#"true"#, "Parameter required" + "[1].deprecated" = r#"true"#, "Parameter deprecated" + "[1].schema.type" = r#""string""#, "Parameter schema type" + "[1].schema.format" = r#"null"#, "Parameter schema format" + + "[2].in" = r#""query""#, "Parameter in" + "[2].name" = r#""numbers""#, "Parameter name" + "[2].description" = r#""Foo numbers list""#, "Parameter description" + "[2].required" = r#"false"#, "Parameter required" + "[2].deprecated" = r#"null"#, "Parameter deprecated" + "[2].schema.type" = r#"["array","null"]"#, "Parameter schema type" + "[2].schema.format" = r#"null"#, "Parameter schema format" + "[2].schema.items.type" = r#""integer""#, "Parameter schema items type" + "[2].schema.items.format" = r#""int64""#, "Parameter schema items format" + + "[3].in" = r#""header""#, "Parameter in" + "[3].name" = r#""token""#, "Parameter name" + "[3].description" = r#""Token of foo""#, "Parameter description" + "[3].required" = r#"true"#, "Parameter required" + "[3].deprecated" = r#"true"#, "Parameter deprecated" + "[3].schema.type" = r#""string""#, "Parameter schema type" + "[3].schema.format" = r#"null"#, "Parameter schema format" + + "[4].in" = r#""cookie""#, "Parameter in" + "[4].name" = r#""cookieval""#, "Parameter name" + "[4].description" = r#""Foo cookie""#, "Parameter description" + "[4].required" = r#"true"#, "Parameter required" + "[4].deprecated" = r#"true"#, "Parameter deprecated" + "[4].schema.type" = r#""string""#, "Parameter schema type" + "[4].schema.format" = r#"null"#, "Parameter schema format" + }; +} + +mod derive_params_without_args { + #[fastapi::path( + get, + path = "/foo/{id}", + responses( + (status = 200, description = "success"), + ), + params( + ("id" = i32, Path, description = "Foo id"), + ) + )] + #[allow(unused)] + async fn get_foo_by_id() -> String { + "".to_string() + } +} + +#[test] +fn derive_params_without_fn_args() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_params_without_args::get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo~1{id}/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""path""#, "Parameter in" + "[0].name" = r#""id""#, "Parameter name" + "[0].description" = r#""Foo id""#, "Parameter description" + "[0].required" = r#"true"#, "Parameter required" + "[0].deprecated" = r#"null"#, "Parameter deprecated" + "[0].schema.type" = r#""integer""#, "Parameter schema type" + "[0].schema.format" = r#""int32""#, "Parameter schema format" + }; +} + +#[test] +fn derive_params_with_params_ext() { + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, description = "success"), + ), + params( + ("value" = Option<[String]>, Query, description = "Foo value description", style = Form, allow_reserved, deprecated, explode) + ) + )] + #[allow(unused)] + async fn get_foo_by_id() -> String { + "".to_string() + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + assert_value! {parameters=> + "[0].in" = r#""query""#, "Parameter in" + "[0].name" = r#""value""#, "Parameter name" + "[0].description" = r#""Foo value description""#, "Parameter description" + "[0].required" = r#"false"#, "Parameter required" + "[0].deprecated" = r#"true"#, "Parameter deprecated" + "[0].schema.type" = r#"["array","null"]"#, "Parameter schema type" + "[0].schema.items.type" = r#""string""#, "Parameter schema items type" + "[0].style" = r#""form""#, "Parameter style" + "[0].allowReserved" = r#"true"#, "Parameter allowReserved" + "[0].explode" = r#"true"#, "Parameter explode" + }; +} + +#[test] +fn derive_path_params_with_parameter_type_args() { + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, description = "success"), + ), + params( + ("value" = Option<[String]>, Query, description = "Foo value description", style = Form, allow_reserved, deprecated, explode, max_items = 1, max_length = 20, pattern = r"\w") + ) + )] + #[allow(unused)] + async fn get_foo_by_id() -> String { + "".to_string() + } + + #[derive(OpenApi, Default)] + #[openapi(paths(get_foo_by_id))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let parameters = doc.pointer("/paths/~1foo/get/parameters").unwrap(); + + common::assert_json_array_len(parameters, 1); + + assert_json_eq!( + parameters, + json!([ + { + "in": "query", + "name": "value", + "required": false, + "deprecated": true, + "description": "Foo value description", + "schema": { + "type": ["array", "null"], + "items": { + "maxLength": 20, + "pattern": r"\w", + "type": "string" + }, + "maxItems": 1, + }, + "style": "form", + "allowReserved": true, + "explode": true + } + ]) + ); +} + +macro_rules! into_params { + ( $( #[$me:meta] )* $key:ident $name:ident $( $tt:tt )*) => { + { + #[derive(fastapi::IntoParams)] + $(#[$me])* + $key $name $( $tt )* + + #[fastapi::path(get, path = "/handler", params($name))] + #[allow(unused)] + fn handler() {} + + let value = serde_json::to_value(__path_handler::operation()) + .expect("path item should serialize to json"); + value.pointer("/parameters").expect("operation should have parameters").clone() + } + }; +} + +#[test] +fn derive_into_params_required_custom_query_parameter_required() { + #[allow(unused)] + struct Param<T>(T); + + let value = into_params! { + #[into_params(parameter_in = Query)] + #[allow(unused)] + struct TasksFilterQuery { + /// Maximum number of results to return. + #[param(required = false, value_type = u32, example = 12)] + pub limit: Param<u32>, + /// Maximum number of results to return. + #[param(required = true, value_type = u32, example = 12)] + pub limit_explisit_required: Param<u32>, + /// Maximum number of results to return. + #[param(value_type = Option<u32>, example = 12)] + pub not_required: Param<u32>, + /// Maximum number of results to return. + #[param(required = true, value_type = Option<u32>, example = 12)] + pub option_required: Param<u32>, + } + }; + + assert_json_eq!( + value, + json!([ + { + "description": "Maximum number of results to return.", + "example": 12, + "in": "query", + "name": "limit", + "required": false, + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Maximum number of results to return.", + "example": 12, + "in": "query", + "name": "limit_explisit_required", + "required": true, + "schema": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + }, + { + "description": "Maximum number of results to return.", + "example": 12, + "in": "query", + "name": "not_required", + "required": false, + "schema": { + "format": "int32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + { + "description": "Maximum number of results to return.", + "example": 12, + "in": "query", + "name": "option_required", + "required": true, + "schema": { + "format": "int32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + } + ]) + ); +} diff --git a/fastapi-gen/tests/path_response_derive_test.rs b/fastapi-gen/tests/path_response_derive_test.rs new file mode 100644 index 0000000..b300019 --- /dev/null +++ b/fastapi-gen/tests/path_response_derive_test.rs @@ -0,0 +1,717 @@ +use assert_json_diff::assert_json_eq; +use fastapi::openapi::{RefOr, Response}; +use fastapi::{OpenApi, Path, ToResponse}; +use serde_json::{json, Value}; + +mod common; + +macro_rules! test_fn { + ( module: $name:ident, responses: $($responses:tt)* ) => { + #[allow(unused)] + mod $name { + #[allow(unused)] + #[derive(fastapi::ToSchema)] + struct Foo { + name: String, + } + + #[fastapi::path(get,path = "/foo",responses $($responses)*)] + fn get_foo() {} + } + }; +} + +macro_rules! api_doc { + ( module: $module:expr ) => {{ + use fastapi::OpenApi; + #[derive(OpenApi, Default)] + #[openapi(paths($module::get_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + doc.pointer("/paths/~1foo/get") + .unwrap_or(&serde_json::Value::Null) + .clone() + }}; +} + +test_fn! { + module: simple_success_response, + responses: ( + (status = 200, description = "success") + ) +} + +#[test] +fn derive_path_with_simple_success_response() { + let doc = api_doc!(module: simple_success_response); + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + "responses.200.content" = r#"null"#, "Response content" + "responses.200.headers" = r#"null"#, "Response headers" + } +} + +test_fn! { + module: multiple_simple_responses, + responses: ( + (status = 200, description = "success"), + (status = 401, description = "unauthorized"), + (status = 404, description = "not found"), + (status = 500, description = "server error"), + (status = "5XX", description = "all other server errors"), + (status = "default", description = "default") + ) +} + +#[test] +fn derive_path_with_multiple_simple_responses() { + let doc = api_doc!(module: multiple_simple_responses); + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + "responses.200.content" = r#"null"#, "Response content" + "responses.200.headers" = r#"null"#, "Response headers" + "responses.401.description" = r#""unauthorized""#, "Response description" + "responses.401.content" = r#"null"#, "Response content" + "responses.401.headers" = r#"null"#, "Response headers" + "responses.404.description" = r#""not found""#, "Response description" + "responses.404.content" = r#"null"#, "Response content" + "responses.404.headers" = r#"null"#, "Response headers" + "responses.500.description" = r#""server error""#, "Response description" + "responses.500.content" = r#"null"#, "Response content" + "responses.500.headers" = r#"null"#, "Response headers" + "responses.5XX.description" = r#""all other server errors""#, "Response description" + "responses.5XX.content" = r#"null"#, "Response content" + "responses.5XX.headers" = r#"null"#, "Response headers" + "responses.default.description" = r#""default""#, "Response description" + "responses.default.content" = r#"null"#, "Response content" + "responses.default.headers" = r#"null"#, "Response headers" + } +} + +test_fn! { + module: http_status_code_responses, + responses: ( + (status = OK, description = "success"), + (status = http::status::StatusCode::NOT_FOUND, description = "not found"), + ) +} + +#[test] +fn derive_http_status_code_responses() { + let doc = api_doc!(module: http_status_code_responses); + let responses = doc.pointer("/responses"); + + assert_json_eq!( + responses, + json!({ + "200": { + "description": "success" + }, + "404": { + "description": "not found" + } + }) + ) +} + +struct ReusableResponse; + +impl<'r> ToResponse<'r> for ReusableResponse { + fn response() -> (&'r str, RefOr<Response>) { + ( + "ReusableResponseName", + Response::new("reusable response").into(), + ) + } +} + +test_fn! { + module: reusable_responses, + responses: ( + (status = 200, description = "success"), + (status = "default", response = crate::ReusableResponse) + ) +} + +#[test] +fn derive_path_with_reusable_responses() { + let doc = api_doc!(module: reusable_responses); + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + "responses.default.$ref" = "\"#/components/responses/ReusableResponseName\"", "Response reference" + } +} + +macro_rules! test_response_types { + ( $( $name:ident=> $(body: $expected:expr,)? $( $content_type:literal, )? $( headers: $headers:expr, )? + assert: $( $path:literal = $expectation:literal, $comment:literal )* )* ) => { + $( + paste::paste! { + test_fn! { + module: [<mod_ $name>], + responses: ( + (status = 200, description = "success", + $(body = $expected ,)* + $(content_type = $content_type,)* + $(headers $headers, )* + ), + ) + } + } + + #[test] + fn $name() { + paste::paste! { + let doc = api_doc!(module: [<mod_ $name>]); + } + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + $($path = $expectation, $comment)* + } + } + )* + }; +} + +test_response_types! { +primitive_string_body => body: String, assert: + "responses.200.content.text~1plain.schema.type" = r#""string""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_string_slice_body => body: [String], assert: + "responses.200.content.application~1json.schema.items.type" = r#""string""#, "Response content items type" + "responses.200.content.application~1json.schema.type" = r#""array""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_integer_slice_body => body: [i32], assert: + "responses.200.content.application~1json.schema.items.type" = r#""integer""#, "Response content items type" + "responses.200.content.application~1json.schema.items.format" = r#""int32""#, "Response content items format" + "responses.200.content.application~1json.schema.type" = r#""array""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_integer_body => body: i64, assert: + "responses.200.content.text~1plain.schema.type" = r#""integer""#, "Response content type" + "responses.200.content.text~1plain.schema.format" = r#""int64""#, "Response content format" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_big_integer_body => body: u128, assert: + "responses.200.content.text~1plain.schema.type" = r#""integer""#, "Response content type" + "responses.200.content.text~1plain.schema.format" = r#"null"#, "Response content format" + "responses.200.headers" = r###"null"###, "Response headers" +primitive_bool_body => body: bool, assert: + "responses.200.content.text~1plain.schema.type" = r#""boolean""#, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +object_body => body: Foo, assert: + "responses.200.content.application~1json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +object_slice_body => body: [Foo], assert: + "responses.200.content.application~1json.schema.type" = r###""array""###, "Response content type" + "responses.200.content.application~1json.schema.items.$ref" = r###""#/components/schemas/Foo""###, "Response content items type" + "responses.200.headers" = r###"null"###, "Response headers" +object_body_override_content_type_to_xml => body: Foo, "text/xml", assert: + "responses.200.content.application~1json.schema.$ref" = r###"null"###, "Response content type" + "responses.200.content.text~1xml.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers" = r###"null"###, "Response headers" +object_body_with_simple_header => body: Foo, headers: ( + ("xsrf-token") +), assert: + "responses.200.content.application~1json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers.xsrf-token.schema.type" = r###""string""###, "xsrf-token header type" + "responses.200.headers.xsrf-token.description" = r###"null"###, "xsrf-token header description" +object_body_with_multiple_headers => body: Foo, headers: ( + ("xsrf-token"), + ("another-header") +), assert: + "responses.200.content.application~1json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers.xsrf-token.schema.type" = r###""string""###, "xsrf-token header type" + "responses.200.headers.xsrf-token.description" = r###"null"###, "xsrf-token header description" + "responses.200.headers.another-header.schema.type" = r###""string""###, "another-header header type" + "responses.200.headers.another-header.description" = r###"null"###, "another-header header description" +object_body_with_header_with_type => body: Foo, headers: ( + ("random-digits" = [i64]), +), assert: + "responses.200.content.application~1json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content type" + "responses.200.headers.random-digits.schema.type" = r###""array""###, "random-digits header type" + "responses.200.headers.random-digits.description" = r###"null"###, "random-digits header description" + "responses.200.headers.random-digits.schema.items.type" = r###""integer""###, "random-digits header items type" + "responses.200.headers.random-digits.schema.items.format" = r###""int64""###, "random-digits header items format" +response_no_body_with_complex_header_with_description => headers: ( + ("random-digits" = [i64], description = "Random digits response header"), +), assert: + "responses.200.content" = r###"null"###, "Response content type" + "responses.200.headers.random-digits.description" = r###""Random digits response header""###, "random-digits header description" + "responses.200.headers.random-digits.schema.type" = r###""array""###, "random-digits header type" + "responses.200.headers.random-digits.schema.items.type" = r###""integer""###, "random-digits header items type" + "responses.200.headers.random-digits.schema.items.format" = r###""int64""###, "random-digits header items format" +binary_octet_stream => body: [u8], assert: + "responses.200.content.application~1octet-stream.schema.type" = r#""array""#, "Response content type" + "responses.200.content.application~1octet-stream.schema.format" = r#"null"#, "Response content format" + "responses.200.headers" = r###"null"###, "Response headers" +} + +test_fn! { + module: response_with_json_example, + responses: ( + (status = 200, description = "success", body = Foo, example = json!({"foo": "bar"})) + ) +} + +#[test] +fn derive_response_with_json_example_success() { + let doc = api_doc!(module: response_with_json_example); + + assert_value! {doc=> + "responses.200.description" = r#""success""#, "Response description" + "responses.200.content.application~1json.schema.$ref" = r###""#/components/schemas/Foo""###, "Response content ref" + "responses.200.content.application~1json.example" = r###"{"foo":"bar"}"###, "Response content example" + "responses.200.headers" = r#"null"#, "Response headers" + } +} + +#[test] +fn derive_response_body_inline_schema_component() { + test_fn! { + module: response_body_inline_schema, + responses: ( + (status = 200, description = "success", body = inline(Foo), content_type = "application/json") + ) + } + + let doc: Value = api_doc!(module: response_body_inline_schema); + + assert_json_eq!( + doc, + json!({ + "operationId": "get_foo", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + }, + "description": "success" + } + }, + "tags": [ + "response_body_inline_schema" + ] + }) + ); +} + +#[test] +fn derive_path_with_multiple_responses_via_content_attribute_auto_collect_responses() { + #[derive(serde::Serialize, fastapi::ToSchema)] + #[allow(unused)] + struct User { + id: String, + } + + #[derive(serde::Serialize, fastapi::ToSchema)] + #[allow(unused)] + struct User2 { + id: i32, + } + + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, content( + (User = "application/vnd.user.v1+json" , example = json!(User {id: "id".to_string()})), + (User2 = "application/vnd.user.v2+json", example = json!(User2 {id: 2})) + ) + ) + ) + )] + #[allow(unused)] + fn get_item() {} + + #[derive(fastapi::OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let responses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("doc must have schemas"); + + assert_json_eq!( + schemas, + json!({ + "User": { + "properties": { + "id": { + "type": "string", + } + }, + "required": ["id"], + "type": "object", + }, + "User2": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + } + }, + "required": ["id"], + "type": "object", + } + }) + ); + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/vnd.user.v1+json": { + "example": { + "id": "id", + }, + "schema": { + "$ref": + "#/components/schemas/User", + }, + }, + "application/vnd.user.v2+json": { + "example": { + "id": 2, + }, + "schema": { + "$ref": "#/components/schemas/User2", + }, + }, + }, + "description": "", + }, + }) + ) +} + +#[test] +fn derive_path_with_multiple_examples_auto_collect_schemas() { + #[derive(serde::Serialize, fastapi::ToSchema)] + #[allow(unused)] + struct User { + name: String, + } + + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, body = User, + examples( + ("Demo" = (summary = "This is summary", description = "Long description", + value = json!(User{name: "Demo".to_string()}))), + ("John" = (summary = "Another user", value = json!({"name": "John"}))) + ) + ) + ) + )] + #[allow(unused)] + fn get_item() {} + + #[derive(fastapi::OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let responses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + let schemas = doc + .pointer("/components/schemas") + .expect("doc must have schemas"); + + assert_json_eq!( + schemas, + json!({ + "User": { + "properties": { + "name": { + "type": "string", + } + }, + "required": ["name"], + "type": "object", + } + }) + ); + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/json": { + "examples": { + "Demo": { + "summary": "This is summary", + "description": "Long description", + "value": { + "name": "Demo" + } + }, + "John": { + "summary": "Another user", + "value": { + "name": "John" + } + } + }, + "schema": { + "$ref": + "#/components/schemas/User", + }, + }, + }, + "description": "", + }, + }) + ) +} + +#[test] +fn derive_path_with_multiple_responses_with_multiple_examples() { + #[derive(serde::Serialize, fastapi::ToSchema)] + #[allow(unused)] + struct User { + id: String, + } + + #[derive(serde::Serialize, fastapi::ToSchema)] + #[allow(unused)] + struct User2 { + id: i32, + } + + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, content( + (User = "application/vnd.user.v1+json", + examples( + ("StringUser" = (value = json!({"id": "1"}))), + ("StringUser2" = (value = json!({"id": "2"}))) + ), + ), + (User2 = "application/vnd.user.v2+json", + examples( + ("IntUser" = (value = json!({"id": 1}))), + ("IntUser2" = (value = json!({"id": 2}))) + ), + ) + ) + ) + ) + )] + #[allow(unused)] + fn get_item() {} + + #[derive(fastapi::OpenApi)] + #[openapi(paths(get_item), components(schemas(User, User2)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let responses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/vnd.user.v1+json": { + "examples": { + "StringUser": { + "value": { + "id": "1" + } + }, + "StringUser2": { + "value": { + "id": "2" + } + } + }, + "schema": { + "$ref": + "#/components/schemas/User", + }, + }, + "application/vnd.user.v2+json": { + "examples": { + "IntUser": { + "value": { + "id": 1 + } + }, + "IntUser2": { + "value": { + "id": 2 + } + } + }, + "schema": { + "$ref": "#/components/schemas/User2", + }, + }, + }, + "description": "", + }, + }) + ) +} + +#[test] +fn path_response_with_external_ref() { + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, body = ref("./MyUser.json")) + ) + )] + #[allow(unused)] + fn get_item() {} + + #[derive(fastapi::OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let responses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/json": { + "schema": { + "$ref": + "./MyUser.json", + }, + }, + }, + "description": "", + }, + }) + ) +} + +#[test] +fn path_response_with_inline_ref_type() { + #[derive(serde::Serialize, fastapi::ToSchema, fastapi::ToResponse)] + #[allow(unused)] + struct User { + name: String, + } + + #[fastapi::path( + get, + path = "/foo", + responses( + (status = 200, response = inline(User)) + ) + )] + #[allow(unused)] + fn get_item() {} + + #[derive(fastapi::OpenApi)] + #[openapi(paths(get_item), components(schemas(User)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let responses = doc.pointer("/paths/~1foo/get/responses").unwrap(); + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object", + }, + }, + }, + "description": "", + }, + }) + ) +} + +#[test] +fn path_response_default_no_value_nor_ref() { + #![allow(unused)] + + /// Post some secret inner handler + #[fastapi::path(post, path = "/api/inner/secret", responses((status = OK)))] + pub async fn post_secret() {} + + let operation = __path_post_secret::operation(); + let value = serde_json::to_value(operation).expect("operation is JSON serializable"); + + assert_json_eq!( + value, + json!({ + "operationId": "post_secret", + "responses": { + "200": { + "description": "" + } + }, + "summary": "Post some secret inner handler" + }) + ) +} + +#[test] +fn path_response_with_no_schema() { + #![allow(unused)] + + /// Post some secret inner handler + #[fastapi::path(post, path = "/api/inner/secret", responses( + (status = OK, content_type = "application/octet-stream") + ))] + pub async fn post_secret() {} + + let operation = __path_post_secret::operation(); + let value = serde_json::to_value(operation).expect("operation is JSON serializable"); + + assert_json_eq!( + value, + json!({ + "operationId": "post_secret", + "responses": { + "200": { + "content": { + "application/octet-stream": {} + }, + "description": "" + } + }, + "summary": "Post some secret inner handler" + }) + ) +} diff --git a/fastapi-gen/tests/request_body_derive_test.rs b/fastapi-gen/tests/request_body_derive_test.rs new file mode 100644 index 0000000..08fdf19 --- /dev/null +++ b/fastapi-gen/tests/request_body_derive_test.rs @@ -0,0 +1,892 @@ +use assert_json_diff::assert_json_eq; +use fastapi::{OpenApi, Path}; +use fastapi_gen::ToSchema; +use serde_json::{json, Value}; + +mod common; + +macro_rules! test_fn { + ( module: $name:ident, body: $($body:tt)* ) => { + #[allow(unused)] + mod $name { + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + #[fastapi::path( + post, + path = "/foo", + request_body $($body)*, + responses( + (status = 200, description = "success response") + ) + )] + fn post_foo() {} + } + }; +} + +test_fn! { + module: derive_request_body_simple, + body: = Foo +} + +#[test] +fn derive_path_request_body_simple_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_simple::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths.~1foo.post.requestBody.content.application~1json.schema.$ref" = r###""#/components/schemas/Foo""###, "Request body content object type" + "paths.~1foo.post.requestBody.content.text~1plain" = r###"null"###, "Request body content object type not text/plain" + "paths.~1foo.post.requestBody.required" = r###"true"###, "Request body required" + "paths.~1foo.post.requestBody.description" = r###"null"###, "Request body description" + } +} + +#[test] +fn derive_path_request_body_simple_array_success() { + #![allow(unused)] + + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + #[fastapi::path( + post, + path = "/foo", + request_body = [Foo], + responses( + (status = 200, description = "success response") + ) + )] + fn post_foo() {} + #[derive(OpenApi, Default)] + #[openapi(paths(post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths.~1foo.post.requestBody.content.application~1json.schema.$ref" = r###"null"###, "Request body content object type" + "paths.~1foo.post.requestBody.content.application~1json.schema.items.$ref" = r###""#/components/schemas/Foo""###, "Request body content items object type" + "paths.~1foo.post.requestBody.content.application~1json.schema.type" = r###""array""###, "Request body content items type" + "paths.~1foo.post.requestBody.content.text~1plain" = r###"null"###, "Request body content object type not text/plain" + "paths.~1foo.post.requestBody.required" = r###"true"###, "Request body required" + "paths.~1foo.post.requestBody.description" = r###"null"###, "Request body description" + } +} + +test_fn! { + module: derive_request_body_option_array, + body: = Option<[Foo]> +} + +#[test] +fn derive_request_body_option_array_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_option_array::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let body = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + body, + json!({ + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/Foo" + }, + "type": ["array", "null"], + }, + } + }, + }) + ); +} + +test_fn! { + module: derive_request_body_primitive_simple, + body: = String +} + +#[test] +fn derive_request_body_primitive_simple_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_primitive_simple::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + assert_value! {doc=> + "paths.~1foo.post.requestBody.content.application~1json.schema.$ref" = r###"null"###, "Request body content object type not application/json" + "paths.~1foo.post.requestBody.content.application~1json.schema.items.$ref" = r###"null"###, "Request body content items object type" + "paths.~1foo.post.requestBody.content.application~1json.schema.type" = r###"null"###, "Request body content items type" + "paths.~1foo.post.requestBody.content.text~1plain.schema.type" = r###""string""###, "Request body content object type" + "paths.~1foo.post.requestBody.required" = r###"true"###, "Request body required" + "paths.~1foo.post.requestBody.description" = r###"null"###, "Request body description" + } +} + +#[test] +fn request_body_with_only_single_content_type() { + #![allow(unused)] + + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + #[fastapi::path(post, path = "/foo", request_body(content_type = "application/json"))] + fn post_foo() {} + + #[derive(OpenApi, Default)] + #[openapi(paths(post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let content = doc + .pointer("/paths/~1foo/post/requestBody/content") + .unwrap(); + + assert_json_eq!( + content, + json!({ + "application/json": {} + }) + ); +} + +test_fn! { + module: derive_request_body_primitive_simple_array, + body: = [i64] +} + +#[test] +fn derive_request_body_primitive_array_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_primitive_simple_array::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let content = doc + .pointer("/paths/~1foo/post/requestBody/content") + .unwrap(); + + assert_json_eq!( + content, + json!( + { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int64", + } + } + } + } + ) + ); +} + +test_fn! { + module: derive_request_body_complex, + body: (content = Foo, description = "Create new Foo", content_type = "text/xml") +} + +#[test] +fn derive_request_body_complex_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_complex::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let request_body: &Value = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +#[test] +fn derive_request_body_complex_multi_content_type_success() { + #![allow(unused)] + + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + + #[fastapi::path( + post, + path = "/foo", + request_body(content( ( Foo = "application/json" ), ( Foo = "text/xml") ), description = "Create new Foo" ), + responses( + (status = 200, description = "success response") + ) + )] + fn post_foo() {} + + let operation = serde_json::to_value(__path_post_foo::operation()).unwrap(); + let request_body: &Value = operation.pointer("/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +#[test] +fn derive_request_body_with_multiple_content_type_guess_default_content_type() { + #![allow(unused)] + + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + + #[fastapi::path( + post, + path = "/foo", + request_body(content( ( Foo ), ( Foo = "text/xml") ), description = "Create new Foo" ), + responses( + (status = 200, description = "success response") + ) + )] + fn post_foo() {} + + let operation = serde_json::to_value(__path_post_foo::operation()).unwrap(); + let request_body: &Value = operation.pointer("/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +#[test] +fn multiple_request_body_with_only_content_type() { + #![allow(unused)] + + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + + #[fastapi::path( + post, + path = "/foo", + request_body(content( ( "application/json" ), ( Foo = "text/xml") ), description = "Create new Foo" ), + responses( + (status = 200, description = "success response") + ) + )] + fn post_foo() {} + + let operation = serde_json::to_value(__path_post_foo::operation()).unwrap(); + let request_body = operation.pointer("/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Foo" + } + }, + "application/json": { }, + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +#[test] +fn multiple_content_with_examples() { + #![allow(unused)] + + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + + #[fastapi::path( + post, + path = "/foo", + request_body( + description = "Create new Foo", + content( + ( Foo, examples( + ("example1" = (value = json!("Foo name"), description = "Foo name example") ), + ("example2" = (value = json!("example value"), description = "example value") ), + ), + ), + ( Foo = "text/xml", example = "Value" ) + ), + ), + responses( + (status = 200, description = "success response") + ) + )] + fn post_foo() {} + + let operation = serde_json::to_value(__path_post_foo::operation()).unwrap(); + let request_body = operation.pointer("/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "$ref": "#/components/schemas/Foo" + }, + "example": "Value" + }, + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foo" + }, + "examples": { + "example1": { + "description": "Foo name example", + "value": "Foo name" + }, + "example2": { + "description": "example value", + "value": "example value" + } + } + }, + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +test_fn! { + module: derive_request_body_complex_inline, + body: (content = inline(Foo), description = "Create new Foo", content_type = "text/xml") +} + +#[test] +fn derive_request_body_complex_success_inline() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_complex_inline::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let request_body: &Value = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "description": "Some struct", + "properties": { + "name": { + "description": "Some name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +test_fn! { + module: derive_request_body_complex_array, + body: (content = [Foo], description = "Create new Foo", content_type = "text/xml") +} + +#[test] +fn derive_request_body_complex_success_array() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_complex_array::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let request_body: &Value = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "items": { + "$ref": "#/components/schemas/Foo" + }, + "type": "array" + } + } + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +test_fn! { + module: derive_request_body_complex_inline_array, + body: (content = inline([Foo]), description = "Create new Foo", content_type = "text/xml") +} + +#[test] +fn derive_request_body_complex_success_inline_array() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_complex_inline_array::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let request_body: &Value = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "text/xml": { + "schema": { + "items": { + "description": "Some struct", + "properties": { + "name": { + "description": "Some name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + } + } + }, + "description": "Create new Foo", + "required": true + }) + ); +} + +test_fn! { + module: derive_request_body_simple_inline, + body: = inline(Foo) +} + +#[test] +fn derive_request_body_simple_inline_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_simple_inline::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let request_body: &Value = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "application/json": { + "schema": { + "description": "Some struct", + "properties": { + "name": { + "description": "Some name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + } + } + }, + "required": true + }) + ); +} + +#[test] +fn derive_request_body_complex_required_explicit_false_success() { + #![allow(unused)] + + #[derive(fastapi::ToSchema)] + /// Some struct + pub struct Foo { + /// Some name + name: String, + } + #[fastapi::path( + post, + path = "/foo", + request_body(content = Option<Foo>, description = "Create new Foo", content_type = "text/xml"), + responses( + (status = 200, description = "success response") + ) + )] + fn post_foo() {} + #[derive(OpenApi, Default)] + #[openapi(paths(post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let body = doc.pointer("/paths/~1foo/post/requestBody").unwrap(); + + assert_json_eq!( + body, + json!({ + "content": { + "text/xml": { + "schema": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Foo" + } + ], + } + } + }, + "description": "Create new Foo", + }) + ); +} + +test_fn! { + module: derive_request_body_complex_primitive_array, + body: (content = [i32], description = "Create new foo references") +} + +#[test] +fn derive_request_body_complex_primitive_array_success() { + #[derive(OpenApi, Default)] + #[openapi(paths(derive_request_body_complex_primitive_array::post_foo))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let content = doc + .pointer("/paths/~1foo/post/requestBody/content") + .unwrap(); + assert_json_eq!( + content, + json!( + { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "integer", + "format": "int32", + } + } + } + } + ) + ); +} + +#[test] +fn derive_request_body_ref_path_success() { + /// Some struct + #[derive(ToSchema)] + #[schema(as = path::to::Foo)] + #[allow(unused)] + pub struct Foo { + /// Some name + name: String, + } + + #[fastapi::path( + post, + path = "/foo", + request_body = Foo, + responses( + (status = 200, description = "success response") + ) + )] + #[allow(unused)] + fn post_foo() {} + + #[derive(OpenApi, Default)] + #[openapi(paths(post_foo), components(schemas(Foo)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let schemas = doc.pointer("/components/schemas").unwrap(); + assert!(schemas.get("path.to.Foo").is_some()); + + let component_ref: &str = doc + .pointer("/paths/~1foo/post/requestBody/content/application~1json/schema/$ref") + .unwrap() + .as_str() + .unwrap(); + assert_eq!(component_ref, "#/components/schemas/path.to.Foo"); +} + +#[test] +fn unit_type_request_body() { + #[fastapi::path( + post, + path = "/unit_type_test", + request_body = () + )] + #[allow(unused)] + fn unit_type_test() {} + + #[derive(OpenApi)] + #[openapi(paths(unit_type_test))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let request_body = doc + .pointer("/paths/~1unit_type_test/post/requestBody") + .unwrap(); + + assert_json_eq!( + request_body, + json!({ + "content": { + "application/json": { + "schema": { + "default": null, + } + } + }, + "required": true + }) + ) +} + +#[test] +fn request_body_with_example() { + #[derive(ToSchema)] + #[allow(unused)] + struct Foo<'v> { + value: &'v str, + } + + #[fastapi::path(get, path = "/item", request_body(content = Foo, example = json!({"value": "this is value"})))] + #[allow(dead_code)] + fn get_item() {} + + #[derive(OpenApi)] + #[openapi(components(schemas(Foo)), paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let content = doc + .pointer("/paths/~1item/get/requestBody/content") + .unwrap(); + assert_json_eq!( + content, + json!( + {"application/json": { + "example": { + "value": "this is value" + }, + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + }) + ) +} + +#[test] +fn request_body_with_examples() { + #[derive(ToSchema)] + #[allow(unused)] + struct Foo<'v> { + value: &'v str, + } + + #[fastapi::path( + get, + path = "/item", + request_body(content = Foo, + examples( + ("Value1" = (value = json!({"value": "this is value"}) ) ), + ("Value2" = (value = json!({"value": "this is value2"}) ) ) + ) + ) + )] + #[allow(dead_code)] + fn get_item() {} + + #[derive(OpenApi)] + #[openapi(components(schemas(Foo)), paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let content = doc + .pointer("/paths/~1item/get/requestBody/content") + .unwrap(); + assert_json_eq!( + content, + json!( + {"application/json": { + "examples": { + "Value1": { + "value": { + "value": "this is value" + } + }, + "Value2": { + "value": { + "value": "this is value2" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Foo" + } + } + }) + ) +} + +#[test] +fn request_body_with_binary() { + #[fastapi::path(get, path = "/item", request_body(content = [u8]))] + #[allow(dead_code)] + fn get_item() {} + + #[derive(OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let content = doc + .pointer("/paths/~1item/get/requestBody/content") + .unwrap(); + + assert_json_eq!( + content, + json!( + {"application/octet-stream": { + "schema": { + "type": "array", + "items": { + "format": "int32", + "minimum": 0, + "type": "integer" + } + } + } + }) + ) +} + +#[test] +fn request_body_with_external_ref() { + #[fastapi::path(get, path = "/item", request_body(content = ref("./MyUser.json")))] + #[allow(dead_code)] + fn get_item() {} + + #[derive(fastapi::OpenApi)] + #[openapi(paths(get_item))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + + let content = doc + .pointer("/paths/~1item/get/requestBody/content") + .unwrap(); + assert_json_eq!( + content, + json!( + {"application/json": { + "schema": { + "$ref": "./MyUser.json" + } + } + }) + ) +} diff --git a/fastapi-gen/tests/response_derive_test.rs b/fastapi-gen/tests/response_derive_test.rs new file mode 100644 index 0000000..bb48fab --- /dev/null +++ b/fastapi-gen/tests/response_derive_test.rs @@ -0,0 +1,760 @@ +use assert_json_diff::assert_json_eq; +use fastapi::ToSchema; +use fastapi_gen::ToResponse; +use serde_json::json; + +#[test] +fn derive_name_struct_response() { + #[derive(ToResponse)] + #[allow(unused)] + struct Person { + name: String, + } + let (name, v) = <Person as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("Person", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json": { + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object", + "required": ["name"] + } + } + }, + "description": "" + }) + ) +} + +#[test] +fn derive_unnamed_struct_response() { + #[derive(ToResponse)] + #[allow(unused)] + struct Person(Vec<String>); + + let (name, v) = <Person as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("Person", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json": { + "schema": { + "items": { + "type": "string" + }, + "type": "array" + } + } + }, + "description": "" + }) + ) +} + +#[test] +fn derive_enum_response() { + #[derive(ToResponse)] + #[allow(unused)] + enum PersonType { + Value(String), + Foobar, + } + let (name, v) = <PersonType as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("PersonType", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "properties": { + "Value": { + "type": "string" + } + }, + "required": ["Value"], + "type": "object", + }, + { + "enum": ["Foobar"], + "type": "string" + } + ] + } + } + }, + "description": "" + }) + ) +} + +#[test] +fn derive_struct_response_with_description() { + /// This is description + /// + /// It will also be used in `ToSchema` if present + #[derive(ToResponse)] + #[allow(unused)] + struct Person { + name: String, + } + let (name, v) = <Person as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("Person", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json": { + "schema": { + "description": "This is description\n\nIt will also be used in `ToSchema` if present", + "properties": { + "name": { + "type": "string" + } + }, + "type": "object", + "required": ["name"] + } + } + }, + "description": "This is description\n\nIt will also be used in `ToSchema` if present" + }) + ) +} + +#[test] +fn derive_response_with_attributes() { + /// This is description + /// + /// It will also be used in `ToSchema` if present + #[derive(ToSchema, ToResponse)] + #[response( + description = "Override description for response", + content_type = "text/xml" + )] + #[response( + example = json!({"name": "the name"}), + headers( + ("csrf-token", description = "response csrf token"), + ("random-id" = i32) + ) + )] + #[allow(unused)] + struct Person { + name: String, + } + let (name, v) = <Person as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("Person", name); + assert_json_eq!( + value, + json!({ + "content": { + "text/xml": { + "example": { + "name": "the name" + }, + "schema": { + "description": "This is description\n\nIt will also be used in `ToSchema` if present", + "properties": { + "name": { + "type": "string" + } + }, + "type": "object", + "required": ["name"] + } + } + }, + "description": "Override description for response", + "headers": { + "csrf-token": { + "description": "response csrf token", + "schema": { + "type": "string" + } + }, + "random-id": { + "schema": { + "type": "integer", + "format": "int32" + } + } + } + }) + ) +} + +#[test] +fn derive_response_multiple_examples() { + #[derive(ToSchema, ToResponse)] + #[response(examples( + ("Person1" = (value = json!({"name": "name1"}))), + ("Person2" = (value = json!({"name": "name2"}))) + ))] + #[allow(unused)] + struct Person { + name: String, + } + let (name, v) = <Person as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("Person", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json": { + "examples": { + "Person1": { + "value": { + "name": "name1" + } + }, + "Person2": { + "value": { + "name": "name2" + } + } + }, + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "type": "object", + "required": ["name"] + } + }, + }, + "description": "" + }) + ) +} + +#[test] +fn derive_response_with_enum_contents() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Admin { + name: String, + } + #[allow(unused)] + #[derive(fastapi::ToSchema)] + struct Moderator { + name: String, + } + #[derive(ToSchema, ToResponse)] + #[allow(unused)] + enum Person { + #[response(examples( + ("Person1" = (value = json!({"name": "name1"}))), + ("Person2" = (value = json!({"name": "name2"}))) + ))] + Admin(#[content("application/json/1")] Admin), + #[response(example = json!({"name": "name3"}))] + Moderator(#[content("application/json/2")] Moderator), + } + let (name, v) = <Person as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("Person", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json/1": { + "examples": { + "Person1": { + "value": { + "name": "name1" + } + }, + "Person2": { + "value": { + "name": "name2" + } + } + }, + "schema": { + "$ref": "#/components/schemas/Admin" + } + }, + "application/json/2": { + "example": { + "name": "name3" + }, + "schema": { + "$ref": "#/components/schemas/Moderator" + } + } + }, + "description": "" + }) + ) +} + +#[test] +fn derive_response_with_enum_contents_inlined() { + #[allow(unused)] + #[derive(ToSchema)] + struct Admin { + name: String, + } + + #[derive(ToSchema)] + #[allow(unused)] + struct Moderator { + name: String, + } + #[derive(ToSchema, ToResponse)] + #[allow(unused)] + enum Person { + #[response(examples( + ("Person1" = (value = json!({"name": "name1"}))), + ("Person2" = (value = json!({"name": "name2"}))) + ))] + Admin( + #[content("application/json/1")] + #[to_schema] + Admin, + ), + #[response(example = json!({"name": "name3"}))] + Moderator( + #[content("application/json/2")] + #[to_schema] + Moderator, + ), + } + let (name, v) = <Person as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("Person", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json/1": { + "examples": { + "Person1": { + "value": { + "name": "name1" + } + }, + "Person2": { + "value": { + "name": "name2" + } + } + }, + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + } + }, + "application/json/2": { + "example": { + "name": "name3" + }, + "schema": { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + } + } + }, + "description": "" + }) + ) +} + +#[test] +fn derive_response_with_unit_type() { + #[derive(ToSchema, ToResponse)] + #[allow(unused)] + struct PersonSuccessResponse; + + let (name, v) = <PersonSuccessResponse as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("PersonSuccessResponse", name); + assert_json_eq!( + value, + json!({ + "description": "" + }) + ) +} + +#[test] +fn derive_response_with_inline_unnamed_schema() { + #[allow(unused)] + #[derive(ToSchema)] + struct Person { + name: String, + } + #[derive(ToResponse)] + #[allow(unused)] + struct PersonSuccessResponse(#[to_schema] Vec<Person>); + + let (name, v) = <PersonSuccessResponse as fastapi::ToResponse>::response(); + let value = serde_json::to_value(v).unwrap(); + + assert_eq!("PersonSuccessResponse", name); + assert_json_eq!( + value, + json!({ + "content": { + "application/json": { + "schema": { + "items": { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object", + }, + "type": "array" + } + }, + }, + "description": "" + }) + ) +} + +macro_rules! into_responses { + ( $(#[$meta:meta])* $key:ident $ident:ident $($tt:tt)* ) => { + { + #[derive(fastapi::IntoResponses)] + $(#[$meta])* + #[allow(unused)] + $key $ident $( $tt )* + + let responses = <$ident as fastapi::IntoResponses>::responses(); + serde_json::to_value(responses).unwrap() + } + }; +} + +#[test] +fn derive_into_responses_inline_named_struct_response() { + let responses = into_responses! { + /// This is success response + #[response(status = 200)] + struct SuccessResponse { + value: String, + } + }; + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/json": { + "schema": { + "description": "This is success response", + "properties": { + "value": { + "type": "string" + }, + }, + "required": ["value"], + "type": "object" + } + } + }, + "description": "This is success response" + } + }) + ) +} + +#[test] +fn derive_into_responses_unit_struct() { + let responses = into_responses! { + /// Not found response + #[response(status = NOT_FOUND)] + struct NotFound; + }; + + assert_json_eq!( + responses, + json!({ + "404": { + "description": "Not found response" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_inline_schema() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(#[to_schema] Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "bar": { + "type": "string" + }, + }, + "required": ["bar"], + "type": "object" + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_with_primitive_schema() { + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(String); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "text/plain": { + "schema": { + "type": "string", + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_ref_schema() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Foo", + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_ref_response() { + #[derive(fastapi::ToResponse)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(#[ref_response] Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "$ref": "#/components/responses/Foo" + } + }) + ) +} + +#[test] +fn derive_into_responses_unnamed_struct_to_response() { + #[derive(fastapi::ToResponse)] + #[allow(unused)] + struct Foo { + bar: String, + } + + let responses = into_responses! { + #[response(status = 201)] + struct CreatedResponse(#[to_response] Foo); + }; + + assert_json_eq!( + responses, + json!({ + "201": { + "content": { + "application/json": { + "schema": { + "properties": { + "bar": { + "type": "string" + } + }, + "required": ["bar"], + "type": "object", + } + } + }, + "description": "" + } + }) + ) +} + +#[test] +fn derive_into_responses_enum_with_multiple_responses() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct BadRequest { + value: String, + } + + #[derive(fastapi::ToResponse)] + #[allow(unused)] + struct Response { + message: String, + } + + let responses = into_responses! { + enum UserResponses { + /// Success response + #[response(status = 200)] + Success { value: String }, + + #[response(status = 404)] + NotFound, + + #[response(status = 400)] + BadRequest(BadRequest), + + #[response(status = 500)] + ServerError(#[ref_response] Response), + + #[response(status = 418)] + TeaPot(#[to_response] Response), + } + }; + + assert_json_eq!( + responses, + json!({ + "200": { + "content": { + "application/json": { + "schema": { + "properties": { + "value": { + "type": "string" + } + }, + "description": "Success response", + "required": ["value"], + "type": "object", + } + } + }, + "description": "Success response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequest" + } + } + }, + "description": "", + }, + "404": { + "description": "" + }, + "418": { + "content": { + "application/json": { + "schema": { + "properties": { + "message": { + "type": "string" + } + }, + "required": ["message"], + "type": "object", + } + } + }, + "description": "", + }, + "500": { + "$ref": "#/components/responses/Response" + } + }) + ) +} diff --git a/fastapi-gen/tests/schema_derive_test.rs b/fastapi-gen/tests/schema_derive_test.rs new file mode 100644 index 0000000..fc462fe --- /dev/null +++ b/fastapi-gen/tests/schema_derive_test.rs @@ -0,0 +1,6129 @@ +use std::{borrow::Cow, cell::RefCell, collections::HashMap, marker::PhantomData}; + +use assert_json_diff::{assert_json_eq, assert_json_matches, CompareMode, Config, NumericMode}; +use fastapi::openapi::{Object, ObjectBuilder}; +use fastapi::{OpenApi, ToSchema}; +use serde::Serialize; +use serde_json::{json, Value}; + +mod common; + +macro_rules! api_doc { + ( $(#[$meta:meta])* $key:ident $ident:ident $($tt:tt)* ) => { + { + #[derive(ToSchema)] + $(#[$meta])* + #[allow(unused)] + $key $ident $( $tt )* + + let schema = api_doc!( @schema $ident $($tt)* ); + serde_json::to_value(schema).unwrap() + } + }; + ( @schema $ident:ident < $($life:lifetime , )? $generic:ident > $($tt:tt)* ) => { + <$ident<$generic> as fastapi::PartialSchema>::schema() + }; + ( @schema $ident:ident $($tt:tt)* ) => { + <$ident as fastapi::PartialSchema>::schema() + }; +} + +#[test] +fn derive_map_type() { + let map = api_doc! { + struct Map { + map: HashMap<String, String>, + } + }; + + assert_value! { map=> + "properties.map.additionalProperties.type" = r#""string""#, "Additional Property Type" + }; +} + +#[test] +fn derive_map_ref() { + #[derive(ToSchema)] + #[allow(unused)] + enum Foo { + Variant, + } + + let map = api_doc! { + struct Map { + map: HashMap<String, Foo>, + #[schema(inline)] + map2: HashMap<String, Foo> + } + }; + + assert_json_eq!( + map, + json!({ + "properties": { + "map": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Foo" + }, + "type": "object", + }, + "map2": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + "enum": ["Variant"] + }, + "type": "object" + } + }, + "required": ["map", "map2"], + "type": "object" + }) + ) +} + +#[test] +fn derive_map_free_form_property() { + let map = api_doc! { + struct Map { + #[schema(additional_properties)] + map: HashMap<String, String>, + } + }; + + assert_json_eq!( + map, + json!({ + "properties": { + "map": { + "additionalProperties": true, + "type": "object", + }, + }, + "required": ["map"], + "type": "object" + }) + ) +} + +#[test] +fn derive_flattened_map_string_property() { + let map = api_doc! { + #[derive(Serialize)] + struct Map { + #[serde(flatten)] + map: HashMap<String, String>, + } + }; + + assert_json_eq!( + map, + json!({ + "additionalProperties": {"type": "string"}, + "type": "object" + }) + ) +} + +#[test] +fn derive_flattened_map_ref_property() { + #[derive(Serialize, ToSchema)] + #[allow(unused)] + enum Foo { + Variant, + } + + let map = api_doc! { + #[derive(Serialize)] + struct Map { + #[serde(flatten)] + map: HashMap<String, Foo>, + } + }; + + assert_json_eq!( + map, + json!({ + "additionalProperties": {"$ref": "#/components/schemas/Foo"}, + "type": "object" + }) + ) +} + +#[test] +fn derive_enum_with_additional_properties_success() { + let mode = api_doc! { + #[schema(default = "Mode1", example = "Mode2")] + enum Mode { + Mode1, Mode2 + } + }; + + assert_value! {mode=> + "default" = r#""Mode1""#, "Mode default" + "example" = r#""Mode2""#, "Mode example" + "enum" = r#"["Mode1","Mode2"]"#, "Mode enum variants" + "type" = r#""string""#, "Mode type" + }; +} + +#[test] +fn derive_enum_with_defaults_success() { + let mode = api_doc! { + enum Mode { + Mode1, + Mode2 + } + }; + + assert_value! {mode=> + "enum" = r#"["Mode1","Mode2"]"#, "Mode enum variants" + "type" = r#""string""#, "Mode type" + }; + assert_value! {mode=> + "default" = Value::Null, "Mode default" + "example" = Value::Null, "Mode example" + } +} + +#[test] +fn derive_enum_with_with_custom_default_fn_success() { + let mode = api_doc! { + #[schema(default = mode_custom_default_fn)] + enum Mode { + Mode1, + Mode2 + } + }; + + assert_value! {mode=> + "default" = r#""Mode2""#, "Mode default" + "enum" = r#"["Mode1","Mode2"]"#, "Mode enum variants" + "type" = r#""string""#, "Mode type" + }; + assert_value! {mode=> + "example" = Value::Null, "Mode example" + } +} + +fn mode_custom_default_fn() -> String { + "Mode2".to_string() +} + +#[test] +fn derive_struct_with_defaults_success() { + let book = api_doc! { + struct Book { + name: String, + hash: String, + } + }; + + assert_value! {book=> + "type" = r#""object""#, "Book type" + "properties.name.type" = r#""string""#, "Book name type" + "properties.hash.type" = r#""string""#, "Book hash type" + "required" = r#"["name","hash"]"#, "Book required fields" + }; +} + +#[test] +fn derive_struct_with_custom_properties_success() { + let book = api_doc! { + struct Book { + #[schema(default = String::default)] + name: String, + #[schema( + default = "testhash", + example = "base64 text", + format = Byte, + )] + hash: String, + } + }; + + assert_value! {book=> + "type" = r#""object""#, "Book type" + "properties.name.type" = r#""string""#, "Book name type" + "properties.name.default" = r#""""#, "Book name default" + "properties.hash.type" = r#""string""#, "Book hash type" + "properties.hash.format" = r#""byte""#, "Book hash format" + "properties.hash.example" = r#""base64 text""#, "Book hash example" + "properties.hash.default" = r#""testhash""#, "Book hash default" + "required" = r#"["name","hash"]"#, "Book required fields" + }; +} + +#[test] +fn derive_struct_with_default_attr() { + let book = api_doc! { + #[schema(default)] + struct Book { + name: String, + #[schema(default = 0)] + id: u64, + year: u64, + hash: String, + } + + impl Default for Book { + fn default() -> Self { + Self { + name: "No name".to_string(), + id: 999, + year: 2020, + hash: "Test hash".to_string(), + } + } + } + }; + + assert_value! { book => + "properties.name.default" = r#""No name""#, "Book name default" + "properties.id.default" = r#"0"#, "Book id default" + "properties.year.default" = r#"2020"#, "Book year default" + "properties.hash.default" = r#""Test hash""#, "Book hash default" + }; +} + +#[test] +fn derive_struct_with_default_attr_field() { + #[derive(ToSchema)] + struct Book; + let owner = api_doc! { + struct Owner { + #[schema(default = json!({ "name": "Dune" }))] + favorite_book: Book, + #[schema(default = json!([{ "name": "The Fellowship Of The Ring" }]))] + books: Vec<Book>, + #[schema(default = json!({ "National Library": { "name": "The Stranger" } }))] + leases: HashMap<String, Book>, + #[schema(default = json!({ "name": "My Book" }))] + authored: Option<Book>, + } + }; + + assert_json_eq!( + owner, + json!({ + "properties": { + "favorite_book": { + "oneOf": [ + { + "$ref": "#/components/schemas/Book", + }, + ], + "default": { + "name": "Dune", + }, + }, + "books": { + "items": { + "$ref": "#/components/schemas/Book", + }, + "type": "array", + "default": [ + { + "name": "The Fellowship Of The Ring" + } + ] + }, + "leases": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/Book", + }, + "default": { + "National Library": { + "name": "The Stranger", + }, + }, + "type": "object", + }, + "authored": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Book", + }, + ], + "default": { + "name": "My Book", + } + }, + }, + "required": [ + "favorite_book", + "books", + "leases", + ], + "type": "object", + }) + ); +} + +#[test] +fn derive_struct_with_serde_default_attr() { + let book = api_doc! { + #[derive(serde::Deserialize)] + #[serde(default)] + struct Book { + name: String, + #[schema(default = 0)] + id: u64, + year: u64, + hash: String, + } + + impl Default for Book { + fn default() -> Self { + Self { + name: "No name".to_string(), + id: 999, + year: 2020, + hash: "Test hash".to_string(), + } + } + } + }; + + assert_value! { book => + "properties.name.default" = r#""No name""#, "Book name default" + "properties.id.default" = r#"0"#, "Book id default" + "properties.year.default" = r#"2020"#, "Book year default" + "properties.hash.default" = r#""Test hash""#, "Book hash default" + }; +} + +#[test] +fn derive_struct_with_optional_properties() { + #[derive(ToSchema)] + struct Book; + let owner = api_doc! { + struct Owner { + #[schema(default = 1)] + id: i64, + enabled: Option<bool>, + books: Option<Vec<Book>>, + metadata: Option<HashMap<String, String>>, + optional_book: Option<Book> + } + }; + + assert_json_eq!( + owner, + json!({ + "properties": { + "id": { + "type": "integer", + "format": "int64", + "default": 1, + }, + "enabled": { + "type": ["boolean", "null"], + }, + "books": { + "items": { + "$ref": "#/components/schemas/Book", + }, + "type": ["array", "null"] + }, + "metadata": { + "type": ["object", "null"], + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + "optional_book": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Book" + } + ] + } + }, + "required": [ + "id", + ], + "type": "object" + }) + ); +} + +#[test] +fn derive_struct_with_comments() { + #[derive(ToSchema)] + struct Foobar; + let account = api_doc! { + /// This is user account dto object + /// + /// Detailed documentation here. + /// More than the first line is added to the description as well. + struct Account { + /// Database autogenerated id + id: i64, + /// Users username + username: String, + /// Role ids + role_ids: Vec<i32>, + /// Foobars + foobars: Vec<Foobar>, + /// Map description + map: HashMap<String, String> + } + }; + + assert_json_eq!( + account, + json!({ + "description": "This is user account dto object\n\nDetailed documentation here.\nMore than the first line is added to the description as well.", + "properties": { + "foobars": { + "description": "Foobars", + "type": "array", + "items": { + "$ref": "#/components/schemas/Foobar" + } + }, + "id": { + "type": "integer", + "format": "int64", + "description": "Database autogenerated id", + }, + "role_ids": { + "description": "Role ids", + "type": "array", + "items": { + "type": "integer", + "format": "int32", + } + }, + "username": { + "type": "string", + "description": "Users username", + }, + "map": { + "description": "Map description", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + } + }, + "required": ["id", "username", "role_ids", "foobars", "map"], + "type": "object" + }) + ) +} + +#[test] +fn derive_enum_with_comments_success() { + let account = api_doc! { + /// This is user account status enum + /// + /// Detailed documentation here. + /// More than the first line is added to the description as well. + enum AccountStatus { + /// When user is valid to login, these enum variant level docs are omitted!!!!! + /// Since the OpenAPI spec does not have a place to put such information. + Enabled, + /// Login failed too many times + Locked, + Disabled + } + }; + + assert_value! {account=> + "description" = r#""This is user account status enum\n\nDetailed documentation here.\nMore than the first line is added to the description as well.""#, "AccountStatus description" + } +} + +#[test] +fn derive_struct_unnamed_field_single_value_type_success() { + let point = api_doc! { + struct Point(f32); + }; + + assert_value! {point=> + "type" = r#""number""#, "Point type" + "format" = r#""float""#, "Point format" + } +} + +#[test] +fn derive_struct_unnamed_fields_tuple_with_same_type_success() { + let point = api_doc! { + /// Contains x and y coordinates + /// + /// Coordinates are used to pinpoint location on a map + struct Point(f64, f64); + }; + + assert_value! {point=> + "type" = r#""array""#, "Point type" + "items.type" = r#""number""#, "Point items type" + "items.format" = r#""double""#, "Point items format" + "items.description" = r#""Contains x and y coordinates\n\nCoordinates are used to pinpoint location on a map""#, "Point items description" + "maxItems" = r#"2"#, "Wrapper max items" + "minItems" = r#"2"#, "Wrapper min items" + } +} + +#[test] +fn derive_struct_unnamed_fields_tuple_with_different_types_success() { + let point = api_doc! { + struct Point(f64, String); + }; + + assert_value! {point=> + "type" = r#""array""#, "Point type" + "items.type" = r#""object""#, "Point items type" + "items.format" = r#"null"#, "Point items format" + } +} + +#[test] +fn derive_struct_unnamed_field_with_generic_types_success() { + let point = api_doc! { + struct Wrapper(Option<String>); + }; + + assert_value! {point=> + "type" = r#"["string","null"]"#, "Wrapper type" + } +} + +#[test] +fn derive_struct_unnamed_field_with_nested_generic_type_success() { + let point = api_doc! { + /// Some description + struct Wrapper(Option<Vec<i32>>); + }; + + assert_value! {point=> + "type" = r#"["array","null"]"#, "Wrapper type" + "items.type" = r#""integer""#, "Wrapper items type" + "items.format" = r#""int32""#, "Wrapper items format" + "description" = r#""Some description""#, "Wrapper description" + } +} + +#[test] +fn derive_struct_unnamed_field_with_multiple_nested_generic_type_success() { + let point = api_doc! { + /// Some documentation + struct Wrapper(Option<Vec<i32>>, String); + }; + + assert_value! {point=> + "type" = r#""array""#, "Wrapper type" + "items.type" = r#""object""#, "Wrapper items type" + "items.format" = r#"null"#, "Wrapper items format" + "description" = r#""Some documentation""#, "Wrapper description" + } +} + +#[test] +fn derive_struct_unnamed_field_vec_type_success() { + let point = api_doc! { + /// Some documentation + /// more documentation + struct Wrapper(Vec<i32>); + }; + + assert_value! {point=> + "type" = r#""array""#, "Wrapper type" + "items.type" = r#""integer""#, "Wrapper items type" + "items.format" = r#""int32""#, "Wrapper items format" + "maxItems" = r#"null"#, "Wrapper max items" + "minItems" = r#"null"#, "Wrapper min items" + "description" = r#""Some documentation\nmore documentation""#, "Wrapper description" + } +} + +#[test] +fn derive_struct_unnamed_field_single_value_default_success() { + let point = api_doc! { + #[schema(default)] + struct Point(f32); + + impl Default for Point { + fn default() -> Self { + Self(3.5) + } + } + }; + + assert_value! {point=> + "type" = r#""number""#, "Point type" + "format" = r#""float""#, "Point format" + "default" = r#"3.5"#, "Point default" + } +} + +#[test] +fn derive_struct_unnamed_field_multiple_value_default_ignored() { + let point = api_doc! { + #[schema(default)] + struct Point(f32, f32); + + impl Default for Point { + fn default() -> Self { + Self(3.5, 6.4) + } + } + }; + // Default values shouldn't be assigned as the struct is represented + // as an array + assert!(!point.to_string().contains("default")) +} + +#[test] +fn derive_struct_nested_vec_success() { + let vecs = api_doc! { + struct VecTest { + vecs: Vec<Vec<String>> + } + }; + + assert_value! {vecs=> + "properties.vecs.type" = r#""array""#, "Vecs property type" + "properties.vecs.items.type" = r#""array""#, "Vecs property items type" + "properties.vecs.items.items.type" = r#""string""#, "Vecs property items item type" + "type" = r#""object""#, "Property type" + "required.[0]" = r#""vecs""#, "Required properties" + } + common::assert_json_array_len(vecs.get("required").unwrap(), 1); +} + +#[test] +fn derive_struct_with_example() { + let pet = api_doc! { + #[schema(example = json!({"name": "bob the cat", "age": 8}))] + struct Pet { + name: String, + age: i32 + } + }; + + assert_value! {pet=> + "example.name" = r#""bob the cat""#, "Pet example name" + "example.age" = r#"8"#, "Pet example age" + } +} + +#[test] +fn derive_struct_with_deprecated() { + #[allow(deprecated)] + let pet = api_doc! { + #[deprecated] + struct Pet { + name: String, + #[deprecated] + age: i32 + } + }; + + assert_value! {pet=> + "deprecated" = r#"true"#, "Pet deprecated" + "properties.name.type" = r#""string""#, "Pet properties name type" + "properties.name.deprecated" = r#"null"#, "Pet properties name deprecated" + "properties.age.type" = r#""integer""#, "Pet properties age type" + "properties.age.deprecated" = r#"true"#, "Pet properties age deprecated" + "example" = r#"null"#, "Pet example" + } +} + +#[test] +fn derive_struct_with_schema_deprecated() { + let pet = api_doc! { + #[schema(deprecated)] + struct Pet { + name: String, + #[schema(deprecated)] + age: i32 + } + }; + + assert_value! {pet=> + "deprecated" = r#"true"#, "Pet deprecated" + "properties.name.type" = r#""string""#, "Pet properties name type" + "properties.name.deprecated" = r#"null"#, "Pet properties name deprecated" + "properties.age.type" = r#""integer""#, "Pet properties age type" + "properties.age.deprecated" = r#"true"#, "Pet properties age deprecated" + "example" = r#"null"#, "Pet example" + } +} + +#[test] +fn derive_unnamed_struct_deprecated_success() { + #[allow(deprecated)] + let pet_age = api_doc! { + #[deprecated] + #[schema(example = 8)] + struct PetAge(u64); + }; + + assert_value! {pet_age=> + "deprecated" = r#"true"#, "PetAge deprecated" + "example" = r#"8"#, "PetAge example" + } +} + +#[test] +fn derive_unnamed_struct_schema_deprecated_success() { + let pet_age = api_doc! { + #[schema(deprecated, example = 8)] + struct PetAge(u64); + }; + + assert_value! {pet_age=> + "deprecated" = r#"true"#, "PetAge deprecated" + "example" = r#"8"#, "PetAge example" + } +} + +#[test] +fn derive_unnamed_struct_example_json_array_success() { + let pet_age = api_doc! { + #[schema(example = "0", default = i64::default)] + struct PetAge(i64, i64); + }; + + assert_value! {pet_age=> + "type" = r#""array""#, "PetAge type" + "items.example" = r#""0""#, "PetAge example" + "items.default" = r#"0"#, "PetAge default" + "items.type" = r#""integer""#, "PetAge default" + "items.format" = r#""int64""#, "PetAge default" + "maxItems" = r#"2"#, "PetAge max items" + "minItems" = r#"2"#, "PetAge min items" + } +} + +#[test] +fn derive_enum_with_deprecated() { + #[allow(deprecated)] + let mode = api_doc! { + #[deprecated] + enum Mode { + Mode1, Mode2 + } + }; + + assert_value! {mode=> + "enum" = r#"["Mode1","Mode2"]"#, "Mode enum variants" + "type" = r#""string""#, "Mode type" + "deprecated" = r#"true"#, "Mode deprecated" + }; +} + +#[test] +fn derive_enum_with_schema_deprecated() { + let mode = api_doc! { + #[schema(deprecated)] + enum Mode { + Mode1, Mode2 + } + }; + + assert_value! {mode=> + "enum" = r#"["Mode1","Mode2"]"#, "Mode enum variants" + "type" = r#""string""#, "Mode type" + "deprecated" = r#"true"#, "Mode deprecated" + }; +} + +#[test] +fn derive_struct_with_lifetime_generics() { + #[allow(unused)] + let greeting = api_doc! { + struct Greeting<'a> { + greeting: &'a str + } + }; + + assert_value! {greeting=> + "properties.greeting.type" = r###""string""###, "Greeting greeting field type" + }; +} + +#[test] +fn derive_struct_with_cow() { + #[allow(unused)] + let greeting = api_doc! { + struct Greeting<'a> { + greeting: Cow<'a, str> + } + }; + + common::assert_json_array_len(greeting.get("required").unwrap(), 1); + assert_value! {greeting=> + "properties.greeting.type" = r###""string""###, "Greeting greeting field type" + "required.[0]" = r###""greeting""###, "Greeting required" + }; +} + +#[test] +fn derive_with_box_and_refcell() { + #[allow(unused)] + #[derive(ToSchema)] + struct Foo { + name: &'static str, + } + + let greeting = api_doc! { + struct Greeting { + foo: Box<Foo>, + ref_cell_foo: RefCell<Foo> + } + }; + + common::assert_json_array_len(greeting.get("required").unwrap(), 2); + assert_value! {greeting=> + "properties.foo.$ref" = r###""#/components/schemas/Foo""###, "Greeting foo field" + "properties.ref_cell_foo.$ref" = r###""#/components/schemas/Foo""###, "Greeting ref_cell_foo field" + "required.0" = r###""foo""###, "Greeting required 0" + "required.1" = r###""ref_cell_foo""###, "Greeting required 1" + }; +} + +#[test] +fn derive_struct_with_inline() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Foo { + name: &'static str, + } + + let greeting = api_doc! { + struct Greeting { + #[schema(inline)] + foo1: Foo, + #[schema(inline)] + foo2: Option<Foo>, + #[schema(inline)] + foo3: Option<Box<Foo>>, + #[schema(inline)] + foo4: Vec<Foo>, + } + }; + + assert_json_eq!( + &greeting, + json!({ + "properties": { + "foo1": { + "properties": { + "name": { + "type": "string" + }, + }, + "required": [ + "name" + ], + "type": "object" + }, + "foo2": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "name": { + "type": "string" + }, + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, + "foo3": { + "oneOf": [ + { + "type": "null" + }, + { + "properties": { + "name": { + "type": "string" + }, + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, + "foo4": { + "items": { + "properties": { + "name": { + "type": "string" + }, + }, + "required": [ + "name" + ], + "type": "object" + }, + "type": "array" + }, + }, + "required": [ + "foo1", + "foo4", + ], + "type": "object" + }) + ); +} + +#[test] +fn derive_simple_enum() { + let value: Value = api_doc! { + #[derive(Serialize)] + enum Bar { + A, + B, + C, + } + }; + + assert_json_eq!( + value, + json!({ + "enum": [ + "A", + "B", + "C", + ], + "type": "string", + }) + ); +} + +#[test] +fn derive_simple_enum_serde_tag() { + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag")] + enum Bar { + A, + B, + C, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "A", + ], + }, + }, + "required": [ + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "B", + ], + }, + }, + "required": [ + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "C", + ], + }, + }, + "required": [ + "tag", + ], + }, + ], + }) + ); +} + +#[test] +fn derive_simple_enum_serde_tag_with_flatten_content() { + #[derive(Serialize, ToSchema)] + #[allow(unused)] + struct Foo { + name: &'static str, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag")] + enum Bar { + One { + #[serde(flatten)] + foo: Foo, + }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/Foo", + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "One", + ], + }, + }, + "required": [ + "tag", + ], + }, + ], + }, + ], + }) + ); +} + +#[test] +fn derive_simple_enum_serde_untagged() { + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + enum Foo { + One, + Two, + } + }; + + assert_json_eq!( + value, + json!({ + "type": "null", + "default": null, + }) + ); +} + +#[test] +fn derive_struct_unnamed_field_reference_with_comment() { + #[derive(ToSchema, Serialize)] + struct Bar { + value: String, + } + + let value = api_doc! { + #[derive(Serialize)] + /// Since OpenAPI 3.1 the description can be applied to Ref types + struct Foo(Bar); + }; + + assert_json_eq!( + value, + json!({ + "$ref": "#/components/schemas/Bar", + "description": "Since OpenAPI 3.1 the description can be applied to Ref types" + }) + ); +} + +/// Derive a mixed enum with named and unnamed fields. +#[test] +fn derive_complex_unnamed_field_reference_with_comment() { + #[derive(Serialize, ToSchema)] + struct CommentedReference(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + enum EnumWithReference { + /// Since OpenAPI 3.1 the comments can be added to the Ref types as well + UnnamedFieldWithCommentReference(CommentedReference), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "description": "Since OpenAPI 3.1 the comments can be added to the Ref types as well", + "properties": { + "UnnamedFieldWithCommentReference": { + "$ref": "#/components/schemas/CommentedReference", + "description": "Since OpenAPI 3.1 the comments can be added to the Ref types as well" + }, + }, + "required": ["UnnamedFieldWithCommentReference"], + }, + ], + }) + ); +} + +#[test] +fn derive_enum_with_unnamed_primitive_field_with_tag() { + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag")] + enum EnumWithReference { + Value(String), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": ["Value"] + }, + }, + "required": ["tag"] + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_with_schema_properties() { + let value: Value = api_doc! { + /// This is the description + #[derive(Serialize)] + #[schema(example = json!(EnumWithProperties::Variant2{name: String::from("foobar")}), + default = json!(EnumWithProperties::Variant{id: String::from("1")}))] + enum EnumWithProperties { + Variant { + id: String + }, + Variant2{ + name: String + } + } + }; + + assert_json_eq!( + value, + json!({ + "description": "This is the description", + "default": { + "Variant": { + "id": "1" + } + }, + "example": { + "Variant2": { + "name": "foobar" + } + }, + "oneOf": [ + { + "properties": { + "Variant": { + "properties": { + "id": { + "type": "string" + } + }, + "required": ["id"], + "type": "object" + } + }, + "required": ["Variant"], + "type": "object" + }, + { + "properties": { + "Variant2": { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + } + }, + "required": ["Variant2"], + "type": "object" + } + ] + }) + ) +} + +// TODO fixme https://github.com/nxpkg/fastapi/issues/285#issuecomment-1249625860 +#[test] +fn derive_enum_with_unnamed_single_field_with_tag() { + #[derive(Serialize, ToSchema)] + struct ReferenceValue(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "enum")] + enum EnumWithReference { + Value(ReferenceValue), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "allOf": [ + { + "$ref": "#/components/schemas/ReferenceValue", + }, + { + "type": "object", + "properties": { + "enum": { + "type": "string", + "enum": ["Value"] + + }, + }, + "required": ["enum"] + }, + ], + } + ] + }) + ); +} + +#[test] +fn derive_enum_with_named_fields_with_reference_with_tag() { + #[derive(Serialize, ToSchema)] + struct ReferenceValue(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "enum")] + enum EnumWithReference { + Value { + field: ReferenceValue, + a: String + }, + UnnamedValue(ReferenceValue), + UnitValue, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "properties": { + "a": { + "type": "string" + }, + "enum": { + "enum": [ + "Value" + ], + "type": "string" + }, + "field": { + "$ref": "#/components/schemas/ReferenceValue" + } + }, + "required": [ + "field", + "a", + "enum" + ], + "type": "object" + }, + { + "allOf": [ + { + "$ref": "#/components/schemas/ReferenceValue", + }, + { + "type": "object", + "properties": { + "enum": { + "type": "string", + "enum": ["UnnamedValue"] + + }, + }, + "required": ["enum"] + } + ], + }, + { + "properties": { + "enum": { + "enum": [ + "UnitValue" + ], + "type": "string" + } + }, + "required": [ + "enum" + ], + "type": "object" + } + ], + }) + ); +} + +#[test] +fn derive_mixed_enum() { + #[derive(Serialize, ToSchema)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + enum Bar { + UnitValue, + NamedFields { + id: &'static str, + names: Option<Vec<String>> + }, + UnnamedFields(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "string", + "enum": [ + "UnitValue", + ], + }, + { + "type": "object", + "properties": { + "NamedFields": { + "type": "object", + "properties": { + "id": { + "type": "string", + }, + "names": { + "type": ["array", "null"], + "items": { + "type": "string", + }, + }, + }, + "required": [ + "id", + ], + }, + }, + "required": ["NamedFields"], + }, + { + "type": "object", + "properties": { + "UnnamedFields": { + "$ref": "#/components/schemas/Foo", + }, + }, + "required": ["UnnamedFields"], + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_deprecated_variants() { + #![allow(deprecated)] + + #[derive(Serialize, ToSchema)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + enum Bar { + #[schema(deprecated)] + UnitValue, + #[deprecated] + NamedFields { + id: &'static str, + names: Option<Vec<String>> + }, + #[deprecated] + UnnamedFields(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "deprecated": true, + "type": "string", + "enum": [ + "UnitValue", + ], + }, + { + "deprecated": true, + "type": "object", + "properties": { + "NamedFields": { + "deprecated": true, + "type": "object", + "properties": { + "id": { + "type": "string", + }, + "names": { + "type": ["array", "null"], + "items": { + "type": "string", + }, + }, + }, + "required": [ + "id", + ], + }, + }, + "required": ["NamedFields"], + }, + { + "deprecated": true, + "type": "object", + "properties": { + "UnnamedFields": { + "$ref": "#/components/schemas/Foo", + }, + }, + "required": ["UnnamedFields"], + }, + ], + }) + ); +} +#[test] +fn derive_mixed_enum_title() { + #[derive(Serialize, ToSchema)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + enum Bar { + #[schema(title = "Unit")] + UnitValue, + #[schema(title = "Named")] + NamedFields { + id: &'static str, + }, + #[schema(title = "Unnamed")] + UnnamedFields(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "string", + "title": "Unit", + "enum": [ + "UnitValue", + ], + }, + { + "type": "object", + "title": "Named", + "properties": { + "NamedFields": { + "type": "object", + "properties": { + "id": { + "type": "string", + }, + }, + "required": [ + "id", + ], + }, + }, + "required": ["NamedFields"], + }, + { + "type": "object", + "title": "Unnamed", + "properties": { + "UnnamedFields": { + "$ref": "#/components/schemas/Foo", + }, + }, + "required": ["UnnamedFields"] + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_example() { + #[derive(Serialize, ToSchema)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + enum EnumWithExample { + #[schema(example = "EX: Unit")] + UnitValue, + #[schema(example = "EX: Named")] + NamedFields { + #[schema(example = "EX: Named id field")] + id: &'static str, + }, + #[schema(example = "EX: Unnamed")] + UnnamedFields(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "string", + "example": "EX: Unit", + "enum": [ + "UnitValue", + ], + }, + { + "type": "object", + "example": "EX: Named", + "properties": { + "NamedFields": { + "type": "object", + "properties": { + "id": { + "type": "string", + "example": "EX: Named id field", + }, + }, + "required": [ + "id", + ], + }, + }, + "required": ["NamedFields"] + }, + { + "type": "object", + "example": "EX: Unnamed", + "properties": { + "UnnamedFields": { + "$ref": "#/components/schemas/Foo", + }, + }, + "required": ["UnnamedFields"] + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_serde_rename_all() { + #[derive(Serialize, ToSchema)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(rename_all = "snake_case")] + enum Bar { + UnitValue, + NamedFields { + id: &'static str, + names: Option<Vec<String>> + }, + UnnamedFields(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "string", + "enum": [ + "unit_value", + ], + }, + { + "type": "object", + "properties": { + "named_fields": { + "type": "object", + "properties": { + "id": { + "type": "string", + }, + "names": { + "type": ["array", "null"], + "items": { + "type": "string", + }, + }, + }, + "required": [ + "id", + ], + }, + }, + "required": ["named_fields"] + }, + { + "type": "object", + "properties": { + "unnamed_fields": { + "$ref": "#/components/schemas/Foo", + }, + }, + "required": ["unnamed_fields"] + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_serde_rename_variant() { + #[derive(Serialize, ToSchema)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + enum Bar { + #[serde(rename = "renamed_unit_value")] + UnitValue, + #[serde(rename = "renamed_named_fields")] + NamedFields { + #[serde(rename = "renamed_id")] + id: &'static str, + #[serde(rename = "renamed_names")] + names: Option<Vec<String>> + }, + #[serde(rename = "renamed_unnamed_fields")] + UnnamedFields(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "string", + "enum": [ + "renamed_unit_value", + ], + }, + { + "type": "object", + "properties": { + "renamed_named_fields": { + "type": "object", + "properties": { + "renamed_id": { + "type": "string", + }, + "renamed_names": { + "type": [ "array", "null" ], + "items": { + "type": "string", + }, + }, + }, + "required": [ + "renamed_id", + ], + }, + }, + "required": ["renamed_named_fields"] + }, + { + "type": "object", + "properties": { + "renamed_unnamed_fields": { + "$ref": "#/components/schemas/Foo", + }, + }, + "required": ["renamed_unnamed_fields"] + }, + ], + }) + ); +} + +#[test] +fn derive_struct_custom_rename() { + let value: Value = api_doc! { + #[schema(rename_all = "SCREAMING-KEBAB-CASE")] + struct Post { + post_id: i64, + created_at: i64, + #[schema(rename = "post_comment")] + comment: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "POST-ID": { + "type": "integer", + "format": "int64", + }, + "CREATED-AT": { + "type": "integer", + "format": "int64", + }, + "post_comment": { + "type": "string", + }, + }, + "type": "object", + "required": [ + "POST-ID", + "CREATED-AT", + "post_comment" + ] + }) + ) +} + +#[test] +fn derive_mixed_enum_custom_rename() { + let value: Value = api_doc! { + #[schema(rename_all = "UPPERCASE")] + enum PostType { + NewPost(String), + + #[schema(rename = "update_post", rename_all = "PascalCase")] + Update { + post_id: i64, + created_at: i64, + #[schema(rename = "post_comment")] + comment: String, + }, + + RandomValue { + id: i64, + }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "properties": { + "NEWPOST": { + "type": "string" + } + }, + "required": ["NEWPOST"], + "type": "object", + }, + { + "properties": { + "update_post": { + "properties": { + "PostId": { + "type": "integer", + "format": "int64", + }, + "CreatedAt": { + "type": "integer", + "format": "int64", + }, + "post_comment": { + "type": "string", + }, + }, + "type": "object", + "required": [ + "PostId", + "CreatedAt", + "post_comment" + ] + } + }, + "required": ["update_post"], + "type": "object", + }, + { + "properties": { + "RANDOMVALUE": { + "properties": { + "id": { + "type": "integer", + "format": "int64", + }, + }, + "type": "object", + "required": [ + "id", + ] + } + }, + "required": ["RANDOMVALUE"], + "type": "object", + } + ] + }) + ) +} + +#[test] +fn derive_mixed_enum_use_serde_rename_over_custom_rename() { + let value: Value = api_doc! { + #[derive(serde::Deserialize)] + #[serde(rename_all = "lowercase")] + #[schema(rename_all = "UPPERCASE")] + enum Random { + #[serde(rename = "string_value")] + #[schema(rename = "custom_value")] + String(String), + + Number { + id: i32, + } + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "properties": { + "string_value": { + "type": "string", + }, + }, + "type": "object", + "required": ["string_value"] + }, + { + "properties": { + "number": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + } + }, + "type": "object", + "required": ["id"] + } + }, + "type": "object", + "required": ["number"] + } + ] + }) + ) +} + +#[test] +fn derive_struct_with_title() { + let value: Value = api_doc! { + #[schema(title = "Post")] + struct Post { + id: i64, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "id": { + "type": "integer", + "format": "int64", + } + }, + "title": "Post", + "required": ["id"], + "type": "object", + }) + ) +} + +#[test] +fn derive_enum_with_title() { + let value: Value = api_doc! { + #[schema(title = "UserType")] + enum UserType { + Admin, + Moderator, + User, + } + }; + + assert_json_eq!( + value, + json!({ + "enum": ["Admin", "Moderator", "User"], + "title": "UserType", + "type": "string", + }) + ) +} + +#[test] +fn derive_mixed_enum_with_title() { + let value: Value = api_doc! { + enum UserType { + #[schema(title = "admin")] + Admin(String), + #[schema(title = "moderator")] + Moderator{id: i32}, + #[schema(title = "user")] + User, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "properties": { + "Admin": { + "type": "string" + } + }, + "title": "admin", + "type": "object", + "required": ["Admin"] + }, + { + "properties": { + "Moderator": { + "properties": { + "id": { + "type": "integer", + "format": "int32", + } + }, + "required": ["id"], + "type": "object", + } + }, + "required": ["Moderator"], + "title": "moderator", + "type": "object", + }, + { + "enum": ["User"], + "title": "user", + "type": "string" + } + ] + }) + ) +} + +/// Derive a mixed enum with the serde `tag` container attribute applied for internal tagging. +/// Note that tuple fields are not supported. +#[test] +fn derive_mixed_enum_serde_tag() { + #[derive(Serialize)] + #[allow(dead_code)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag")] + enum Bar { + UnitValue, + NamedFields { + id: &'static str, + names: Option<Vec<String>> + }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "UnitValue", + ], + }, + }, + "required": [ + "tag", + ], + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + }, + "names": { + "type": ["array", "null"], + "items": { + "type": "string", + }, + }, + "tag": { + "type": "string", + "enum": [ + "NamedFields", + ], + }, + }, + "required": [ + "id", + "tag", + ], + }, + ], + }) + ); +} + +#[test] +fn derive_serde_flatten() { + #[derive(Serialize, ToSchema)] + struct Metadata { + category: String, + total: u64, + } + + #[derive(Serialize, ToSchema)] + struct Record { + amount: i64, + description: String, + #[serde(flatten)] + metadata: Metadata, + } + + #[derive(Serialize, ToSchema)] + struct Pagination { + page: i64, + next_page: i64, + per_page: i64, + } + + // Single flatten field + let value: Value = api_doc! { + #[derive(Serialize)] + struct Record { + amount: i64, + description: String, + #[serde(flatten)] + metadata: Metadata, + } + }; + + assert_json_eq!( + value, + json!({ + "allOf": [ + { + "$ref": "#/components/schemas/Metadata" + }, + { + "type": "object", + "properties": { + "amount": { + "type": "integer", + "format": "int64" + }, + "description": { + "type": "string", + }, + }, + "required": [ + "amount", + "description" + ], + }, + ] + }) + ); + + // Multiple flatten fields, with field that contain flatten as well. + // Record contain Metadata that is flatten as well, but it doesn't matter + // here as the generated spec will reference to Record directly. + let value: Value = api_doc! { + #[derive(Serialize)] + struct NamedFields { + id: &'static str, + #[serde(flatten)] + record: Record, + #[serde(flatten)] + pagination: Pagination + } + }; + + assert_json_eq!( + value, + json!({ + "allOf": [ + { + "$ref": "#/components/schemas/Record" + }, + { + "$ref": "#/components/schemas/Pagination" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string", + }, + }, + "required": [ + "id", + ], + }, + ] + }) + ); +} + +#[test] +fn derive_mixed_enum_serde_untagged() { + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + #[schema(title = "FooTitle")] + enum Foo { + Bar(i32), + Baz(String), + } + }; + + assert_json_eq!( + value, + json!({ + "title": "FooTitle", + "oneOf": [ + { + "format": "int32", + "type": "integer", + }, + { + "type": "string", + }, + ], + }) + ); +} + +#[test] +fn derive_untagged_with_unit_variant() { + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + enum EnumWithUnit { + ValueNumber(i32), + ThisIsUnit, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "format": "int32", + "type": "integer", + }, + { + "type": "null", + "default": null, + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_with_ref_serde_untagged() { + #[derive(Serialize, ToSchema)] + struct Foo { + name: String, + age: u32, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + enum Bar { + Baz(i32), + FooBar(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "format": "int32", + "type": "integer", + }, + { + "$ref": "#/components/schemas/Foo", + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_with_ref_serde_untagged_named_fields() { + #[derive(Serialize, ToSchema)] + struct Bar { + name: String, + age: u32, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + enum Foo { + One { n: i32 }, + Two { bar: Bar }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "properties": { + "n": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "n" + ], + "type": "object" + }, + { + "properties": { + "bar": { + "$ref": "#/components/schemas/Bar" + } + }, + "required": [ + "bar" + ], + "type": "object" + } + ] + }) + ); +} + +#[test] +fn derive_mixed_enum_with_ref_serde_untagged_named_fields_rename_all() { + #[derive(Serialize, ToSchema)] + struct Bar { + name: String, + age: u32, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + enum Foo { + #[serde(rename_all = "camelCase")] + One { some_number: i32 }, + #[serde(rename_all = "camelCase")] + Two { some_bar: Bar }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "properties": { + "someNumber": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "someNumber" + ], + "type": "object" + }, + { + "properties": { + "someBar": { + "$ref": "#/components/schemas/Bar" + } + }, + "required": [ + "someBar" + ], + "type": "object" + } + ] + }) + ); +} + +#[test] +fn derive_mixed_enum_serde_adjacently_tagged() { + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag", content = "content")] + enum Foo { + Bar(i32), + Baz(String), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "Bar", + ], + }, + "content": { + "format": "int32", + "type": "integer", + }, + }, + "required": [ + "content", + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "Baz", + ] + }, + "content": { + "type": "string", + }, + }, + "required": [ + "content", + "tag", + ], + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_with_ref_serde_adjacently_tagged() { + #[derive(Serialize, ToSchema)] + struct Foo { + name: String, + age: u32, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag", content = "content")] + enum Bar { + Baz(i32), + FooBar(Foo), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "Baz", + ], + }, + "content": { + "type": "integer", + "format": "int32", + }, + }, + "required": [ + "content", + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "FooBar", + ], + }, + "content": { + "$ref": "#/components/schemas/Foo" + }, + }, + "required": [ + "content", + "tag", + ], + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_with_discriminator_simple_form() { + #[derive(Serialize, ToSchema)] + struct FooInternal { + name: String, + age: u32, + bar: String, + } + + #[derive(ToSchema, Serialize)] + struct BarBarInternal { + value: String, + bar: String, + } + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + #[schema(discriminator = "bar")] + enum BarInternal { + Baz(BarBarInternal), + FooBar(FooInternal), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "$ref": "#/components/schemas/BarBarInternal" + }, + { + "$ref": "#/components/schemas/FooInternal" + }, + ], + "discriminator": { + "propertyName": "bar", + } + }) + ); +} + +#[test] +fn derive_mixed_enum_with_discriminator_with_mapping() { + #[derive(Serialize, ToSchema)] + struct FooInternal { + name: String, + age: u32, + bar_type: String, + } + + #[derive(ToSchema, Serialize)] + struct BarBarInternal { + value: String, + bar_type: String, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + #[schema(discriminator(property_name = "bar_type", mapping( + ("bar" = "#/components/schemas/BarBarInternal"), + ("foo" = "#/components/schemas/FooInternal"), + )))] + enum BarInternal { + Baz(BarBarInternal), + FooBar(FooInternal), + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "$ref": "#/components/schemas/BarBarInternal" + }, + { + "$ref": "#/components/schemas/FooInternal" + }, + ], + "discriminator": { + "propertyName": "bar_type", + "mapping": { + "bar": "#/components/schemas/BarBarInternal", + "foo": "#/components/schemas/FooInternal" + } + } + }) + ); +} + +#[test] +fn derive_mixed_enum_with_ref_serde_adjacently_tagged_named_fields() { + #[derive(Serialize, ToSchema)] + struct Bar { + name: String, + age: u32, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag", content = "content")] + enum Foo { + One { n: i32 }, + Two { bar: Bar }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "One", + ], + }, + "content": { + "type": "object", + "properties": { + "n": { + "format": "int32", + "type": "integer", + }, + }, + "required": [ + "n", + ], + }, + }, + "required": [ + "content", + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "Two", + ], + }, + "content": { + "type": "object", + "properties": { + "bar": { + "$ref": "#/components/schemas/Bar", + }, + }, + "required": [ + "bar", + ], + }, + }, + "required": [ + "content", + "tag", + ], + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_with_ref_serde_adjacently_tagged_named_fields_rename_all() { + #[derive(Serialize, ToSchema)] + struct Bar { + name: String, + age: u32, + } + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag", content = "content")] + enum Foo { + #[serde(rename_all = "camelCase")] + One { some_number: i32 }, + #[serde(rename_all = "camelCase")] + Two { some_bar: Bar }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "One", + ], + }, + "content": { + "type": "object", + "properties": { + "someNumber": { + "format": "int32", + "type": "integer", + }, + }, + "required": [ + "someNumber", + ], + }, + }, + "required": [ + "content", + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "string", + "enum": [ + "Two", + ], + }, + "content": { + "type": "object", + "properties": { + "someBar": { + "$ref": "#/components/schemas/Bar", + }, + }, + "required": [ + "someBar", + ], + }, + }, + "required": [ + "content", + "tag", + ], + }, + ], + }) + ); +} + +#[test] +fn derive_mixed_enum_serde_tag_title() { + #[derive(Serialize)] + #[allow(dead_code)] + struct Foo(String); + + let value: Value = api_doc! { + #[derive(Serialize)] + #[serde(tag = "tag")] + enum Bar { + #[schema(title = "Unit")] + UnitValue, + #[schema(title = "Named")] + NamedFields { + id: &'static str, + }, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "title": "Unit", + "properties": { + "tag": { + "type": "string", + "enum": [ + "UnitValue", + ], + }, + }, + "required": [ + "tag", + ], + }, + { + "type": "object", + "title": "Named", + "properties": { + "id": { + "type": "string", + }, + "tag": { + "type": "string", + "enum": [ + "NamedFields", + ], + }, + }, + "required": [ + "id", + "tag", + ], + }, + ], + }) + ); +} + +#[test] +fn derive_struct_with_read_only_and_write_only() { + let user = api_doc! { + struct User { + #[schema(read_only)] + username: String, + #[schema(write_only)] + password: String + } + }; + + assert_value! {user=> + "properties.password.type" = r###""string""###, "User password type" + "properties.password.writeOnly" = r###"true"###, "User password write only" + "properties.password.readOnly" = r###"null"###, "User password read only" + "properties.username.type" = r###""string""###, "User username type" + "properties.username.readOnly" = r###"true"###, "User username read only" + "properties.username.writeOnly" = r###"null"###, "User username write only" + } +} + +#[test] +fn derive_struct_with_nullable_and_required() { + let user = api_doc! { + #[derive(Serialize)] + struct User { + #[schema(nullable)] + #[serde(with = "::serde_with::rust::double_option")] + fax: Option<Option<String>>, + #[schema(nullable)] + phone: Option<Option<String>>, + #[schema(nullable = false)] + email: String, + name: String, + #[schema(nullable)] + edit_history: Vec<String>, + #[serde(skip_serializing_if = "Vec::is_empty")] + friends: Vec<Option<String>>, + #[schema(required)] + updated: Option<String>, + } + }; + + assert_json_eq!( + user, + json!({ + "properties": { + "fax": { + "type": ["string", "null"], + }, + "phone": { + "type": ["string", "null"], + }, + "email": { + "type": "string", + }, + "name": { + "type": "string", + }, + "edit_history": { + "type": ["array", "null"], + "items": { + "type": "string" + }, + }, + "friends": { + "type": "array", + "items": { + "type": ["string", "null"], + }, + }, + "updated": { + "type": ["string", "null"], + } + }, + "required": [ + "email", + "name", + "edit_history", + "updated", + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_enum_with_inline_variant() { + #[allow(dead_code)] + #[derive(ToSchema)] + enum Number { + One, + Two, + Three, + Four, + Five, + Six, + Seven, + Height, + Nine, + } + + #[allow(dead_code)] + #[derive(ToSchema)] + enum Color { + Spade, + Heart, + Club, + Diamond, + } + + let card = api_doc! { + enum Card { + Number(#[schema(inline)] Number), + Color(#[schema(inline)] Color), + } + }; + + assert_json_eq!( + card, + json!({ + "oneOf": [ + { + "properties": { + "Number": { + "enum": [ + "One", + "Two", + "Three", + "Four", + "Five", + "Six", + "Seven", + "Height", + "Nine", + ], + "type": "string", + }, + }, + "required": [ + "Number", + ], + "type": "object", + }, + { + "properties": { + "Color": { + "enum": [ + "Spade", + "Heart", + "Club", + "Diamond", + ], + "type": "string", + }, + }, + "required": [ + "Color", + ], + "type": "object", + }, + ], + }) + ); +} + +#[test] +fn derive_struct_xml() { + let user = api_doc! { + #[schema(xml(name = "user", prefix = "u", namespace = "https://mynamespace.test"))] + struct User { + #[schema(xml(attribute, prefix = "u"))] + id: i64, + #[schema(xml(name = "user_name", prefix = "u"))] + username: String, + #[schema(xml(wrapped(name = "linkList"), name = "link"))] + links: Vec<String>, + #[schema(xml(wrapped, name = "photo_url"))] + photos_urls: Vec<String> + } + }; + + assert_value! {user=> + "xml.attribute" = r###"null"###, "User xml attribute" + "xml.name" = r###""user""###, "User xml name" + "xml.prefix" = r###""u""###, "User xml prefix" + "xml.namespace" = r###""https://mynamespace.test""###, "User xml namespace" + "properties.id.xml.attribute" = r###"true"###, "User id xml attribute" + "properties.id.xml.name" = r###"null"###, "User id xml name" + "properties.id.xml.prefix" = r###""u""###, "User id xml prefix" + "properties.id.xml.namespace" = r###"null"###, "User id xml namespace" + "properties.username.xml.attribute" = r###"null"###, "User username xml attribute" + "properties.username.xml.name" = r###""user_name""###, "User username xml name" + "properties.username.xml.prefix" = r###""u""###, "User username xml prefix" + "properties.username.xml.namespace" = r###"null"###, "User username xml namespace" + "properties.links.xml.attribute" = r###"null"###, "User links xml attribute" + "properties.links.xml.name" = r###""linkList""###, "User links xml name" + "properties.links.xml.prefix" = r###"null"###, "User links xml prefix" + "properties.links.xml.namespace" = r###"null"###, "User links xml namespace" + "properties.links.xml.wrapped" = r###"true"###, "User links xml wrapped" + "properties.links.items.xml.attribute" = r###"null"###, "User links xml items attribute" + "properties.links.items.xml.name" = r###""link""###, "User links xml items name" + "properties.links.items.xml.prefix" = r###"null"###, "User links xml items prefix" + "properties.links.items.xml.namespace" = r###"null"###, "User links xml items namespace" + "properties.links.items.xml.wrapped" = r###"null"###, "User links xml items wrapped" + "properties.photos_urls.xml.attribute" = r###"null"###, "User photos_urls xml attribute" + "properties.photos_urls.xml.name" = r###"null"###, "User photos_urls xml name" + "properties.photos_urls.xml.prefix" = r###"null"###, "User photos_urls xml prefix" + "properties.photos_urls.xml.namespace" = r###"null"###, "User photos_urls xml namespace" + "properties.photos_urls.xml.wrapped" = r###"true"###, "User photos_urls xml wrapped" + "properties.photos_urls.items.xml.attribute" = r###"null"###, "User photos_urls xml items attribute" + "properties.photos_urls.items.xml.name" = r###""photo_url""###, "User photos_urls xml items name" + "properties.photos_urls.items.xml.prefix" = r###"null"###, "User photos_urls xml items prefix" + "properties.photos_urls.items.xml.namespace" = r###"null"###, "User photos_urls xml items namespace" + "properties.photos_urls.items.xml.wrapped" = r###"null"###, "User photos_urls links xml items wrapped" + } +} + +#[test] +fn derive_struct_xml_with_optional_vec() { + let user = api_doc! { + #[schema(xml(name = "user"))] + struct User { + #[schema(xml(attribute, prefix = "u"))] + id: i64, + #[schema(xml(wrapped(name = "linkList"), name = "link"))] + links: Option<Vec<String>>, + } + }; + + assert_json_eq!( + user, + json!({ + "properties": { + "id": { + "type": "integer", + "format": "int64", + "xml": { + "attribute": true, + "prefix": "u" + } + }, + "links": { + "type": ["array", "null"], + "items": { + "type": "string", + "xml": { + "name": "link" + } + }, + "xml": { + "name": "linkList", + "wrapped": true, + } + } + }, + "required": ["id"], + "type": "object", + "xml": { + "name": "user" + } + }) + ); +} + +#[cfg(feature = "chrono")] +#[test] +fn derive_component_with_chrono_feature() { + #![allow(deprecated)] // allow deprecated Date in tests as long as it is available from chrono + use chrono::{Date, DateTime, Duration, NaiveDate, NaiveDateTime, NaiveTime, Utc}; + + let post = api_doc! { + struct Post { + id: i32, + value: String, + datetime: DateTime<Utc>, + naive_datetime: NaiveDateTime, + date: Date<Utc>, + naive_date: NaiveDate, + naive_time: NaiveTime, + duration: Duration, + } + }; + + assert_value! {post=> + "properties.datetime.type" = r#""string""#, "Post datetime type" + "properties.datetime.format" = r#""date-time""#, "Post datetime format" + "properties.naive_datetime.type" = r#""string""#, "Post datetime type" + "properties.naive_datetime.format" = r#""date-time""#, "Post datetime format" + "properties.date.type" = r#""string""#, "Post date type" + "properties.date.format" = r#""date""#, "Post date format" + "properties.naive_date.type" = r#""string""#, "Post date type" + "properties.naive_date.format" = r#""date""#, "Post date format" + "properties.naive_time.type" = r#""string""#, "Post time type" + "properties.naive_time.format" = r#"null"#, "Post time format" + "properties.duration.type" = r#""string""#, "Post duration type" + "properties.duration.format" = r#"null"#, "Post duration format" + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.value.type" = r#""string""#, "Post value type" + "properties.value.format" = r#"null"#, "Post value format" + } +} + +#[cfg(feature = "time")] +#[test] +fn derive_component_with_time_feature() { + use time::{Date, Duration, OffsetDateTime, PrimitiveDateTime}; + + let times = api_doc! { + struct Timetest { + datetime: OffsetDateTime, + primitive_date_time: PrimitiveDateTime, + date: Date, + duration: Duration, + } + }; + + assert_json_eq!( + ×, + json!({ + "properties": { + "date": { + "format": "date", + "type": "string" + }, + "datetime": { + "format": "date-time", + "type": "string" + }, + "primitive_date_time": { + "format": "date-time", + "type": "string" + }, + "duration": { + "type": "string" + } + }, + "required": [ + "datetime", + "primitive_date_time", + "date", + "duration" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_struct_component_field_type_override() { + let post = api_doc! { + struct Post { + id: i32, + #[schema(value_type = String)] + value: i64, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.value.type" = r#""string""#, "Post value type" + "properties.value.format" = r#"null"#, "Post value format" + } +} + +#[test] +fn derive_struct_component_field_type_path_override_returns_default_name() { + mod path { + pub mod to { + #[derive(fastapi::ToSchema)] + pub struct Foo(()); + } + } + let post = api_doc! { + struct Post { + id: i32, + #[schema(value_type = path::to::Foo)] + value: i64, + } + }; + + let component_ref: &str = post + .pointer("/properties/value/$ref") + .unwrap() + .as_str() + .unwrap(); + assert_eq!(component_ref, "#/components/schemas/Foo"); +} + +#[test] +fn derive_struct_component_field_type_path_override_with_as_returns_custom_name() { + mod path { + pub mod to { + #[derive(fastapi::ToSchema)] + #[schema(as = path::to::Foo)] + pub struct Foo(()); + } + } + let post = api_doc! { + struct Post { + id: i32, + #[schema(value_type = path::to::Foo)] + value: i64, + } + }; + + let component_ref: &str = post + .pointer("/properties/value/$ref") + .unwrap() + .as_str() + .unwrap(); + assert_eq!(component_ref, "#/components/schemas/path.to.Foo"); +} + +#[test] +fn derive_struct_component_field_type_override_with_format() { + let post = api_doc! { + struct Post { + id: i32, + #[schema(value_type = String, format = Byte)] + value: i64, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.value.type" = r#""string""#, "Post value type" + "properties.value.format" = r#""byte""#, "Post value format" + } +} + +#[test] +fn derive_struct_component_field_type_override_with_custom_format() { + let post = api_doc! { + struct Post { + #[schema(value_type = String, format = "uri")] + value: String, + } + }; + + assert_value! {post=> + "properties.value.type" = r#""string""#, "Post value type" + "properties.value.format" = r#""uri""#, "Post value format" + } +} + +#[test] +fn derive_struct_component_field_type_override_with_format_with_vec() { + let post = api_doc! { + struct Post { + id: i32, + #[schema(value_type = String, format = Binary)] + value: Vec<u8>, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.value.type" = r#""string""#, "Post value type" + "properties.value.format" = r#""binary""#, "Post value format" + } +} + +#[test] +fn derive_unnamed_struct_schema_type_override() { + let value = api_doc! { + #[schema(value_type = String)] + struct Value(i64); + }; + + assert_value! {value=> + "type" = r#""string""#, "Value type" + "format" = r#"null"#, "Value format" + } +} + +#[test] +fn derive_unnamed_struct_schema_type_override_with_format() { + let value = api_doc! { + #[schema(value_type = String, format = Byte)] + struct Value(i64); + }; + + assert_value! {value=> + "type" = r#""string""#, "Value type" + "format" = r#""byte""#, "Value format" + } +} + +#[test] +fn derive_unnamed_struct_schema_ipv4() { + let value = api_doc! { + #[schema(format = Ipv4)] + struct Ipv4(String); + }; + + assert_value! {value=> + "type" = r#""string""#, "Value type" + "format" = r#""ipv4""#, "Value format" + } +} + +#[test] +fn derive_struct_override_type_with_object_type() { + let value = api_doc! { + struct Value { + #[schema(value_type = Object)] + field: String, + } + }; + + assert_json_eq!( + value, + json!({ + "type": "object", + "properties": { + "field": { + "type": "object" + } + }, + "required": ["field"] + }) + ) +} + +#[test] +fn derive_struct_override_type_with_a_reference() { + mod custom { + #[derive(fastapi::ToSchema)] + #[allow(dead_code)] + pub struct NewBar(()); + } + + let value = api_doc! { + struct Value { + #[schema(value_type = custom::NewBar)] + field: String, + } + }; + + assert_json_eq!( + value, + json!({ + "type": "object", + "properties": { + "field": { + "$ref": "#/components/schemas/NewBar" + } + }, + "required": ["field"] + }) + ) +} + +#[cfg(feature = "decimal")] +#[test] +fn derive_struct_with_rust_decimal() { + use rust_decimal::Decimal; + + let post = api_doc! { + struct Post { + id: i32, + rating: Decimal, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.rating.type" = r#""string""#, "Post rating type" + "properties.rating.format" = r#"null"#, "Post rating format" + } +} + +#[cfg(feature = "decimal")] +#[test] +fn derive_struct_with_rust_decimal_with_type_override() { + use rust_decimal::Decimal; + + let post = api_doc! { + struct Post { + id: i32, + #[schema(value_type = f64)] + rating: Decimal, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.rating.type" = r#""number""#, "Post rating type" + "properties.rating.format" = r#""double""#, "Post rating format" + } +} + +#[cfg(feature = "decimal_float")] +#[test] +fn derive_struct_with_rust_decimal_float() { + use rust_decimal::Decimal; + + let post = api_doc! { + struct Post { + id: i32, + rating: Decimal, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.rating.type" = r#""number""#, "Post rating type" + "properties.rating.format" = r#""double""#, "Post rating format" + } +} + +#[cfg(feature = "decimal_float")] +#[test] +fn derive_struct_with_rust_decimal_float_with_type_override() { + use rust_decimal::Decimal; + + let post = api_doc! { + struct Post { + id: i32, + #[schema(value_type = String)] + rating: Decimal, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""integer""#, "Post id type" + "properties.id.format" = r#""int32""#, "Post id format" + "properties.rating.type" = r#""string""#, "Post rating type" + "properties.rating.format" = r#"null"#, "Post rating format" + } +} + +#[cfg(feature = "uuid")] +#[test] +fn derive_struct_with_uuid_type() { + use uuid::Uuid; + + let post = api_doc! { + struct Post { + id: Uuid, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""string""#, "Post id type" + "properties.id.format" = r#""uuid""#, "Post id format" + } +} + +#[cfg(feature = "ulid")] +#[test] +fn derive_struct_with_ulid_type() { + use ulid::Ulid; + + let post = api_doc! { + struct Post { + id: Ulid, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""string""#, "Post id type" + "properties.id.format" = r#""ulid""#, "Post id format" + } +} + +#[cfg(feature = "url")] +#[test] +fn derive_struct_with_url_type() { + use url::Url; + + let post = api_doc! { + struct Post { + id: Url, + } + }; + + assert_value! {post=> + "properties.id.type" = r#""string""#, "Post id type" + "properties.id.format" = r#""uri""#, "Post id format" + } +} + +#[test] +fn derive_parse_serde_field_attributes() { + struct S; + let post = api_doc! { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + #[schema(bound = "")] + struct Post<S> { + #[serde(rename = "uuid")] + id: String, + #[serde(skip)] + _p: PhantomData<S>, + #[serde(skip_serializing)] + _p2: PhantomData<S>, + long_field_num: i64, + } + }; + + assert_json_eq!( + post, + json!({ + "properties": { + "longFieldNum": { + "format": "int64", + "type": "integer" + }, + "uuid": { + "type": "string" + } + }, + "required": [ + "uuid", + "longFieldNum" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_parse_serde_simple_enum_attributes() { + let value = api_doc! { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + enum Value { + A, + B, + #[serde(skip)] + C, + } + }; + + assert_value! {value=> + "enum" = r#"["a","b"]"#, "Value enum variants" + } +} + +#[test] +fn derive_parse_serde_mixed_enum() { + #[derive(Serialize, ToSchema)] + struct Foo; + let mixed_enum = api_doc! { + #[derive(Serialize)] + #[serde(rename_all = "camelCase")] + enum Bar { + UnitValue, + #[serde(rename_all = "camelCase")] + NamedFields { + #[serde(rename = "id")] + named_id: &'static str, + name_list: Option<Vec<String>> + }, + UnnamedFields(Foo), + #[serde(skip)] + Random, + } + }; + + assert_value! {mixed_enum=> + "oneOf.[0].enum" = r#"["unitValue"]"#, "Unit value enum" + "oneOf.[0].type" = r#""string""#, "Unit value type" + + "oneOf.[1].properties.namedFields.properties.id.type" = r#""string""#, "Named fields id type" + "oneOf.[1].properties.namedFields.properties.nameList.type" = r#"["array","null"]"#, "Named fields nameList type" + "oneOf.[1].properties.namedFields.properties.nameList.items.type" = r#""string""#, "Named fields nameList items type" + "oneOf.[1].properties.namedFields.required" = r#"["id"]"#, "Named fields required" + + "oneOf.[2].properties.unnamedFields.$ref" = r###""#/components/schemas/Foo""###, "Unnamed fields ref" + } +} + +#[test] +fn derive_component_with_generic_types_having_path_expression() { + let ty = api_doc! { + struct Bar { + args: Vec<std::vec::Vec<std::string::String>> + } + }; + + let args = ty.pointer("/properties/args").unwrap(); + + assert_json_eq!( + args, + json!({ + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }) + ); +} + +#[test] +fn derive_mixed_enum_as() { + #[derive(ToSchema)] + struct Foobar; + + #[derive(ToSchema)] + #[schema(as = named::BarBar)] + #[allow(unused)] + enum BarBar { + Foo { foo: Foobar }, + } + + #[derive(OpenApi)] + #[openapi(components(schemas(BarBar)))] + struct ApiDoc; + + let doc = serde_json::to_value(ApiDoc::openapi()).unwrap(); + let value = doc + .pointer("/components/schemas/named.BarBar") + .expect("Should have BarBar named to named.BarBar"); + + assert_json_eq!( + &value, + json!({ + "oneOf": [ + { + "properties": { + "Foo": { + "properties": { + "foo": { + "$ref": "#/components/schemas/Foobar" + } + }, + "required": ["foo"], + "type": "object" + } + }, + "required": ["Foo"], + "type": "object", + } + ] + }) + ) +} + +#[test] +fn derive_component_with_to_schema_value_type() { + #[derive(ToSchema)] + #[allow(dead_code)] + struct Foo { + #[allow(unused)] + value: String, + } + + let doc = api_doc! { + #[allow(unused)] + struct Random { + #[schema(value_type = i64)] + id: String, + #[schema(value_type = Object)] + another_id: String, + #[schema(value_type = Vec<Vec<String>>)] + value1: Vec<i64>, + #[schema(value_type = Vec<String>)] + value2: Vec<i64>, + #[schema(value_type = Option<String>)] + value3: i64, + #[schema(value_type = Option<Object>)] + value4: i64, + #[schema(value_type = Vec<Object>)] + value5: i64, + #[schema(value_type = Vec<Foo>)] + value6: i64, + } + }; + + assert_json_eq!( + doc, + json!({ + "properties": { + "another_id": { + "type": "object" + }, + "id": { + "type": "integer", + "format": "int64" + }, + "value1": { + "items": { + "items": { + "type": "string" + }, + "type": "array" + }, + "type": "array" + }, + "value2": { + "items": { + "type": "string" + }, + "type": "array" + }, + "value3": { + "type": ["string", "null"], + }, + "value4": { + "type": ["object", "null"], + }, + "value5": { + "items": { + "type": "object" + }, + "type": "array" + }, + "value6": { + "items": { + "$ref": "#/components/schemas/Foo" + }, + "type": "array" + } + }, + "required": [ + "id", + "another_id", + "value1", + "value2", + "value5", + "value6", + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_component_with_mixed_enum_lifetimes() { + #[derive(ToSchema)] + struct Foo<'foo> { + #[allow(unused)] + field: &'foo str, + } + + let doc = api_doc! { + enum Bar<'bar> { + A { foo: Foo<'bar> }, + B, + C, + } + }; + + assert_json_eq!( + doc, + json!({ + "oneOf": [ + { + "properties": { + "A": { + "properties": { + "foo": { + "$ref": "#/components/schemas/Foo" + } + }, + "required": ["foo"], + "type": "object" + }, + }, + "required": ["A"], + "type": "object" + }, + { + "enum": ["B"], + "type": "string" + }, + { + "enum": ["C"], + "type": "string" + } + ] + }) + ) +} + +#[test] +fn derive_component_with_raw_identifier() { + let doc = api_doc! { + struct Bar { + r#in: String + } + }; + + assert_json_eq!( + doc, + json!({ + "properties": { + "in": { + "type": "string" + } + }, + "required": ["in"], + "type": "object" + }) + ) +} + +#[test] +fn derive_component_with_linked_list() { + use std::collections::LinkedList; + + let example_schema = api_doc! { + struct ExampleSchema { + values: LinkedList<f64> + } + }; + + assert_json_eq!( + example_schema, + json!({ + "properties": { + "values": { + "items": { + "type": "number", + "format": "double" + }, + "type": "array" + } + }, + "required": ["values"], + "type": "object" + }) + ) +} + +#[test] +#[cfg(feature = "smallvec")] +fn derive_component_with_smallvec_feature() { + use smallvec::SmallVec; + + let bar = api_doc! { + struct Bar<'b> { + links: SmallVec<[&'b str; 2]> + } + }; + + assert_json_eq!( + bar, + json!({ + "properties": { + "links": { + "items": { + "type": "string" + }, + "type": "array", + } + }, + "required": ["links"], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_default_field() { + let value = api_doc! { + #[derive(serde::Deserialize)] + struct MyValue { + #[serde(default)] + field: String + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "field": { + "type": "string" + } + }, + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_default_struct() { + let value = api_doc! { + #[derive(serde::Deserialize, Default)] + #[serde(default)] + struct MyValue { + field: String + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "field": { + "type": "string", + "default": "" + } + }, + "type": "object" + }) + ) +} + +#[test] +fn derive_struct_with_no_additional_properties() { + let value = api_doc! { + #[derive(serde::Deserialize, Default)] + #[serde(deny_unknown_fields)] + struct MyValue { + field: String + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "field": { + "type": "string", + } + }, + "required": ["field"], + "additionalProperties": false, + "type": "object" + }) + ) +} + +#[test] +#[cfg(feature = "repr")] +fn derive_schema_for_repr_enum() { + let value = api_doc! { + #[derive(serde::Deserialize)] + #[repr(i32)] + #[schema(example = 1, default = 0)] + enum ExitCode { + Error = -1, + Ok = 0, + Unknow = 1, + } + }; + + assert_json_eq!( + value, + json!({ + "enum": [-1, 0, 1], + "type": "integer", + "default": 0, + "example": 1, + }) + ); +} + +#[test] +#[cfg(feature = "repr")] +fn derive_schema_for_tagged_repr_enum() { + let value: Value = api_doc! { + #[derive(serde::Deserialize, serde::Serialize)] + #[serde(tag = "tag")] + #[repr(u8)] + enum TaggedEnum { + One = 0, + Two, + Three, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "type": "object", + "properties": { + "tag": { + "type": "integer", + "enum": [ + 0, + ], + }, + }, + "required": [ + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "integer", + "enum": [ + 1, + ], + }, + }, + "required": [ + "tag", + ], + }, + { + "type": "object", + "properties": { + "tag": { + "type": "integer", + "enum": [ + 2, + ], + }, + }, + "required": [ + "tag", + ], + }, + ], + }) + ); +} + +#[test] +#[cfg(feature = "repr")] +fn derive_schema_for_skipped_repr_enum() { + let value: Value = api_doc! { + #[derive(serde::Deserialize, serde::Serialize)] + #[repr(i32)] + enum SkippedEnum { + Error = -1, + Ok = 0, + #[serde(skip)] + Unknown = 1, + } + }; + + assert_value! {value=> + "enum" = r#"[-1,0]"#, "SkippedEnum enum variants" + "type" = r#""integer""#, "SkippedEnum enum type" + }; +} + +#[test] +#[cfg(feature = "repr")] +fn derive_repr_enum_with_with_custom_default_fn_success() { + let mode = api_doc! { + #[schema(default = repr_mode_default_fn)] + #[repr(u16)] + enum ReprDefaultMode { + Mode1 = 0, + Mode2 + } + }; + + assert_value! {mode=> + "default" = r#"1"#, "ReprDefaultMode default" + "enum" = r#"[0,1]"#, "ReprDefaultMode enum variants" + "type" = r#""integer""#, "ReprDefaultMode type" + }; + assert_value! {mode=> + "example" = Value::Null, "ReprDefaultMode example" + } +} + +#[cfg(feature = "repr")] +fn repr_mode_default_fn() -> u16 { + 1 +} + +#[test] +#[cfg(feature = "repr")] +fn derive_repr_enum_with_with_custom_default_fn_and_example() { + let mode = api_doc! { + #[schema(default = repr_mode_default_fn, example = 1)] + #[repr(u16)] + enum ReprDefaultMode { + Mode1 = 0, + Mode2 + } + }; + + assert_value! {mode=> + "default" = r#"1"#, "ReprDefaultMode default" + "enum" = r#"[0,1]"#, "ReprDefaultMode enum variants" + "type" = r#""integer""#, "ReprDefaultMode type" + "example" = r#"1"#, "ReprDefaultMode example" + }; +} + +#[test] +fn derive_struct_with_vec_field_with_example() { + let post = api_doc! { + struct Post { + id: i32, + #[schema(example = json!(["foobar", "barfoo"]))] + value: Vec<String>, + } + }; + + assert_json_eq!( + post, + json!({ + "type": "object", + "properties": { + "id": { + "type": "integer", + "format": "int32" + }, + "value": { + "type": "array", + "example": ["foobar", "barfoo"], + "items": { + "type": "string" + } + } + }, + "required": ["id", "value"] + }) + ); +} + +#[test] +fn derive_struct_field_with_example() { + #[derive(ToSchema)] + struct MyStruct; + let doc = api_doc! { + struct MyValue { + #[schema(example = "test")] + field1: String, + #[schema(example = json!("test"))] + field2: String, + #[schema(example = json!({ + "key1": "value1" + }))] + field3: HashMap<String, String>, + #[schema(example = json!({ + "key1": "value1" + }))] + field4: HashMap<String, MyStruct> + } + }; + + assert_json_eq!( + doc, + json!({ + "properties": { + "field1": { + "type": "string", + "example": "test" + }, + "field2": { + "type": "string", + "example": "test" + }, + "field3": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string", + }, + "example": { + "key1": "value1" + } + }, + "field4": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "$ref": "#/components/schemas/MyStruct", + }, + "example": { + "key1": "value1" + } + } + }, + "required": [ + "field1", + "field2", + "field3", + "field4" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_unnamed_structs_with_examples() { + let doc = api_doc! { + #[derive(serde::Serialize, serde::Deserialize)] + #[schema(examples(json!("kim"), json!("jim")))] + struct UsernameRequestWrapper(String); + }; + + assert_json_eq!( + doc, + json!({ + "type": "string", + "examples": ["kim", "jim"] + }) + ); + + #[derive(ToSchema, serde::Serialize, serde::Deserialize)] + struct Username(String); + + // Refs cannot have examples + let doc = api_doc! { + #[derive(serde::Serialize, serde::Deserialize)] + #[schema(examples(json!("kim"), json!("jim")))] + struct UsernameRequestWrapper(Username); + }; + + assert_json_eq!( + doc, + json!({ + "$ref": "#/components/schemas/Username", + }) + ) +} + +#[test] +fn derive_struct_with_examples() { + let doc = api_doc! { + #[derive(serde::Serialize, serde::Deserialize)] + #[schema(examples(json!({"username": "kim"}), json!(UsernameRequest {username: "jim".to_string()})))] + struct UsernameRequest { + #[schema(examples(json!("foobar"), "barfoo"))] + username: String, + } + }; + + assert_json_eq!( + doc, + json!({ + "properties": { + "username": { + "type": "string", + "examples": ["foobar", "barfoo"] + }, + }, + "required": [ + "username", + ], + "type": "object", + "examples": [ + {"username": "kim"}, + {"username": "jim"} + ] + }) + ) +} + +#[test] +fn derive_struct_with_self_reference() { + let value = api_doc! { + struct Item { + id: String, + previous: Box<Self>, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "id": { + "type": "string", + }, + "previous": { + "$ref": "#/components/schemas/Item", + }, + }, + "type": "object", + "required": ["id", "previous"] + }) + ) +} + +#[test] +fn derive_unnamed_struct_with_self_reference() { + let value = api_doc! { + struct Item(Box<Item>); + }; + + assert_json_eq!( + value, + json!({ + "$ref": "#/components/schemas/Item" + }) + ) +} + +#[test] +fn derive_enum_with_self_reference() { + let value = api_doc! { + enum EnumValue { + Item(Box<Self>), + Item2 { + value: Box<Self> + } + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "properties": { + "Item": { + "$ref": "#/components/schemas/EnumValue" + } + }, + "type": "object", + "required": ["Item"], + }, + { + "properties": { + "Item2": { + "properties": { + "value": { + "$ref": "#/components/schemas/EnumValue" + } + }, + "required": ["value"], + "type": "object", + } + }, + "required": ["Item2"], + "type": "object", + } + ] + }) + ) +} + +#[test] +fn derive_struct_with_validation_fields() { + let value = api_doc! { + struct Item { + #[schema(maximum = 10, minimum = 5, multiple_of = 2.5)] + id: i32, + + #[schema(max_length = 10, min_length = 5, pattern = "[a-z]*")] + value: String, + + #[schema(max_items = 5, min_items = 1, min_length = 1)] + items: Vec<String>, + + unsigned: u16, + + #[schema(minimum = 2)] + unsigned_value: u32, + + } + }; + + let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat); + + #[cfg(feature = "non_strict_integers")] + assert_json_matches!( + value, + json!({ + "properties": { + "id": { + "format": "int32", + "type": "integer", + "maximum": 10.0, + "minimum": 5.0, + "multipleOf": 2.5, + }, + "value": { + "type": "string", + "maxLength": 10, + "minLength": 5, + "pattern": "[a-z]*" + }, + "items": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + }, + "maxItems": 5, + "minItems": 1, + }, + "unsigned": { + "type": "integer", + "format": "uint16", + "minimum": 0.0 + }, + "unsigned_value": { + "type": "integer", + "format": "uint32", + "minimum": 2.0, + } + }, + "type": "object", + "required": [ + "id", + "value", + "items", + "unsigned", + "unsigned_value" + ] + }), + config + ); + + #[cfg(not(feature = "non_strict_integers"))] + assert_json_matches!( + value, + json!({ + "properties": { + "id": { + "format": "int32", + "type": "integer", + "maximum": 10.0, + "minimum": 5.0, + "multipleOf": 2.5, + }, + "value": { + "type": "string", + "maxLength": 10, + "minLength": 5, + "pattern": "[a-z]*" + }, + "items": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + }, + "maxItems": 5, + "minItems": 1, + }, + "unsigned": { + "type": "integer", + "format": "int32", + "minimum": 0.0 + }, + "unsigned_value": { + "type": "integer", + "format": "int32", + "minimum": 2.0, + } + }, + "type": "object", + "required": [ + "id", + "value", + "items", + "unsigned", + "unsigned_value" + ] + }), + config + ); +} + +#[test] +#[cfg(feature = "non_strict_integers")] +fn uint_non_strict_integers_format() { + let value = api_doc! { + struct Numbers { + #[schema(format = UInt8)] + ui8: String, + #[schema(format = UInt16)] + ui16: String, + #[schema(format = UInt32)] + ui32: String, + #[schema(format = UInt64)] + ui64: String, + #[schema(format = UInt16)] + i16: String, + #[schema(format = Int8)] + i8: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "ui8": { + "type": "integer", + "format": "uint8" + }, + "ui16": { + "type": "integer", + "format": "uint16" + }, + "ui32": { + "type": "integer", + "format": "uint32" + }, + "ui64": { + "type": "integer", + "format": "uint64" + }, + "i16": { + "type": "integer", + "format": "int16" + }, + "i8": { + "type": "integer", + "format": "int8" + } + } + }) + ) +} + +#[test] +fn derive_schema_with_slice_and_array() { + let value = api_doc! { + struct Item<'a> { + array: [&'a str; 10], + slice: &'a [&'a str], + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "array": { + "type": "array", + "items": { + "type": "string" + } + }, + "slice": { + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "array", + "slice" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_multiple_serde_definitions() { + let value = api_doc! { + #[derive(serde::Deserialize)] + struct Value { + #[serde(default)] + #[serde(rename = "ID")] + id: String + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "ID": { + "type": "string", + } + }, + "type": "object", + }) + ); +} + +#[test] +fn derive_schema_with_custom_field_with_schema() { + fn custom_type() -> Object { + ObjectBuilder::new() + .schema_type(fastapi::openapi::Type::String) + .format(Some(fastapi::openapi::SchemaFormat::Custom( + "email".to_string(), + ))) + .description(Some("this is the description")) + .build() + } + let value = api_doc! { + struct Value { + #[schema(schema_with = custom_type)] + id: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "id": { + "description": "this is the description", + "type": "string", + "format": "email" + } + }, + "required": [ "id" ], + "type": "object" + }) + ) +} + +#[test] +fn derive_unit_type() { + let data = api_doc! { + struct Data { + unit_type: () + } + }; + + assert_json_eq!( + data, + json!({ + "type": "object", + "required": [ "unit_type" ], + "properties": { + "unit_type": { + "default": null, + } + } + }) + ) +} + +#[test] +fn derive_unit_struct_schema() { + let value = api_doc! { + struct UnitValue; + }; + + assert_json_eq!( + value, + json!({ + "default": null, + }) + ) +} + +#[test] +fn derive_schema_with_multiple_schema_attributes() { + let value = api_doc! { + struct UserName { + #[schema(min_length = 5)] + #[schema(max_length = 10)] + name: String, + } + }; + + assert_json_eq!( + value, + json!({ + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 5, + "maxLength": 10, + } + }, + "required": ["name"] + }) + ) +} + +#[test] +fn derive_struct_with_deprecated_fields() { + #[derive(ToSchema)] + struct Foobar; + let account = api_doc! { + struct Account { + #[deprecated] + id: i64, + #[deprecated] + username: String, + #[deprecated] + role_ids: Vec<i32>, + #[deprecated] + foobars: Vec<Foobar>, + #[deprecated] + map: HashMap<String, String> + } + }; + + assert_json_eq!( + account, + json!({ + "properties": { + "id": { + "type": "integer", + "format": "int64", + "deprecated": true + }, + "username": { + "type": "string", + "deprecated": true + }, + "role_ids": { + "type": "array", + "deprecated": true, + "items": { + "type": "integer", + "format": "int32" + } + }, + "foobars": { + "type": "array", + "deprecated": true, + "items": { + "$ref": "#/components/schemas/Foobar" + } + }, + "map": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "deprecated": true, + "type": "object" + } + }, + "required": ["id", "username", "role_ids", "foobars", "map"], + "type": "object" + }) + ) +} + +#[test] +fn derive_struct_with_schema_deprecated_fields() { + #[derive(ToSchema)] + struct Foobar; + let account = api_doc! { + struct AccountA { + #[schema(deprecated)] + id: i64, + #[schema(deprecated)] + username: String, + #[schema(deprecated)] + role_ids: Vec<i32>, + #[schema(deprecated)] + foobars: Vec<Foobar>, + #[schema(deprecated)] + map: HashMap<String, String> + } + }; + + assert_json_eq!( + account, + json!({ + "properties": { + "id": { + "type": "integer", + "format": "int64", + "deprecated": true + }, + "username": { + "type": "string", + "deprecated": true + }, + "role_ids": { + "type": "array", + "deprecated": true, + "items": { + "type": "integer", + "format": "int32" + } + }, + "foobars": { + "type": "array", + "deprecated": true, + "items": { + "$ref": "#/components/schemas/Foobar" + } + }, + "map": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "deprecated": true, + "type": "object" + } + }, + "required": ["id", "username", "role_ids", "foobars", "map"], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_object_type_description() { + let value = api_doc! { + struct Value { + /// This is object value + #[schema(value_type = Object)] + object: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "object": { + "description": "This is object value", + "type": "object" + }, + }, + "required": ["object"], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_explicit_value_type() { + let value = api_doc! { + struct Value { + #[schema(value_type = Value)] + any: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "any": { + }, + }, + "required": ["any"], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_implicit_value_type() { + let value = api_doc! { + struct Value { + any: serde_json::Value, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "any": { + }, + }, + "required": ["any"], + "type": "object" + }) + ) +} + +#[test] +fn derive_tuple_named_struct_field() { + #[derive(ToSchema)] + #[allow(unused)] + struct Person { + name: String, + } + + let value = api_doc! { + struct Post { + info: (String, i64, bool, Person) + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "info": { + "prefixItems": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64", + }, + { + "type": "boolean", + }, + { + "properties": { + "name": { + "type": "string" + } + }, + "required": ["name"], + "type": "object" + } + ], + "items": false, + "type": "array" + } + }, + "type": "object", + "required": ["info"] + }) + ) +} + +#[test] +fn derive_nullable_tuple() { + let value = api_doc! { + struct Post { + /// This is description + #[deprecated] + info: Option<(String, i64)> + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "info": { + "prefixItems": [ + { + "type": "string" + }, + { + "type": "integer", + "format": "int64", + }, + ], + "items": false, + "type": ["array", "null"], + "deprecated": true, + "description": "This is description", + } + }, + "type": "object", + }) + ) +} + +#[test] +fn derive_unit_type_untagged_enum() { + #[derive(Serialize, ToSchema)] + struct AggregationRequest; + + let value = api_doc! { + #[derive(Serialize)] + #[serde(untagged)] + enum ComputeRequest { + Aggregation(AggregationRequest), + Breakdown, + } + }; + + assert_json_eq!( + value, + json!({ + "oneOf": [ + { + "$ref": "#/components/schemas/AggregationRequest" + }, + { + "type": "null", + "default": null, + } + ] + }) + ) +} + +#[test] +fn derive_schema_with_unit_hashmap() { + let value = api_doc! { + struct Container { + volumes: HashMap<String, HashMap<(), ()>> + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "volumes": { + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "propertyNames": { + "default": null, + }, + "additionalProperties": { + "default": null, + }, + "type": "object" + }, + "type": "object" + }, + }, + "required": [ + "volumes" + ], + "type": "object" + }) + ) +} + +#[test] +#[cfg(feature = "rc_schema")] +fn derive_struct_with_arc() { + use std::sync::Arc; + + let greeting = api_doc! { + struct Greeting { + greeting: Arc<String> + } + }; + + assert_json_eq!( + greeting, + json!({ + "properties": { + "greeting": { + "type": "string" + }, + }, + "required": [ + "greeting" + ], + "type": "object" + }) + ) +} + +#[test] +#[cfg(feature = "rc_schema")] +fn derive_struct_with_nested_arc() { + use std::sync::Arc; + + let greeting = api_doc! { + struct Greeting { + #[allow(clippy::redundant_allocation)] + greeting: Arc<Arc<String>> + } + }; + + assert_json_eq!( + greeting, + json!({ + "properties": { + "greeting": { + "type": "string" + }, + }, + "required": [ + "greeting" + ], + "type": "object" + }) + ) +} + +#[test] +#[cfg(feature = "rc_schema")] +fn derive_struct_with_collection_of_arcs() { + use std::sync::Arc; + + let greeting = api_doc! { + struct Greeting { + greeting: Arc<Vec<String>> + } + }; + + assert_json_eq!( + greeting, + json!({ + "properties": { + "greeting": { + "items": { + "type": "string", + }, + "type": "array" + }, + }, + "required": [ + "greeting" + ], + "type": "object" + }) + ) +} + +#[test] +#[cfg(feature = "rc_schema")] +fn derive_struct_with_rc() { + use std::rc::Rc; + + let greeting = api_doc! { + struct Greeting { + greeting: Rc<String> + } + }; + + assert_json_eq!( + greeting, + json!({ + "properties": { + "greeting": { + "type": "string" + }, + }, + "required": [ + "greeting" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_btreeset() { + use std::collections::BTreeSet; + + let greeting = api_doc! { + struct Greeting { + values: BTreeSet<String>, + } + }; + + assert_json_eq!( + greeting, + json!({ + "properties": { + "values": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + }, + "required": [ + "values" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_hashset() { + use std::collections::HashSet; + + let greeting = api_doc! { + struct Greeting { + values: HashSet<String>, + } + }; + + assert_json_eq!( + greeting, + json!({ + "properties": { + "values": { + "type": "array", + "uniqueItems": true, + "items": { + "type": "string" + } + }, + }, + "required": [ + "values" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_doc_hidden() { + let map = api_doc! { + #[doc(hidden)] + struct Map { + map: HashMap<String, String>, + } + }; + + assert_value! { map=> + "properties.map.additionalProperties.type" = r#""string""#, "Additional Property Type" + }; +} + +#[test] +fn derive_schema_with_docstring_on_unit_variant_of_enum() { + let value: Value = api_doc! { + /// top level doc for My enum + #[derive(Serialize)] + enum MyEnum { + /// unit variant doc + UnitVariant, + /// non-unit doc + NonUnitVariant(String), + } + }; + + assert_json_eq!( + value, + json!({ + "description": "top level doc for My enum", + "oneOf": [ + { + "description": "unit variant doc", + "enum": [ + "UnitVariant" + ], + "type": "string" + }, + { + "description": "non-unit doc", + "properties": { + "NonUnitVariant": { + "description": "non-unit doc", + "type": "string" + } + }, + "required": [ + "NonUnitVariant" + ], + "type": "object" + } + ] + }) + ); +} + +#[test] +fn derive_schema_with_docstring_on_tuple_variant_first_element_option() { + let value: Value = api_doc! { + /// top level doc for My enum + enum MyEnum { + /// doc for tuple variant with Option as first element - I now produce a description + TupleVariantWithOptionFirst(Option<String>), + + /// doc for tuple variant without Option as first element - I produce a description + TupleVariantWithNoOption(String), + } + }; + + assert_json_eq!( + value, + json!( + { + "oneOf": [ + { + "type": "object", + "required": [ "TupleVariantWithOptionFirst" ], + "description": "doc for tuple variant with Option as first element - I now produce a description", + "properties": { + "TupleVariantWithOptionFirst": { + "type": ["string", "null"], + "description": "doc for tuple variant with Option as first element - I now produce a description" + } + } + }, + { + "type": "object", + "required": [ "TupleVariantWithNoOption" ], + "description": "doc for tuple variant without Option as first element - I produce a description", + "properties": { + "TupleVariantWithNoOption": { + "type": "string", + "description": "doc for tuple variant without Option as first element - I produce a description" + } + } + } + ], + "description": "top level doc for My enum" + } + ) + ); + + let value: Value = api_doc! { + /// top level doc for My enum + enum MyEnum { + /// doc for tuple variant with Option as first element - I now produce a description + TupleVariantWithOptionFirst(Option<String>, String), + + /// doc for tuple variant without Option as first element - I produce a description + TupleVariantWithOptionSecond(String, Option<String>), + } + }; + + assert_json_eq!( + value, + json!({ + "description": "top level doc for My enum", + "oneOf": [ + { + "description": "doc for tuple variant with Option as first element - I now produce a description", + "properties": { + "TupleVariantWithOptionFirst": { + "description": "doc for tuple variant with Option as first element - I now produce a description", + "items": { + "type": "object" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + } + }, + "required": [ + "TupleVariantWithOptionFirst" + ], + "type": "object" + }, + { + "description": "doc for tuple variant without Option as first element - I produce a description", + "properties": { + "TupleVariantWithOptionSecond": { + "description": "doc for tuple variant without Option as first element - I produce a description", + "items": { + "type": "object" + }, + "maxItems": 2, + "minItems": 2, + "type": "array" + } + }, + "required": [ + "TupleVariantWithOptionSecond" + ], + "type": "object" + } + ] + }) + ); +} + +#[test] +fn derive_struct_with_description_override() { + let value = api_doc! { + /// Normal description + #[schema( + description = "This is overridden description" + )] + struct SchemaDescOverride { + field1: &'static str + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "field1": { + "type": "string" + } + }, + "required": ["field1"], + "description": "This is overridden description", + "type": "object" + }) + ) +} + +#[test] +fn derive_unnamed_struct_with_description_override() { + let value = api_doc! { + /// Normal description + #[schema( + description = include_str!("./testdata/description_override") + )] + struct SchemaDescOverride(&'static str); + }; + + assert_json_eq!( + value, + json!({ + "description": "This is description from include_str!\n", + "type": "string" + }) + ) +} + +#[test] +fn derive_simple_enum_description_override() { + let value = api_doc! { + /// Normal description + #[schema( + description = include_str!("./testdata/description_override") + )] + enum SimpleEnum { + Value1 + } + }; + + assert_json_eq!( + value, + json!({ + "description": "This is description from include_str!\n", + "type": "string", + "enum": [ "Value1" ] + }) + ) +} + +#[test] +fn derive_mixed_enum_description_override() { + #[allow(unused)] + #[derive(ToSchema)] + struct User { + name: &'static str, + } + let value = api_doc! { + /// Normal description + #[schema( + description = include_str!("./testdata/description_override") + )] + enum UserEnumComplex { + Value1, + User(User) + } + }; + + assert_json_eq!( + value, + json!({ + "description": "This is description from include_str!\n", + "oneOf": [ + { + "type": "string", + "enum": [ "Value1" ] + }, + { + "type": "object", + "properties": { + "User": { + "$ref": "#/components/schemas/User" + } + }, + "required": [ "User" ] + } + ] + }) + ) +} + +#[test] +fn content_encoding_named_field() { + let item = api_doc! { + struct PersonRequest { + #[schema(content_encoding = "bas64", value_type = String)] + picture: Vec<u8> + } + }; + + assert_json_eq!( + item, + json!({ + "properties": { + "picture": { + "type": "string", + "contentEncoding": "bas64" + } + }, + "required": [ + "picture" + ], + "type": "object" + }) + ) +} + +#[test] +fn content_media_type_named_field() { + let item = api_doc! { + struct PersonRequest { + #[schema(content_media_type = "application/octet-stream", value_type = String)] + doc: Vec<u8> + } + }; + + assert_json_eq!( + item, + json!({ + "properties": { + "doc": { + "type": "string", + "contentMediaType": "application/octet-stream" + } + }, + "required": [ + "doc" + ], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_required_custom_type_required() { + #[allow(unused)] + struct Param<T>(T); + + let value = api_doc! { + #[allow(unused)] + struct Params { + /// Maximum number of results to return. + #[schema(required = false, value_type = u32, example = 12)] + limit: Param<u32>, + /// Maximum number of results to return. + #[schema(required = true, value_type = u32, example = 12)] + limit_explisit_required: Param<u32>, + /// Maximum number of results to return. + #[schema(value_type = Option<u32>, example = 12)] + not_required: Param<u32>, + /// Maximum number of results to return. + #[schema(required = true, value_type = Option<u32>, example = 12)] + option_required: Param<u32>, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "limit": { + "description": "Maximum number of results to return.", + "example": 12, + "format": "int32", + "minimum": 0, + "type": "integer" + }, + "limit_explisit_required": { + "description": "Maximum number of results to return.", + "example": 12, + "format": "int32", + "minimum": 0, + "type": "integer" + }, + "not_required": { + "description": "Maximum number of results to return.", + "example": 12, + "format": "int32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + }, + "option_required": { + "description": "Maximum number of results to return.", + "example": 12, + "format": "int32", + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object", + "required": [ + "limit_explisit_required", + "option_required" + ] + }) + ); +} + +#[test] +fn derive_negative_numbers() { + let value = api_doc! { + #[schema(default)] + #[derive(Default)] + struct Negative { + #[schema(default = -1, minimum = -2.1)] + number: f64, + #[schema(default = -2, maximum = -3)] + solid_number: i64, + } + }; + + assert_json_eq! { + value, + json!({ + "properties": { + "number": { + "type": "number", + "format": "double", + "default": -1, + "minimum": -2.1 + }, + "solid_number": { + "format": "int64", + "type": "integer", + "default": -2, + "maximum": -3, + } + }, + "required": [ "number", "solid_number" ], + "type": "object" + }) + } +} + +#[test] +fn derive_map_with_property_names() { + #![allow(unused)] + + #[derive(ToSchema)] + enum Names { + Foo, + Bar, + } + + let value = api_doc! { + struct Mapped(std::collections::BTreeMap<Names, String>); + }; + + assert_json_eq!( + value, + json!({ + "propertyNames": { + "type": "string", + "enum": ["Foo", "Bar"] + }, + "additionalProperties": { + "type": "string" + }, + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_ignored_field() { + #![allow(unused)] + + let value = api_doc! { + struct SchemaIgnoredField { + value: String, + #[schema(ignore)] + __this_is_private: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "value": { + "type": "string" + } + }, + "required": [ "value" ], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_ignore_eq_false_field() { + #![allow(unused)] + let value = api_doc! { + struct SchemaIgnoredField { + value: String, + #[schema(ignore = false)] + this_is_not_private: String, + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "value": { + "type": "string" + }, + "this_is_not_private": { + "type": "string" + } + }, + "required": [ "value", "this_is_not_private" ], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_with_ignore_eq_call_field() { + #![allow(unused)] + + let value = api_doc! { + struct SchemaIgnoredField { + value: String, + #[schema(ignore = Self::ignore)] + this_is_not_private: String, + } + + impl SchemaIgnoredField { + fn ignore() -> bool { + false + } + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "value": { + "type": "string" + }, + "this_is_not_private": { + "type": "string" + } + }, + "required": [ "value", "this_is_not_private" ], + "type": "object" + }) + ) +} + +#[test] +fn derive_schema_unnamed_title() { + #![allow(unused)] + + let value = api_doc! { + #[schema(title = "This is vec title")] + struct SchemaIgnoredField (Vec<String>); + }; + + assert_json_eq!( + value, + json!({ + "title": "This is vec title", + "items": { + "type": "string" + }, + "type": "array" + }) + ); + + #[derive(ToSchema)] + enum UnnamedEnum { + One, + Two, + } + + let enum_value = api_doc! { + #[schema(title = "This is enum ref title")] + struct SchemaIgnoredField (UnnamedEnum); + }; + + assert_json_eq!( + enum_value, + json!({ + "title": "This is enum ref title", + "oneOf": [ + { + "$ref": "#/components/schemas/UnnamedEnum" + } + ], + }) + ) +} + +#[test] +fn derive_struct_inline_with_description() { + #[derive(fastapi::ToSchema)] + #[allow(unused)] + struct Foo { + name: &'static str, + } + + let value = api_doc! { + struct FooInlined { + /// This is description + #[schema(inline)] + with_description: Foo, + + #[schema(inline)] + no_description_inline: Foo, + } + }; + + assert_json_eq!( + &value, + json!({ + "properties": { + "no_description_inline": { + "properties": { + "name": { + "type": "string" + }, + }, + "required": [ + "name" + ], + "type": "object" + }, + "with_description": { + "description": "This is description", + "oneOf": [ + { + "properties": { + "name": { + "type": "string" + }, + }, + "required": [ + "name" + ], + "type": "object" + } + ] + }, + }, + "required": [ + "with_description", + "no_description_inline", + ], + "type": "object" + }) + ); +} + +#[test] +fn schema_manual_impl() { + #![allow(unused)] + + struct Newtype(String); + + impl ToSchema for Newtype { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed("Newtype") + } + } + + impl fastapi::PartialSchema for Newtype { + fn schema() -> fastapi::openapi::RefOr<fastapi::openapi::schema::Schema> { + String::schema() + } + } + + let value = api_doc! { + struct Dto { + customer: Newtype + } + }; + + assert_json_eq!( + value, + json!({ + "properties": { + "customer": { + "$ref": "#/components/schemas/Newtype" + } + }, + "required": ["customer"], + "type": "object" + }) + ) +} + +#[test] +fn const_generic_test() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct ArrayResponse<T: ToSchema, const N: usize> { + array: [T; N], + } + + #[derive(ToSchema)] + struct CombinedResponse<T: ToSchema, const N: usize> { + pub array_response: ArrayResponse<T, N>, + } + + use fastapi::PartialSchema; + let schema = <CombinedResponse<String, 1> as PartialSchema>::schema(); + let value = serde_json::to_value(schema).expect("schema is JSON serializable"); + + assert_json_eq! { + value, + json!({ + "properties": { + "array_response": { + "$ref": "#/components/schemas/ArrayResponse_String" + } + }, + "required": ["array_response"], + "type": "object" + }) + } +} + +#[test] +fn unit_struct_schema() { + #![allow(unused)] + + /// This is description + #[derive(ToSchema)] + #[schema(title = "Title")] + struct UnitType; + + use fastapi::PartialSchema; + let schema = <UnitType as PartialSchema>::schema(); + let value = serde_json::to_value(schema).expect("schema is JSON serializable"); + + assert_json_eq! { + value, + json!({ + "description": "This is description", + "title": "Title", + "default": null, + }) + } +} + +#[test] +fn test_recursion_compiles() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct Instance { + #[schema(no_recursion)] + kind: Kind, + } + + #[derive(ToSchema)] + pub enum Kind { + MultipleNested(Vec<Instance>), + } + + #[derive(ToSchema)] + pub struct Error { + instance: Instance, + } + + #[derive(ToSchema)] + pub enum Recursion { + Named { + #[schema(no_recursion)] + foobar: Box<Recur>, + }, + #[schema(no_recursion)] + Unnamed(Box<Recur>), + NoValue, + } + + #[derive(ToSchema)] + pub struct Recur { + unname: UnnamedError, + e: Recursion, + } + + #[derive(ToSchema)] + #[schema(no_recursion)] + pub struct UnnamedError(Kind); + + #[derive(OpenApi)] + #[openapi(components(schemas(Error, Recur)))] + pub struct ApiDoc {} + + let json = ApiDoc::openapi() + .to_pretty_json() + .expect("OpenApi is JSON serializable"); + println!("{json}") +} + +#[test] +fn test_named_and_enum_container_recursion_compiles() { + #![allow(unused)] + + #[derive(ToSchema)] + #[schema(no_recursion)] + pub struct Tree { + left: Box<Tree>, + right: Box<Tree>, + map: HashMap<String, Tree>, + } + + #[derive(ToSchema)] + #[schema(no_recursion)] + pub enum TreeRecursion { + Named { left: Box<TreeRecursion> }, + Unnamed(Box<TreeRecursion>), + NoValue, + } + + #[derive(ToSchema)] + pub enum Recursion { + #[schema(no_recursion)] + Named { + left: Box<Recursion>, + right: Box<Recursion>, + }, + #[schema(no_recursion)] + Unnamed(HashMap<String, Recursion>), + NoValue, + } + + #[derive(OpenApi)] + #[openapi(components(schemas(Recursion, Tree, TreeRecursion)))] + pub struct ApiDoc {} + + let json = ApiDoc::openapi() + .to_pretty_json() + .expect("OpenApi is JSON serializable"); + println!("{json}") +} diff --git a/fastapi-gen/tests/schema_generics.rs b/fastapi-gen/tests/schema_generics.rs new file mode 100644 index 0000000..1d3ab87 --- /dev/null +++ b/fastapi-gen/tests/schema_generics.rs @@ -0,0 +1,506 @@ +use std::borrow::Cow; +use std::marker::PhantomData; + +use assert_json_diff::assert_json_eq; +use fastapi::openapi::{Info, RefOr, Schema}; +use fastapi::{schema, OpenApi, PartialSchema, ToSchema}; +use serde::Serialize; +use serde_json::json; + +#[test] +fn generic_schema_custom_bound() { + #![allow(unused)] + + #[derive(Serialize, ToSchema)] + #[schema(bound = "T: Clone + Sized, T: Sized")] + struct Type<T> { + #[serde(skip)] + t: PhantomData<T>, + } + + #[derive(Clone)] + struct NoToSchema; + fn assert_is_to_schema<T: ToSchema>() {} + + assert_is_to_schema::<Type<NoToSchema>>(); +} + +#[test] +fn generic_request_body_schema() { + #![allow(unused)] + + #[derive(ToSchema)] + #[schema(as = path::MyType<T>)] + struct Type<T> { + #[schema(inline)] + t: T, + } + + #[derive(ToSchema)] + struct Person<T: Sized, P> { + field: T, + #[schema(inline)] + t: P, + } + + #[fastapi::path( + get, + path = "/handler", + request_body = inline(Person<String, Type<i32>>), + )] + async fn handler() {} + + #[derive(OpenApi)] + #[openapi( + components( + schemas( + Person::<String, Type<i32>>, + ) + ), + paths( + handler + ) + )] + struct ApiDoc; + + let mut doc = ApiDoc::openapi(); + doc.info = Info::new("title", "version"); + + let actual = serde_json::to_value(&doc).expect("operation is JSON serializable"); + let json = serde_json::to_string_pretty(&actual).unwrap(); + + println!("{json}"); + + assert_json_eq!( + actual, + json!({ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": { + "/handler": { + "get": { + "tags": [], + "operationId": "handler", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "field", + "t" + ], + "properties": { + "field": { + "type": "string" + }, + "t": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, + "required": true + }, + "responses": {} + } + } + }, + "components": { + "schemas": { + "Person_String_path.MyType_i32": { + "type": "object", + "required": [ + "field", + "t" + ], + "properties": { + "field": { + "type": "string" + }, + "t": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + } + }) + ); +} + +#[test] +fn generic_schema_full_api() { + #![allow(unused)] + + #[derive(ToSchema)] + #[schema(as = path::MyType<T>)] + struct Type<T> { + t: T, + } + + #[derive(ToSchema)] + struct Person<'p, T: Sized, P> { + id: usize, + name: Option<Cow<'p, str>>, + field: T, + t: P, + } + + #[derive(ToSchema)] + #[schema(as = path::to::PageList)] + struct Page<T> { + total: usize, + page: usize, + pages: usize, + items: Vec<T>, + } + + #[derive(ToSchema)] + #[schema(as = path::to::Element<T>)] + enum E<T> { + One(T), + Many(Vec<T>), + } + + struct NoToSchema; + fn assert_no_need_to_schema_outside_api(_: Type<NoToSchema>) {} + + #[fastapi::path( + get, + path = "/handler", + request_body = inline(Person<'_, String, Type<i32>>), + responses( + (status = OK, body = inline(Page<Person<'_, String, Type<i32>>>)), + (status = 400, body = Page<Person<'_, String, Type<i32>>>) + ) + )] + async fn handler() {} + + #[derive(OpenApi)] + #[openapi( + components( + schemas( + Person::<'_, String, Type<i32>>, + Page::<Person<'_, String, Type<i32>>>, + E::<String>, + ) + ), + paths( + handler + ) + )] + struct ApiDoc; + + let mut doc = ApiDoc::openapi(); + doc.info = Info::new("title", "version"); + + let actual = doc.to_pretty_json().expect("OpenApi is JSON serializable"); + println!("{actual}"); + let expected = include_str!("./testdata/schema_generics_openapi"); + + assert_eq!(expected.trim(), actual.trim()); +} + +#[test] +fn schema_with_non_generic_root() { + #![allow(unused)] + + #[derive(ToSchema)] + struct Foo<T> { + bar: Bar<T>, + } + + #[derive(ToSchema)] + struct Bar<T> { + #[schema(inline)] + value: T, + } + + #[derive(ToSchema)] + struct Top { + foo1: Foo<String>, + foo2: Foo<i32>, + } + + #[derive(OpenApi)] + #[openapi(components(schemas(Top)))] + struct ApiDoc; + let mut api = ApiDoc::openapi(); + api.info = Info::new("title", "version"); + + let actual = api.to_pretty_json().expect("schema is JSON serializable"); + println!("{actual}"); + let expected = include_str!("./testdata/schema_non_generic_root_generic_references"); + + assert_eq!(actual.trim(), expected.trim()) +} + +#[test] +fn derive_generic_schema_enum_variants() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct FooStruct<B> { + pub foo: B, + } + + #[derive(ToSchema)] + enum FoosEnum { + ThingNoAliasOption(FooStruct<Option<i32>>), + FooEnumThing(#[schema(inline)] FooStruct<Vec<i32>>), + FooThingOptionVec(#[schema(inline)] FooStruct<Option<Vec<i32>>>), + FooThingLinkedList(#[schema(inline)] FooStruct<std::collections::LinkedList<i32>>), + FooThingBTreeMap(#[schema(inline)] FooStruct<std::collections::BTreeMap<String, String>>), + FooThingHashMap(#[schema(inline)] FooStruct<std::collections::HashMap<i32, String>>), + FooThingHashSet(#[schema(inline)] FooStruct<std::collections::HashSet<i32>>), + FooThingBTreeSet(#[schema(inline)] FooStruct<std::collections::BTreeSet<i32>>), + } + + let schema = FoosEnum::schema(); + let json = serde_json::to_string_pretty(&schema).expect("Schema is JSON serializable"); + let value = json.trim(); + + #[derive(OpenApi)] + #[openapi(components(schemas(FoosEnum)))] + struct Api; + + let mut api = Api::openapi(); + api.info = Info::new("title", "version"); + let api_json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + println!("{api_json}"); + let expected = include_str!("./testdata/schema_generic_enum_variant_with_generic_type"); + assert_eq!(expected.trim(), api_json.trim()); +} + +#[test] +fn derive_generic_schema_collect_recursive_schema_not_inlined() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct FooStruct<B> { + pub foo: B, + } + + #[derive(ToSchema)] + pub struct Value(String); + + #[derive(ToSchema)] + pub struct Person<T> { + name: String, + account: Account, + t: T, + } + + #[derive(ToSchema)] + pub struct Account { + name: String, + } + + #[derive(ToSchema, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct Ty<T> { + t: T, + } + + #[derive(ToSchema, PartialEq, Eq, PartialOrd, Ord, Hash)] + enum Ky { + One, + Two, + } + + #[derive(ToSchema)] + enum FoosEnum { + LinkedList(std::collections::LinkedList<Person<Value>>), + BTreeMap(FooStruct<std::collections::BTreeMap<String, Person<Value>>>), + HashMap(FooStruct<std::collections::HashMap<i32, Person<i64>>>), + HashSet(FooStruct<std::collections::HashSet<i32>>), + Btre(FooStruct<std::collections::BTreeMap<Ty<Ky>, Person<Value>>>), + } + let schema = FoosEnum::schema(); + let json = serde_json::to_string_pretty(&schema).expect("Schema is JSON serializable"); + let value = json.trim(); + + #[derive(OpenApi)] + #[openapi(components(schemas(FoosEnum)))] + struct Api; + + let mut api = Api::openapi(); + api.info = Info::new("title", "version"); + let api_json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + println!("{api_json}"); + let expected = include_str!("./testdata/schema_generic_collect_non_inlined_schema"); + assert_eq!(expected.trim(), api_json.trim()); +} + +#[test] +fn high_order_types() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct High<T> { + #[schema(inline)] + high: T, + } + + #[derive(ToSchema)] + pub struct HighBox { + value: High<Box<i32>>, + } + + #[derive(ToSchema)] + pub struct HighCow(High<Cow<'static, i32>>); + + #[derive(ToSchema)] + pub struct HighRefCell(High<std::cell::RefCell<i32>>); + + #[derive(OpenApi)] + #[openapi(components(schemas(HighBox, HighCow, HighRefCell)))] + struct Api; + + let mut api = Api::openapi(); + api.info = Info::new("title", "version"); + let api_json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + println!("{api_json}"); + let expected = include_str!("./testdata/schema_high_order_types"); + assert_eq!(expected.trim(), api_json.trim()); +} + +#[test] +#[cfg(feature = "rc_schema")] +fn rc_schema_high_order_types() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct High<T> { + high: T, + } + + #[derive(ToSchema)] + pub struct HighArc(High<std::sync::Arc<i32>>); + + #[derive(ToSchema)] + pub struct HighRc(High<std::rc::Rc<i32>>); + + #[derive(OpenApi)] + #[openapi(components(schemas(HighArc, HighRc)))] + struct Api; + + let mut api = Api::openapi(); + api.info = Info::new("title", "version"); + let api_json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + println!("{api_json}"); + + let expected = include_str!("./testdata/rc_schema_high_order_types"); + assert_eq!(expected.trim(), api_json.trim()); +} + +#[test] +#[cfg(feature = "uuid")] +fn uuid_type_generic_argument() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct High<T> { + high: T, + } + + #[derive(ToSchema)] + pub struct HighUuid(High<Option<uuid::Uuid>>); + + #[derive(OpenApi)] + #[openapi(components(schemas(HighUuid)))] + struct Api; + + let mut api = Api::openapi(); + api.info = Info::new("title", "version"); + let api_json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + println!("{api_json}"); + + let expected = include_str!("./testdata/uuid_type_generic_argument"); + assert_eq!(expected.trim(), api_json.trim()); +} + +#[test] +#[ignore = "arrays, slices, tuples as generic argument is not supported at the moment"] +fn slice_generic_args() { + #![allow(unused)] + + #[derive(ToSchema)] + pub struct High<T> { + high: T, + } + + // // #[derive(ToSchema)] + // pub struct HighSlice(High<&'static [i32]>); + // + // #[derive(OpenApi)] + // // #[openapi(components(schemas(HighSlice)))] + // struct Api; + // + // let mut api = Api::openapi(); + // api.info = Info::new("title", "version"); + // let api_json = api.to_pretty_json().expect("OpenAPI is JSON serializable"); + // println!("{api_json}"); + // + // let expected = include_str!("./testdata/rc_schema_high_order_types"); + // assert_eq!(expected.trim(), api_json.trim()); +} + +#[test] +#[ignore = "For debugging only"] +fn schema_macro_run() { + #![allow(unused)] + + #[derive(ToSchema)] + #[schema(as = path::MyType<T>)] + struct Type<T> { + t: T, + } + + #[derive(ToSchema)] + struct Person<'p, T: Sized, P> { + id: usize, + name: Option<Cow<'p, str>>, + field: T, + t: P, + } + + #[derive(ToSchema)] + #[schema(as = path::to::PageList)] + struct Page<T> { + total: usize, + page: usize, + pages: usize, + items: Vec<T>, + } + + let schema: RefOr<Schema> = schema!(Page<Person<'_, String, Type<i32>>>).into(); + // let schema: RefOr<Schema> = schema!(Person<'_, String, Type<i32>>).into(); + // let schema: RefOr<Schema> = schema!(Vec<Person<'_, String, Type<i32>>>).into(); + println!( + "{}", + serde_json::to_string_pretty(&schema).expect("schema is JSON serializable") + ); +} diff --git a/fastapi-gen/tests/testdata/description_override b/fastapi-gen/tests/testdata/description_override new file mode 100644 index 0000000..2e263bf --- /dev/null +++ b/fastapi-gen/tests/testdata/description_override @@ -0,0 +1 @@ +This is description from include_str! diff --git a/fastapi-gen/tests/testdata/openapi-derive-info-description b/fastapi-gen/tests/testdata/openapi-derive-info-description new file mode 100644 index 0000000..ee70662 --- /dev/null +++ b/fastapi-gen/tests/testdata/openapi-derive-info-description @@ -0,0 +1 @@ +this is include description diff --git a/fastapi-gen/tests/testdata/openapi_schemas_resolve_inner_schema_references b/fastapi-gen/tests/testdata/openapi_schemas_resolve_inner_schema_references new file mode 100644 index 0000000..9e0cd74 --- /dev/null +++ b/fastapi-gen/tests/testdata/openapi_schemas_resolve_inner_schema_references @@ -0,0 +1,293 @@ +{ + "schemas": { + "Account": { + "properties": { + "id": { + "format": "int32", + "type": "integer" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "Boo": { + "properties": { + "boo": { + "type": "boolean" + } + }, + "required": [ + "boo" + ], + "type": "object" + }, + "Element_String": { + "oneOf": [ + { + "properties": { + "One": { + "type": "string" + } + }, + "required": [ + "One" + ], + "type": "object" + }, + { + "properties": { + "Many": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "Many" + ], + "type": "object" + } + ] + }, + "Element_Yeah": { + "oneOf": [ + { + "properties": { + "One": { + "properties": { + "accounts": { + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Account" + } + ] + }, + "type": "array" + }, + "foo_bar": { + "$ref": "#/components/schemas/Foobar" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "foo_bar", + "accounts" + ], + "type": "object" + } + }, + "required": [ + "One" + ], + "type": "object" + }, + { + "properties": { + "Many": { + "items": { + "properties": { + "accounts": { + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Account" + } + ] + }, + "type": "array" + }, + "foo_bar": { + "$ref": "#/components/schemas/Foobar" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "foo_bar", + "accounts" + ], + "type": "object" + }, + "type": "array" + } + }, + "required": [ + "Many" + ], + "type": "object" + } + ] + }, + "EnumMixedContent": { + "oneOf": [ + { + "enum": [ + "ContentZero" + ], + "type": "string" + }, + { + "properties": { + "One": { + "$ref": "#/components/schemas/Foobar" + } + }, + "required": [ + "One" + ], + "type": "object" + }, + { + "properties": { + "NamedSchema": { + "properties": { + "f": { + "type": "boolean" + }, + "foo": { + "$ref": "#/components/schemas/ThisIsNone" + }, + "int": { + "format": "int32", + "type": "integer" + }, + "value": { + "$ref": "#/components/schemas/Account" + }, + "value2": { + "$ref": "#/components/schemas/Boo" + } + }, + "required": [ + "value", + "value2", + "foo", + "int", + "f" + ], + "type": "object" + } + }, + "required": [ + "NamedSchema" + ], + "type": "object" + }, + { + "properties": { + "Many": { + "items": { + "$ref": "#/components/schemas/Person" + }, + "type": "array" + } + }, + "required": [ + "Many" + ], + "type": "object" + } + ] + }, + "Foob": { + "properties": { + "item": { + "$ref": "#/components/schemas/Element_String" + }, + "item2": { + "$ref": "#/components/schemas/Element_Yeah" + } + }, + "required": [ + "item", + "item2" + ], + "type": "object" + }, + "Foobar": { + "default": null + }, + "OneOfOne": { + "$ref": "#/components/schemas/Person" + }, + "OneOfYeah": { + "$ref": "#/components/schemas/Yeah" + }, + "Person": { + "properties": { + "accounts": { + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Account" + } + ] + }, + "type": "array" + }, + "foo_bar": { + "$ref": "#/components/schemas/Foobar" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "foo_bar", + "accounts" + ], + "type": "object" + }, + "ThisIsNone": { + "default": null + }, + "Yeah": { + "properties": { + "accounts": { + "items": { + "oneOf": [ + { + "type": "null" + }, + { + "$ref": "#/components/schemas/Account" + } + ] + }, + "type": "array" + }, + "foo_bar": { + "$ref": "#/components/schemas/Foobar" + }, + "name": { + "type": "string" + } + }, + "required": [ + "name", + "foo_bar", + "accounts" + ], + "type": "object" + } + } +} diff --git a/fastapi-gen/tests/testdata/rc_schema_high_order_types b/fastapi-gen/tests/testdata/rc_schema_high_order_types new file mode 100644 index 0000000..fec4ce5 --- /dev/null +++ b/fastapi-gen/tests/testdata/rc_schema_high_order_types @@ -0,0 +1,42 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": {}, + "components": { + "schemas": { + "HighArc": { + "$ref": "#/components/schemas/High_Arc_i32" + }, + "HighRc": { + "$ref": "#/components/schemas/High_Rc_i32" + }, + "High_Arc_i32": { + "type": "object", + "required": [ + "high" + ], + "properties": { + "high": { + "type": "integer", + "format": "int32" + } + } + }, + "High_Rc_i32": { + "type": "object", + "required": [ + "high" + ], + "properties": { + "high": { + "type": "integer", + "format": "int32" + } + } + } + } + } +} diff --git a/fastapi-gen/tests/testdata/schema_generic_collect_non_inlined_schema b/fastapi-gen/tests/testdata/schema_generic_collect_non_inlined_schema new file mode 100644 index 0000000..d91775c --- /dev/null +++ b/fastapi-gen/tests/testdata/schema_generic_collect_non_inlined_schema @@ -0,0 +1,233 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": {}, + "components": { + "schemas": { + "Account": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "name": { + "type": "string" + } + } + }, + "FooStruct_BTreeMap_String_Person_Value": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "name", + "account", + "t" + ], + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, + "name": { + "type": "string" + }, + "t": { + "type": "string" + } + } + }, + "propertyNames": { + "type": "string" + } + } + } + }, + "FooStruct_BTreeMap_Ty_Ky_Person_Value": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "name", + "account", + "t" + ], + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, + "name": { + "type": "string" + }, + "t": { + "type": "string" + } + } + }, + "propertyNames": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "type": "string", + "enum": [ + "One", + "Two" + ] + } + } + } + } + } + }, + "FooStruct_HashMap_i32_Person_i64": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "name", + "account", + "t" + ], + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, + "name": { + "type": "string" + }, + "t": { + "type": "integer", + "format": "int64" + } + } + }, + "propertyNames": { + "type": "integer", + "format": "int32" + } + } + } + }, + "FooStruct_HashSet_i32": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "uniqueItems": true + } + } + }, + "FoosEnum": { + "oneOf": [ + { + "type": "object", + "required": [ + "LinkedList" + ], + "properties": { + "LinkedList": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Person_Value" + } + } + } + }, + { + "type": "object", + "required": [ + "BTreeMap" + ], + "properties": { + "BTreeMap": { + "$ref": "#/components/schemas/FooStruct_BTreeMap_String_Person_Value" + } + } + }, + { + "type": "object", + "required": [ + "HashMap" + ], + "properties": { + "HashMap": { + "$ref": "#/components/schemas/FooStruct_HashMap_i32_Person_i64" + } + } + }, + { + "type": "object", + "required": [ + "HashSet" + ], + "properties": { + "HashSet": { + "$ref": "#/components/schemas/FooStruct_HashSet_i32" + } + } + }, + { + "type": "object", + "required": [ + "Btre" + ], + "properties": { + "Btre": { + "$ref": "#/components/schemas/FooStruct_BTreeMap_Ty_Ky_Person_Value" + } + } + } + ] + }, + "Person_Value": { + "type": "object", + "required": [ + "name", + "account", + "t" + ], + "properties": { + "account": { + "$ref": "#/components/schemas/Account" + }, + "name": { + "type": "string" + }, + "t": { + "type": "string" + } + } + } + } + } +} diff --git a/fastapi-gen/tests/testdata/schema_generic_enum_variant_with_generic_type b/fastapi-gen/tests/testdata/schema_generic_enum_variant_with_generic_type new file mode 100644 index 0000000..83fb8be --- /dev/null +++ b/fastapi-gen/tests/testdata/schema_generic_enum_variant_with_generic_type @@ -0,0 +1,221 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": {}, + "components": { + "schemas": { + "FooStruct_Option_i32": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "integer", + "format": "int32" + } + ] + } + } + }, + "FoosEnum": { + "oneOf": [ + { + "type": "object", + "required": [ + "ThingNoAliasOption" + ], + "properties": { + "ThingNoAliasOption": { + "$ref": "#/components/schemas/FooStruct_Option_i32" + } + } + }, + { + "type": "object", + "required": [ + "FooEnumThing" + ], + "properties": { + "FooEnumThing": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "FooThingOptionVec" + ], + "properties": { + "FooThingOptionVec": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + ] + } + } + } + } + }, + { + "type": "object", + "required": [ + "FooThingLinkedList" + ], + "properties": { + "FooThingLinkedList": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "FooThingBTreeMap" + ], + "properties": { + "FooThingBTreeMap": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "string" + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "FooThingHashMap" + ], + "properties": { + "FooThingHashMap": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "propertyNames": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + { + "type": "object", + "required": [ + "FooThingHashSet" + ], + "properties": { + "FooThingHashSet": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "uniqueItems": true + } + } + } + } + }, + { + "type": "object", + "required": [ + "FooThingBTreeSet" + ], + "properties": { + "FooThingBTreeSet": { + "type": "object", + "required": [ + "foo" + ], + "properties": { + "foo": { + "type": "array", + "items": { + "type": "integer", + "format": "int32" + }, + "uniqueItems": true + } + } + } + } + } + ] + } + } + } +} diff --git a/fastapi-gen/tests/testdata/schema_generics_openapi b/fastapi-gen/tests/testdata/schema_generics_openapi new file mode 100644 index 0000000..f69eda2 --- /dev/null +++ b/fastapi-gen/tests/testdata/schema_generics_openapi @@ -0,0 +1,266 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": { + "/handler": { + "get": { + "tags": [], + "operationId": "handler", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "id", + "field", + "t" + ], + "properties": { + "field": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "t": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "type": "integer", + "format": "int32" + } + } + } + } + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "total", + "page", + "pages", + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "field", + "t" + ], + "properties": { + "field": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "t": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + } + } + } + }, + "400": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/path.to.PageList_Person_String_path.MyType_i32" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Person_String_path.MyType_i32": { + "type": "object", + "required": [ + "id", + "field", + "t" + ], + "properties": { + "field": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "t": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "type": "integer", + "format": "int32" + } + } + } + } + }, + "path.to.Element_String": { + "oneOf": [ + { + "type": "object", + "required": [ + "One" + ], + "properties": { + "One": { + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "Many" + ], + "properties": { + "Many": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + }, + "path.to.PageList_Person_String_path.MyType_i32": { + "type": "object", + "required": [ + "total", + "page", + "pages", + "items" + ], + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "required": [ + "id", + "field", + "t" + ], + "properties": { + "field": { + "type": "string" + }, + "id": { + "type": "integer", + "minimum": 0 + }, + "name": { + "type": [ + "string", + "null" + ] + }, + "t": { + "type": "object", + "required": [ + "t" + ], + "properties": { + "t": { + "type": "integer", + "format": "int32" + } + } + } + } + } + }, + "page": { + "type": "integer", + "minimum": 0 + }, + "pages": { + "type": "integer", + "minimum": 0 + }, + "total": { + "type": "integer", + "minimum": 0 + } + } + } + } + } +} diff --git a/fastapi-gen/tests/testdata/schema_high_order_types b/fastapi-gen/tests/testdata/schema_high_order_types new file mode 100644 index 0000000..35c0f83 --- /dev/null +++ b/fastapi-gen/tests/testdata/schema_high_order_types @@ -0,0 +1,65 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": {}, + "components": { + "schemas": { + "HighBox": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "$ref": "#/components/schemas/High_Box_i32" + } + } + }, + "HighCow": { + "$ref": "#/components/schemas/High_Cow_i32" + }, + "HighRefCell": { + "$ref": "#/components/schemas/High_RefCell_i32" + }, + "High_Box_i32": { + "type": "object", + "required": [ + "high" + ], + "properties": { + "high": { + "type": "integer", + "format": "int32" + } + } + }, + "High_Cow_i32": { + "type": "object", + "required": [ + "high" + ], + "properties": { + "high": { + "type": "integer", + "format": "int32" + } + } + }, + "High_RefCell_i32": { + "type": "object", + "required": [ + "high" + ], + "properties": { + "high": { + "type": "integer", + "format": "int32" + } + } + } + } + } +} diff --git a/fastapi-gen/tests/testdata/schema_non_generic_root_generic_references b/fastapi-gen/tests/testdata/schema_non_generic_root_generic_references new file mode 100644 index 0000000..f41b53a --- /dev/null +++ b/fastapi-gen/tests/testdata/schema_non_generic_root_generic_references @@ -0,0 +1,72 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": {}, + "components": { + "schemas": { + "Bar_String": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "string" + } + } + }, + "Bar_i32": { + "type": "object", + "required": [ + "value" + ], + "properties": { + "value": { + "type": "integer", + "format": "int32" + } + } + }, + "Foo_String": { + "type": "object", + "required": [ + "bar" + ], + "properties": { + "bar": { + "$ref": "#/components/schemas/Bar_String" + } + } + }, + "Foo_i32": { + "type": "object", + "required": [ + "bar" + ], + "properties": { + "bar": { + "$ref": "#/components/schemas/Bar_i32" + } + } + }, + "Top": { + "type": "object", + "required": [ + "foo1", + "foo2" + ], + "properties": { + "foo1": { + "$ref": "#/components/schemas/Foo_String" + }, + "foo2": { + "$ref": "#/components/schemas/Foo_i32" + } + } + } + } + } +} diff --git a/fastapi-gen/tests/testdata/uuid_type_generic_argument b/fastapi-gen/tests/testdata/uuid_type_generic_argument new file mode 100644 index 0000000..cc03373 --- /dev/null +++ b/fastapi-gen/tests/testdata/uuid_type_generic_argument @@ -0,0 +1,33 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "version" + }, + "paths": {}, + "components": { + "schemas": { + "HighUuid": { + "$ref": "#/components/schemas/High_Option_String" + }, + "High_Option_String": { + "type": "object", + "required": [ + "high" + ], + "properties": { + "high": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "string" + } + ] + } + } + } + } + } +} diff --git a/fastapi-rapidoc/Cargo.toml b/fastapi-rapidoc/Cargo.toml new file mode 100644 index 0000000..da6e8fb --- /dev/null +++ b/fastapi-rapidoc/Cargo.toml @@ -0,0 +1,34 @@ +[package] +name = "fastapi-rapidoc" +description = "RapiDoc for fastapi" +edition = "2021" +version = "0.1.1" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["rapidoc", "openapi", "documentation"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman <dev.sulaiman@icloud.com>"] +rust-version.workspace = true + +[package.metadata.docs.rs] +features = ["actix-web", "axum", "rocket"] +rustdoc-args = ["--cfg", "doc_cfg"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +fastapi = { version = "0.1.1", path = "../fastapi", default-features = false, features = [ + "macros", +] } +actix-web = { version = "4", optional = true, default-features = false } +rocket = { version = "0.5", features = ["json"], optional = true } +axum = { version = "0.7", default-features = false, features = [ + "json", +], optional = true } + +[dev-dependencies] +fastapi-rapidoc = { path = ".", features = ["actix-web", "axum", "rocket"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi-rapidoc/LICENSE-APACHE b/fastapi-rapidoc/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-rapidoc/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-rapidoc/LICENSE-MIT b/fastapi-rapidoc/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-rapidoc/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-rapidoc/README.md b/fastapi-rapidoc/README.md new file mode 100644 index 0000000..055c4de --- /dev/null +++ b/fastapi-rapidoc/README.md @@ -0,0 +1,116 @@ +# fastapi-rapidoc + +This crate works as a bridge between [fastapi](https://docs.rs/fastapi/latest/fastapi/) and [RapiDoc](https://rapidocweb.com/) OpenAPI visualizer. + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-rapidoc.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-rapidoc) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-rapidoc&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-rapidoc/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +Fastapi-rapidoc provides simple mechanism to transform OpenAPI spec resource to a servable HTML +file which can be served via [predefined framework integration](#examples) or used +[standalone](#using-standalone) and served manually. + +You may find fullsize examples from fastapi's Github [repository][examples]. + +# Crate Features + +* **actix-web** Allows serving `RapiDoc` via _**`actix-web`**_. `version >= 4` +* **rocket** Allows serving `RapiDoc` via _**`rocket`**_. `version >=0.5` +* **axum** Allows serving `RapiDoc` via _**`axum`**_. `version >=0.7` + +# Install + +Use RapiDoc only without any boiler plate implementation. +```toml +[dependencies] +fastapi-rapidoc = "5" +``` + +Enable actix-web integration with RapiDoc. +```toml +[dependencies] +fastapi-rapidoc = { version = "5", features = ["actix-web"] } +``` + +# Using standalone + +Fastapi-rapidoc can be used standalone as simply as creating a new `RapiDoc` instance and then +serving it by what ever means available as `text/html` from http handler in your favourite web +framework. + +`RapiDoc::to_html` method can be used to convert the `RapiDoc` instance to a servable html +file. +```rust +let rapidoc = RapiDoc::new("/api-docs/openapi.json"); + +// Then somewhere in your application that handles http operation. +// Make sure you return correct content type `text/html`. +let rapidoc_handler = move || { + rapidoc.to_html() +}; +``` + +# Customization + +Fastapi-rapidoc can be customized and configured only via `RapiDoc::custom_html` method. This +method empowers users to use a custom HTML template to modify the looks of the RapiDoc UI. + +* [All allowed RapiDoc configuration options][rapidoc_api] +* [Default HTML template][rapidoc_quickstart] + +The template should contain _**`$specUrl`**_ variable which will be replaced with user defined +OpenAPI spec url provided with `RapiDoc::new` function when creating a new `RapiDoc` +instance. Variable will be replaced during `RapiDoc::to_html` function execution. + +_**Overriding the HTML template with a custom one.**_ +```rust +let html = "..."; +RapiDoc::new("/api-docs/openapi.json").custom_html(html); +``` + +# Examples + +_**Serve `RapiDoc` via `actix-web` framework.**_ +```rust +use actix_web::App; +use fastapi_rapidoc::RapiDoc; + +App::new() + .service( + RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc") + ); +``` + +_**Serve `RapiDoc` via `rocket` framework.**_ +```rust +use fastapi_rapidoc::RapiDoc; + +rocket::build() + .mount( + "/", + RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc"), + ); +``` + +_**Serve `RapiDoc` via `axum` framework.**_ +```rust +use axum::Router; +use fastapi_rapidoc::RapiDoc; + +let app = Router::<S>::new() + .merge( + RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc") + ); +``` + +# License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. + +[rapidoc_api]: <https://rapidocweb.com/api.html> +[examples]: <https://github.com/nxpkg/fastapi/tree/master/examples> +[rapidoc_quickstart]: <https://rapidocweb.com/quickstart.html> diff --git a/fastapi-rapidoc/res/rapidoc.html b/fastapi-rapidoc/res/rapidoc.html new file mode 100644 index 0000000..104ef19 --- /dev/null +++ b/fastapi-rapidoc/res/rapidoc.html @@ -0,0 +1,10 @@ +<!doctype html> +<html> + <head> + <meta charset="utf-8"> + <script type="module" src="https://unpkg.com/rapidoc/dist/rapidoc-min.js"></script> + </head> + <body> + <rapi-doc spec-url="$specUrl"></rapi-doc> + </body> +</html> diff --git a/fastapi-rapidoc/src/lib.rs b/fastapi-rapidoc/src/lib.rs new file mode 100644 index 0000000..4b3feb2 --- /dev/null +++ b/fastapi-rapidoc/src/lib.rs @@ -0,0 +1,455 @@ +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![cfg_attr(doc_cfg, feature(doc_cfg))] +//! This crate works as a bridge between [fastapi](https://docs.rs/fastapi/latest/fastapi/) and [RapiDoc](https://rapidocweb.com/) OpenAPI visualizer. +//! +//! Fastapi-rapidoc provides simple mechanism to transform OpenAPI spec resource to a servable HTML +//! file which can be served via [predefined framework integration][Self#examples] or used +//! [standalone][Self#using-standalone] and served manually. +//! +//! You may find fullsize examples from fastapi's Github [repository][examples]. +//! +//! # Crate Features +//! +//! * **actix-web** Allows serving [`RapiDoc`] via _**`actix-web`**_. +//! * **rocket** Allows serving [`RapiDoc`] via _**`rocket`**_. +//! * **axum** Allows serving [`RapiDoc`] via _**`axum`**_. +//! +//! # Install +//! +//! Use RapiDoc only without any boiler plate implementation. +//! ```toml +//! [dependencies] +//! fastapi-rapidoc = "4" +//! ``` +//! +//! Enable actix-web integration with RapiDoc. +//! ```toml +//! [dependencies] +//! fastapi-rapidoc = { version = "4", features = ["actix-web"] } +//! ``` +//! +//! # Using standalone +//! +//! Fastapi-rapidoc can be used standalone as simply as creating a new [`RapiDoc`] instance and then +//! serving it by what ever means available as `text/html` from http handler in your favourite web +//! framework. +//! +//! [`RapiDoc::to_html`] method can be used to convert the [`RapiDoc`] instance to a servable html +//! file. +//! ``` +//! # use fastapi_rapidoc::RapiDoc; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let rapidoc = RapiDoc::new("/api-docs/openapi.json"); +//! +//! // Then somewhere in your application that handles http operation. +//! // Make sure you return correct content type `text/html`. +//! let rapidoc_handler = move || { +//! rapidoc.to_html() +//! }; +//! ``` +//! +//! # Customization +//! +//! Fastapi-rapidoc can be customized and configured only via [`RapiDoc::custom_html`] method. This +//! method empowers users to use a custom HTML template to modify the looks of the RapiDoc UI. +//! +//! * [All allowed RapiDoc configuration options][rapidoc_api] +//! * [Default HTML template][rapidoc_quickstart] +//! +//! The template should contain _**`$specUrl`**_ variable which will be replaced with user defined +//! OpenAPI spec url provided with [`RapiDoc::new`] function when creating a new [`RapiDoc`] +//! instance. Variable will be replaced during [`RapiDoc::to_html`] function execution. +//! +//! _**Overriding the HTML template with a custom one.**_ +//! ```rust +//! # use fastapi_rapidoc::RapiDoc; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let html = "..."; +//! RapiDoc::new("/api-docs/openapi.json").custom_html(html); +//! ``` +//! +//! # Examples +//! +//! _**Serve [`RapiDoc`] via `actix-web` framework.**_ +//! ```no_run +//! use actix_web::App; +//! use fastapi_rapidoc::RapiDoc; +//! +//! # use fastapi::OpenApi; +//! # use std::net::Ipv4Addr; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! App::new() +//! .service( +//! RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc") +//! ); +//! ``` +//! +//! _**Serve [`RapiDoc`] via `rocket` framework.**_ +//! ```no_run +//! # use rocket; +//! use fastapi_rapidoc::RapiDoc; +//! +//! # use fastapi::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! rocket::build() +//! .mount( +//! "/", +//! RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc"), +//! ); +//! ``` +//! +//! _**Serve [`RapiDoc`] via `axum` framework.**_ +//! ```no_run +//! use axum::Router; +//! use fastapi_rapidoc::RapiDoc; +//! # use fastapi::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! # fn inner<S>() +//! # where +//! # S: Clone + Send + Sync + 'static, +//! # { +//! +//! let app = Router::<S>::new() +//! .merge( +//! RapiDoc::with_openapi("/api-docs/openapi.json", ApiDoc::openapi()).path("/rapidoc") +//! ); +//! # } +//! ``` +//! +//! [rapidoc_api]: <https://rapidocweb.com/api.html> +//! [examples]: <https://github.com/nxpkg/fastapi/tree/master/examples> +//! [rapidoc_quickstart]: <https://rapidocweb.com/quickstart.html> + +use std::borrow::Cow; + +const DEFAULT_HTML: &str = include_str!("../res/rapidoc.html"); + +/// Is [RapiDoc][rapidoc] UI. +/// +/// This is an entry point for serving [RapiDoc][rapidoc] via predefined framework integration or +/// in standalone fashion by calling [`RapiDoc::to_html`] within custom HTTP handler handles +/// serving the [RapiDoc][rapidoc] UI. See more at [running standalone][standalone] +/// +/// [rapidoc]: <https://rapidocweb.com> +/// [standalone]: index.html#using-standalone +#[non_exhaustive] +pub struct RapiDoc { + #[allow(unused)] + path: Cow<'static, str>, + spec_url: Cow<'static, str>, + html: Cow<'static, str>, + #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] + openapi: Option<fastapi::openapi::OpenApi>, +} + +impl RapiDoc { + /// Construct a new [`RapiDoc`] that points to given `spec_url`. Spec url must be valid URL and + /// available for RapiDoc to consume. + /// + /// # Examples + /// + /// _**Create new [`RapiDoc`].**_ + /// + /// ``` + /// # use fastapi_rapidoc::RapiDoc; + /// RapiDoc::new("https://petstore3.swagger.io/api/v3/openapi.json"); + /// ``` + pub fn new<U: Into<Cow<'static, str>>>(spec_url: U) -> Self { + Self { + path: Cow::Borrowed(""), + spec_url: spec_url.into(), + html: Cow::Borrowed(DEFAULT_HTML), + #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] + openapi: None, + } + } + + /// Construct a new [`RapiDoc`] with given `spec_url` and `openapi`. The spec url must point to + /// the location where the `openapi` will be served. + /// + /// [`RapiDoc`] is only able to create endpoint that serves the `openapi` JSON for predefined + /// frameworks. _**For other frameworks such endpoint must be created manually.**_ + /// + /// # Examples + /// + /// _**Create new [`RapiDoc`].**_ + /// + /// ``` + /// # use fastapi_rapidoc::RapiDoc; + /// # use fastapi::OpenApi; + /// # #[derive(OpenApi)] + /// # #[openapi()] + /// # struct ApiDoc; + /// RapiDoc::with_openapi( + /// "/api-docs/openapi.json", + /// ApiDoc::openapi() + /// ); + /// ``` + #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] + #[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) + )] + pub fn with_openapi<U: Into<Cow<'static, str>>>( + spec_url: U, + openapi: fastapi::openapi::OpenApi, + ) -> Self { + Self { + path: Cow::Borrowed(""), + spec_url: spec_url.into(), + html: Cow::Borrowed(DEFAULT_HTML), + openapi: Some(openapi), + } + } + + /// Construct a new [`RapiDoc`] with given `url`, `spec_url` and `openapi`. The `url` defines + /// the location where the RapiDoc UI will be served. The spec url must point to the location + /// where the `openapi` will be served. + /// + /// [`RapiDoc`] is only able to create an endpoint that serves the `openapi` JSON for predefined + /// frameworks. _**For other frameworks such an endpoint must be created manually.**_ + /// + /// # Examples + /// + /// _**Create new [`RapiDoc`] with custom location.**_ + /// + /// ``` + /// # use fastapi_rapidoc::RapiDoc; + /// # use fastapi::OpenApi; + /// # #[derive(OpenApi)] + /// # #[openapi()] + /// # struct ApiDoc; + /// RapiDoc::with_url( + /// "/rapidoc", + /// "/api-docs/openapi.json", + /// ApiDoc::openapi() + /// ); + /// ``` + #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] + #[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) + )] + pub fn with_url<U: Into<Cow<'static, str>>, S: Into<Cow<'static, str>>>( + url: U, + spec_url: S, + openapi: fastapi::openapi::OpenApi, + ) -> Self { + Self { + path: url.into(), + spec_url: spec_url.into(), + html: Cow::Borrowed(DEFAULT_HTML), + openapi: Some(openapi), + } + } + + /// Override the [default HTML template][rapidoc_quickstart] with new one. See + /// [customization] for more details. + /// + /// [rapidoc_quickstart]: <https://rapidocweb.com/quickstart.html> + /// [customization]: index.html#customization + pub fn custom_html<H: Into<Cow<'static, str>>>(mut self, html: H) -> Self { + self.html = html.into(); + + self + } + + /// Add `path` the [`RapiDoc`] will be served from. + /// + /// # Examples + /// + /// _**Make [`RapiDoc`] servable from `/rapidoc` path.**_ + /// ``` + /// # use fastapi_rapidoc::RapiDoc; + /// + /// RapiDoc::new("https://petstore3.swagger.io/api/v3/openapi.json") + /// .path("/rapidoc"); + /// ``` + #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] + pub fn path<U: Into<Cow<'static, str>>>(mut self, path: U) -> Self { + self.path = path.into(); + + self + } + + /// Converts this [`RapiDoc`] instance to servable HTML file. + /// + /// This will replace _**`$specUrl`**_ variable placeholder with the spec + /// url provided to the [`RapiDoc`] instance. If HTML template is not overridden with + /// [`RapiDoc::custom_html`] then the [default HTML template][rapidoc_quickstart] + /// will be used. + /// + /// See more details in [customization][customization]. + /// + /// [rapidoc_quickstart]: <https://rapidocweb.com/quickstart.html> + /// [customization]: index.html#customization + pub fn to_html(&self) -> String { + self.html.replace("$specUrl", self.spec_url.as_ref()) + } +} + +mod actix { + #![cfg(feature = "actix-web")] + + use actix_web::dev::HttpServiceFactory; + use actix_web::guard::Get; + use actix_web::web::Data; + use actix_web::{HttpResponse, Resource, Responder}; + + use crate::RapiDoc; + + impl HttpServiceFactory for RapiDoc { + fn register(self, config: &mut actix_web::dev::AppService) { + let html = self.to_html(); + + async fn serve_rapidoc(rapidoc: Data<String>) -> impl Responder { + HttpResponse::Ok() + .content_type("text/html") + .body(rapidoc.to_string()) + } + + Resource::new(self.path.as_ref()) + .guard(Get()) + .app_data(Data::new(html)) + .to(serve_rapidoc) + .register(config); + + if let Some(openapi) = self.openapi { + async fn serve_openapi(openapi: Data<String>) -> impl Responder { + HttpResponse::Ok() + .content_type("application/json") + .body(openapi.into_inner().to_string()) + } + + Resource::new(self.spec_url.as_ref()) + .guard(Get()) + .app_data(Data::new( + openapi.to_json().expect("Should serialize to JSON"), + )) + .to(serve_openapi) + .register(config); + } + } + } +} + +mod axum { + #![cfg(feature = "axum")] + + use axum::response::Html; + use axum::{routing, Json, Router}; + + use crate::RapiDoc; + + impl<R> From<RapiDoc> for Router<R> + where + R: Clone + Send + Sync + 'static, + { + fn from(value: RapiDoc) -> Self { + let html = value.to_html(); + let openapi = value.openapi; + + let path = value.path.as_ref(); + let path = if path.is_empty() { "/" } else { path }; + let mut router = + Router::<R>::new().route(path, routing::get(move || async { Html(html) })); + + if let Some(openapi) = openapi { + router = router.route( + value.spec_url.as_ref(), + routing::get(move || async { Json(openapi) }), + ); + } + + router + } + } +} + +mod rocket { + #![cfg(feature = "rocket")] + + use rocket::http::Method; + use rocket::response::content::RawHtml; + use rocket::route::{Handler, Outcome}; + use rocket::serde::json::Json; + use rocket::{Data, Request, Route}; + + use crate::RapiDoc; + + impl From<RapiDoc> for Vec<Route> { + fn from(value: RapiDoc) -> Self { + let mut routes = vec![Route::new( + Method::Get, + value.path.as_ref(), + RapiDocHandler(value.to_html()), + )]; + + if let Some(openapi) = value.openapi { + routes.push(Route::new( + Method::Get, + value.spec_url.as_ref(), + OpenApiHandler(openapi), + )); + } + + routes + } + } + + #[derive(Clone)] + struct RapiDocHandler(String); + + #[rocket::async_trait] + impl Handler for RapiDocHandler { + async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> { + Outcome::from(request, RawHtml(self.0.clone())) + } + } + + #[derive(Clone)] + struct OpenApiHandler(fastapi::openapi::OpenApi); + + #[rocket::async_trait] + impl Handler for OpenApiHandler { + async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> { + Outcome::from(request, Json(self.0.clone())) + } + } +} + +#[cfg(test)] +mod tests { + #[test] + #[cfg(feature = "axum")] + fn test_axum_with_empty_path() { + use ::axum::Router; + use fastapi::OpenApi; + + use super::RapiDoc; + + #[derive(fastapi::OpenApi)] + #[openapi()] + struct ApiDoc; + + let _: Router = Router::new().merge(RapiDoc::with_openapi("/rapidoc", ApiDoc::openapi())); + } +} diff --git a/fastapi-redoc/Cargo.toml b/fastapi-redoc/Cargo.toml new file mode 100644 index 0000000..d8c3639 --- /dev/null +++ b/fastapi-redoc/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "fastapi-redoc" +description = "Redoc for fastapi" +version = "0.1.1" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["redoc", "openapi", "documentation"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman <dev.sulaiman@icloud.com>"] +rust-version.workspace = true + +[package.metadata.docs.rs] +features = ["actix-web", "axum", "rocket"] +rustdoc-args = ["--cfg", "doc_cfg"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +fastapi = { version = "0.1.1", path = "../fastapi", default-features = false, features = [ + "macros", +] } +actix-web = { version = "4", optional = true } +rocket = { version = "0.5", features = ["json"], optional = true } +axum = { version = "0.7", default-features = false, optional = true } + +[dev-dependencies] +fastapi-redoc = { path = ".", features = ["actix-web", "axum", "rocket"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi-redoc/LICENSE-APACHE b/fastapi-redoc/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-redoc/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-redoc/LICENSE-MIT b/fastapi-redoc/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-redoc/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-redoc/README.md b/fastapi-redoc/README.md new file mode 100644 index 0000000..259a1dd --- /dev/null +++ b/fastapi-redoc/README.md @@ -0,0 +1,142 @@ +# fastapi-redoc + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-redoc.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-redoc) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-redoc&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-redoc/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +This crate works as a bridge between [fastapi](https://docs.rs/fastapi/latest/fastapi/) and [Redoc](https://redocly.com/) OpenAPI visualizer. + +Fastapi-redoc provides simple mechanism to transform OpenAPI spec resource to a servable HTML +file which can be served via [predefined framework integration](#examples) or used +[standalone](#using-standalone) and served manually. + +You may find fullsize examples from fastapi's Github [repository][examples]. + +# Crate Features + +* **actix-web** Allows serving `Redoc` via _**`actix-web`**_. `version >= 4` +* **rocket** Allows serving `Redoc` via _**`rocket`**_. `version >=0.5` +* **axum** Allows serving `Redoc` via _**`axum`**_. `version >=0.7` + +# Install + +Use Redoc only without any boiler plate implementation. +```toml +[dependencies] +fastapi-redoc = "5" +``` + +Enable actix-web integration with Redoc. +```toml +[dependencies] +fastapi-redoc = { version = "5", features = ["actix-web"] } +``` + +# Using standalone + +Fastapi-redoc can be used standalone as simply as creating a new `Redoc` instance and then +serving it by what ever means available as `text/html` from http handler in your favourite web +framework. + +`Redoc::to_html` method can be used to convert the `Redoc` instance to a servable html +file. +```rust +let redoc = Redoc::new(ApiDoc::openapi()); + +// Then somewhere in your application that handles http operation. +// Make sure you return correct content type `text/html`. +let redoc_handler = move || async { + redoc.to_html() +}; +``` + +# Customization + +Fastapi-redoc enables full customization support for [Redoc][redoc] according to what can be +customized by modifying the HTML template and [configuration options](#configuration). + +The default [HTML template][redoc_html_quickstart] can be fully overridden to ones liking with +`Redoc::custom_html` method. The HTML template **must** contain **`$spec`** and **`$config`** +variables which are replaced during `Redoc::to_html` execution. + +* **`$spec`** Will be the `Spec` that will be rendered via [Redoc][redoc]. +* **`$config`** Will be the current `Config`. By default this is `EmptyConfig`. + +_**Overriding the HTML template with a custom one.**_ +```rust +let html = "..."; +Redoc::new(ApiDoc::openapi()).custom_html(html); +``` + +# Configuration + +Redoc can be configured with JSON either inlined with the `Redoc` declaration or loaded from +user defined file with `FileConfig`. + +* [All supported Redoc configuration options][redoc_config]. + +_**Inlining the configuration.**_ +```rust +Redoc::with_config(ApiDoc::openapi(), || json!({ "disableSearch": true })); +``` + +_**Using `FileConfig`.**_ +```rust +Redoc::with_config(ApiDoc::openapi(), FileConfig); +``` + +Read more details in `Config`. + +# Examples + +_**Serve `Redoc` via `actix-web` framework.**_ +```rust +use actix_web::App; +use fastapi_redoc::{Redoc, Servable}; + +App::new().service(Redoc::with_url("/redoc", ApiDoc::openapi())); +``` + +_**Serve `Redoc` via `rocket` framework.**_ +```rust +use fastapi_redoc::{Redoc, Servable}; + +rocket::build() + .mount( + "/", + Redoc::with_url("/redoc", ApiDoc::openapi()), + ); +``` + +_**Serve `Redoc` via `axum` framework.**_ + ```rust + use axum::Router; + use fastapi_redoc::{Redoc, Servable}; + + let app = Router::<S>::new() + .merge(Redoc::with_url("/redoc", ApiDoc::openapi())); +``` + +_**Use `Redoc` to serve OpenAPI spec from url.**_ +```rust +Redoc::new( + "https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml") +``` + +_**Use `Redoc` to serve custom OpenAPI spec using serde's `json!()` macro.**_ +```rust +Redoc::new(json!({"openapi": "3.1.0"})); +``` + +# License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. + +[redoc]: <https://redocly.com/> +[redoc_html_quickstart]: <https://redocly.com/docs/redoc/quickstart/> +[redoc_config]: <https://redocly.com/docs/api-reference-docs/configuration/functionality/#configuration-options-for-api-docs> +[examples]: <https://github.com/nxpkg/fastapi/tree/master/examples> diff --git a/fastapi-redoc/res/redoc.html b/fastapi-redoc/res/redoc.html new file mode 100644 index 0000000..6f51126 --- /dev/null +++ b/fastapi-redoc/res/redoc.html @@ -0,0 +1,31 @@ +<!DOCTYPE html> +<html> + <head> + <title>Redoc + + + + + + + + +
+ + + + diff --git a/fastapi-redoc/src/actix.rs b/fastapi-redoc/src/actix.rs new file mode 100644 index 0000000..53a0fdb --- /dev/null +++ b/fastapi-redoc/src/actix.rs @@ -0,0 +1,26 @@ +#![cfg(feature = "actix-web")] + +use actix_web::dev::HttpServiceFactory; +use actix_web::guard::Get; +use actix_web::web::Data; +use actix_web::{HttpResponse, Resource, Responder}; + +use crate::{Redoc, Spec}; + +impl HttpServiceFactory for Redoc { + fn register(self, config: &mut actix_web::dev::AppService) { + let html = self.to_html(); + + async fn serve_redoc(redoc: Data) -> impl Responder { + HttpResponse::Ok() + .content_type("text/html") + .body(redoc.to_string()) + } + + Resource::new(self.url.as_ref()) + .guard(Get()) + .app_data(Data::new(html)) + .to(serve_redoc) + .register(config); + } +} diff --git a/fastapi-redoc/src/axum.rs b/fastapi-redoc/src/axum.rs new file mode 100644 index 0000000..6f42cf3 --- /dev/null +++ b/fastapi-redoc/src/axum.rs @@ -0,0 +1,19 @@ +#![cfg(feature = "axum")] + +use axum::response::Html; +use axum::{routing, Router}; + +use crate::{Redoc, Spec}; + +impl From> for Router +where + R: Clone + Send + Sync + 'static, +{ + fn from(value: Redoc) -> Self { + let html = value.to_html(); + Router::::new().route( + value.url.as_ref(), + routing::get(move || async { Html(html) }), + ) + } +} diff --git a/fastapi-redoc/src/lib.rs b/fastapi-redoc/src/lib.rs new file mode 100644 index 0000000..496b86e --- /dev/null +++ b/fastapi-redoc/src/lib.rs @@ -0,0 +1,490 @@ +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![cfg_attr(doc_cfg, feature(doc_cfg))] +//! This crate works as a bridge between [fastapi](https://docs.rs/fastapi/latest/fastapi/) and [Redoc](https://redocly.com/) OpenAPI visualizer. +//! +//! Fastapi-redoc provides simple mechanism to transform OpenAPI spec resource to a servable HTML +//! file which can be served via [predefined framework integration][Self#examples] or used +//! [standalone][Self#using-standalone] and served manually. +//! +//! You may find fullsize examples from fastapi's Github [repository][examples]. +//! +//! # Crate Features +//! +//! * **actix-web** Allows serving [`Redoc`] via _**`actix-web`**_. +//! * **rocket** Allows serving [`Redoc`] via _**`rocket`**_. +//! * **axum** Allows serving [`Redoc`] via _**`axum`**_. +//! +//! # Install +//! +//! Use Redoc only without any boiler plate implementation. +//! ```toml +//! [dependencies] +//! fastapi-redoc = "4" +//! ``` +//! +//! Enable actix-web integration with Redoc. +//! ```toml +//! [dependencies] +//! fastapi-redoc = { version = "4", features = ["actix-web"] } +//! ``` +//! +//! # Using standalone +//! +//! Fastapi-redoc can be used standalone as simply as creating a new [`Redoc`] instance and then +//! serving it by what ever means available as `text/html` from http handler in your favourite web +//! framework. +//! +//! [`Redoc::to_html`] method can be used to convert the [`Redoc`] instance to a servable html +//! file. +//! ``` +//! # use fastapi_redoc::Redoc; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let redoc = Redoc::new(ApiDoc::openapi()); +//! +//! // Then somewhere in your application that handles http operation. +//! // Make sure you return correct content type `text/html`. +//! let redoc_handler = move || { +//! redoc.to_html() +//! }; +//! ``` +//! +//! # Customization +//! +//! Fastapi-redoc enables full customization support for [Redoc][redoc] according to what can be +//! customized by modifying the HTML template and [configuration options][Self#configuration]. +//! +//! The default [HTML template][redoc_html_quickstart] can be fully overridden to ones liking with +//! [`Redoc::custom_html`] method. The HTML template **must** contain **`$spec`** and **`$config`** +//! variables which are replaced during [`Redoc::to_html`] execution. +//! +//! * **`$spec`** Will be the [`Spec`] that will be rendered via [Redoc][redoc]. +//! * **`$config`** Will be the current [`Config`]. By default this is [`EmptyConfig`]. +//! +//! _**Overriding the HTML template with a custom one.**_ +//! ```rust +//! # use fastapi_redoc::Redoc; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let html = "..."; +//! Redoc::new(ApiDoc::openapi()).custom_html(html); +//! ``` +//! +//! # Configuration +//! +//! Redoc can be configured with JSON either inlined with the [`Redoc`] declaration or loaded from +//! user defined file with [`FileConfig`]. +//! +//! * [All supported Redoc configuration options][redoc_config]. +//! +//! _**Inlining the configuration.**_ +//! ```rust +//! # use fastapi_redoc::Redoc; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! Redoc::with_config(ApiDoc::openapi(), || json!({ "disableSearch": true })); +//! ``` +//! +//! _**Using [`FileConfig`].**_ +//! ```no_run +//! # use fastapi_redoc::{Redoc, FileConfig}; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! Redoc::with_config(ApiDoc::openapi(), FileConfig); +//! ``` +//! +//! Read more details in [`Config`]. +//! +//! # Examples +//! +//! _**Serve [`Redoc`] via `actix-web` framework.**_ +//! ```no_run +//! use actix_web::App; +//! use fastapi_redoc::{Redoc, Servable}; +//! +//! # use fastapi::OpenApi; +//! # use std::net::Ipv4Addr; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! App::new().service(Redoc::with_url("/redoc", ApiDoc::openapi())); +//! ``` +//! +//! _**Serve [`Redoc`] via `rocket` framework.**_ +//! ```no_run +//! # use rocket; +//! use fastapi_redoc::{Redoc, Servable}; +//! +//! # use fastapi::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! rocket::build() +//! .mount( +//! "/", +//! Redoc::with_url("/redoc", ApiDoc::openapi()), +//! ); +//! ``` +//! +//! _**Serve [`Redoc`] via `axum` framework.**_ +//! ```no_run +//! use axum::Router; +//! use fastapi_redoc::{Redoc, Servable}; +//! # use fastapi::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! # fn inner() +//! # where +//! # S: Clone + Send + Sync + 'static, +//! # { +//! +//! let app = Router::::new() +//! .merge(Redoc::with_url("/redoc", ApiDoc::openapi())); +//! # } +//! ``` +//! +//! _**Use [`Redoc`] to serve OpenAPI spec from url.**_ +//! ``` +//! # use fastapi_redoc::Redoc; +//! Redoc::new( +//! "https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml"); +//! ``` +//! +//! _**Use [`Redoc`] to serve custom OpenAPI spec using serde's `json!()` macro.**_ +//! ```rust +//! # use fastapi_redoc::Redoc; +//! # use serde_json::json; +//! Redoc::new(json!({"openapi": "3.1.0"})); +//! ``` +//! +//! [redoc]: +//! [redoc_html_quickstart]: +//! [redoc_config]: +//! [examples]: + +use std::fs::OpenOptions; +use std::{borrow::Cow, env}; + +use fastapi::openapi::OpenApi; +use serde::Serialize; +use serde_json::{json, Value}; + +mod actix; +mod axum; +mod rocket; + +const DEFAULT_HTML: &str = include_str!("../res/redoc.html"); + +/// Trait makes [`Redoc`] to accept an _`URL`_ the [Redoc][redoc] will be served via predefined web +/// server. +/// +/// This is used **only** with **`actix-web`**, **`rocket`** or **`axum`** since they have implicit +/// implementation for serving the [`Redoc`] via the _`URL`_. +/// +/// [redoc]: +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +#[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) +)] +pub trait Servable +where + S: Spec, +{ + /// Construct a new [`Servable`] instance of _`openapi`_ with given _`url`_. + /// + /// * **url** Must point to location where the [`Servable`] is served. + /// * **openapi** Is [`Spec`] that is served via this [`Servable`] from the _**url**_. + fn with_url>>(url: U, openapi: S) -> Self; + + /// Construct a new [`Servable`] instance of _`openapi`_ with given _`url`_ and _`config`_. + /// + /// * **url** Must point to location where the [`Servable`] is served. + /// * **openapi** Is [`Spec`] that is served via this [`Servable`] from the _**url**_. + /// * **config** Is custom [`Config`] that is used to configure the [`Servable`]. + fn with_url_and_config>, C: Config>( + url: U, + openapi: S, + config: C, + ) -> Self; +} + +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +impl Servable for Redoc { + fn with_url>>(url: U, openapi: S) -> Self { + Self::with_url_and_config(url, openapi, EmptyConfig) + } + + fn with_url_and_config>, C: Config>( + url: U, + openapi: S, + config: C, + ) -> Self { + Self { + url: url.into(), + html: Cow::Borrowed(DEFAULT_HTML), + openapi, + config: config.load(), + } + } +} + +/// Is standalone instance of [Redoc UI][redoc]. +/// +/// This can be used together with predefined web framework integration or standalone with +/// framework of your choice. [`Redoc::to_html`] method will convert this [`Redoc`] instance to +/// servable HTML file. +/// +/// [redoc]: +#[non_exhaustive] +#[derive(Clone)] +pub struct Redoc { + #[allow(unused)] + url: Cow<'static, str>, + html: Cow<'static, str>, + openapi: S, + config: Value, +} + +impl Redoc { + /// Constructs a new [`Redoc`] instance for given _`openapi`_ [`Spec`]. + /// + /// This will create [`Redoc`] with [`EmptyConfig`]. + /// + /// # Examples + /// + /// _**Create new [`Redoc`] instance with [`EmptyConfig`].**_ + /// ``` + /// # use fastapi_redoc::Redoc; + /// # use serde_json::json; + /// Redoc::new(json!({"openapi": "3.1.0"})); + /// ``` + pub fn new(openapi: S) -> Self { + Self::with_config(openapi, EmptyConfig) + } + + /// Constructs a new [`Redoc`] instance for given _`openapi`_ [`Spec`] and _`config`_ [`Config`] of choice. + /// + /// # Examples + /// + /// _**Create new [`Redoc`] instance with [`FileConfig`].**_ + /// ```no_run + /// # use fastapi_redoc::{Redoc, FileConfig}; + /// # use serde_json::json; + /// Redoc::with_config(json!({"openapi": "3.1.0"}), FileConfig); + /// ``` + pub fn with_config(openapi: S, config: C) -> Self { + Self { + html: Cow::Borrowed(DEFAULT_HTML), + url: Cow::Borrowed(""), + openapi, + config: config.load(), + } + } + + /// Override the [default HTML template][redoc_html_quickstart] with new one. See + /// [customization] for more details. + /// + /// [redoc_html_quickstart]: + /// [customization]: index.html#customization + pub fn custom_html>>(mut self, html: H) -> Self { + self.html = html.into(); + + self + } + + /// Converts this [`Redoc`] instance to servable HTML file. + /// + /// This will replace _**`$config`**_ variable placeholder with [`Config`] of this instance and + /// _**`$spec`**_ with [`Spec`] provided to this instance serializing it to JSON from the HTML + /// template used with the [`Redoc`]. If HTML template is not overridden with + /// [`Redoc::custom_html`] then the [default HTML template][redoc_html_quickstart] will be used. + /// + /// See more details in [customization][customization]. + /// + /// [redoc_html_quickstart]: + /// [customization]: index.html#customization + pub fn to_html(&self) -> String { + self.html + .replace("$config", &self.config.to_string()) + .replace( + "$spec", + &serde_json::to_string(&self.openapi).expect( + "Invalid OpenAPI spec, expected OpenApi, String, &str or serde_json::Value", + ), + ) + } +} + +/// Trait defines OpenAPI spec resource types supported by [`Redoc`]. +/// +/// By default this trait is implemented for [`fastapi::openapi::OpenApi`], [`String`], [`&str`] and +/// [`serde_json::Value`]. +/// +/// * **OpenApi** implementation allows using fastapi's OpenApi struct as a OpenAPI spec resource +/// for the [`Redoc`]. +/// * **String** and **&str** implementations allows defining HTTP URL for [`Redoc`] to load the +/// OpenAPI spec from. +/// * **Value** implementation enables the use of arbitrary JSON values with serde's `json!()` +/// macro as a OpenAPI spec for the [`Redoc`]. +/// +/// # Examples +/// +/// _**Use [`Redoc`] to serve fastapi's OpenApi.**_ +/// ```no_run +/// # use fastapi_redoc::Redoc; +/// # use fastapi::openapi::OpenApiBuilder; +/// # +/// Redoc::new(OpenApiBuilder::new().build()); +/// ``` +/// +/// _**Use [`Redoc`] to serve OpenAPI spec from url.**_ +/// ``` +/// # use fastapi_redoc::Redoc; +/// Redoc::new( +/// "https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml"); +/// ``` +/// +/// _**Use [`Redoc`] to serve custom OpenAPI spec using serde's `json!()` macro.**_ +/// ```rust +/// # use fastapi_redoc::Redoc; +/// # use serde_json::json; +/// Redoc::new(json!({"openapi": "3.1.0"})); +/// ``` +pub trait Spec: Serialize {} + +impl Spec for OpenApi {} + +impl Spec for String {} + +impl Spec for &str {} + +impl Spec for Value {} + +/// Trait defines configuration options for [`Redoc`]. +/// +/// There are 3 configuration methods [`EmptyConfig`], [`FileConfig`] and [`FnOnce`] closure +/// config. The [`Config`] must be able to load and serialize valid JSON. +/// +/// * **EmptyConfig** is the default config and serializes to empty JSON object _`{}`_. +/// * **FileConfig** Allows [`Redoc`] to be configured via user defined file which serializes to +/// JSON. +/// * **FnOnce** closure config allows inlining JSON serializable config directly to [`Redoc`] +/// declaration. +/// +/// Configuration format and allowed options can be found from Redocly's own API documentation. +/// +/// * [All supported Redoc configuration options][redoc_config]. +/// +/// **Note!** There is no validity check for configuration options and all options provided are +/// serialized as is to the [Redoc][redoc]. It is users own responsibility to check for possible +/// misspelled configuration options against the valid configuration options. +/// +/// # Examples +/// +/// _**Using [`FnOnce`] closure config.**_ +/// ```rust +/// # use fastapi_redoc::Redoc; +/// # use fastapi::OpenApi; +/// # use serde_json::json; +/// # #[derive(OpenApi)] +/// # #[openapi()] +/// # struct ApiDoc; +/// # +/// Redoc::with_config(ApiDoc::openapi(), || json!({ "disableSearch": true })); +/// ``` +/// +/// _**Using [`FileConfig`].**_ +/// ```no_run +/// # use fastapi_redoc::{Redoc, FileConfig}; +/// # use fastapi::OpenApi; +/// # use serde_json::json; +/// # #[derive(OpenApi)] +/// # #[openapi()] +/// # struct ApiDoc; +/// # +/// Redoc::with_config(ApiDoc::openapi(), FileConfig); +/// ``` +/// +/// [redoc]: +/// [redoc_config]: +pub trait Config { + /// Implementor must implement the logic which loads the configuration of choice and converts it + /// to serde's [`serde_json::Value`]. + fn load(self) -> Value; +} + +impl S> Config for F { + fn load(self) -> Value { + json!(self()) + } +} + +/// Makes [`Redoc`] load it's configuration from a user defined file. +/// +/// The config file must be defined via _**`FASTAPI_REDOC_CONFIG_FILE`**_ env variable for your +/// application. It can either be defined in runtime before the [`Redoc`] declaration or before +/// application startup or at compile time via `build.rs` file. +/// +/// The file must be located relative to your application runtime directory. +/// +/// The file must be loadable via [`Config`] and it must return a JSON object representing the +/// [Redoc configuration][redoc_config]. +/// +/// # Examples +/// +/// _**Using a `build.rs` file to define the config file.**_ +/// ```rust +/// # fn main() { +/// println!("cargo:rustc-env=FASTAPI_REDOC_CONFIG_FILE=redoc.config.json"); +/// # } +/// ``` +/// +/// _**Defining config file at application startup.**_ +/// ```bash +/// FASTAPI_REDOC_CONFIG_FILE=redoc.config.json cargo run +/// ``` +/// +/// [redoc_config]: +pub struct FileConfig; + +impl Config for FileConfig { + fn load(self) -> Value { + let path = env::var("FASTAPI_REDOC_CONFIG_FILE") + .expect("Missing `FASTAPI_REDOC_CONFIG_FILE` env variable, cannot load file config."); + + let file = OpenOptions::new() + .read(true) + .open(&path) + .unwrap_or_else(|_| panic!("File `{path}` is not readable or does not exist.")); + serde_json::from_reader(file).expect("Config file cannot be parsed to JSON") + } +} + +/// Is the default configuration and serializes to empty JSON object _`{}`_. +pub struct EmptyConfig; + +impl Config for EmptyConfig { + fn load(self) -> Value { + json!({}) + } +} diff --git a/fastapi-redoc/src/rocket.rs b/fastapi-redoc/src/rocket.rs new file mode 100644 index 0000000..1609d4a --- /dev/null +++ b/fastapi-redoc/src/rocket.rs @@ -0,0 +1,28 @@ +#![cfg(feature = "rocket")] + +use rocket::http::Method; +use rocket::response::content::RawHtml; +use rocket::route::{Handler, Outcome}; +use rocket::{Data, Request, Route}; + +use crate::{Redoc, Spec}; + +impl From> for Vec { + fn from(value: Redoc) -> Self { + vec![Route::new( + Method::Get, + value.url.as_ref(), + RedocHandler(value.to_html()), + )] + } +} + +#[derive(Clone)] +struct RedocHandler(String); + +#[rocket::async_trait] +impl Handler for RedocHandler { + async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> { + Outcome::from(request, RawHtml(self.0.clone())) + } +} diff --git a/fastapi-scalar/Cargo.toml b/fastapi-scalar/Cargo.toml new file mode 100644 index 0000000..1a18fe6 --- /dev/null +++ b/fastapi-scalar/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "fastapi-scalar" +description = "Scalar for fastapi" +version = "0.2.0" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["scalar", "openapi", "documentation"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[package.metadata.docs.rs] +features = ["actix-web", "axum", "rocket"] +rustdoc-args = ["--cfg", "doc_cfg"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +fastapi = { version = "0.1.1", path = "../fastapi", default-features = false, features = [ + "macros", +] } +actix-web = { version = "4", optional = true, default-features = false } +rocket = { version = "0.5", features = ["json"], optional = true } +axum = { version = "0.7", default-features = false, optional = true } + +[dev-dependencies] +fastapi-scalar = { path = ".", features = ["actix-web", "axum", "rocket"] } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi-scalar/LICENSE-APACHE b/fastapi-scalar/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-scalar/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-scalar/LICENSE-MIT b/fastapi-scalar/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-scalar/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-scalar/README.md b/fastapi-scalar/README.md new file mode 100644 index 0000000..c71d606 --- /dev/null +++ b/fastapi-scalar/README.md @@ -0,0 +1,131 @@ +# fastapi-scalar + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-scalar.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-scalar) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-scalar&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-scalar/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +This crate works as a bridge between [fastapi](https://docs.rs/fastapi/latest/fastapi/) and [Scalar](https://scalar.com/) OpenAPI visualizer. + +Fastapi-scalar provides simple mechanism to transform OpenAPI spec resource to a servable HTML +file which can be served via [predefined framework integration](#examples) or used +[standalone](#using-standalone) and served manually. + +You may find fullsize examples from fastapi's Github [repository][examples]. + +# Crate Features + +* **actix-web** Allows serving `Scalar` via _**`actix-web`**_. `version >= 4` +* **rocket** Allows serving `Scalar` via _**`rocket`**_. `version >=0.5` +* **axum** Allows serving `Scalar` via _**`axum`**_. `version >=0.7` + +# Install + +Use Scalar only without any boiler plate implementation. +```toml +[dependencies] +fastapi-scalar = "0.2" +``` + +Enable actix-web integration with Scalar. +```toml +[dependencies] +fastapi-scalar = { version = "0.2", features = ["actix-web"] } +``` + +# Using standalone + +Fastapi-scalar can be used standalone as simply as creating a new `Scalar` instance and then +serving it by what ever means available as `text/html` from http handler in your favourite web +framework. + +`Scalar::to_html` method can be used to convert the `Scalar` instance to a servable html +file. +```rust +let scalar = Scalar::new(ApiDoc::openapi()); + +// Then somewhere in your application that handles http operation. +// Make sure you return correct content type `text/html`. +let scalar = move || async { + scalar.to_html() +}; +``` + +# Customization + +Scalar supports customization via [`Scalar::custom_html`] method which allows overriding the +default HTML template with customized one. + +**See more about configuration options.** + +* [Quick HTML configuration instructions](https://github.com/scalar/scalar/blob/main/documentation/integrations/html.md) +* [Configuration options](https://github.com/scalar/scalar/blob/main/documentation/configuration.md) +* [Themes](https://github.com/scalar/scalar/blob/main/documentation/themes.md) + +The HTML template must contain **`$spec`** variable which will be overridden during +`Scalar::to_html` execution. + +* **`$spec`** Will be the `Spec` that will be rendered via `Scalar`. + +_**Overriding the HTML template with a custom one.**_ +```rust +# use fastapi_redoc::Redoc; +# use fastapi::OpenApi; +# use serde_json::json; +# #[derive(OpenApi)] +# #[openapi()] +# struct ApiDoc; +# +let html = "..."; +Redoc::new(ApiDoc::openapi()).custom_html(html); +``` + +# Examples + +_**Serve `Scalar` via `actix-web` framework.**_ +```rust +use actix_web::App; +use fastapi_scalar::{Scalar, Servable}; + +App::new().service(Scalar::with_url("/scalar", ApiDoc::openapi())); +``` + +_**Serve `Scalar` via `rocket` framework.**_ +```rust +use fastapi_scalar::{Scalar, Servable}; + +rocket::build() + .mount( + "/", + Scalar::with_url("/scalar", ApiDoc::openapi()), + ); +``` + +_**Serve `Scalar` via `axum` framework.**_ + ```rust + use axum::Router; + use fastapi_scalar::{Scalar, Servable}; + + let app = Router::::new() + .merge(Scalar::with_url("/scalar", ApiDoc::openapi())); +``` + +_**Use `Scalar` to serve OpenAPI spec from url.**_ +```rust +Scalar::new( + "https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml") +``` + +_**Use `Scalar` to serve custom OpenAPI spec using serde's `json!()` macro.**_ +```rust +Scalar::new(json!({"openapi": "3.1.0"})); +``` + +# License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. + +[examples]: diff --git a/fastapi-scalar/res/scalar.html b/fastapi-scalar/res/scalar.html new file mode 100644 index 0000000..e35945b --- /dev/null +++ b/fastapi-scalar/res/scalar.html @@ -0,0 +1,19 @@ + + + + Scalar + + + + + + + + + diff --git a/fastapi-scalar/src/actix.rs b/fastapi-scalar/src/actix.rs new file mode 100644 index 0000000..669e2ea --- /dev/null +++ b/fastapi-scalar/src/actix.rs @@ -0,0 +1,26 @@ +#![cfg(feature = "actix-web")] + +use actix_web::dev::HttpServiceFactory; +use actix_web::guard::Get; +use actix_web::web::Data; +use actix_web::{HttpResponse, Resource, Responder}; + +use crate::{Scalar, Spec}; + +impl HttpServiceFactory for Scalar { + fn register(self, config: &mut actix_web::dev::AppService) { + let html = self.to_html(); + + async fn serve_scalar(scalar: Data) -> impl Responder { + HttpResponse::Ok() + .content_type("text/html") + .body(scalar.to_string()) + } + + Resource::new(self.url.as_ref()) + .guard(Get()) + .app_data(Data::new(html)) + .to(serve_scalar) + .register(config); + } +} diff --git a/fastapi-scalar/src/axum.rs b/fastapi-scalar/src/axum.rs new file mode 100644 index 0000000..4d3cd42 --- /dev/null +++ b/fastapi-scalar/src/axum.rs @@ -0,0 +1,19 @@ +#![cfg(feature = "axum")] + +use axum::response::Html; +use axum::{routing, Router}; + +use crate::{Scalar, Spec}; + +impl From> for Router +where + R: Clone + Send + Sync + 'static, +{ + fn from(value: Scalar) -> Self { + let html = value.to_html(); + Router::::new().route( + value.url.as_ref(), + routing::get(move || async { Html(html) }), + ) + } +} diff --git a/fastapi-scalar/src/lib.rs b/fastapi-scalar/src/lib.rs new file mode 100644 index 0000000..4c4c707 --- /dev/null +++ b/fastapi-scalar/src/lib.rs @@ -0,0 +1,287 @@ +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![cfg_attr(doc_cfg, feature(doc_cfg))] +//! This crate works as a bridge between [fastapi](https://docs.rs/fastapi/latest/fastapi/) and [Scalar](https://scalar.com/) OpenAPI visualizer. +//! +//! Fastapi-scalar provides simple mechanism to transform OpenAPI spec resource to a servable HTML +//! file which can be served via [predefined framework integration][Self#examples] or used +//! [standalone][Self#using-standalone] and served manually. +//! +//! You may find fullsize examples from fastapi's Github [repository][examples]. +//! +//! # Crate Features +//! +//! * **actix-web** Allows serving [`Scalar`] via _**`actix-web`**_. +//! * **rocket** Allows serving [`Scalar`] via _**`rocket`**_. +//! * **axum** Allows serving [`Scalar`] via _**`axum`**_. +//! +//! # Install +//! +//! Use Scalar only without any boiler plate implementation. +//! ```toml +//! [dependencies] +//! fastapi-scalar = "0.1" +//! ``` +//! +//! Enable actix-web integration with Scalar. +//! ```toml +//! [dependencies] +//! fastapi-scalar = { version = "0.1", features = ["actix-web"] } +//! ``` +//! +//! # Using standalone +//! +//! Fastapi-scalar can be used standalone as simply as creating a new [`Scalar`] instance and then +//! serving it by what ever means available as `text/html` from http handler in your favourite web +//! framework. +//! +//! [`Scalar::to_html`] method can be used to convert the [`Scalar`] instance to a servable html +//! file. +//! ``` +//! # use fastapi_scalar::Scalar; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let scalar = Scalar::new(ApiDoc::openapi()); +//! +//! // Then somewhere in your application that handles http operation. +//! // Make sure you return correct content type `text/html`. +//! let scalar_handler = move || { +//! scalar.to_html() +//! }; +//! ``` +//! +//! # Customization +//! +//! Scalar supports customization via [`Scalar::custom_html`] method which allows overriding the +//! default HTML template with customized one. +//! +//! **See more about configuration options.** +//! +//! * [Quick HTML configuration instructions][html] +//! * [Configuration options][configuration] +//! * [Themes][themes] +//! +//! The HTML template must contain **`$spec`** variable which will be overridden during +//! [`Scalar::to_html`] execution. +//! +//! * **`$spec`** Will be the [`Spec`] that will be rendered via [Scalar][scalar]. +//! +//! _**Overriding the HTML template with a custom one.**_ +//! ```rust +//! # use fastapi_scalar::Scalar; +//! # use fastapi::OpenApi; +//! # use serde_json::json; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! let html = "..."; +//! Scalar::new(ApiDoc::openapi()).custom_html(html); +//! ``` +//! # Examples +//! +//! _**Serve [`Scalar`] via `actix-web` framework.**_ +//! ```no_run +//! use actix_web::App; +//! use fastapi_scalar::{Scalar, Servable}; +//! +//! # use fastapi::OpenApi; +//! # use std::net::Ipv4Addr; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! App::new().service(Scalar::with_url("/scalar", ApiDoc::openapi())); +//! ``` +//! +//! _**Serve [`Scalar`] via `rocket` framework.**_ +//! ```no_run +//! # use rocket; +//! use fastapi_scalar::{Scalar, Servable}; +//! +//! # use fastapi::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! rocket::build() +//! .mount( +//! "/", +//! Scalar::with_url("/scalar", ApiDoc::openapi()), +//! ); +//! ``` +//! +//! _**Serve [`Scalar`] via `axum` framework.**_ +//! ```no_run +//! use axum::Router; +//! use fastapi_scalar::{Scalar, Servable}; +//! # use fastapi::OpenApi; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! # fn inner() +//! # where +//! # S: Clone + Send + Sync + 'static, +//! # { +//! +//! let app = Router::::new() +//! .merge(Scalar::with_url("/scalar", ApiDoc::openapi())); +//! # } +//! ``` +//! +//! _**Use [`Scalar`] to serve custom OpenAPI spec using serde's `json!()` macro.**_ +//! ```rust +//! # use fastapi_scalar::Scalar; +//! # use serde_json::json; +//! Scalar::new(json!({"openapi": "3.1.0"})); +//! ``` +//! +//! [examples]: +//! [scalar]: +//! [configuration]: +//! [themes]: +//! [html]: + +use std::borrow::Cow; + +use fastapi::openapi::OpenApi; +use serde::Serialize; +use serde_json::Value; + +mod actix; +mod axum; +mod rocket; + +const DEFAULT_HTML: &str = include_str!("../res/scalar.html"); + +/// Trait makes [`Scalar`] to accept an _`URL`_ the [Scalar][scalar] will be served via predefined +/// web server. +/// +/// This is used **only** with **`actix-web`**, **`rocket`** or **`axum`** since they have implicit +/// implementation for serving the [`Scalar`] via the _`URL`_. +/// +/// [scalar]: +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +#[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) +)] +pub trait Servable +where + S: Spec, +{ + /// Construct a new [`Servable`] instance of _`openapi`_ with given _`url`_. + /// + /// * **url** Must point to location where the [`Servable`] is served. + /// * **openapi** Is [`Spec`] that is served via this [`Servable`] from the _**url**_. + fn with_url>>(url: U, openapi: S) -> Self; +} + +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +impl Servable for Scalar { + fn with_url>>(url: U, openapi: S) -> Self { + Self { + html: Cow::Borrowed(DEFAULT_HTML), + url: url.into(), + openapi, + } + } +} + +/// Is standalone instance of [Scalar][scalar]. +/// +/// This can be used together with predefined web framework integration or standalone with +/// framework of your choice. [`Scalar::to_html`] method will convert this [`Scalar`] instance to +/// servable HTML file. +/// +/// [scalar]: +#[non_exhaustive] +#[derive(Clone)] +pub struct Scalar { + #[allow(unused)] + url: Cow<'static, str>, + html: Cow<'static, str>, + openapi: S, +} + +impl Scalar { + /// Constructs a new [`Scalar`] instance for given _`openapi`_ [`Spec`]. + /// + /// # Examples + /// + /// _**Create new [`Scalar`] instance.**_ + /// ``` + /// # use fastapi_scalar::Scalar; + /// # use serde_json::json; + /// Scalar::new(json!({"openapi": "3.1.0"})); + /// ``` + pub fn new(openapi: S) -> Self { + Self { + html: Cow::Borrowed(DEFAULT_HTML), + url: Cow::Borrowed("/"), + openapi, + } + } + + /// Converts this [`Scalar`] instance to servable HTML file. + /// + /// This will replace _**`$spec`**_ variable placeholder with [`Spec`] of this instance + /// provided to this instance serializing it to JSON from the HTML template used with the + /// [`Scalar`]. + /// + /// At this point in time, it is not possible to customize the HTML template used by the + /// [`Scalar`] instance. + pub fn to_html(&self) -> String { + self.html.replace( + "$spec", + &serde_json::to_string(&self.openapi).expect( + "Invalid OpenAPI spec, expected OpenApi, String, &str or serde_json::Value", + ), + ) + } + + /// Override the [default HTML template][scalar_html_quickstart] with new one. Refer to + /// [customization] for more comprehensive guide for customization options. + /// + /// [customization]: + /// [scalar_html_quickstart]: + pub fn custom_html>>(mut self, html: H) -> Self { + self.html = html.into(); + + self + } +} + +/// Trait defines OpenAPI spec resource types supported by [`Scalar`]. +/// +/// By default this trait is implemented for [`fastapi::openapi::OpenApi`] and [`serde_json::Value`]. +/// +/// * **OpenApi** implementation allows using fastapi's OpenApi struct as a OpenAPI spec resource +/// for the [`Scalar`]. +/// * **Value** implementation enables the use of arbitrary JSON values with serde's `json!()` +/// macro as a OpenAPI spec for the [`Scalar`]. +/// +/// # Examples +/// +/// _**Use [`Scalar`] to serve fastapi's OpenApi.**_ +/// ```no_run +/// # use fastapi_scalar::Scalar; +/// # use fastapi::openapi::OpenApiBuilder; +/// # +/// Scalar::new(OpenApiBuilder::new().build()); +/// ``` +/// +/// _**Use [`Scalar`] to serve custom OpenAPI spec using serde's `json!()` macro.**_ +/// ```rust +/// # use fastapi_scalar::Scalar; +/// # use serde_json::json; +/// Scalar::new(json!({"openapi": "3.1.0"})); +/// ``` +pub trait Spec: Serialize {} + +impl Spec for OpenApi {} + +impl Spec for Value {} diff --git a/fastapi-scalar/src/rocket.rs b/fastapi-scalar/src/rocket.rs new file mode 100644 index 0000000..50e5039 --- /dev/null +++ b/fastapi-scalar/src/rocket.rs @@ -0,0 +1,28 @@ +#![cfg(feature = "rocket")] + +use rocket::http::Method; +use rocket::response::content::RawHtml; +use rocket::route::{Handler, Outcome}; +use rocket::{Data, Request, Route}; + +use crate::{Scalar, Spec}; + +impl From> for Vec { + fn from(value: Scalar) -> Self { + vec![Route::new( + Method::Get, + value.url.as_ref(), + ScalarHandler(value.to_html()), + )] + } +} + +#[derive(Clone)] +struct ScalarHandler(String); + +#[rocket::async_trait] +impl Handler for ScalarHandler { + async fn handle<'r>(&self, request: &'r Request<'_>, _: Data<'r>) -> Outcome<'r> { + Outcome::from(request, RawHtml(self.0.clone())) + } +} diff --git a/fastapi-swagger-ui-vendored/Cargo.toml b/fastapi-swagger-ui-vendored/Cargo.toml new file mode 100644 index 0000000..a2cdb30 --- /dev/null +++ b/fastapi-swagger-ui-vendored/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "fastapi-swagger-ui-vendored" +description = "Vendored Swagger UI for fastapi" +license = "MIT OR Apache-2.0" +readme = "README.md" +version = "0.1.1" +edition = "2021" +keywords = ["swagger-ui", "vendored", "openapi", "documentation"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[dependencies] diff --git a/fastapi-swagger-ui-vendored/LICENSE-APACHE b/fastapi-swagger-ui-vendored/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-swagger-ui-vendored/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-swagger-ui-vendored/LICENSE-MIT b/fastapi-swagger-ui-vendored/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-swagger-ui-vendored/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-swagger-ui-vendored/README.md b/fastapi-swagger-ui-vendored/README.md new file mode 100644 index 0000000..e6dfac2 --- /dev/null +++ b/fastapi-swagger-ui-vendored/README.md @@ -0,0 +1,22 @@ +# fastapi-swagger-ui-vendored + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-swagger-ui-vendored.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-swagger-ui-vendored) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-swagger-ui-vendored&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-swagger-ui-vendored/latest/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +This crate holds the [Swagger UI](https://github.com/swagger-api/swagger-ui) zip archive re-packaged as +Rust crate. The crate serves as a build dependency for `fastapi-swagger-ui` and is used to serve the +Swagger UI when `vendored` crate feature is enabled for `fastapi-swagger-ui` crate. + +Vendored Swagger UI provides the means to serve Swagger UI in sandboxed environments where network access or +even other means to provide Swagger UI is not possible. + +**Swagger UI version: `5.17.14`** + +## License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. diff --git a/fastapi-swagger-ui-vendored/res/v0.1.1.zip b/fastapi-swagger-ui-vendored/res/v0.1.1.zip new file mode 100644 index 0000000..1becba2 --- /dev/null +++ b/fastapi-swagger-ui-vendored/res/v0.1.1.zip @@ -0,0 +1 @@ +404: Not Found \ No newline at end of file diff --git a/fastapi-swagger-ui-vendored/res/v5.17.14.zip b/fastapi-swagger-ui-vendored/res/v5.17.14.zip new file mode 100644 index 0000000..4d5749e Binary files /dev/null and b/fastapi-swagger-ui-vendored/res/v5.17.14.zip differ diff --git a/fastapi-swagger-ui-vendored/src/lib.rs b/fastapi-swagger-ui-vendored/src/lib.rs new file mode 100644 index 0000000..bc52834 --- /dev/null +++ b/fastapi-swagger-ui-vendored/src/lib.rs @@ -0,0 +1,19 @@ +//! This crate holds the [Swagger UI](https://github.com/swagger-api/swagger-ui) zip archive re-packaged as +//! Rust crate. The crate serves as a build dependency for `fastapi-swagger-ui` and is used to serve the +//! Swagger UI when `vendored` crate feature is enabled for `fastapi-swagger-ui` crate. +//! +//! Vendored Swagger UI provides the means to serve Swagger UI in sandboxed environments where network access or +//! even other means to provide Swagger UI is not possible. +//! +//! **Swagger UI version: `5.17.14`** +//! +//! ## License +//! +//! Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. +//! +//! Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +//! by you, shall be dual licensed, without any additional terms or conditions. + +/// Swagger UI zip bytes +#[doc(hidden)] +pub const SWAGGER_UI_VENDORED: &[u8] = std::include_bytes!("../res/v5.17.14.zip"); diff --git a/fastapi-swagger-ui/Cargo.toml b/fastapi-swagger-ui/Cargo.toml new file mode 100644 index 0000000..8a50385 --- /dev/null +++ b/fastapi-swagger-ui/Cargo.toml @@ -0,0 +1,67 @@ +[package] +name = "fastapi-swagger-ui" +description = "Swagger UI for fastapi" +version = "0.1.1" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = ["swagger-ui", "openapi", "documentation"] +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[features] +default = ["url"] +debug = [] +debug-embed = ["rust-embed/debug-embed"] +reqwest = ["dep:reqwest"] +url = ["dep:url"] +vendored = ["dep:fastapi-swagger-ui-vendored"] +# cache swagger ui zip +cache = ["dep:dirs", "dep:sha2"] + +[dependencies] +rust-embed = { version = "8" } +mime_guess = { version = "2.0" } +actix-web = { version = "4", optional = true, default-features = false } +rocket = { version = "0.5", features = ["json"], optional = true } +axum = { version = "0.7", default-features = false, features = [ + "json", +], optional = true } +fastapi = { version = "0.1.1", path = "../fastapi", default-features = false, features = [ + "macros", +] } +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } + +[dev-dependencies] +axum-test = "16.2.0" +similar = "2.5" +tokio = { version = "1", features = ["macros"] } +fastapi-swagger-ui = { path = ".", features = ["actix-web", "axum", "rocket"] } + +[package.metadata.docs.rs] +features = ["actix-web", "axum", "rocket", "vendored", "cache"] +no-default-features = true +rustdoc-args = ["--cfg", "doc_cfg"] + +[build-dependencies] +zip = { version = "2", default-features = false, features = ["deflate"] } +regex = "1.7" + +# used by cache feature +dirs = { version = "5.0.1", optional = true } +sha2 = { version = "0.10.8", optional = true } + +# enabled optionally to allow rust only build with expense of bigger dependency tree and platform +# independent build. By default `curl` system package is tried for downloading the Swagger UI. +reqwest = { version = "0.12", features = [ + "blocking", + "rustls-tls", +], default-features = false, optional = true } +url = { version = "2", optional = true } +fastapi-swagger-ui-vendored = { version = "0.1", path = "../fastapi-swagger-ui-vendored", optional = true } + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi-swagger-ui/LICENSE-APACHE b/fastapi-swagger-ui/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi-swagger-ui/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi-swagger-ui/LICENSE-MIT b/fastapi-swagger-ui/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi-swagger-ui/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi-swagger-ui/README.md b/fastapi-swagger-ui/README.md new file mode 100644 index 0000000..053df5b --- /dev/null +++ b/fastapi-swagger-ui/README.md @@ -0,0 +1,123 @@ +# fastapi-swagger-ui + +[![Fastapi build](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml/badge.svg)](https://github.com/nxpkg/fastapi/actions/workflows/build.yaml) +[![crates.io](https://img.shields.io/crates/v/fastapi-swagger-ui.svg?label=crates.io&color=orange&logo=rust)](https://crates.io/crates/fastapi-swagger-ui) +[![docs.rs](https://img.shields.io/static/v1?label=docs.rs&message=fastapi-swagger-ui&color=blue&logo=data:image/svg+xml;base64,PHN2ZyByb2xlPSJpbWciIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgdmlld0JveD0iMCAwIDUxMiA1MTIiPjxwYXRoIGZpbGw9IiNmNWY1ZjUiIGQ9Ik00ODguNiAyNTAuMkwzOTIgMjE0VjEwNS41YzAtMTUtOS4zLTI4LjQtMjMuNC0zMy43bC0xMDAtMzcuNWMtOC4xLTMuMS0xNy4xLTMuMS0yNS4zIDBsLTEwMCAzNy41Yy0xNC4xIDUuMy0yMy40IDE4LjctMjMuNCAzMy43VjIxNGwtOTYuNiAzNi4yQzkuMyAyNTUuNSAwIDI2OC45IDAgMjgzLjlWMzk0YzAgMTMuNiA3LjcgMjYuMSAxOS45IDMyLjJsMTAwIDUwYzEwLjEgNS4xIDIyLjEgNS4xIDMyLjIgMGwxMDMuOS01MiAxMDMuOSA1MmMxMC4xIDUuMSAyMi4xIDUuMSAzMi4yIDBsMTAwLTUwYzEyLjItNi4xIDE5LjktMTguNiAxOS45LTMyLjJWMjgzLjljMC0xNS05LjMtMjguNC0yMy40LTMzLjd6TTM1OCAyMTQuOGwtODUgMzEuOXYtNjguMmw4NS0zN3Y3My4zek0xNTQgMTA0LjFsMTAyLTM4LjIgMTAyIDM4LjJ2LjZsLTEwMiA0MS40LTEwMi00MS40di0uNnptODQgMjkxLjFsLTg1IDQyLjV2LTc5LjFsODUtMzguOHY3NS40em0wLTExMmwtMTAyIDQxLjQtMTAyLTQxLjR2LS42bDEwMi0zOC4yIDEwMiAzOC4ydi42em0yNDAgMTEybC04NSA0Mi41di03OS4xbDg1LTM4Ljh2NzUuNHptMC0xMTJsLTEwMiA0MS40LTEwMi00MS40di0uNmwxMDItMzguMiAxMDIgMzguMnYuNnoiPjwvcGF0aD48L3N2Zz4K)](https://docs.rs/fastapi-swagger-ui/latest/fastapi_swagger_ui/) +![rustc](https://img.shields.io/static/v1?label=rustc&message=1.75&color=orange&logo=rust) + +This crate implements necessary boilerplate code to serve Swagger UI via web server. It +works as a bridge for serving the OpenAPI documentation created with +[fastapi](https://docs.rs/fastapi/) library in the Swagger UI. + +**Currently implemented boilerplate for:** + +* **actix-web** `version >= 4` +* **rocket** `version >=0.5` +* **axum** `version >=0.7` + +Serving Swagger UI is framework independent thus this crate also supports serving the Swagger UI with +other frameworks as well. With other frameworks, there is a bit more manual implementation to be done. See +more details at [serve](https://docs.rs/fastapi-swagger-ui/latest/fastapi_swagger_ui/fn.serve.html) or +[examples](https://github.com/nxpkg/fastapi/tree/master/examples). + +## Crate Features + +* **`actix-web`** Enables actix-web integration with pre-configured SwaggerUI service factory allowing + users to use the Swagger UI without a hassle. +* **`rocket`** Enables rocket integration with pre-configured routes for serving the Swagger UI + and api doc without a hassle. +* **`axum`** Enables `axum` integration with pre-configured Router serving Swagger UI and OpenAPI specs + hassle free. +* **`debug-embed`** Enables `debug-embed` feature on `rust_embed` crate to allow embedding files in debug + builds as well. +* **`reqwest`** Use `reqwest` for downloading Swagger UI according to the `SWAGGER_UI_DOWNLOAD_URL` environment + variable. This is only enabled by default on _Windows_. +* **`url`** Enabled by default for parsing and encoding the download URL. +* **`vendored`** Enables vendored Swagger UI via `fastapi-swagger-ui-vendored` crate. +- **`cache`** Enables caching of the Swagger UI download in `fastapi-swagger-ui` during the build process. + +## Install + +Use only the raw types without any boilerplate implementation. + +```toml +[dependencies] +fastapi-swagger-ui = "8" +``` + +Enable actix-web framework with Swagger UI you could define the dependency as follows. + +```toml +[dependencies] +fastapi-swagger-ui = { version = "8", features = ["actix-web"] } +``` + +**Note!** Also remember that you already have defined `fastapi` dependency in your `Cargo.toml` + +## Build Config + +> [!IMPORTANT] +> _`fastapi-swagger-ui` crate will by default try to use system `curl` package for downloading the Swagger UI. It +> can optionally be downloaded with `reqwest` by enabling `reqwest` feature. Reqwest can be useful for platform +> independent builds however bringing quite a few unnecessary dependencies just to download a file. +> If the `SWAGGER_UI_DOWNLOAD_URL` is a file path then no downloading will happen._ + +> [!TIP] +> Use **`vendored`** feature flag to use vendored Swagger UI. This is especially useful for no network +> environments. + +**The following configuration env variables are available at build time:** + + * `SWAGGER_UI_DOWNLOAD_URL`: Defines the url from where to download the swagger-ui zip file. + + * Current Swagger UI version: + * [All available Swagger UI versions](https://github.com/swagger-api/swagger-ui/tags) + + * `SWAGGER_UI_OVERWRITE_FOLDER`: Defines an _optional_ absolute path to a directory containing files + to overwrite the Swagger UI files. Typically you might want to overwrite `index.html`. + +## Examples + +Serve Swagger UI with api doc via **`actix-web`**. See full example from [examples](https://github.com/nxpkg/fastapi/tree/master/examples/todo-actix). + +```rust +HttpServer::new(move || { + App::new() + .service( + SwaggerUi::new("/swagger-ui/{_:.*}") + .url("/api-docs/openapi.json", ApiDoc::openapi()), + ) + }) + .bind((Ipv4Addr::UNSPECIFIED, 8989)).unwrap() + .run(); +``` + +Serve Swagger UI with api doc via **`rocket`**. See full example from [examples](https://github.com/nxpkg/fastapi/tree/master/examples/rocket-todo). + +```rust +#[rocket::launch] +fn rocket() -> Rocket { + rocket::build() + .mount( + "/", + SwaggerUi::new("/swagger-ui/<_..>") + .url("/api-docs/openapi.json", ApiDoc::openapi()), + ) +} +``` + +Setup Router to serve Swagger UI with **`axum`** framework. See full implementation of how to serve +Swagger UI with axum from [examples](https://github.com/nxpkg/fastapi/tree/master/examples/todo-axum). + +```rust +let app = Router::new() + .merge(SwaggerUi::new("/swagger-ui") + .url("/api-docs/openapi.json", ApiDoc::openapi())); +``` + +## License + +Licensed under either of [Apache 2.0](LICENSE-APACHE) or [MIT](LICENSE-MIT) license at your option. + +Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in this crate +by you, shall be dual licensed, without any additional terms or conditions. diff --git a/fastapi-swagger-ui/build.rs b/fastapi-swagger-ui/build.rs new file mode 100644 index 0000000..b94096b --- /dev/null +++ b/fastapi-swagger-ui/build.rs @@ -0,0 +1,385 @@ +use std::{ + env, + error::Error, + fs::{self, File}, + io::{self, Cursor}, + path::{Path, PathBuf}, +}; + +use regex::Regex; +use zip::{result::ZipError, ZipArchive}; + +/// the following env variables control the build process: +/// 1. SWAGGER_UI_DOWNLOAD_URL: +/// + the url from where to download the swagger-ui zip file if starts with http:// or https:// +/// + the file path from where to copy the swagger-ui zip file if starts with file:// +/// + default value is SWAGGER_UI_DOWNLOAD_URL_DEFAULT +/// + for other versions, check https://github.com/swagger-api/swagger-ui/tags +/// 2. SWAGGER_UI_OVERWRITE_FOLDER +/// + absolute path to a folder containing files to overwrite the default swagger-ui files + +const SWAGGER_UI_DOWNLOAD_URL_DEFAULT: &str = + "https://github.com/swagger-api/swagger-ui/archive/refs/tags/v5.17.14.zip"; + +const SWAGGER_UI_DOWNLOAD_URL: &str = "SWAGGER_UI_DOWNLOAD_URL"; +const SWAGGER_UI_OVERWRITE_FOLDER: &str = "SWAGGER_UI_OVERWRITE_FOLDER"; + +#[cfg(feature = "cache")] +fn sha256(data: &[u8]) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(data); + let hash = hasher.finalize(); + format!("{:x}", hash).to_uppercase() +} + +#[cfg(feature = "cache")] +fn get_cache_dir() -> Option { + dirs::cache_dir().map(|p| p.join("fastapi-swagger-ui")) +} + +fn main() { + let target_dir = env::var("OUT_DIR").unwrap(); + println!("OUT_DIR: {target_dir}"); + + let url = + env::var(SWAGGER_UI_DOWNLOAD_URL).unwrap_or(SWAGGER_UI_DOWNLOAD_URL_DEFAULT.to_string()); + + println!("{SWAGGER_UI_DOWNLOAD_URL}: {url}"); + + let mut swagger_zip = get_zip_archive(&url, &target_dir); + let zip_top_level_folder = swagger_zip + .extract_dist(&target_dir) + .expect("should extract dist"); + println!("zip_top_level_folder: {:?}", zip_top_level_folder); + + replace_default_url_with_config(&target_dir, &zip_top_level_folder); + + write_embed_code(&target_dir, &zip_top_level_folder); + + let overwrite_folder = + PathBuf::from(env::var(SWAGGER_UI_OVERWRITE_FOLDER).unwrap_or("overwrite".to_string())); + + if overwrite_folder.exists() { + println!("{SWAGGER_UI_OVERWRITE_FOLDER}: {overwrite_folder:?}"); + + for entry in fs::read_dir(overwrite_folder).unwrap() { + let entry = entry.unwrap(); + let path_in = entry.path(); + println!("replacing file: {:?}", path_in.clone()); + overwrite_target_file(&target_dir, &zip_top_level_folder, path_in); + } + } else { + println!("{SWAGGER_UI_OVERWRITE_FOLDER} not found: {overwrite_folder:?}"); + } +} + +enum SwaggerZip { + #[allow(unused)] + Bytes(ZipArchive>), + File(ZipArchive), +} + +impl SwaggerZip { + fn len(&self) -> usize { + match self { + Self::File(file) => file.len(), + Self::Bytes(bytes) => bytes.len(), + } + } + + fn by_index(&mut self, index: usize) -> Result { + match self { + Self::File(file) => file.by_index(index), + Self::Bytes(bytes) => bytes.by_index(index), + } + } + + fn extract_dist(&mut self, target_dir: &str) -> Result { + let mut zip_top_level_folder = String::new(); + + for index in 0..self.len() { + let mut file = self.by_index(index)?; + let filepath = file + .enclosed_name() + .ok_or(ZipError::InvalidArchive("invalid path file"))?; + + if index == 0 { + zip_top_level_folder = filepath + .iter() + .take(1) + .map(|x| x.to_str().unwrap_or_default()) + .collect::(); + } + + let next_folder = filepath + .iter() + .skip(1) + .take(1) + .map(|x| x.to_str().unwrap_or_default()) + .collect::(); + + if next_folder == "dist" { + let directory = [&target_dir].iter().collect::(); + let out_path = directory.join(filepath); + + if file.name().ends_with('/') { + fs::create_dir_all(&out_path)?; + } else { + if let Some(p) = out_path.parent() { + if !p.exists() { + fs::create_dir_all(p)?; + } + } + let mut out_file = fs::File::create(&out_path)?; + io::copy(&mut file, &mut out_file)?; + } + // Get and Set permissions + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if let Some(mode) = file.unix_mode() { + fs::set_permissions(&out_path, fs::Permissions::from_mode(mode))?; + } + } + } + } + + Ok(zip_top_level_folder) + } +} + +fn get_zip_archive(url: &str, target_dir: &str) -> SwaggerZip { + let zip_filename = url.split('/').last().unwrap().to_string(); + #[allow(unused_mut)] + let mut zip_path = [target_dir, &zip_filename].iter().collect::(); + + if env::var("CARGO_FEATURE_VENDORED").is_ok() { + #[cfg(not(feature = "vendored"))] + unreachable!("Cannot get vendored Swagger UI without `vendored` flag"); + + #[cfg(feature = "vendored")] + { + println!("using vendored Swagger UI"); + let vendred_bytes = fastapi_swagger_ui_vendored::SWAGGER_UI_VENDORED; + let zip = ZipArchive::new(io::Cursor::new(vendred_bytes)) + .expect("failed to open vendored Swagger UI"); + SwaggerZip::Bytes(zip) + } + } else if url.starts_with("file:") { + #[cfg(feature = "url")] + let mut file_path = url::Url::parse(url).unwrap().to_file_path().unwrap(); + #[cfg(not(feature = "url"))] + let mut file_path = { + use std::str::FromStr; + PathBuf::from_str(url).unwrap() + }; + file_path = fs::canonicalize(file_path).expect("swagger ui download path should exists"); + + // with file protocol fastapi swagger ui should compile when file changes + println!("cargo:rerun-if-changed={:?}", file_path); + + println!("start copy to : {:?}", zip_path); + fs::copy(file_path, zip_path.clone()).unwrap(); + + let swagger_ui_zip = + File::open([target_dir, &zip_filename].iter().collect::()).unwrap(); + let zip = ZipArchive::new(swagger_ui_zip) + .expect("failed to open file protocol copied Swagger UI"); + SwaggerZip::File(zip) + } else if url.starts_with("http://") || url.starts_with("https://") { + // with http protocol we update when the 'SWAGGER_UI_DOWNLOAD_URL' changes + println!("cargo:rerun-if-env-changed={SWAGGER_UI_DOWNLOAD_URL}"); + + // Update zip_path to point to the resolved cache directory + #[cfg(feature = "cache")] + { + // Compute cache key based hashed URL + crate version + let mut cache_key = String::new(); + cache_key.push_str(url); + cache_key.push_str(&env::var("CARGO_PKG_VERSION").unwrap_or_default()); + let cache_key = sha256(cache_key.as_bytes()); + // Store the cache in the cache_key directory inside the OS's default cache folder + let mut cache_dir = if let Some(dir) = get_cache_dir() { + dir.join("swagger-ui").join(&cache_key) + } else { + println!("cargo:warning=Could not determine cache directory, using OUT_DIR"); + PathBuf::from(env::var("OUT_DIR").unwrap()) + }; + if fs::create_dir_all(&cache_dir).is_err() { + cache_dir = env::var("OUT_DIR").unwrap().into(); + } + zip_path = cache_dir.join(&zip_filename); + } + + if zip_path.exists() { + println!("using cached zip path from : {:?}", zip_path); + } else { + println!("start download to : {:?}", zip_path); + download_file(url, zip_path.clone()).expect("failed to download Swagger UI"); + } + let swagger_ui_zip = File::open(zip_path).unwrap(); + let zip = ZipArchive::new(swagger_ui_zip).expect("failed to open downloaded Swagger UI"); + SwaggerZip::File(zip) + } else { + panic!("`vendored` feature not enabled and invalid {SWAGGER_UI_DOWNLOAD_URL}: {url} -> must start with http:// | https:// | file:"); + } +} + +fn replace_default_url_with_config(target_dir: &str, zip_top_level_folder: &str) { + let regex = Regex::new(r#"(?ms)url:.*deep.*true,"#).unwrap(); + + let path = [ + target_dir, + zip_top_level_folder, + "dist", + "swagger-initializer.js", + ] + .iter() + .collect::(); + + let mut swagger_initializer = fs::read_to_string(&path).unwrap(); + swagger_initializer = swagger_initializer.replace("layout: \"StandaloneLayout\"", ""); + + let replaced_swagger_initializer = regex.replace(&swagger_initializer, "{{config}},"); + + fs::write(&path, replaced_swagger_initializer.as_ref()).unwrap(); +} + +fn write_embed_code(target_dir: &str, zip_top_level_folder: &str) { + let contents = format!( + r#" +// This file is auto-generated during compilation, do not modify +#[derive(RustEmbed)] +#[folder = r"{}/{}/dist/"] +struct SwaggerUiDist; +"#, + target_dir, zip_top_level_folder + ); + let path = [target_dir, "embed.rs"].iter().collect::(); + fs::write(path, contents).unwrap(); +} + +fn download_file(url: &str, path: PathBuf) -> Result<(), Box> { + let reqwest_feature = env::var("CARGO_FEATURE_REQWEST"); + println!("reqwest feature: {reqwest_feature:?}"); + if reqwest_feature.is_ok() { + #[cfg(feature = "reqwest")] + download_file_reqwest(url, path)?; + Ok(()) + } else { + println!("trying to download using `curl` system package"); + download_file_curl(url, path.as_path()) + } +} + +#[cfg(feature = "reqwest")] +fn download_file_reqwest(url: &str, path: PathBuf) -> Result<(), Box> { + let mut client_builder = reqwest::blocking::Client::builder(); + + if let Ok(cainfo) = env::var("CARGO_HTTP_CAINFO") { + match parse_ca_file(&cainfo) { + Ok(cert) => client_builder = client_builder.add_root_certificate(cert), + Err(e) => println!( + "failed to load certificate from CARGO_HTTP_CAINFO `{cainfo}`, attempting to download without it. Error: {e:?}", + ), + } + } + + let client = client_builder.build()?; + + let mut response = client.get(url).send()?; + let mut file = File::create(path)?; + io::copy(&mut response, &mut file)?; + Ok(()) +} + +#[cfg(feature = "reqwest")] +fn parse_ca_file(path: &str) -> Result> { + let mut buf = Vec::new(); + use io::Read; + File::open(path)?.read_to_end(&mut buf)?; + let cert = reqwest::Certificate::from_pem(&buf)?; + Ok(cert) +} + +fn download_file_curl>(url: &str, target_dir: T) -> Result<(), Box> { + // Not using `CARGO_CFG_TARGET_OS` because of the possibility of cross-compilation. + // When targeting `x86_64-pc-windows-gnu` on Linux for example, `cfg!()` in the + // build script still reports `target_os = "linux"`, which is desirable. + let curl_bin_name = if cfg!(target_os = "windows") { + // powershell aliases `curl` to `Invoke-WebRequest` + "curl.exe" + } else { + "curl" + }; + + #[cfg(feature = "url")] + let url = url::Url::parse(url)?; + + let mut args = Vec::with_capacity(6); + args.extend([ + "-sSL", + "-o", + target_dir + .as_ref() + .as_os_str() + .to_str() + .expect("target dir should be valid utf-8"), + #[cfg(feature = "url")] + { + url.as_str() + }, + #[cfg(not(feature = "url"))] + url, + ]); + let cacert = env::var("CARGO_HTTP_CAINFO").unwrap_or_default(); + if !cacert.is_empty() { + args.extend(["--cacert", &cacert]); + } + + let download = std::process::Command::new(curl_bin_name) + .args(args) + .spawn() + .and_then(|mut child| child.wait()); + + Ok(download + .and_then(|status| { + if status.success() { + Ok(()) + } else { + Err(std::io::Error::new( + io::ErrorKind::Other, + format!("curl download file exited with error status: {status}"), + )) + } + }) + .map_err(|error| { + if error.kind() == io::ErrorKind::NotFound { + io::Error::new(error.kind(), format!("`{curl_bin_name}` command not found")) + } else { + error + } + }) + .map_err(Box::new)?) +} + +fn overwrite_target_file(target_dir: &str, swagger_ui_dist_zip: &str, path_in: PathBuf) { + let filename = path_in.file_name().unwrap().to_str().unwrap(); + println!("overwrite file: {:?}", path_in.file_name().unwrap()); + + let content = fs::read(path_in.clone()); + + match content { + Ok(content) => { + let path = [target_dir, swagger_ui_dist_zip, "dist", filename] + .iter() + .collect::(); + + fs::write(path, content).unwrap(); + } + Err(_) => { + println!("cannot read content from file: {:?}", path_in); + } + } +} diff --git a/fastapi-swagger-ui/src/actix.rs b/fastapi-swagger-ui/src/actix.rs new file mode 100644 index 0000000..f0166cd --- /dev/null +++ b/fastapi-swagger-ui/src/actix.rs @@ -0,0 +1,66 @@ +#![cfg(feature = "actix-web")] + +use actix_web::{ + dev::HttpServiceFactory, guard::Get, web, web::Data, HttpResponse, Resource, + Responder as ActixResponder, +}; + +use crate::{ApiDoc, Config, SwaggerUi}; + +impl HttpServiceFactory for SwaggerUi { + fn register(self, config: &mut actix_web::dev::AppService) { + let mut urls = self + .urls + .into_iter() + .map(|(url, openapi)| { + register_api_doc_url_resource(url.url.as_ref(), ApiDoc::Fastapi(openapi), config); + url + }) + .collect::>(); + let external_api_docs = self.external_urls.into_iter().map(|(url, api_doc)| { + register_api_doc_url_resource(url.url.as_ref(), ApiDoc::Value(api_doc), config); + url + }); + urls.extend(external_api_docs); + + let swagger_resource = Resource::new(self.path.as_ref()) + .guard(Get()) + .app_data(Data::new(if let Some(config) = self.config { + if config.url.is_some() || !config.urls.is_empty() { + config + } else { + config.configure_defaults(urls) + } + } else { + Config::new(urls) + })) + .to(serve_swagger_ui); + + HttpServiceFactory::register(swagger_resource, config); + } +} + +fn register_api_doc_url_resource(url: &str, api: ApiDoc, config: &mut actix_web::dev::AppService) { + async fn get_api_doc(api_doc: web::Data) -> impl ActixResponder { + HttpResponse::Ok().json(api_doc.as_ref()) + } + + let url_resource = Resource::new(url) + .guard(Get()) + .app_data(Data::new(api)) + .to(get_api_doc); + HttpServiceFactory::register(url_resource, config); +} + +async fn serve_swagger_ui(path: web::Path, data: web::Data>) -> HttpResponse { + match super::serve(&path.into_inner(), data.into_inner()) { + Ok(swagger_file) => swagger_file + .map(|file| { + HttpResponse::Ok() + .content_type(file.content_type) + .body(file.bytes.to_vec()) + }) + .unwrap_or_else(|| HttpResponse::NotFound().finish()), + Err(error) => HttpResponse::InternalServerError().body(error.to_string()), + } +} diff --git a/fastapi-swagger-ui/src/axum.rs b/fastapi-swagger-ui/src/axum.rs new file mode 100644 index 0000000..c28c8b8 --- /dev/null +++ b/fastapi-swagger-ui/src/axum.rs @@ -0,0 +1,155 @@ +#![cfg(feature = "axum")] + +use std::sync::Arc; + +use axum::{ + extract::Path, http::StatusCode, response::IntoResponse, routing, Extension, Json, Router, +}; + +use crate::{ApiDoc, Config, SwaggerUi, Url}; + +impl From for Router +where + S: Clone + Send + Sync + 'static, +{ + fn from(swagger_ui: SwaggerUi) -> Self { + let urls_capacity = swagger_ui.urls.len(); + let external_urls_capacity = swagger_ui.external_urls.len(); + + let (router, urls) = swagger_ui.urls.into_iter().fold( + ( + Router::::new(), + Vec::::with_capacity(urls_capacity + external_urls_capacity), + ), + |router_and_urls, (url, openapi)| { + add_api_doc_to_urls(router_and_urls, (url, ApiDoc::Fastapi(openapi))) + }, + ); + let (router, urls) = swagger_ui.external_urls.into_iter().fold( + (router, urls), + |router_and_urls, (url, openapi)| { + add_api_doc_to_urls(router_and_urls, (url, ApiDoc::Value(openapi))) + }, + ); + + let config = if let Some(config) = swagger_ui.config { + if config.url.is_some() || !config.urls.is_empty() { + config + } else { + config.configure_defaults(urls) + } + } else { + Config::new(urls) + }; + + let handler = routing::get(serve_swagger_ui).layer(Extension(Arc::new(config))); + let path: &str = swagger_ui.path.as_ref(); + + if path == "/" { + router + .route(path, handler.clone()) + .route(&format!("{}*rest", path), handler) + } else { + let path = if path.ends_with('/') { + &path[..path.len() - 1] + } else { + path + }; + debug_assert!(!path.is_empty()); + + let slash_path = format!("{}/", path); + router + .route( + path, + routing::get(|| async move { axum::response::Redirect::to(&slash_path) }), + ) + .route(&format!("{}/", path), handler.clone()) + .route(&format!("{}/*rest", path), handler) + } + } +} + +fn add_api_doc_to_urls( + router_and_urls: (Router, Vec>), + url: (Url<'static>, ApiDoc), +) -> (Router, Vec>) +where + S: Clone + Send + Sync + 'static, +{ + let (router, mut urls) = router_and_urls; + let (url, openapi) = url; + ( + router.route( + url.url.as_ref(), + routing::get(move || async { Json(openapi) }), + ), + { + urls.push(url); + urls + }, + ) +} + +async fn serve_swagger_ui( + path: Option>, + Extension(state): Extension>>, +) -> impl IntoResponse { + let tail = match path.as_ref() { + Some(tail) => tail, + None => "", + }; + + match super::serve(tail, state) { + Ok(file) => file + .map(|file| { + ( + StatusCode::OK, + [("Content-Type", file.content_type)], + file.bytes, + ) + .into_response() + }) + .unwrap_or_else(|| StatusCode::NOT_FOUND.into_response()), + Err(error) => (StatusCode::INTERNAL_SERVER_ERROR, error.to_string()).into_response(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use axum_test::TestServer; + + #[tokio::test] + async fn mount_onto_root() { + let app = Router::<()>::from(SwaggerUi::new("/")); + let server = TestServer::new(app).unwrap(); + let response = server.get("/").await; + response.assert_status_ok(); + let response = server.get("/swagger-ui.css").await; + response.assert_status_ok(); + } + + #[tokio::test] + async fn mount_onto_path_ends_with_slash() { + let app = Router::<()>::from(SwaggerUi::new("/swagger-ui/")); + let server = TestServer::new(app).unwrap(); + let response = server.get("/swagger-ui").await; + response.assert_status_see_other(); + let response = server.get("/swagger-ui/").await; + response.assert_status_ok(); + let response = server.get("/swagger-ui/swagger-ui.css").await; + response.assert_status_ok(); + } + + #[tokio::test] + async fn mount_onto_path_not_end_with_slash() { + let app = Router::<()>::from(SwaggerUi::new("/swagger-ui")); + let server = TestServer::new(app).unwrap(); + let response = server.get("/swagger-ui").await; + response.assert_status_see_other(); + let response = server.get("/swagger-ui/").await; + response.assert_status_ok(); + let response = server.get("/swagger-ui/swagger-ui.css").await; + response.assert_status_ok(); + } +} diff --git a/fastapi-swagger-ui/src/lib.rs b/fastapi-swagger-ui/src/lib.rs new file mode 100644 index 0000000..b97499f --- /dev/null +++ b/fastapi-swagger-ui/src/lib.rs @@ -0,0 +1,1895 @@ +#![cfg_attr(doc_cfg, feature(doc_cfg))] +//! This crate implements necessary boiler plate code to serve Swagger UI via web server. It +//! works as a bridge for serving the OpenAPI documentation created with [`fastapi`][fastapi] library in the +//! Swagger UI. +//! +//! [fastapi]: +//! +//! **Currently implemented boiler plate for:** +//! +//! * **actix-web** `version >= 4` +//! * **rocket** `version >=0.5` +//! * **axum** `version >=0.7` +//! +//! Serving Swagger UI is framework independent thus this crate also supports serving the Swagger UI with +//! other frameworks as well. With other frameworks there is bit more manual implementation to be done. See +//! more details at [`serve`] or [`examples`][examples]. +//! +//! [examples]: +//! +//! # Crate Features +//! +//! * **`actix-web`** Enables `actix-web` integration with pre-configured SwaggerUI service factory allowing +//! users to use the Swagger UI without a hassle. +//! * **`rocket`** Enables `rocket` integration with with pre-configured routes for serving the Swagger UI +//! and api doc without a hassle. +//! * **`axum`** Enables `axum` integration with pre-configured Router serving Swagger UI and OpenAPI specs +//! hassle free. +//! * **`debug-embed`** Enables `debug-embed` feature on `rust_embed` crate to allow embedding files in debug +//! builds as well. +//! * **`reqwest`** Use `reqwest` for downloading Swagger UI according to the `SWAGGER_UI_DOWNLOAD_URL` environment +//! variable. This is only enabled by default on _Windows_. +//! * **`url`** Enabled by default for parsing and encoding the download URL. +//! * **`vendored`** Enables vendored Swagger UI via `fastapi-swagger-ui-vendored` crate. +//! +//! # Install +//! +//! Use only the raw types without any boiler plate implementation. +//! ```toml +//! [dependencies] +//! fastapi-swagger-ui = "8" +//! ``` +//! +//! Enable actix-web framework with Swagger UI you could define the dependency as follows. +//! ```toml +//! [dependencies] +//! fastapi-swagger-ui = { version = "8", features = ["actix-web"] } +//! ``` +//! +//! **Note!** Also remember that you already have defined `fastapi` dependency in your `Cargo.toml` +//! +//! ## Build Config +//! +//!
+//! +//! **Note!** _`fastapi-swagger-ui` crate will by default try to use system `curl` package for downloading the Swagger UI. It +//! can optionally be downloaded with `reqwest` by enabling `reqwest` feature. On Windows the `reqwest` feature +//! is enabled by default. Reqwest can be useful for platform independent builds however bringing quite a few +//! unnecessary dependencies just to download a file. If the `SWAGGER_UI_DOWNLOAD_URL` is a file path then no +//! downloading will happen._ +//! +//!
+//! +//!
+//! +//! **Tip!** Use **`vendored`** feature flag to use vendored Swagger UI. This is especially useful for no network +//! environments. +//! +//!
+//! +//! **The following configuration env variables are available at build time:** +//! +//! * `SWAGGER_UI_DOWNLOAD_URL`: Defines the url from where to download the swagger-ui zip file. +//! +//! * Current Swagger UI version: +//! * [All available Swagger UI versions](https://github.com/swagger-api/swagger-ui/tags) +//! +//! * `SWAGGER_UI_OVERWRITE_FOLDER`: Defines an _optional_ absolute path to a directory containing files +//! to overwrite the Swagger UI files. Typically you might want to overwrite `index.html`. +//! +//! # Examples +//! +//! Serve Swagger UI with api doc via **`actix-web`**. See full example from +//! [examples](https://github.com/nxpkg/fastapi/tree/master/examples/todo-actix). +//! ```no_run +//! # use actix_web::{App, HttpServer}; +//! # use fastapi_swagger_ui::SwaggerUi; +//! # use fastapi::OpenApi; +//! # use std::net::Ipv4Addr; +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! HttpServer::new(move || { +//! App::new() +//! .service( +//! SwaggerUi::new("/swagger-ui/{_:.*}") +//! .url("/api-docs/openapi.json", ApiDoc::openapi()), +//! ) +//! }) +//! .bind((Ipv4Addr::UNSPECIFIED, 8989)).unwrap() +//! .run(); +//! ``` +//! +//! Serve Swagger UI with api doc via **`rocket`**. See full example from +//! [examples](https://github.com/nxpkg/fastapi/tree/master/examples/rocket-todo). +//! ```no_run +//! # use rocket::{Build, Rocket}; +//! # use fastapi_swagger_ui::SwaggerUi; +//! # use fastapi::OpenApi; +//! #[rocket::launch] +//! fn rocket() -> Rocket { +//! # +//! # #[derive(OpenApi)] +//! # #[openapi()] +//! # struct ApiDoc; +//! # +//! rocket::build() +//! .mount( +//! "/", +//! SwaggerUi::new("/swagger-ui/<_..>") +//! .url("/api-docs/openapi.json", ApiDoc::openapi()), +//! ) +//! } +//! ``` +//! +//! Setup Router to serve Swagger UI with **`axum`** framework. See full implementation of how to serve +//! Swagger UI with axum from [examples](https://github.com/nxpkg/fastapi/tree/master/examples/todo-axum). +//!```no_run +//! # use axum::{routing, Router}; +//! # use fastapi_swagger_ui::SwaggerUi; +//! # use fastapi::OpenApi; +//!# #[derive(OpenApi)] +//!# #[openapi()] +//!# struct ApiDoc; +//!# +//!# fn inner() +//!# where +//!# S: Clone + Send + Sync + 'static, +//!# { +//! let app = Router::::new() +//! .merge(SwaggerUi::new("/swagger-ui") +//! .url("/api-docs/openapi.json", ApiDoc::openapi())); +//!# } +//! ``` +use std::{borrow::Cow, error::Error, mem, sync::Arc}; + +mod actix; +mod axum; +pub mod oauth; +mod rocket; + +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +use fastapi::openapi::OpenApi; +use rust_embed::RustEmbed; +use serde::Serialize; + +include!(concat!(env!("OUT_DIR"), "/embed.rs")); + +/// Entry point for serving Swagger UI and api docs in application. It provides +/// builder style chainable configuration methods for configuring api doc urls. +/// +/// # Examples +/// +/// Create new [`SwaggerUi`] with defaults. +/// ```rust +/// # use fastapi_swagger_ui::SwaggerUi; +/// # use fastapi::OpenApi; +/// # #[derive(OpenApi)] +/// # #[openapi()] +/// # struct ApiDoc; +/// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") +/// .url("/api-docs/openapi.json", ApiDoc::openapi()); +/// ``` +/// +/// Create a new [`SwaggerUi`] with custom [`Config`] and [`oauth::Config`]. +/// ```rust +/// # use fastapi_swagger_ui::{SwaggerUi, Config, oauth}; +/// # use fastapi::OpenApi; +/// # #[derive(OpenApi)] +/// # #[openapi()] +/// # struct ApiDoc; +/// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") +/// .url("/api-docs/openapi.json", ApiDoc::openapi()) +/// .config(Config::default().try_it_out_enabled(true).filter(true)) +/// .oauth(oauth::Config::new()); +/// ``` +/// +#[non_exhaustive] +#[derive(Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +#[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) +)] +pub struct SwaggerUi { + path: Cow<'static, str>, + urls: Vec<(Url<'static>, OpenApi)>, + config: Option>, + external_urls: Vec<(Url<'static>, serde_json::Value)>, +} + +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +#[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) +)] +impl SwaggerUi { + /// Create a new [`SwaggerUi`] for given path. + /// + /// Path argument will expose the Swagger UI to the user and should be something that + /// the underlying application framework / library supports. + /// + /// # Examples + /// + /// Exposes Swagger UI using path `/swagger-ui` using actix-web supported syntax. + /// + /// ```rust + /// # use fastapi_swagger_ui::SwaggerUi; + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}"); + /// ``` + pub fn new>>(path: P) -> Self { + Self { + path: path.into(), + urls: Vec::new(), + config: None, + external_urls: Vec::new(), + } + } + + /// Add api doc [`Url`] into [`SwaggerUi`]. + /// + /// Method takes two arguments where first one is path which exposes the [`OpenApi`] to the user. + /// Second argument is the actual Rust implementation of the OpenAPI doc which is being exposed. + /// + /// Calling this again will add another url to the Swagger UI. + /// + /// # Examples + /// + /// Expose manually created OpenAPI doc. + /// ```rust + /// # use fastapi_swagger_ui::SwaggerUi; + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .url("/api-docs/openapi.json", fastapi::openapi::OpenApi::new( + /// fastapi::openapi::Info::new("my application", "0.1.0"), + /// fastapi::openapi::Paths::new(), + /// )); + /// ``` + /// + /// Expose derived OpenAPI doc. + /// ```rust + /// # use fastapi_swagger_ui::SwaggerUi; + /// # use fastapi::OpenApi; + /// # #[derive(OpenApi)] + /// # #[openapi()] + /// # struct ApiDoc; + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .url("/api-docs/openapi.json", ApiDoc::openapi()); + /// ``` + pub fn url>>(mut self, url: U, openapi: OpenApi) -> Self { + self.urls.push((url.into(), openapi)); + + self + } + + /// Add multiple [`Url`]s to Swagger UI. + /// + /// Takes one [`Vec`] argument containing tuples of [`Url`] and [`OpenApi`]. + /// + /// Situations where this comes handy is when there is a need or wish to separate different parts + /// of the api to separate api docs. + /// + /// # Examples + /// + /// Expose multiple api docs via Swagger UI. + /// ```rust + /// # use fastapi_swagger_ui::{SwaggerUi, Url}; + /// # use fastapi::OpenApi; + /// # #[derive(OpenApi)] + /// # #[openapi()] + /// # struct ApiDoc; + /// # #[derive(OpenApi)] + /// # #[openapi()] + /// # struct ApiDoc2; + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .urls( + /// vec![ + /// (Url::with_primary("api doc 1", "/api-docs/openapi.json", true), ApiDoc::openapi()), + /// (Url::new("api doc 2", "/api-docs/openapi2.json"), ApiDoc2::openapi()) + /// ] + /// ); + /// ``` + pub fn urls(mut self, urls: Vec<(Url<'static>, OpenApi)>) -> Self { + self.urls = urls; + + self + } + + /// Add external API doc to the [`SwaggerUi`]. + /// + /// This operation is unchecked and so it does not check any validity of provided content. + /// Users are required to do their own check if any regarding validity of the external + /// OpenAPI document. + /// + /// Method accepts two arguments, one is [`Url`] the API doc is served at and the second one is + /// the [`serde_json::Value`] of the OpenAPI doc to be served. + /// + /// # Examples + /// + /// Add external API doc to the [`SwaggerUi`]. + /// ```rust + /// # use fastapi_swagger_ui::{SwaggerUi, Url}; + /// # use fastapi::OpenApi; + /// # use serde_json::json; + /// let external_openapi = json!({"openapi": "3.0.0"}); + /// + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .external_url_unchecked("/api-docs/openapi.json", external_openapi); + /// ``` + pub fn external_url_unchecked>>( + mut self, + url: U, + openapi: serde_json::Value, + ) -> Self { + self.external_urls.push((url.into(), openapi)); + + self + } + + /// Add external API docs to the [`SwaggerUi`] from iterator. + /// + /// This operation is unchecked and so it does not check any validity of provided content. + /// Users are required to do their own check if any regarding validity of the external + /// OpenAPI documents. + /// + /// Method accepts one argument, an `iter` of [`Url`] and [`serde_json::Value`] tuples. The + /// [`Url`] will point to location the OpenAPI document is served and the [`serde_json::Value`] + /// is the OpenAPI document to be served. + /// + /// # Examples + /// + /// Add external API docs to the [`SwaggerUi`]. + /// ```rust + /// # use fastapi_swagger_ui::{SwaggerUi, Url}; + /// # use fastapi::OpenApi; + /// # use serde_json::json; + /// let external_openapi = json!({"openapi": "3.0.0"}); + /// let external_openapi2 = json!({"openapi": "3.0.0"}); + /// + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .external_urls_from_iter_unchecked([ + /// ("/api-docs/openapi.json", external_openapi), + /// ("/api-docs/openapi2.json", external_openapi2) + /// ]); + /// ``` + pub fn external_urls_from_iter_unchecked< + I: IntoIterator, + U: Into>, + >( + mut self, + external_urls: I, + ) -> Self { + self.external_urls.extend( + external_urls + .into_iter() + .map(|(url, doc)| (url.into(), doc)), + ); + + self + } + + /// Add oauth [`oauth::Config`] into [`SwaggerUi`]. + /// + /// Method takes one argument which exposes the [`oauth::Config`] to the user. + /// + /// # Examples + /// + /// Enable pkce with default client_id. + /// ```rust + /// # use fastapi_swagger_ui::{SwaggerUi, oauth}; + /// # use fastapi::OpenApi; + /// # #[derive(OpenApi)] + /// # #[openapi()] + /// # struct ApiDoc; + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .url("/api-docs/openapi.json", ApiDoc::openapi()) + /// .oauth(oauth::Config::new() + /// .client_id("client-id") + /// .scopes(vec![String::from("openid")]) + /// .use_pkce_with_authorization_code_grant(true) + /// ); + /// ``` + pub fn oauth(mut self, oauth: oauth::Config) -> Self { + let config = self.config.get_or_insert(Default::default()); + config.oauth = Some(oauth); + + self + } + + /// Add custom [`Config`] into [`SwaggerUi`] which gives users more granular control over + /// Swagger UI options. + /// + /// Methods takes one [`Config`] argument which exposes Swagger UI's configurable options + /// to the users. + /// + /// # Examples + /// + /// Create a new [`SwaggerUi`] with custom configuration. + /// ```rust + /// # use fastapi_swagger_ui::{SwaggerUi, Config}; + /// # use fastapi::OpenApi; + /// # #[derive(OpenApi)] + /// # #[openapi()] + /// # struct ApiDoc; + /// let swagger = SwaggerUi::new("/swagger-ui/{_:.*}") + /// .url("/api-docs/openapi.json", ApiDoc::openapi()) + /// .config(Config::default().try_it_out_enabled(true).filter(true)); + /// ``` + pub fn config(mut self, config: Config<'static>) -> Self { + self.config = Some(config); + + self + } +} + +/// Rust type for Swagger UI url configuration object. +#[non_exhaustive] +#[cfg_attr(feature = "debug", derive(Debug))] +#[derive(Default, Serialize, Clone)] +pub struct Url<'a> { + name: Cow<'a, str>, + url: Cow<'a, str>, + #[serde(skip)] + primary: bool, +} + +impl<'a> Url<'a> { + /// Create new [`Url`]. + /// + /// Name is shown in the select dropdown when there are multiple docs in Swagger UI. + /// + /// Url is path which exposes the OpenAPI doc. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::Url; + /// let url = Url::new("My Api", "/api-docs/openapi.json"); + /// ``` + pub fn new(name: &'a str, url: &'a str) -> Self { + Self { + name: Cow::Borrowed(name), + url: Cow::Borrowed(url), + ..Default::default() + } + } + + /// Create new [`Url`] with primary flag. + /// + /// Primary flag allows users to override the default behavior of the Swagger UI for selecting the primary + /// doc to display. By default when there are multiple docs in Swagger UI the first one in the list + /// will be the primary. + /// + /// Name is shown in the select dropdown when there are multiple docs in Swagger UI. + /// + /// Url is path which exposes the OpenAPI doc. + /// + /// # Examples + /// + /// Set "My Api" as primary. + /// ```rust + /// # use fastapi_swagger_ui::Url; + /// let url = Url::with_primary("My Api", "/api-docs/openapi.json", true); + /// ``` + pub fn with_primary(name: &'a str, url: &'a str, primary: bool) -> Self { + Self { + name: Cow::Borrowed(name), + url: Cow::Borrowed(url), + primary, + } + } +} + +impl<'a> From<&'a str> for Url<'a> { + fn from(url: &'a str) -> Self { + Self { + url: Cow::Borrowed(url), + ..Default::default() + } + } +} + +impl From for Url<'_> { + fn from(url: String) -> Self { + Self { + url: Cow::Owned(url), + ..Default::default() + } + } +} + +impl<'a> From> for Url<'a> { + fn from(url: Cow<'static, str>) -> Self { + Self { + url, + ..Default::default() + } + } +} + +const SWAGGER_STANDALONE_LAYOUT: &str = "StandaloneLayout"; +const SWAGGER_BASE_LAYOUT: &str = "BaseLayout"; + +/// Object used to alter Swagger UI settings. +/// +/// Config struct provides [Swagger UI configuration](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/configuration.md) +/// for settings which could be altered with **docker variables**. +/// +/// # Examples +/// +/// In simple case, create config directly from url that points to the api doc json. +/// ```rust +/// # use fastapi_swagger_ui::Config; +/// let config = Config::from("/api-doc.json"); +/// ``` +/// +/// If there is multiple api docs to serve config, the [`Config`] can be also be directly created with [`Config::new`] +/// ```rust +/// # use fastapi_swagger_ui::Config; +/// let config = Config::new(["/api-docs/openapi1.json", "/api-docs/openapi2.json"]); +/// ``` +/// +/// Or same as above but more verbose syntax. +/// ```rust +/// # use fastapi_swagger_ui::{Config, Url}; +/// let config = Config::new([ +/// Url::new("api1", "/api-docs/openapi1.json"), +/// Url::new("api2", "/api-docs/openapi2.json") +/// ]); +/// ``` +/// +/// With oauth config. +/// ```rust +/// # use fastapi_swagger_ui::{Config, oauth}; +/// let config = Config::with_oauth_config( +/// ["/api-docs/openapi1.json", "/api-docs/openapi2.json"], +/// oauth::Config::new(), +/// ); +/// ``` +#[non_exhaustive] +#[derive(Serialize, Clone)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "camelCase")] +pub struct Config<'a> { + /// Url to fetch external configuration from. + #[serde(skip_serializing_if = "Option::is_none")] + config_url: Option, + + /// Id of the DOM element where `Swagger UI` will put it's user interface. + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(rename = "dom_id")] + dom_id: Option, + + /// [`Url`] the Swagger UI is serving. + #[serde(skip_serializing_if = "Option::is_none")] + url: Option, + + /// Name of the primary url if any. + #[serde(skip_serializing_if = "Option::is_none", rename = "urls.primaryName")] + urls_primary_name: Option, + + /// [`Url`]s the Swagger UI is serving. + #[serde(skip_serializing_if = "Vec::is_empty")] + urls: Vec>, + + /// Enables overriding configuration parameters with url query parameters. + #[serde(skip_serializing_if = "Option::is_none")] + query_config_enabled: Option, + + /// Controls whether [deep linking](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/deep-linking.md) + /// is enabled in OpenAPI spec. + /// + /// Deep linking automatically scrolls and expands UI to given url fragment. + #[serde(skip_serializing_if = "Option::is_none")] + deep_linking: Option, + + /// Controls whether operation id is shown in the operation list. + #[serde(skip_serializing_if = "Option::is_none")] + display_operation_id: Option, + + /// Default models expansion depth; -1 will completely hide the models. + #[serde(skip_serializing_if = "Option::is_none")] + default_models_expand_depth: Option, + + /// Default model expansion depth from model example section. + #[serde(skip_serializing_if = "Option::is_none")] + default_model_expand_depth: Option, + + /// Defines how models is show when API is first rendered. + #[serde(skip_serializing_if = "Option::is_none")] + default_model_rendering: Option, + + /// Define whether request duration in milliseconds is displayed for "Try it out" requests. + #[serde(skip_serializing_if = "Option::is_none")] + display_request_duration: Option, + + /// Controls default expansion for operations and tags. + #[serde(skip_serializing_if = "Option::is_none")] + doc_expansion: Option, + + /// Defines is filtering of tagged operations allowed with edit box in top bar. + #[serde(skip_serializing_if = "Option::is_none")] + filter: Option, + + /// Controls how many tagged operations are shown. By default all operations are shown. + #[serde(skip_serializing_if = "Option::is_none")] + max_displayed_tags: Option, + + /// Defines whether extensions are shown. + #[serde(skip_serializing_if = "Option::is_none")] + show_extensions: Option, + + /// Defines whether common extensions are shown. + #[serde(skip_serializing_if = "Option::is_none")] + show_common_extensions: Option, + + /// Defines whether "Try it out" section should be enabled by default. + #[serde(skip_serializing_if = "Option::is_none")] + try_it_out_enabled: Option, + + /// Defines whether request snippets section is enabled. If disabled legacy curl snipped + /// will be used. + #[serde(skip_serializing_if = "Option::is_none")] + request_snippets_enabled: Option, + + /// Oauth redirect url. + #[serde(skip_serializing_if = "Option::is_none")] + oauth2_redirect_url: Option, + + /// Defines whether request mutated with `requestInterceptor` will be used to produce curl command + /// in the UI. + #[serde(skip_serializing_if = "Option::is_none")] + show_mutated_request: Option, + + /// Define supported http request submit methods. + #[serde(skip_serializing_if = "Option::is_none")] + supported_submit_methods: Option>, + + /// Define validator url which is used to validate the Swagger spec. By default the validator swagger.io's + /// online validator is used. Setting this to none will disable spec validation. + #[serde(skip_serializing_if = "Option::is_none")] + validator_url: Option, + + /// Enables passing credentials to CORS requests as defined + /// [fetch standards](https://fetch.spec.whatwg.org/#credentials). + #[serde(skip_serializing_if = "Option::is_none")] + with_credentials: Option, + + /// Defines whether authorizations is persisted throughout browser refresh and close. + #[serde(skip_serializing_if = "Option::is_none")] + persist_authorization: Option, + + /// [`oauth::Config`] the Swagger UI is using for auth flow. + #[serde(skip)] + oauth: Option, + + /// Defines syntax highlighting specific options. + #[serde(skip_serializing_if = "Option::is_none")] + syntax_highlight: Option, + + /// The layout of Swagger UI uses, default is `"StandaloneLayout"`. + layout: &'a str, +} + +impl<'a> Config<'a> { + fn new_, U: Into>>( + urls: I, + oauth_config: Option, + ) -> Self { + let urls = urls.into_iter().map(Into::into).collect::>>(); + let urls_len = urls.len(); + + Self { + oauth: oauth_config, + ..if urls_len == 1 { + Self::new_config_with_single_url(urls) + } else { + Self::new_config_with_multiple_urls(urls) + } + } + } + + fn new_config_with_multiple_urls(urls: Vec>) -> Self { + let primary_name = urls + .iter() + .find(|url| url.primary) + .map(|url| url.name.to_string()); + + Self { + urls_primary_name: primary_name, + urls: urls + .into_iter() + .map(|mut url| { + if url.name == "" { + url.name = Cow::Owned(String::from(&url.url[..])); + + url + } else { + url + } + }) + .collect(), + ..Default::default() + } + } + + fn new_config_with_single_url(mut urls: Vec>) -> Self { + let url = urls.get_mut(0).map(mem::take).unwrap(); + let primary_name = if url.primary { + Some(url.name.to_string()) + } else { + None + }; + + Self { + urls_primary_name: primary_name, + url: if url.name == "" { + Some(url.url.to_string()) + } else { + None + }, + urls: if url.name != "" { + vec![url] + } else { + Vec::new() + }, + ..Default::default() + } + } + + /// Constructs a new [`Config`] from [`Iterator`] of [`Url`]s. + /// + /// [`Url`]s provided to the [`Config`] will only change the urls Swagger UI is going to use to + /// fetch the API document. This does not change the URL that is defined with [`SwaggerUi::url`] + /// or [`SwaggerUi::urls`] which defines the URL the API document is exposed from. + /// + /// # Examples + /// Create new config with 2 api doc urls. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi1.json", "/api-docs/openapi2.json"]); + /// ``` + pub fn new, U: Into>>(urls: I) -> Self { + Self::new_(urls, None) + } + + /// Constructs a new [`Config`] from [`Iterator`] of [`Url`]s. + /// + /// # Examples + /// Create new config with oauth config. + /// ```rust + /// # use fastapi_swagger_ui::{Config, oauth}; + /// let config = Config::with_oauth_config( + /// ["/api-docs/openapi1.json", "/api-docs/openapi2.json"], + /// oauth::Config::new(), + /// ); + /// ``` + pub fn with_oauth_config, U: Into>>( + urls: I, + oauth_config: oauth::Config, + ) -> Self { + Self::new_(urls, Some(oauth_config)) + } + + /// Configure defaults for current [`Config`]. + /// + /// A new [`Config`] will be created with given `urls` and its _**default values**_ and + /// _**url, urls and urls_primary_name**_ will be moved to the current [`Config`] the method + /// is called on. + /// + /// Current config will be returned with configured default values. + #[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] + #[cfg_attr( + doc_cfg, + doc(cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))) + )] + fn configure_defaults, U: Into>>(mut self, urls: I) -> Self { + let Config { + dom_id, + deep_linking, + url, + urls, + urls_primary_name, + .. + } = Config::new(urls); + + self.dom_id = dom_id; + self.deep_linking = deep_linking; + self.url = url; + self.urls = urls; + self.urls_primary_name = urls_primary_name; + + self + } + + /// Add url to fetch external configuration from. + /// + /// # Examples + /// + /// Set external config url. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .config_url("http://url.to.external.config"); + /// ``` + pub fn config_url>(mut self, config_url: S) -> Self { + self.config_url = Some(config_url.into()); + + self + } + + /// Add id of the DOM element where `Swagger UI` will put it's user interface. + /// + /// The default value is `#swagger-ui`. + /// + /// # Examples + /// + /// Set custom dom id where the Swagger UI will place it's content. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]).dom_id("#my-id"); + /// ``` + pub fn dom_id>(mut self, dom_id: S) -> Self { + self.dom_id = Some(dom_id.into()); + + self + } + + /// Set `query_config_enabled` to allow overriding configuration parameters via url `query` + /// parameters. + /// + /// Default value is `false`. + /// + /// # Examples + /// + /// Enable query config. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .query_config_enabled(true); + /// ``` + pub fn query_config_enabled(mut self, query_config_enabled: bool) -> Self { + self.query_config_enabled = Some(query_config_enabled); + + self + } + + /// Set `deep_linking` to allow deep linking tags and operations. + /// + /// Deep linking will automatically scroll to and expand operation when Swagger UI is + /// given corresponding url fragment. See more at + /// [deep linking docs](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/deep-linking.md). + /// + /// Deep linking is enabled by default. + /// + /// # Examples + /// + /// Disable the deep linking. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .deep_linking(false); + /// ``` + pub fn deep_linking(mut self, deep_linking: bool) -> Self { + self.deep_linking = Some(deep_linking); + + self + } + + /// Set `display_operation_id` to `true` to show operation id in the operations list. + /// + /// Default value is `false`. + /// + /// # Examples + /// + /// Allow operation id to be shown. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .display_operation_id(true); + /// ``` + pub fn display_operation_id(mut self, display_operation_id: bool) -> Self { + self.display_operation_id = Some(display_operation_id); + + self + } + + /// Set 'layout' to 'BaseLayout' to only use the base swagger layout without a search header. + /// + /// Default value is 'StandaloneLayout'. + /// + /// # Examples + /// + /// Configure Swagger to use Base Layout instead of Standalone + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .use_base_layout(); + /// ``` + pub fn use_base_layout(mut self) -> Self { + self.layout = SWAGGER_BASE_LAYOUT; + + self + } + + /// Add default models expansion depth. + /// + /// Setting this to `-1` will completely hide the models. + /// + /// # Examples + /// + /// Hide all the models. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .default_models_expand_depth(-1); + /// ``` + pub fn default_models_expand_depth(mut self, default_models_expand_depth: isize) -> Self { + self.default_models_expand_depth = Some(default_models_expand_depth); + + self + } + + /// Add default model expansion depth for model on the example section. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .default_model_expand_depth(1); + /// ``` + pub fn default_model_expand_depth(mut self, default_model_expand_depth: isize) -> Self { + self.default_model_expand_depth = Some(default_model_expand_depth); + + self + } + + /// Add `default_model_rendering` to set how models is show when API is first rendered. + /// + /// The user can always switch the rendering for given model by clicking the `Model` and `Example Value` links. + /// + /// * `example` Makes example rendered first by default. + /// * `model` Makes model rendered first by default. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .default_model_rendering(r#"["example"*, "model"]"#); + /// ``` + pub fn default_model_rendering>(mut self, default_model_rendering: S) -> Self { + self.default_model_rendering = Some(default_model_rendering.into()); + + self + } + + /// Set to `true` to show request duration of _**'Try it out'**_ requests _**(in milliseconds)**_. + /// + /// Default value is `false`. + /// + /// # Examples + /// Enable request duration of the _**'Try it out'**_ requests. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .display_request_duration(true); + /// ``` + pub fn display_request_duration(mut self, display_request_duration: bool) -> Self { + self.display_request_duration = Some(display_request_duration); + + self + } + + /// Add `doc_expansion` to control default expansion for operations and tags. + /// + /// * `list` Will expand only tags. + /// * `full` Will expand tags and operations. + /// * `none` Will expand nothing. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .doc_expansion(r#"["list"*, "full", "none"]"#); + /// ``` + pub fn doc_expansion>(mut self, doc_expansion: S) -> Self { + self.doc_expansion = Some(doc_expansion.into()); + + self + } + + /// Add `filter` to allow filtering of tagged operations. + /// + /// When enabled top bar will show and edit box that can be used to filter visible tagged operations. + /// Filter behaves case sensitive manner and matches anywhere inside the tag. + /// + /// Default value is `false`. + /// + /// # Examples + /// + /// Enable filtering. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .filter(true); + /// ``` + pub fn filter(mut self, filter: bool) -> Self { + self.filter = Some(filter); + + self + } + + /// Add `max_displayed_tags` to restrict shown tagged operations. + /// + /// By default all operations are shown. + /// + /// # Examples + /// + /// Display only 4 operations. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .max_displayed_tags(4); + /// ``` + pub fn max_displayed_tags(mut self, max_displayed_tags: usize) -> Self { + self.max_displayed_tags = Some(max_displayed_tags); + + self + } + + /// Set `show_extensions` to adjust whether vendor extension _**`(x-)`**_ fields and values + /// are shown for operations, parameters, responses and schemas. + /// + /// # Example + /// + /// Show vendor extensions. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .show_extensions(true); + /// ``` + pub fn show_extensions(mut self, show_extensions: bool) -> Self { + self.show_extensions = Some(show_extensions); + + self + } + + /// Add `show_common_extensions` to define whether common extension + /// _**`(pattern, maxLength, minLength, maximum, minimum)`**_ fields and values are shown + /// for parameters. + /// + /// # Examples + /// + /// Show common extensions. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .show_common_extensions(true); + /// ``` + pub fn show_common_extensions(mut self, show_common_extensions: bool) -> Self { + self.show_common_extensions = Some(show_common_extensions); + + self + } + + /// Add `try_it_out_enabled` to enable _**'Try it out'**_ section by default. + /// + /// Default value is `false`. + /// + /// # Examples + /// + /// Enable _**'Try it out'**_ section by default. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .try_it_out_enabled(true); + /// ``` + pub fn try_it_out_enabled(mut self, try_it_out_enabled: bool) -> Self { + self.try_it_out_enabled = Some(try_it_out_enabled); + + self + } + + /// Set `request_snippets_enabled` to enable request snippets section. + /// + /// If disabled legacy curl snipped will be used. + /// + /// Default value is `false`. + /// + /// # Examples + /// + /// Enable request snippets section. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .request_snippets_enabled(true); + /// ``` + pub fn request_snippets_enabled(mut self, request_snippets_enabled: bool) -> Self { + self.request_snippets_enabled = Some(request_snippets_enabled); + + self + } + + /// Add oauth redirect url. + /// + /// # Examples + /// + /// Add oauth redirect url. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .oauth2_redirect_url("http://my.oauth2.redirect.url"); + /// ``` + pub fn oauth2_redirect_url>(mut self, oauth2_redirect_url: S) -> Self { + self.oauth2_redirect_url = Some(oauth2_redirect_url.into()); + + self + } + + /// Add `show_mutated_request` to use request returned from `requestInterceptor` + /// to produce curl command in the UI. If set to `false` the request before `requestInterceptor` + /// was applied will be used. + /// + /// # Examples + /// + /// Use request after `requestInterceptor` to produce the curl command. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .show_mutated_request(true); + /// ``` + pub fn show_mutated_request(mut self, show_mutated_request: bool) -> Self { + self.show_mutated_request = Some(show_mutated_request); + + self + } + + /// Add supported http methods for _**'Try it out'**_ operation. + /// + /// _**'Try it out'**_ will be enabled based on the given list of http methods when + /// the operation's http method is included within the list. + /// By giving an empty list will disable _**'Try it out'**_ from all operations but it will + /// **not** filter operations from the UI. + /// + /// By default all http operations are enabled. + /// + /// # Examples + /// + /// Set allowed http methods explicitly. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .supported_submit_methods(["get", "put", "post", "delete", "options", "head", "patch", "trace"]); + /// ``` + /// + /// Allow _**'Try it out'**_ for only GET operations. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .supported_submit_methods(["get"]); + /// ``` + pub fn supported_submit_methods, S: Into>( + mut self, + supported_submit_methods: I, + ) -> Self { + self.supported_submit_methods = Some( + supported_submit_methods + .into_iter() + .map(|method| method.into()) + .collect(), + ); + + self + } + + /// Add validator url which is used to validate the Swagger spec. + /// + /// This can also be set to use locally deployed validator for example see + /// [Validator Badge](https://github.com/swagger-api/validator-badge) for more details. + /// + /// By default swagger.io's online validator _**`(https://validator.swagger.io/validator)`**_ will be used. + /// Setting this to `none` will disable the validator. + /// + /// # Examples + /// + /// Disable the validator. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .validator_url("none"); + /// ``` + pub fn validator_url>(mut self, validator_url: S) -> Self { + self.validator_url = Some(validator_url.into()); + + self + } + + /// Set `with_credentials` to enable passing credentials to CORS requests send by browser as defined + /// [fetch standards](https://fetch.spec.whatwg.org/#credentials). + /// + /// **Note!** that Swagger UI cannot currently set cookies cross-domain + /// (see [swagger-js#1163](https://github.com/swagger-api/swagger-js/issues/1163)) - + /// as a result, you will have to rely on browser-supplied cookies (which this setting enables sending) + /// that Swagger UI cannot control. + /// + /// # Examples + /// + /// Enable passing credentials to CORS requests. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .with_credentials(true); + /// ``` + pub fn with_credentials(mut self, with_credentials: bool) -> Self { + self.with_credentials = Some(with_credentials); + + self + } + + /// Set to `true` to enable authorizations to be persisted throughout browser refresh and close. + /// + /// Default value is `false`. + /// + /// + /// # Examples + /// + /// Persists authorization throughout browser close and refresh. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .persist_authorization(true); + /// ``` + pub fn persist_authorization(mut self, persist_authorization: bool) -> Self { + self.persist_authorization = Some(persist_authorization); + + self + } + + /// Set a specific configuration for syntax highlighting responses + /// and curl commands. + /// + /// By default, swagger-ui does syntax highlighting of responses + /// and curl commands. This may consume considerable resources in + /// the browser when executed on large responses. + /// + /// # Example + /// + /// Disable syntax highlighting. + /// ```rust + /// # use fastapi_swagger_ui::Config; + /// let config = Config::new(["/api-docs/openapi.json"]) + /// .with_syntax_highlight(false); + /// ``` + pub fn with_syntax_highlight>(mut self, syntax_highlight: H) -> Self { + self.syntax_highlight = Some(syntax_highlight.into()); + + self + } +} + +impl Default for Config<'_> { + fn default() -> Self { + Self { + config_url: Default::default(), + dom_id: Some("#swagger-ui".to_string()), + url: Default::default(), + urls_primary_name: Default::default(), + urls: Default::default(), + query_config_enabled: Default::default(), + deep_linking: Some(true), + display_operation_id: Default::default(), + default_models_expand_depth: Default::default(), + default_model_expand_depth: Default::default(), + default_model_rendering: Default::default(), + display_request_duration: Default::default(), + doc_expansion: Default::default(), + filter: Default::default(), + max_displayed_tags: Default::default(), + show_extensions: Default::default(), + show_common_extensions: Default::default(), + try_it_out_enabled: Default::default(), + request_snippets_enabled: Default::default(), + oauth2_redirect_url: Default::default(), + show_mutated_request: Default::default(), + supported_submit_methods: Default::default(), + validator_url: Default::default(), + with_credentials: Default::default(), + persist_authorization: Default::default(), + oauth: Default::default(), + syntax_highlight: Default::default(), + layout: SWAGGER_STANDALONE_LAYOUT, + } + } +} + +impl<'a> From<&'a str> for Config<'a> { + fn from(s: &'a str) -> Self { + Self::new([s]) + } +} + +impl From for Config<'_> { + fn from(s: String) -> Self { + Self::new([s]) + } +} + +/// Represents settings related to syntax highlighting of payloads and +/// cURL commands. +#[derive(Serialize, Clone)] +#[non_exhaustive] +pub struct SyntaxHighlight { + /// Boolean telling whether syntax highlighting should be + /// activated or not. Defaults to `true`. + pub activated: bool, + /// Highlight.js syntax coloring theme to use. + #[serde(skip_serializing_if = "Option::is_none")] + pub theme: Option<&'static str>, +} + +impl Default for SyntaxHighlight { + fn default() -> Self { + Self { + activated: true, + theme: None, + } + } +} + +impl From for SyntaxHighlight { + fn from(value: bool) -> Self { + Self { + activated: value, + ..Default::default() + } + } +} + +impl SyntaxHighlight { + /// Explicitly specifies whether syntax highlighting is to be + /// activated or not. Defaults to true. + pub fn activated(mut self, activated: bool) -> Self { + self.activated = activated; + self + } + + /// Explicitly specifies the + /// [Highlight.js](https://highlightjs.org/) coloring theme to + /// utilize for syntax highlighting. + pub fn theme(mut self, theme: &'static str) -> Self { + self.theme = Some(theme); + self + } +} + +/// Represents servable file of Swagger UI. This is used together with [`serve`] function +/// to serve Swagger UI files via web server. +#[non_exhaustive] +pub struct SwaggerFile<'a> { + /// Content of the file as [`Cow`] [`slice`] of bytes. + pub bytes: Cow<'a, [u8]>, + /// Content type of the file e.g `"text/xml"`. + pub content_type: String, +} + +/// User friendly way to serve Swagger UI and its content via web server. +/// +/// * **path** Should be the relative path to Swagger UI resource within the web server. +/// * **config** Swagger [`Config`] to use for the Swagger UI. +/// +/// Typically this function is implemented _**within**_ handler what serves the Swagger UI. Handler itself must +/// match to user defined path that points to the root of the Swagger UI and match everything relatively +/// from the root of the Swagger UI _**(tail path)**_. The relative path from root of the Swagger UI +/// is used to serve [`SwaggerFile`]s. If Swagger UI is served from path `/swagger-ui/` then the `tail` +/// is everything under the `/swagger-ui/` prefix. +/// +/// _There are also implementations in [examples of fastapi repository][examples]._ +/// +/// [examples]: https://github.com/nxpkg/fastapi/tree/master/examples +/// +/// # Examples +/// +/// _**Reference implementation with `actix-web`.**_ +/// ```rust +/// # use actix_web::HttpResponse; +/// # use std::sync::Arc; +/// # use fastapi_swagger_ui::Config; +/// // The config should be created in main function or in initialization before +/// // creation of the handler which will handle serving the Swagger UI. +/// let config = Arc::new(Config::from("/api-doc.json")); +/// +/// // This "/" is for demonstrative purposes only. The actual path should point to +/// // file within Swagger UI. In real implementation this is the `tail` path from root of the +/// // Swagger UI to the file served. +/// let tail_path = "/"; +/// +/// fn get_swagger_ui(tail_path: String, config: Arc) -> HttpResponse { +/// match fastapi_swagger_ui::serve(tail_path.as_ref(), config) { +/// Ok(swagger_file) => swagger_file +/// .map(|file| { +/// HttpResponse::Ok() +/// .content_type(file.content_type) +/// .body(file.bytes.to_vec()) +/// }) +/// .unwrap_or_else(|| HttpResponse::NotFound().finish()), +/// Err(error) => HttpResponse::InternalServerError().body(error.to_string()), +/// } +/// } +/// ``` +pub fn serve<'a>( + path: &str, + config: Arc>, +) -> Result>, Box> { + let mut file_path = path; + + if file_path.is_empty() || file_path == "/" { + file_path = "index.html"; + } + + if let Some(file) = SwaggerUiDist::get(file_path) { + let mut bytes = file.data; + + if file_path == "swagger-initializer.js" { + let mut file = match String::from_utf8(bytes.to_vec()) { + Ok(file) => file, + Err(error) => return Err(Box::new(error)), + }; + + file = format_config(config.as_ref(), file)?; + + if let Some(oauth) = &config.oauth { + match oauth::format_swagger_config(oauth, file) { + Ok(oauth_file) => file = oauth_file, + Err(error) => return Err(Box::new(error)), + } + } + + bytes = Cow::Owned(file.as_bytes().to_vec()) + }; + + Ok(Some(SwaggerFile { + bytes, + content_type: mime_guess::from_path(file_path) + .first_or_octet_stream() + .to_string(), + })) + } else { + Ok(None) + } +} + +#[inline] +fn format_config(config: &Config, file: String) -> Result> { + let config_json = match serde_json::to_string_pretty(&config) { + Ok(config) => config, + Err(error) => return Err(Box::new(error)), + }; + + // Replace {{config}} with pretty config json and remove the curly brackets `{ }` from beginning and the end. + Ok(file.replace("{{config}}", &config_json[2..&config_json.len() - 2])) +} + +/// Is used to provide general way to deliver multiple types of OpenAPI docs via `fastapi-swagger-ui`. +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +#[derive(Clone)] +enum ApiDoc { + Fastapi(fastapi::openapi::OpenApi), + Value(serde_json::Value), +} + +// Delegate serde's `Serialize` to the variant itself. +#[cfg(any(feature = "actix-web", feature = "rocket", feature = "axum"))] +impl Serialize for ApiDoc { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + match self { + Self::Value(value) => value.serialize(serializer), + Self::Fastapi(fastapi) => fastapi.serialize(serializer), + } + } +} + +#[cfg(test)] +mod tests { + use similar::TextDiff; + + use super::*; + + fn assert_diff_equal(expected: &str, new: &str) { + let diff = TextDiff::from_lines(expected, new); + + assert_eq!(expected, new, "\nDifference:\n{}", diff.unified_diff()); + } + + const TEST_INITIAL_CONFIG: &str = r#" +window.ui = SwaggerUIBundle({ + {{config}}, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"#; + + #[test] + fn format_swagger_config_json_single_url() { + let formatted_config = match format_config( + &Config::new(["/api-docs/openapi1.json"]), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "url": "/api-docs/openapi1.json", + "deepLinking": true, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config) + } + + #[test] + fn format_swagger_config_json_single_url_with_name() { + let formatted_config = match format_config( + &Config::new([Url::new("api-doc1", "/api-docs/openapi1.json")]), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "urls": [ + { + "name": "api-doc1", + "url": "/api-docs/openapi1.json" + } + ], + "deepLinking": true, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_json_single_url_primary() { + let formatted_config = match format_config( + &Config::new([Url::with_primary( + "api-doc1", + "/api-docs/openapi1.json", + true, + )]), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "urls.primaryName": "api-doc1", + "urls": [ + { + "name": "api-doc1", + "url": "/api-docs/openapi1.json" + } + ], + "deepLinking": true, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_multiple_urls_with_primary() { + let formatted_config = match format_config( + &Config::new([ + Url::with_primary("api-doc1", "/api-docs/openapi1.json", true), + Url::new("api-doc2", "/api-docs/openapi2.json"), + ]), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "urls.primaryName": "api-doc1", + "urls": [ + { + "name": "api-doc1", + "url": "/api-docs/openapi1.json" + }, + { + "name": "api-doc2", + "url": "/api-docs/openapi2.json" + } + ], + "deepLinking": true, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_multiple_urls() { + let formatted_config = match format_config( + &Config::new(["/api-docs/openapi1.json", "/api-docs/openapi2.json"]), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "urls": [ + { + "name": "/api-docs/openapi1.json", + "url": "/api-docs/openapi1.json" + }, + { + "name": "/api-docs/openapi2.json", + "url": "/api-docs/openapi2.json" + } + ], + "deepLinking": true, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_with_multiple_fields() { + let formatted_config = match format_config( + &Config::new(["/api-docs/openapi1.json"]) + .deep_linking(false) + .dom_id("#another-el") + .default_model_expand_depth(-1) + .default_model_rendering(r#"["example"*]"#) + .default_models_expand_depth(1) + .display_operation_id(true) + .display_request_duration(true) + .filter(true) + .use_base_layout() + .doc_expansion(r#"["list"*]"#) + .max_displayed_tags(1) + .oauth2_redirect_url("http://auth") + .persist_authorization(true) + .query_config_enabled(true) + .request_snippets_enabled(true) + .show_common_extensions(true) + .show_extensions(true) + .show_mutated_request(true) + .supported_submit_methods(["get"]) + .try_it_out_enabled(true) + .validator_url("none") + .with_credentials(true), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#another-el", + "url": "/api-docs/openapi1.json", + "queryConfigEnabled": true, + "deepLinking": false, + "displayOperationId": true, + "defaultModelsExpandDepth": 1, + "defaultModelExpandDepth": -1, + "defaultModelRendering": "[\"example\"*]", + "displayRequestDuration": true, + "docExpansion": "[\"list\"*]", + "filter": true, + "maxDisplayedTags": 1, + "showExtensions": true, + "showCommonExtensions": true, + "tryItOutEnabled": true, + "requestSnippetsEnabled": true, + "oauth2RedirectUrl": "http://auth", + "showMutatedRequest": true, + "supportedSubmitMethods": [ + "get" + ], + "validatorUrl": "none", + "withCredentials": true, + "persistAuthorization": true, + "layout": "BaseLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_with_syntax_highlight_default() { + let formatted_config = match format_config( + &Config::new(["/api-docs/openapi1.json"]) + .with_syntax_highlight(SyntaxHighlight::default()), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "url": "/api-docs/openapi1.json", + "deepLinking": true, + "syntaxHighlight": { + "activated": true + }, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_with_syntax_highlight_on() { + let formatted_config = match format_config( + &Config::new(["/api-docs/openapi1.json"]).with_syntax_highlight(true), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "url": "/api-docs/openapi1.json", + "deepLinking": true, + "syntaxHighlight": { + "activated": true + }, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_with_syntax_highlight_off() { + let formatted_config = match format_config( + &Config::new(["/api-docs/openapi1.json"]).with_syntax_highlight(false), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "url": "/api-docs/openapi1.json", + "deepLinking": true, + "syntaxHighlight": { + "activated": false + }, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } + + #[test] + fn format_swagger_config_with_syntax_highlight_default_with_theme() { + let formatted_config = match format_config( + &Config::new(["/api-docs/openapi1.json"]) + .with_syntax_highlight(SyntaxHighlight::default().theme("monokai")), + String::from(TEST_INITIAL_CONFIG), + ) { + Ok(file) => file, + Err(error) => panic!("{error}"), + }; + + const EXPECTED: &str = r###" +window.ui = SwaggerUIBundle({ + "dom_id": "#swagger-ui", + "url": "/api-docs/openapi1.json", + "deepLinking": true, + "syntaxHighlight": { + "activated": true, + "theme": "monokai" + }, + "layout": "StandaloneLayout", + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], +});"###; + + assert_diff_equal(EXPECTED, &formatted_config); + } +} diff --git a/fastapi-swagger-ui/src/oauth.rs b/fastapi-swagger-ui/src/oauth.rs new file mode 100644 index 0000000..c2f5dc6 --- /dev/null +++ b/fastapi-swagger-ui/src/oauth.rs @@ -0,0 +1,315 @@ +//! Implements Swagger UI [oauth configuration](https://github.com/swagger-api/swagger-ui/blob/master/docs/usage/oauth2.md) options. + +use std::collections::HashMap; + +use serde::Serialize; + +const END_MARKER: &str = "//"; + +/// Object used to alter Swagger UI oauth settings. +/// +/// # Examples +/// +/// ```rust +/// # use fastapi_swagger_ui::oauth; +/// let config = oauth::Config::new() +/// .client_id("client-id") +/// .use_pkce_with_authorization_code_grant(true); +/// ``` +#[non_exhaustive] +#[derive(Default, Clone, Serialize)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "camelCase")] +pub struct Config { + /// oauth client_id the Swagger UI is using for auth flow. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id: Option, + + /// oauth client_secret the Swagger UI is using for auth flow. + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + + /// oauth realm the Swagger UI is using for auth flow. + /// realm query parameter (for oauth1) added to authorizationUrl and tokenUrl. + #[serde(skip_serializing_if = "Option::is_none")] + pub realm: Option, + + /// oauth app_name the Swagger UI is using for auth flow. + /// application name, displayed in authorization popup. + #[serde(skip_serializing_if = "Option::is_none")] + pub app_name: Option, + + /// oauth scope_separator the Swagger UI is using for auth flow. + /// scope separator for passing scopes, encoded before calling, default value is a space (encoded value %20). + #[serde(skip_serializing_if = "Option::is_none")] + pub scope_separator: Option, + + /// oauth scopes the Swagger UI is using for auth flow. + /// [`Vec`] of initially selected oauth scopes, default is empty. + #[serde(skip_serializing_if = "Option::is_none")] + pub scopes: Option>, + + /// oauth additional_query_string_params the Swagger UI is using for auth flow. + /// [`HashMap`] of additional query parameters added to authorizationUrl and tokenUrl. + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_query_string_params: Option>, + + /// oauth use_basic_authentication_with_access_code_grant the Swagger UI is using for auth flow. + /// Only activated for the accessCode flow. During the authorization_code request to the tokenUrl, + /// pass the [Client Password](https://tools.ietf.org/html/rfc6749#section-2.3.1) using the HTTP Basic Authentication scheme + /// (Authorization header with Basic base64encode(client_id + client_secret)). + /// The default is false. + #[serde(skip_serializing_if = "Option::is_none")] + pub use_basic_authentication_with_access_code_grant: Option, + + /// oauth use_pkce_with_authorization_code_grant the Swagger UI is using for auth flow. + /// Only applies to authorizationCode flows. [Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636) + /// brings enhanced security for OAuth public clients. + /// The default is false. + #[serde(skip_serializing_if = "Option::is_none")] + pub use_pkce_with_authorization_code_grant: Option, +} + +impl Config { + /// Create a new [`Config`] for oauth auth flow. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new(); + /// ``` + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Add client_id into [`Config`]. + /// + /// Method takes one argument which exposes the client_id to the user. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .client_id("client-id"); + /// ``` + pub fn client_id(mut self, client_id: &str) -> Self { + self.client_id = Some(String::from(client_id)); + + self + } + + /// Add client_secret into [`Config`]. + /// + /// Method takes one argument which exposes the client_secret to the user. + /// 🚨 Never use this parameter in your production environment. + /// It exposes crucial security information. This feature is intended for dev/test environments only. 🚨 + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .client_secret("client-secret"); + /// ``` + pub fn client_secret(mut self, client_secret: &str) -> Self { + self.client_secret = Some(String::from(client_secret)); + + self + } + + /// Add realm into [`Config`]. + /// + /// Method takes one argument which exposes the realm to the user. + /// realm query parameter (for oauth1) added to authorizationUrl and tokenUrl. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .realm("realm"); + /// ``` + pub fn realm(mut self, realm: &str) -> Self { + self.realm = Some(String::from(realm)); + + self + } + + /// Add app_name into [`Config`]. + /// + /// Method takes one argument which exposes the app_name to the user. + /// application name, displayed in authorization popup. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .app_name("app-name"); + /// ``` + pub fn app_name(mut self, app_name: &str) -> Self { + self.app_name = Some(String::from(app_name)); + + self + } + + /// Add scope_separator into [`Config`]. + /// + /// Method takes one argument which exposes the scope_separator to the user. + /// scope separator for passing scopes, encoded before calling, default value is a space (encoded value %20). + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .scope_separator(","); + /// ``` + pub fn scope_separator(mut self, scope_separator: &str) -> Self { + self.scope_separator = Some(String::from(scope_separator)); + + self + } + + /// Add scopes into [`Config`]. + /// + /// Method takes one argument which exposes the scopes to the user. + /// [`Vec`] of initially selected oauth scopes, default is empty. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .scopes(vec![String::from("openid")]); + /// ``` + pub fn scopes(mut self, scopes: Vec) -> Self { + self.scopes = Some(scopes); + + self + } + + /// Add additional_query_string_params into [`Config`]. + /// + /// Method takes one argument which exposes the additional_query_string_params to the user. + /// [`HashMap`] of additional query parameters added to authorizationUrl and tokenUrl. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// # use std::collections::HashMap; + /// let config = oauth::Config::new() + /// .additional_query_string_params(HashMap::from([(String::from("a"), String::from("1"))])); + /// ``` + pub fn additional_query_string_params( + mut self, + additional_query_string_params: HashMap, + ) -> Self { + self.additional_query_string_params = Some(additional_query_string_params); + + self + } + + /// Add use_basic_authentication_with_access_code_grant into [`Config`]. + /// + /// Method takes one argument which exposes the use_basic_authentication_with_access_code_grant to the user. + /// Only activated for the accessCode flow. During the authorization_code request to the tokenUrl, + /// pass the [Client Password](https://tools.ietf.org/html/rfc6749#section-2.3.1) using the HTTP Basic Authentication scheme + /// (Authorization header with Basic base64encode(client_id + client_secret)). + /// The default is false. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .use_basic_authentication_with_access_code_grant(true); + /// ``` + pub fn use_basic_authentication_with_access_code_grant( + mut self, + use_basic_authentication_with_access_code_grant: bool, + ) -> Self { + self.use_basic_authentication_with_access_code_grant = + Some(use_basic_authentication_with_access_code_grant); + + self + } + + /// Add use_pkce_with_authorization_code_grant into [`Config`]. + /// + /// Method takes one argument which exposes the use_pkce_with_authorization_code_grant to the user. + /// Only applies to authorizationCode flows. [Proof Key for Code Exchange](https://tools.ietf.org/html/rfc7636) + /// brings enhanced security for OAuth public clients. + /// The default is false. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi_swagger_ui::oauth; + /// let config = oauth::Config::new() + /// .use_pkce_with_authorization_code_grant(true); + /// ``` + pub fn use_pkce_with_authorization_code_grant( + mut self, + use_pkce_with_authorization_code_grant: bool, + ) -> Self { + self.use_pkce_with_authorization_code_grant = Some(use_pkce_with_authorization_code_grant); + + self + } +} + +pub(crate) fn format_swagger_config(config: &Config, file: String) -> serde_json::Result { + let init_string = format!( + "{}\nui.initOAuth({});", + END_MARKER, + serde_json::to_string_pretty(config)? + ); + Ok(file.replace(END_MARKER, &init_string)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const TEST_CONTENT: &str = r###"" + // + window.ui = SwaggerUIBundle({ + {{urls}}, + dom_id: '#swagger-ui', + deepLinking: true, + presets: [ + SwaggerUIBundle.presets.apis, + SwaggerUIStandalonePreset + ], + plugins: [ + SwaggerUIBundle.plugins.DownloadUrl + ], + layout: "StandaloneLayout" + }); + // + ""###; + + #[test] + fn format_swagger_config_oauth() { + let config = Config { + client_id: Some(String::from("my-special-client")), + ..Default::default() + }; + let file = super::format_swagger_config(&config, TEST_CONTENT.to_string()).unwrap(); + + let expected = r#" +ui.initOAuth({ + "clientId": "my-special-client" +});"#; + assert!( + file.contains(expected), + "expected file to contain {expected}, was {file}" + ) + } +} diff --git a/fastapi-swagger-ui/src/rocket.rs b/fastapi-swagger-ui/src/rocket.rs new file mode 100644 index 0000000..fe5af30 --- /dev/null +++ b/fastapi-swagger-ui/src/rocket.rs @@ -0,0 +1,119 @@ +#![cfg(feature = "rocket")] + +use std::{borrow::Cow, io::Cursor, sync::Arc}; + +use rocket::{ + http::{Header, Status}, + response::{status::NotFound, Responder as RocketResponder}, + route::{Handler, Outcome}, + serde::json::Json, + Data as RocketData, Request, Response, Route, +}; + +use crate::{ApiDoc, Config, SwaggerFile, SwaggerUi}; + +impl From for Vec { + fn from(swagger_ui: SwaggerUi) -> Self { + let mut routes = + Vec::::with_capacity(swagger_ui.urls.len() + 1 + swagger_ui.external_urls.len()); + let mut api_docs = + Vec::::with_capacity(swagger_ui.urls.len() + swagger_ui.external_urls.len()); + + let urls = swagger_ui + .urls + .into_iter() + .map(|(url, openapi)| (url, ApiDoc::Fastapi(openapi))) + .chain( + swagger_ui + .external_urls + .into_iter() + .map(|(url, api_doc)| (url, ApiDoc::Value(api_doc))), + ) + .map(|(url, openapi)| { + api_docs.push(Route::new( + rocket::http::Method::Get, + &url.url, + ServeApiDoc(openapi), + )); + url + }); + + routes.push(Route::new( + rocket::http::Method::Get, + swagger_ui.path.as_ref(), + ServeSwagger( + swagger_ui.path.clone(), + Arc::new(if let Some(config) = swagger_ui.config { + if config.url.is_some() || !config.urls.is_empty() { + config + } else { + config.configure_defaults(urls) + } + } else { + Config::new(urls) + }), + ), + )); + routes.extend(api_docs); + + routes + } +} + +#[derive(Clone)] +struct ServeApiDoc(ApiDoc); + +#[rocket::async_trait] +impl Handler for ServeApiDoc { + async fn handle<'r>(&self, request: &'r Request<'_>, _: RocketData<'r>) -> Outcome<'r> { + Outcome::from(request, Json(self.0.clone())) + } +} + +#[derive(Clone)] +struct ServeSwagger(Cow<'static, str>, Arc>); + +#[rocket::async_trait] +impl Handler for ServeSwagger { + async fn handle<'r>(&self, request: &'r Request<'_>, _: RocketData<'r>) -> Outcome<'r> { + let mut base_path = self.0.as_ref(); + if let Some(index) = self.0.find('<') { + base_path = &base_path[..index]; + } + + let request_path = request.uri().path().as_str(); + let request_path = match request_path.strip_prefix(base_path) { + Some(stripped) => stripped, + None => return Outcome::from(request, RedirectResponder(base_path.into())), + }; + match super::serve(request_path, self.1.clone()) { + Ok(swagger_file) => swagger_file + .map(|file| Outcome::from(request, file)) + .unwrap_or_else(|| Outcome::from(request, NotFound("Swagger UI file not found"))), + Err(error) => Outcome::from( + request, + rocket::response::status::Custom(Status::InternalServerError, error.to_string()), + ), + } + } +} + +impl<'r, 'o: 'r> RocketResponder<'r, 'o> for SwaggerFile<'o> { + fn respond_to(self, _: &'r Request<'_>) -> rocket::response::Result<'o> { + Ok(Response::build() + .header(Header::new("Content-Type", self.content_type)) + .sized_body(self.bytes.len(), Cursor::new(self.bytes.to_vec())) + .status(Status::Ok) + .finalize()) + } +} + +struct RedirectResponder(String); +impl<'r, 'a: 'r> RocketResponder<'r, 'a> for RedirectResponder { + fn respond_to(self, _request: &'r Request<'_>) -> rocket::response::Result<'a> { + Response::build() + .status(Status::Found) + .raw_header("Location", self.0) + .ok() + } +} diff --git a/fastapi/Cargo.toml b/fastapi/Cargo.toml new file mode 100644 index 0000000..2e3da17 --- /dev/null +++ b/fastapi/Cargo.toml @@ -0,0 +1,76 @@ +[package] +name = "fastapi" +description = "Compile time generated OpenAPI documentation for Rust" +version = "0.1.1" +edition = "2021" +license = "MIT OR Apache-2.0" +readme = "README.md" +keywords = [ + "rest-api", + "openapi", + "auto-generate", + "documentation", + "compile-time", +] +# documentation = "" +# homepage = "" +repository = "https://github.com/nxpkg/fastapi" +categories = ["web-programming"] +authors = ["Md Sulaiman "] +rust-version.workspace = true + +[features] +# See README.md for list and explanations of features +default = ["macros"] +debug = ["fastapi-gen?/debug"] +actix_extras = ["fastapi-gen?/actix_extras"] +rocket_extras = ["fastapi-gen?/rocket_extras"] +axum_extras = ["fastapi-gen?/axum_extras"] +chrono = ["fastapi-gen?/chrono"] +decimal = ["fastapi-gen?/decimal"] +decimal_float = ["fastapi-gen?/decimal_float"] +non_strict_integers = ["fastapi-gen?/non_strict_integers"] +yaml = ["serde_yaml", "fastapi-gen?/yaml"] +uuid = ["fastapi-gen?/uuid"] +ulid = ["fastapi-gen?/ulid"] +url = ["fastapi-gen?/url"] +time = ["fastapi-gen?/time"] +smallvec = ["fastapi-gen?/smallvec"] +indexmap = ["fastapi-gen?/indexmap"] +openapi_extensions = [] +repr = ["fastapi-gen?/repr"] +preserve_order = [] +preserve_path_order = [] +rc_schema = ["fastapi-gen?/rc_schema"] +macros = ["dep:fastapi-gen"] +config = ["fastapi-gen?/config"] + +# EXPERIEMENTAL! use with cauntion +auto_into_responses = ["fastapi-gen?/auto_into_responses"] + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = { version = "1.0" } +serde_yaml = { version = "0.9", optional = true } +fastapi-gen = { version = "0.1.1", path = "../fastapi-gen", optional = true } +indexmap = { version = "2", features = ["serde"] } + +[dev-dependencies] +assert-json-diff = "2" +fastapi = { path = ".", features = ["debug"] } + +[package.metadata.docs.rs] +features = [ + "actix_extras", + "non_strict_integers", + "openapi_extensions", + "uuid", + "ulid", + "url", + "yaml", + "macros", +] +rustdoc-args = ["--cfg", "doc_cfg"] + +[lints.rust] +unexpected_cfgs = { level = "warn", check-cfg = ['cfg(doc_cfg)'] } diff --git a/fastapi/LICENSE-APACHE b/fastapi/LICENSE-APACHE new file mode 120000 index 0000000..965b606 --- /dev/null +++ b/fastapi/LICENSE-APACHE @@ -0,0 +1 @@ +../LICENSE-APACHE \ No newline at end of file diff --git a/fastapi/LICENSE-MIT b/fastapi/LICENSE-MIT new file mode 120000 index 0000000..76219eb --- /dev/null +++ b/fastapi/LICENSE-MIT @@ -0,0 +1 @@ +../LICENSE-MIT \ No newline at end of file diff --git a/fastapi/README.md b/fastapi/README.md new file mode 120000 index 0000000..32d46ee --- /dev/null +++ b/fastapi/README.md @@ -0,0 +1 @@ +../README.md \ No newline at end of file diff --git a/fastapi/src/lib.rs b/fastapi/src/lib.rs new file mode 100644 index 0000000..eca4d4b --- /dev/null +++ b/fastapi/src/lib.rs @@ -0,0 +1,1736 @@ +#![warn(missing_docs)] +#![warn(rustdoc::broken_intra_doc_links)] +#![cfg_attr(doc_cfg, feature(doc_cfg))] +//! Want to have your API documented with OpenAPI? But you don't want to see the +//! trouble with manual yaml or json tweaking? Would like it to be so easy that it would almost +//! be like utopic? Don't worry fastapi is just there to fill this gap. It aims to do if not all then +//! the most of heavy lifting for you enabling you to focus writing the actual API logic instead of +//! documentation. It aims to be *minimal*, *simple* and *fast*. It uses simple proc macros which +//! you can use to annotate your code to have items documented. +//! +//! Fastapi crate provides autogenerated OpenAPI documentation for Rust REST APIs. It treats +//! code first approach as a first class citizen and simplifies API documentation by providing +//! simple macros for generating the documentation from your code. +//! +//! It also contains Rust types of OpenAPI spec allowing you to write the OpenAPI spec only using +//! Rust if auto-generation is not your flavor or does not fit your purpose. +//! +//! Long term goal of the library is to be the place to go when OpenAPI documentation is needed in Rust +//! codebase. +//! +//! Fastapi is framework agnostic and could be used together with any web framework or even without one. While +//! being portable and standalone one of it's key aspects is simple integration with web frameworks. +//! +//! Currently fastapi provides simple integration with actix-web framework but is not limited to the actix-web +//! framework. All functionalities are not restricted to any specific framework. +//! +//! # Choose your flavor and document your API with ice cold IPA +//! +//! |Flavor|Support| +//! |--|--| +//! |[actix-web](https://github.com/actix/actix-web)|Parse path, path parameters and query parameters, recognize request body and response body, [`fastapi-actix-web` bindings](https://docs.rs/fastapi-actix-web). See more at [docs][actix_path]| +//! |[axum](https://github.com/tokio-rs/axum)|Parse path and query parameters, recognize request body and response body, [`fastapi-axum` bindings](https://docs.rs/fastapi-axum). See more at [docs][axum_path]| +//! |[rocket](https://github.com/SergioBenitez/Rocket)| Parse path, path parameters and query parameters, recognize request body and response body. See more at [docs][rocket_path]| +//! |Others*| Plain `fastapi` without extra flavor. This gives you all the basic benefits listed below in **[Features](#features)** section but with little less automation.| +//! +//! > Others* = For example [warp](https://github.com/seanmonstar/warp) but could be anything. +//! +//! Refer to the existing [examples](https://github.com/nxpkg/fastapi/tree/master/examples) to find out more. +//! +//! ## Features +//! +//! * OpenAPI 3.1 +//! * Pluggable, easy setup and integration with frameworks. +//! * No bloat, enable what you need. +//! * Support for generic types +//! * **Note!**
+//! Tuples, arrays and slices cannot be used as generic arguments on types. Types implementing `ToSchema` manually should not have generic arguments, as +//! they are not composeable and will result compile error. +//! * Automatic schema collection from usages recursively. +//! * Request body from either handler function arguments (if supported by framework) or from `request_body` attribute. +//! * Response body from response `body` attribute or response `content` attribute. +//! * Various OpenAPI visualization tools supported out of the box. +//! * Rust type aliases via [`fastapi-config`][fastapi_config]. +//! +//! # What's up with the word play? +//! +//! The name comes from words `utopic` and `api` where `uto` is the first three letters of _utopic_ +//! and the `ipa` is _api_ reversed. Aaand... `ipa` is also awesome type of beer. +//! +//! # Crate Features +//! +//! * **`macros`** Enable `fastapi-gen` macros. **This is enabled by default.** +//! * **`yaml`** Enables **serde_yaml** serialization of OpenAPI objects. +//! * **`actix_extras`** Enhances [actix-web](https://github.com/actix/actix-web/) integration with being able to +//! parse `path`, `path` and `query` parameters from actix web path attribute macros. See [actix extras support][actix_path] or +//! [examples](https://github.com/nxpkg/fastapi/tree/master/examples) for more details. +//! * **`rocket_extras`** Enhances [rocket](https://github.com/SergioBenitez/Rocket) framework integration with being +//! able to parse `path`, `path` and `query` parameters from rocket path attribute macros. See [rocket extras support][rocket_path] +//! or [examples](https://github.com/nxpkg/fastapi/tree/master/examples) for more details +//! * **`axum_extras`** Enhances [axum](https://github.com/tokio-rs/axum) framework integration allowing users to use `IntoParams` +//! without defining the `parameter_in` attribute. See [axum extras support][axum_path] +//! or [examples](https://github.com/nxpkg/fastapi/tree/master/examples) for more details. +//! * **`debug`** Add extra traits such as debug traits to openapi definitions and elsewhere. +//! * **`chrono`** Add support for [chrono](https://crates.io/crates/chrono) `DateTime`, `Date`, `NaiveDate`, `NaiveTime` and `Duration` +//! types. By default these types are parsed to `string` types with additional `format` information. +//! `format: date-time` for `DateTime` and `format: date` for `Date` and `NaiveDate` according +//! [RFC3339](https://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14) as `ISO-8601`. To +//! override default `string` representation users have to use `value_type` attribute to override the type. +//! See [docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html) for more details. +//! * **`time`** Add support for [time](https://crates.io/crates/time) `OffsetDateTime`, `PrimitiveDateTime`, `Date`, and `Duration` types. +//! By default these types are parsed as `string`. `OffsetDateTime` and `PrimitiveDateTime` will use `date-time` format. `Date` will use +//! `date` format and `Duration` will not have any format. To override default `string` representation users have to use `value_type` attribute +//! to override the type. See [docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html) for more details. +//! * **`decimal`** Add support for [rust_decimal](https://crates.io/crates/rust_decimal) `Decimal` type. **By default** +//! it is interpreted as `String`. If you wish to change the format you need to override the type. +//! See the `value_type` in [`ToSchema` derive docs][to_schema_derive]. +//! * **`decimal_float`** Add support for [rust_decimal](https://crates.io/crates/rust_decimal) `Decimal` type. **By default** +//! it is interpreted as `Number`. This feature is mutually exclusive with **decimal** and allow to change the default type used in your +//! documentation for `Decimal` much like `serde_with_float` feature exposed by rust_decimal. +//! * **`uuid`** Add support for [uuid](https://github.com/uuid-rs/uuid). `Uuid` type will be presented as `String` with +//! format `uuid` in OpenAPI spec. +//! * **`ulid`** Add support for [ulid](https://github.com/dylanhart/ulid-rs). `Ulid` type will be presented as `String` with +//! format `ulid` in OpenAPI spec. +//! * **`url`** Add support for [url](https://github.com/servo/rust-url). `Url` type will be presented as `String` with +//! format `uri` in OpenAPI spec. +//! * **`smallvec`** Add support for [smallvec](https://crates.io/crates/smallvec). `SmallVec` will be treated as `Vec`. +//! * **`openapi_extensions`** Adds convenience functions for documenting common scenarios, such as JSON request bodies and responses. +//! See the [`request_body`](https://docs.rs/fastapi/latest/fastapi/openapi/request_body/index.html) and +//! [`response`](https://docs.rs/fastapi/latest/fastapi/openapi/response/index.html) docs for examples. +//! * **`repr`** Add support for [repr_serde](https://github.com/dtolnay/serde-repr)'s `repr(u*)` and `repr(i*)` attributes to unit type enums for +//! C-like enum representation. See [docs](https://docs.rs/fastapi/latest/fastapi/derive.ToSchema.html) for more details. +//! * **`preserve_order`** Preserve order of properties when serializing the schema for a component. +//! When enabled, the properties are listed in order of fields in the corresponding struct definition. +//! When disabled, the properties are listed in alphabetical order. +//! * **`preserve_path_order`** Preserve order of OpenAPI Paths according to order they have been +//! introduced to the `#[openapi(paths(...))]` macro attribute. If disabled the paths will be +//! ordered in alphabetical order. **However** the operations order under the path **will** be always constant according to +//! [specification](https://spec.openapis.org/oas/latest.html#fixed-fields-6) +//! * **`indexmap`** Add support for [indexmap](https://crates.io/crates/indexmap). When enabled `IndexMap` will be rendered as a map similar to +//! `BTreeMap` and `HashMap`. +//! * **`non_strict_integers`** Add support for non-standard integer formats `int8`, `int16`, `uint8`, `uint16`, `uint32`, and `uint64`. +//! * **`rc_schema`** Add `ToSchema` support for `Arc` and `Rc` types. **Note!** serde `rc` feature flag must be enabled separately to allow +//! serialization and deserialization of `Arc` and `Rc` types. See more about [serde feature flags](https://serde.rs/feature-flags.html). +//! * **`config`** Enables [`fastapi-config`](https://docs.rs/fastapi-config/) for the project which allows +//! defining global configuration options for `fastapi`. +//! +//! ### Default Library Support +//! +//! * Implicit partial support for `serde` attributes. See [`ToSchema` derive][serde] for more details. +//! * Support for [http](https://crates.io/crates/http) `StatusCode` in responses. +//! +//! # Install +//! +//! Add dependency declaration to Cargo.toml. +//! ```toml +//! [dependencies] +//! fastapi = "5" +//! ``` +//! +//! # Examples +//! +//! _**Create type with `ToSchema` and use it in `#[fastapi::path(...)]` that is registered to the `OpenApi`.**_ +//! +//! ```rust +//! use fastapi::{OpenApi, ToSchema}; +//! +//! #[derive(ToSchema)] +//! struct Pet { +//! id: u64, +//! name: String, +//! age: Option, +//! } +//! # #[derive(Debug)] +//! # struct NotFound; +//! # +//! # impl std::error::Error for NotFound {} +//! # +//! # impl std::fmt::Display for NotFound { +//! # fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { +//! # f.write_str("NotFound") +//! # } +//! # } +//! +//! /// Get pet by id +//! /// +//! /// Get pet from database by pet id +//! #[fastapi::path( +//! get, +//! path = "/pets/{id}", +//! responses( +//! (status = 200, description = "Pet found successfully", body = Pet), +//! (status = NOT_FOUND, description = "Pet was not found") +//! ), +//! params( +//! ("id" = u64, Path, description = "Pet database id to get Pet for"), +//! ) +//! )] +//! async fn get_pet_by_id(pet_id: u64) -> Result { +//! Ok(Pet { +//! id: pet_id, +//! age: None, +//! name: "lightning".to_string(), +//! }) +//! } +//! +//! #[derive(OpenApi)] +//! #[openapi(paths(get_pet_by_id))] +//! struct ApiDoc; +//! +//! println!("{}", ApiDoc::openapi().to_pretty_json().unwrap()); +//! ``` +//! +//! # Modify OpenAPI at runtime +//! +//! You can modify generated OpenAPI at runtime either via generated types directly or using +//! [`Modify`] trait. +//! +//! _**Modify generated OpenAPI via types directly.**_ +//! ```rust +//! # use fastapi::OpenApi; +//! #[derive(OpenApi)] +//! #[openapi( +//! info(description = "My Api description"), +//! )] +//! struct ApiDoc; +//! +//! let mut doc = ApiDoc::openapi(); +//! doc.info.title = String::from("My Api"); +//! ``` +//! +//! _**You can even convert the generated [`OpenApi`] to [`openapi::OpenApiBuilder`].**_ +//! ```rust +//! # use fastapi::openapi::OpenApiBuilder; +//! # use fastapi::OpenApi; +//! #[derive(OpenApi)] +//! #[openapi( +//! info(description = "My Api description"), +//! )] +//! struct ApiDoc; +//! +//! let builder: OpenApiBuilder = ApiDoc::openapi().into(); +//! ``` +//! +//! See [`Modify`] trait for examples on how to modify generated OpenAPI via it. +//! +//! # Go beyond the surface +//! +//! * See how to serve OpenAPI doc via Swagger UI check [`fastapi-swagger-ui`][fastapi_swagger] crate for more details. +//! * Browse to [examples](https://github.com/nxpkg/fastapi/tree/master/examples) for more comprehensive examples. +//! * Check [`derive@IntoResponses`] and [`derive@ToResponse`] for examples on deriving responses. +//! * More about OpenAPI security in [security documentation][security]. +//! * Dump generated API doc to file at build time. See [issue 214 comment](https://github.com/nxpkg/fastapi/issues/214#issuecomment-1179589373). +//! +//! [path]: attr.path.html +//! [rocket_path]: attr.path.html#rocket_extras-feature-support-for-rocket +//! [actix_path]: attr.path.html#actix_extras-feature-support-for-actix-web +//! [axum_path]: attr.path.html#axum_extras-feature-support-for-axum +//! [serde]: derive.ToSchema.html#partial-serde-attributes-support +//! [fastapi_swagger]: https://docs.rs/fastapi-swagger-ui/ +//! [fastapi_config]: https://docs.rs/fastapi-config/ +//! +//! [security]: openapi/security/index.html +//! [to_schema_derive]: derive.ToSchema.html + +pub mod openapi; + +use std::borrow::Cow; +use std::collections::BTreeMap; +use std::option::Option; + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +pub use fastapi_gen::*; + +/// Trait for implementing OpenAPI specification in Rust. +/// +/// This trait is derivable and can be used with `#[derive]` attribute. The derived implementation +/// will use Cargo provided environment variables to implement the default information. For a details of +/// `#[derive(ToSchema)]` refer to [derive documentation][derive]. +/// +/// # Examples +/// +/// Below is derived example of `OpenApi`. +/// ```rust +/// use fastapi::OpenApi; +/// #[derive(OpenApi)] +/// #[openapi()] +/// struct OpenApiDoc; +/// ``` +/// +/// This manual `OpenApi` trait implementation is approximately equal to the above derived one except the derive +/// implementation will by default use the Cargo environment variables to set defaults for *application name, +/// version, application description, license, author name & email*. +/// +/// ```rust +/// struct OpenApiDoc; +/// +/// impl fastapi::OpenApi for OpenApiDoc { +/// fn openapi() -> fastapi::openapi::OpenApi { +/// use fastapi::{ToSchema, Path}; +/// fastapi::openapi::OpenApiBuilder::new() +/// .info(fastapi::openapi::InfoBuilder::new() +/// .title("application name") +/// .version("version") +/// .description(Some("application description")) +/// .license(Some(fastapi::openapi::License::new("MIT"))) +/// .contact( +/// Some(fastapi::openapi::ContactBuilder::new() +/// .name(Some("author name")) +/// .email(Some("author email")).build()), +/// ).build()) +/// .paths(fastapi::openapi::path::Paths::new()) +/// .components(Some(fastapi::openapi::Components::new())) +/// .build() +/// } +/// } +/// ``` +/// [derive]: derive.OpenApi.html +pub trait OpenApi { + /// Return the [`openapi::OpenApi`] instance which can be parsed with serde or served via + /// OpenAPI visualization tool such as Swagger UI. + fn openapi() -> openapi::OpenApi; +} + +/// Trait for implementing OpenAPI Schema object. +/// +/// Generated schemas can be referenced or reused in path operations. +/// +/// This trait is derivable and can be used with `[#derive]` attribute. For a details of +/// `#[derive(ToSchema)]` refer to [derive documentation][derive]. +/// +/// [derive]: derive.ToSchema.html +/// +/// # Examples +/// +/// Use `#[derive]` to implement `ToSchema` trait. +/// ```rust +/// # use fastapi::ToSchema; +/// #[derive(ToSchema)] +/// #[schema(example = json!({"name": "bob the cat", "id": 1}))] +/// struct Pet { +/// id: u64, +/// name: String, +/// age: Option, +/// } +/// ``` +/// +/// Following manual implementation is equal to above derive one. +/// ```rust +/// # struct Pet { +/// # id: u64, +/// # name: String, +/// # age: Option, +/// # } +/// # +/// impl fastapi::ToSchema for Pet { +/// fn name() -> std::borrow::Cow<'static, str> { +/// std::borrow::Cow::Borrowed("Pet") +/// } +/// } +/// impl fastapi::PartialSchema for Pet { +/// fn schema() -> fastapi::openapi::RefOr { +/// fastapi::openapi::ObjectBuilder::new() +/// .property( +/// "id", +/// fastapi::openapi::ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::Integer) +/// .format(Some(fastapi::openapi::SchemaFormat::KnownFormat( +/// fastapi::openapi::KnownFormat::Int64, +/// ))), +/// ) +/// .required("id") +/// .property( +/// "name", +/// fastapi::openapi::ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::String), +/// ) +/// .required("name") +/// .property( +/// "age", +/// fastapi::openapi::ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::Integer) +/// .format(Some(fastapi::openapi::SchemaFormat::KnownFormat( +/// fastapi::openapi::KnownFormat::Int32, +/// ))), +/// ) +/// .example(Some(serde_json::json!({ +/// "name":"bob the cat","id":1 +/// }))) +/// .into() +/// } +/// } +/// ``` +pub trait ToSchema: PartialSchema { + /// Return name of the schema. + /// + /// Name is used by referencing objects to point to this schema object returned with + /// [`PartialSchema::schema`] within the OpenAPI document. + /// + /// In case a generic schema the _`name`_ will be used as prefix for the name in the OpenAPI + /// documentation. + /// + /// The default implementation naively takes the TypeName by removing + /// the module path and generic elements. + /// But you probably don't want to use the default implementation for generic elements. + /// That will produce collision between generics. (eq. `Foo` ) + /// + /// # Example + /// + /// ```rust + /// # use fastapi::ToSchema; + /// # + /// struct Foo(T); + /// + /// impl ToSchema for Foo {} + /// # impl fastapi::PartialSchema for Foo { + /// # fn schema() -> fastapi::openapi::RefOr { + /// # Default::default() + /// # } + /// # } + /// + /// assert_eq!(Foo::<()>::name(), std::borrow::Cow::Borrowed("Foo")); + /// assert_eq!(Foo::<()>::name(), Foo::::name()); // WARNING: these types have the same name + /// ``` + fn name() -> Cow<'static, str> { + let full_type_name = std::any::type_name::(); + let type_name_without_generic = full_type_name + .split_once("<") + .map(|(s1, _)| s1) + .unwrap_or(full_type_name); + let type_name = type_name_without_generic + .rsplit_once("::") + .map(|(_, tn)| tn) + .unwrap_or(type_name_without_generic); + Cow::Borrowed(type_name) + } + + /// Implement reference [`fastapi::openapi::schema::Schema`]s for this type. + /// + /// When [`ToSchema`] is being derived this is implemented automatically but if one needs to + /// manually implement [`ToSchema`] trait then this is needed for `fastapi` to know + /// referencing schemas that need to be present in the resulting OpenAPI spec. + /// + /// The implementation should push to `schemas` [`Vec`] all such field and variant types that + /// implement `ToSchema` and then call `::schemas(schemas)` on that type + /// to forward the recursive reference collection call on that type. + /// + /// # Examples + /// + /// _**Implement `ToSchema` manually with references.**_ + /// + /// ```rust + /// # use fastapi::{ToSchema, PartialSchema}; + /// # + /// #[derive(ToSchema)] + /// struct Owner { + /// name: String + /// } + /// + /// struct Pet { + /// owner: Owner, + /// name: String + /// } + /// impl PartialSchema for Pet { + /// fn schema() -> fastapi::openapi::RefOr { + /// fastapi::openapi::schema::Object::builder() + /// .property("owner", Owner::schema()) + /// .property("name", String::schema()) + /// .into() + /// } + /// } + /// impl ToSchema for Pet { + /// fn schemas(schemas: + /// &mut Vec<(String, fastapi::openapi::RefOr)>) { + /// schemas.push((Owner::name().into(), Owner::schema())); + /// ::schemas(schemas); + /// } + /// } + /// ``` + #[allow(unused)] + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + // nothing by default + } +} + +impl From for openapi::RefOr { + fn from(_: T) -> Self { + T::schema() + } +} + +/// Represents _`nullable`_ type. This can be used anywhere where "nothing" needs to be evaluated. +/// This will serialize to _`null`_ in JSON and [`openapi::schema::empty`] is used to create the +/// [`openapi::schema::Schema`] for the type. +pub type TupleUnit = (); + +impl PartialSchema for TupleUnit { + fn schema() -> openapi::RefOr { + openapi::schema::empty().into() + } +} + +impl ToSchema for TupleUnit { + fn name() -> Cow<'static, str> { + Cow::Borrowed("TupleUnit") + } +} + +macro_rules! impl_to_schema { + ( $( $ty:ident ),* ) => { + $( + impl ToSchema for $ty { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(stringify!( $ty )) + } + } + )* + }; +} + +#[rustfmt::skip] +impl_to_schema!( + i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, bool, f32, f64, String, str, char +); + +impl ToSchema for &str { + fn name() -> Cow<'static, str> { + str::name() + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for Option +where + Option: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for Vec +where + Vec: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for std::collections::LinkedList +where + std::collections::LinkedList: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for [T] +where + [T]: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl<'t, T: ToSchema> ToSchema for &'t [T] +where + &'t [T]: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl<'t, T: ToSchema> ToSchema for &'t mut [T] +where + &'t mut [T]: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for std::collections::HashMap +where + std::collections::HashMap: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + K::schemas(schemas); + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for std::collections::BTreeMap +where + std::collections::BTreeMap: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + K::schemas(schemas); + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for std::collections::HashSet +where + std::collections::HashSet: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + K::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for std::collections::BTreeSet +where + std::collections::BTreeSet: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + K::schemas(schemas); + } +} + +#[cfg(all(feature = "macros", feature = "indexmap"))] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "indexmap")))] +impl ToSchema for indexmap::IndexMap +where + indexmap::IndexMap: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + K::schemas(schemas); + T::schemas(schemas); + } +} + +#[cfg(all(feature = "macros", feature = "indexmap"))] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "indexmap")))] +impl ToSchema for indexmap::IndexSet +where + indexmap::IndexSet: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + K::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for std::boxed::Box +where + std::boxed::Box: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl<'a, T: ToSchema + Clone> ToSchema for std::borrow::Cow<'a, T> +where + std::borrow::Cow<'a, T>: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +impl ToSchema for std::cell::RefCell +where + std::cell::RefCell: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(all(feature = "macros", feature = "rc_schema"))] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "rc_schema")))] +impl ToSchema for std::rc::Rc +where + std::rc::Rc: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +#[cfg(all(feature = "macros", feature = "rc_schema"))] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "rc_schema")))] +impl ToSchema for std::sync::Arc +where + std::sync::Arc: PartialSchema, +{ + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ) { + T::schemas(schemas); + } +} + +impl PartialSchema for serde_json::Value { + fn schema() -> openapi::RefOr { + fastapi::openapi::schema::Object::builder() + .schema_type(fastapi::openapi::schema::SchemaType::AnyValue) + .into() + } +} + +impl ToSchema for serde_json::Value {} + +// Create `fastapi` module so we can use `fastapi-gen` directly from `fastapi` crate. +// ONLY for internal use! +#[doc(hidden)] +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +mod fastapi { + pub use super::*; +} + +/// Trait used to implement only _`Schema`_ part of the OpenAPI doc. +/// +/// This trait is by default implemented for Rust [`primitive`][primitive] types and some well known types like +/// [`Vec`], [`Option`], [`std::collections::HashMap`] and [`BTreeMap`]. The default implementation adds `schema()` +/// method to the implementing type allowing simple conversion of the type to the OpenAPI Schema +/// object. Moreover this allows handy way of constructing schema objects manually if ever so +/// wished. +/// +/// The trait can be implemented manually easily on any type. This trait comes especially handy +/// with [`macro@schema`] macro that can be used to generate schema for arbitrary types. +/// ```rust +/// # use fastapi::PartialSchema; +/// # use fastapi::openapi::schema::{SchemaType, KnownFormat, SchemaFormat, ObjectBuilder, Schema}; +/// # use fastapi::openapi::RefOr; +/// # +/// struct MyType; +/// +/// impl PartialSchema for MyType { +/// fn schema() -> RefOr { +/// // ... impl schema generation here +/// RefOr::T(Schema::Object(ObjectBuilder::new().build())) +/// } +/// } +/// ``` +/// +/// # Examples +/// +/// _**Create number schema from u64.**_ +/// ```rust +/// # use fastapi::PartialSchema; +/// # use fastapi::openapi::schema::{Type, KnownFormat, SchemaFormat, ObjectBuilder, Schema}; +/// # use fastapi::openapi::RefOr; +/// # +/// let number: RefOr = i64::schema().into(); +/// +// // would be equal to manual implementation +/// let number2 = RefOr::T( +/// Schema::Object( +/// ObjectBuilder::new() +/// .schema_type(Type::Integer) +/// .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int64))) +/// .build() +/// ) +/// ); +/// # assert_json_diff::assert_json_eq!(serde_json::to_value(&number).unwrap(), serde_json::to_value(&number2).unwrap()); +/// ``` +/// +/// _**Construct a Pet object schema manually.**_ +/// ```rust +/// # use fastapi::PartialSchema; +/// # use fastapi::openapi::schema::ObjectBuilder; +/// struct Pet { +/// id: i32, +/// name: String, +/// } +/// +/// let pet_schema = ObjectBuilder::new() +/// .property("id", i32::schema()) +/// .property("name", String::schema()) +/// .required("id").required("name") +/// .build(); +/// ``` +/// +/// [primitive]: https://doc.rust-lang.org/std/primitive/index.html +pub trait PartialSchema { + /// Return ref or schema of implementing type that can then be used to + /// construct combined schemas. + fn schema() -> openapi::RefOr; +} + +/// Trait for implementing OpenAPI PathItem object with path. +/// +/// This trait is implemented via [`#[fastapi::path(...)]`][derive] attribute macro and there +/// is no need to implement this trait manually. +/// +/// # Examples +/// +/// Use `#[fastapi::path(..)]` to implement Path trait +/// ```rust +/// # #[derive(fastapi::ToSchema)] +/// # struct Pet { +/// # id: u64, +/// # name: String, +/// # } +/// # +/// # +/// /// Get pet by id +/// /// +/// /// Get pet from database by pet database id +/// #[fastapi::path( +/// get, +/// path = "/pets/{id}", +/// responses( +/// (status = 200, description = "Pet found successfully", body = Pet), +/// (status = 404, description = "Pet was not found") +/// ), +/// params( +/// ("id" = u64, Path, description = "Pet database id to get Pet for"), +/// ) +/// )] +/// async fn get_pet_by_id(pet_id: u64) -> Pet { +/// Pet { +/// id: pet_id, +/// name: "lightning".to_string(), +/// } +/// } +/// ``` +/// +/// Example of what would manual implementation roughly look like of above `#[fastapi::path(...)]` macro. +/// ```rust +/// fastapi::openapi::PathsBuilder::new().path( +/// "/pets/{id}", +/// fastapi::openapi::PathItem::new( +/// fastapi::openapi::HttpMethod::Get, +/// fastapi::openapi::path::OperationBuilder::new() +/// .responses( +/// fastapi::openapi::ResponsesBuilder::new() +/// .response( +/// "200", +/// fastapi::openapi::ResponseBuilder::new() +/// .description("Pet found successfully") +/// .content("application/json", +/// fastapi::openapi::Content::new( +/// Some(fastapi::openapi::Ref::from_schema_name("Pet")), +/// ), +/// ), +/// ) +/// .response("404", fastapi::openapi::Response::new("Pet was not found")), +/// ) +/// .operation_id(Some("get_pet_by_id")) +/// .deprecated(Some(fastapi::openapi::Deprecated::False)) +/// .summary(Some("Get pet by id")) +/// .description(Some("Get pet by id\n\nGet pet from database by pet database id\n")) +/// .parameter( +/// fastapi::openapi::path::ParameterBuilder::new() +/// .name("id") +/// .parameter_in(fastapi::openapi::path::ParameterIn::Path) +/// .required(fastapi::openapi::Required::True) +/// .deprecated(Some(fastapi::openapi::Deprecated::False)) +/// .description(Some("Pet database id to get Pet for")) +/// .schema( +/// Some(fastapi::openapi::ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::Integer) +/// .format(Some(fastapi::openapi::SchemaFormat::KnownFormat(fastapi::openapi::KnownFormat::Int64)))), +/// ), +/// ) +/// .tag("pet_api"), +/// ), +/// ); +/// ``` +/// +/// [derive]: attr.path.html +pub trait Path { + /// List of HTTP methods this path operation is served at. + fn methods() -> Vec; + + /// The path this operation is served at. + fn path() -> String; + + /// [`openapi::path::Operation`] describing http operation details such as request bodies, + /// parameters and responses. + fn operation() -> openapi::path::Operation; +} + +/// Trait that allows OpenApi modification at runtime. +/// +/// Implement this trait if you wish to modify the OpenApi at runtime before it is being consumed +/// *(Before `fastapi::OpenApi::openapi()` function returns)*. +/// This is trait can be used to add or change already generated OpenApi spec to alter the generated +/// specification by user defined condition. For example you can add definitions that should be loaded +/// from some configuration at runtime what may not be available during compile time. +/// +/// See more about [`OpenApi`][derive] derive at [derive documentation][derive]. +/// +/// [derive]: derive.OpenApi.html +/// [security_scheme]: openapi/security/enum.SecurityScheme.html +/// +/// # Examples +/// +/// Add custom JWT [`SecurityScheme`][security_scheme] to [`OpenApi`][`openapi::OpenApi`]. +/// ```rust +/// # use fastapi::{OpenApi, Modify}; +/// # use fastapi::openapi::security::{SecurityScheme, HttpBuilder, HttpAuthScheme}; +/// #[derive(OpenApi)] +/// #[openapi(modifiers(&SecurityAddon))] +/// struct ApiDoc; +/// +/// struct SecurityAddon; +/// +/// impl Modify for SecurityAddon { +/// fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { +/// openapi.components = Some( +/// fastapi::openapi::ComponentsBuilder::new() +/// .security_scheme( +/// "api_jwt_token", +/// SecurityScheme::Http( +/// HttpBuilder::new() +/// .scheme(HttpAuthScheme::Bearer) +/// .bearer_format("JWT") +/// .build(), +/// ), +/// ) +/// .build(), +/// ) +/// } +/// } +/// ``` +/// +/// Add [OpenAPI Server Object][server] to alter the target server url. This can be used to give context +/// path for api operations. +/// ```rust +/// # use fastapi::{OpenApi, Modify}; +/// # use fastapi::openapi::Server; +/// #[derive(OpenApi)] +/// #[openapi(modifiers(&ServerAddon))] +/// struct ApiDoc; +/// +/// struct ServerAddon; +/// +/// impl Modify for ServerAddon { +/// fn modify(&self, openapi: &mut fastapi::openapi::OpenApi) { +/// openapi.servers = Some(vec![Server::new("/api")]) +/// } +/// } +/// ``` +/// +/// [server]: https://spec.openapis.org/oas/latest.html#server-object +pub trait Modify { + /// Apply mutation for [`openapi::OpenApi`] instance before it is returned by + /// [`openapi::OpenApi::openapi`] method call. + /// + /// This function allows users to run arbitrary code to change the generated + /// [`fastapi::OpenApi`] before it is served. + fn modify(&self, openapi: &mut openapi::OpenApi); +} + +/// Trait used to convert implementing type to OpenAPI parameters. +/// +/// This trait is [derivable][derive] for structs which are used to describe `path` or `query` parameters. +/// For more details of `#[derive(IntoParams)]` refer to [derive documentation][derive]. +/// +/// # Examples +/// +/// Derive [`IntoParams`] implementation. This example will fail to compile because [`IntoParams`] cannot +/// be used alone and it need to be used together with endpoint using the params as well. See +/// [derive documentation][derive] for more details. +/// ``` +/// use fastapi::{IntoParams}; +/// +/// #[derive(IntoParams)] +/// struct PetParams { +/// /// Id of pet +/// id: i64, +/// /// Name of pet +/// name: String, +/// } +/// ``` +/// +/// Roughly equal manual implementation of [`IntoParams`] trait. +/// ```rust +/// # struct PetParams { +/// # /// Id of pet +/// # id: i64, +/// # /// Name of pet +/// # name: String, +/// # } +/// impl fastapi::IntoParams for PetParams { +/// fn into_params( +/// parameter_in_provider: impl Fn() -> Option +/// ) -> Vec { +/// vec![ +/// fastapi::openapi::path::ParameterBuilder::new() +/// .name("id") +/// .required(fastapi::openapi::Required::True) +/// .parameter_in(parameter_in_provider().unwrap_or_default()) +/// .description(Some("Id of pet")) +/// .schema(Some( +/// fastapi::openapi::ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::Integer) +/// .format(Some(fastapi::openapi::SchemaFormat::KnownFormat(fastapi::openapi::KnownFormat::Int64))), +/// )) +/// .build(), +/// fastapi::openapi::path::ParameterBuilder::new() +/// .name("name") +/// .required(fastapi::openapi::Required::True) +/// .parameter_in(parameter_in_provider().unwrap_or_default()) +/// .description(Some("Name of pet")) +/// .schema(Some( +/// fastapi::openapi::ObjectBuilder::new() +/// .schema_type(fastapi::openapi::schema::Type::String), +/// )) +/// .build(), +/// ] +/// } +/// } +/// ``` +/// [derive]: derive.IntoParams.html +pub trait IntoParams { + /// Provide [`Vec`] of [`openapi::path::Parameter`]s to caller. The result is used in `fastapi-gen` library to + /// provide OpenAPI parameter information for the endpoint using the parameters. + fn into_params( + parameter_in_provider: impl Fn() -> Option, + ) -> Vec; +} + +/// This trait is implemented to document a type (like an enum) which can represent multiple +/// responses, to be used in operation. +/// +/// # Examples +/// +/// ``` +/// use std::collections::BTreeMap; +/// use fastapi::{ +/// openapi::{Response, ResponseBuilder, ResponsesBuilder, RefOr}, +/// IntoResponses, +/// }; +/// +/// enum MyResponse { +/// Ok, +/// NotFound, +/// } +/// +/// impl IntoResponses for MyResponse { +/// fn responses() -> BTreeMap> { +/// ResponsesBuilder::new() +/// .response("200", ResponseBuilder::new().description("Ok")) +/// .response("404", ResponseBuilder::new().description("Not Found")) +/// .build() +/// .into() +/// } +/// } +/// ``` +pub trait IntoResponses { + /// Returns an ordered map of response codes to responses. + fn responses() -> BTreeMap>; +} + +#[cfg(feature = "auto_into_responses")] +impl IntoResponses for Result { + fn responses() -> BTreeMap> { + let mut responses = T::responses(); + responses.append(&mut E::responses()); + + responses + } +} + +#[cfg(feature = "auto_into_responses")] +impl IntoResponses for () { + fn responses() -> BTreeMap> { + BTreeMap::new() + } +} + +/// This trait is implemented to document a type which represents a single response which can be +/// referenced or reused as a component in multiple operations. +/// +/// _`ToResponse`_ trait can also be derived with [`#[derive(ToResponse)]`][derive]. +/// +/// # Examples +/// +/// ``` +/// use fastapi::{ +/// openapi::{RefOr, Response, ResponseBuilder}, +/// ToResponse, +/// }; +/// +/// struct MyResponse; +/// +/// impl<'__r> ToResponse<'__r> for MyResponse { +/// fn response() -> (&'__r str, RefOr) { +/// ( +/// "MyResponse", +/// ResponseBuilder::new().description("My Response").build().into(), +/// ) +/// } +/// } +/// ``` +/// +/// [derive]: derive.ToResponse.html +pub trait ToResponse<'__r> { + /// Returns a tuple of response component name (to be referenced) to a response. + fn response() -> (&'__r str, openapi::RefOr); +} + +/// Flexible number wrapper used by validation schema attributes to seamlessly support different +/// number syntaxes. +/// +/// # Examples +/// +/// _**Define object with two different number fields with minimum validation attribute.**_ +/// +/// ```rust +/// # use fastapi::Number; +/// # use fastapi::openapi::schema::{ObjectBuilder, SchemaType, Type}; +/// let _ = ObjectBuilder::new() +/// .property("int_value", ObjectBuilder::new() +/// .schema_type(Type::Integer).minimum(Some(1)) +/// ) +/// .property("float_value", ObjectBuilder::new() +/// .schema_type(Type::Number).minimum(Some(-2.5)) +/// ) +/// .build(); +/// ``` +#[derive(Clone, serde::Deserialize, serde::Serialize)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(untagged)] +pub enum Number { + /// Signed integer e.g. `1` or `-2` + Int(isize), + /// Unsigned integer value e.g. `0`. Unsigned integer cannot be below zero. + UInt(usize), + /// Floating point number e.g. `1.34` + Float(f64), +} + +impl Eq for Number {} + +impl PartialEq for Number { + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (Self::Int(left), Self::Int(right)) => left == right, + (Self::UInt(left), Self::UInt(right)) => left == right, + (Self::Float(left), Self::Float(right)) => left == right, + _ => false, + } + } +} + +macro_rules! impl_from_for_number { + ( $( $ty:ident => $pat:ident $( as $as:ident )? ),* ) => { + $( + impl From<$ty> for Number { + fn from(value: $ty) -> Self { + Self::$pat(value $( as $as )?) + } + } + )* + }; +} + +#[rustfmt::skip] +impl_from_for_number!( + f32 => Float as f64, f64 => Float, + i8 => Int as isize, i16 => Int as isize, i32 => Int as isize, i64 => Int as isize, + u8 => UInt as usize, u16 => UInt as usize, u32 => UInt as usize, u64 => UInt as usize, + isize => Int, usize => UInt +); + +/// Internal dev module used internally by fastapi-gen +#[doc(hidden)] +#[cfg(feature = "macros")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "macros")))] +pub mod __dev { + use fastapi_gen::schema; + + use crate::{fastapi, OpenApi, PartialSchema}; + + pub trait PathConfig { + fn path() -> String; + + fn methods() -> Vec; + + fn tags_and_operation() -> (Vec<&'static str>, fastapi::openapi::path::Operation); + } + + pub trait Tags<'t> { + fn tags() -> Vec<&'t str>; + } + + impl fastapi::Path for T { + fn path() -> String { + ::path() + } + + fn methods() -> Vec { + ::methods() + } + + fn operation() -> crate::openapi::path::Operation { + let (tags, mut operation) = ::tags_and_operation(); + + let operation_tags = operation.tags.get_or_insert(Vec::new()); + operation_tags.extend(tags.iter().map(ToString::to_string)); + + operation + } + } + + pub trait NestedApiConfig { + fn config() -> (fastapi::openapi::OpenApi, Vec<&'static str>, &'static str); + } + + impl OpenApi for T { + fn openapi() -> crate::openapi::OpenApi { + let (mut api, tags, module_path) = T::config(); + + api.paths.paths.iter_mut().for_each(|(_, path_item)| { + let update_tags = |operation: Option<&mut crate::openapi::path::Operation>| { + if let Some(operation) = operation { + let operation_tags = operation.tags.get_or_insert(Vec::new()); + operation_tags.extend(tags.iter().map(ToString::to_string)); + if operation_tags.is_empty() && !module_path.is_empty() { + operation_tags.push(module_path.to_string()); + } + } + }; + + update_tags(path_item.get.as_mut()); + update_tags(path_item.put.as_mut()); + update_tags(path_item.post.as_mut()); + update_tags(path_item.delete.as_mut()); + update_tags(path_item.options.as_mut()); + update_tags(path_item.head.as_mut()); + update_tags(path_item.patch.as_mut()); + update_tags(path_item.trace.as_mut()); + }); + + api + } + } + + pub trait ComposeSchema { + fn compose( + new_generics: Vec>, + ) -> fastapi::openapi::RefOr; + } + + macro_rules! impl_compose_schema { + ( $( $ty:ident ),* ) => { + $( + impl ComposeSchema for $ty { + fn compose(_: Vec>) -> fastapi::openapi::RefOr { + schema!( $ty ).into() + } + } + )* + }; + } + + #[rustfmt::skip] + impl_compose_schema!( + i8, i16, i32, i64, i128, isize, u8, u16, u32, u64, u128, usize, bool, f32, f64, String, str, char + ); + + fn schema_or_compose( + schemas: Vec>, + index: usize, + ) -> fastapi::openapi::RefOr { + if let Some(schema) = schemas.get(index) { + schema.clone() + } else { + T::compose(schemas) + } + } + + impl ComposeSchema for &str { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + str::compose(schemas) + } + } + + impl PartialSchema for T { + fn schema() -> crate::openapi::RefOr { + T::compose(Vec::new()) + } + } + impl ComposeSchema for Option { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::OneOfBuilder::new() + .item( + fastapi::openapi::schema::ObjectBuilder::new() + .schema_type(fastapi::openapi::schema::Type::Null), + ) + .item(schema_or_compose::(schemas, 0)) + .into() + } + } + + impl ComposeSchema for Vec { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .into() + } + } + + impl ComposeSchema for std::collections::LinkedList { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .into() + } + } + + impl ComposeSchema for [T] { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .into() + } + } + + impl ComposeSchema for &[T] { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .into() + } + } + + impl ComposeSchema for &mut [T] { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .into() + } + } + + impl ComposeSchema for std::collections::HashMap { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::ObjectBuilder::new() + .property_names(Some(schema_or_compose::(schemas.clone(), 0))) + .additional_properties(Some(schema_or_compose::(schemas, 1))) + .into() + } + } + + impl ComposeSchema for std::collections::BTreeMap { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::ObjectBuilder::new() + .property_names(Some(schema_or_compose::(schemas.clone(), 0))) + .additional_properties(Some(schema_or_compose::(schemas, 1))) + .into() + } + } + + impl ComposeSchema for std::collections::HashSet { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .unique_items(true) + .into() + } + } + + impl ComposeSchema for std::collections::BTreeSet { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .unique_items(true) + .into() + } + } + + #[cfg(feature = "indexmap")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "indexmap")))] + impl ComposeSchema for indexmap::IndexMap { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::ObjectBuilder::new() + .property_names(Some(schema_or_compose::(schemas.clone(), 0))) + .additional_properties(Some(schema_or_compose::(schemas, 1))) + .into() + } + } + + #[cfg(feature = "indexmap")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "indexmap")))] + impl ComposeSchema for indexmap::IndexSet { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + fastapi::openapi::schema::ArrayBuilder::new() + .items(schema_or_compose::(schemas, 0)) + .unique_items(true) + .into() + } + } + + impl<'a, T: ComposeSchema + Clone> ComposeSchema for std::borrow::Cow<'a, T> { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + schema_or_compose::(schemas, 0) + } + } + + impl ComposeSchema for std::boxed::Box { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + schema_or_compose::(schemas, 0) + } + } + + impl ComposeSchema for std::cell::RefCell { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + schema_or_compose::(schemas, 0) + } + } + + #[cfg(feature = "rc_schema")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "rc_schema")))] + impl ComposeSchema for std::rc::Rc { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + schema_or_compose::(schemas, 0) + } + } + + #[cfg(feature = "rc_schema")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "macros", feature = "rc_schema")))] + impl ComposeSchema for std::sync::Arc { + fn compose( + schemas: Vec>, + ) -> fastapi::openapi::RefOr { + schema_or_compose::(schemas, 0) + } + } + + // For types not implementing `ToSchema` + pub trait SchemaReferences { + fn schemas( + schemas: &mut Vec<( + String, + fastapi::openapi::RefOr, + )>, + ); + } +} + +#[cfg(test)] +mod tests { + use assert_json_diff::assert_json_eq; + use serde_json::json; + + use super::*; + + #[test] + fn test_toschema_name() { + struct Foo; + impl ToSchema for Foo {} + impl PartialSchema for Foo { + fn schema() -> openapi::RefOr { + Default::default() + } + } + assert_eq!(Foo::name(), Cow::Borrowed("Foo")); + + struct FooGeneric(T, U); + impl ToSchema for FooGeneric {} + impl PartialSchema for FooGeneric { + fn schema() -> openapi::RefOr { + Default::default() + } + } + assert_eq!( + FooGeneric::::name(), + Cow::Borrowed("FooGeneric") + ); + assert_eq!( + FooGeneric::::name(), + FooGeneric::<(), ()>::name(), + ); + } + + #[cfg(not(feature = "non_strict_integers"))] + #[test] + fn test_partial_schema_strict_integers() { + use assert_json_diff::{assert_json_matches, CompareMode, Config, NumericMode}; + + for (name, schema, value) in [ + ( + "i8", + i8::schema(), + json!({"type": "integer", "format": "int32"}), + ), + ( + "i16", + i16::schema(), + json!({"type": "integer", "format": "int32"}), + ), + ( + "i32", + i32::schema(), + json!({"type": "integer", "format": "int32"}), + ), + ( + "i64", + i64::schema(), + json!({"type": "integer", "format": "int64"}), + ), + ("i128", i128::schema(), json!({"type": "integer"})), + ("isize", isize::schema(), json!({"type": "integer"})), + ( + "u8", + u8::schema(), + json!({"type": "integer", "format": "int32", "minimum": 0.0}), + ), + ( + "u16", + u16::schema(), + json!({"type": "integer", "format": "int32", "minimum": 0.0}), + ), + ( + "u32", + u32::schema(), + json!({"type": "integer", "format": "int32", "minimum": 0.0}), + ), + ( + "u64", + u64::schema(), + json!({"type": "integer", "format": "int64", "minimum": 0.0}), + ), + ] { + println!( + "{name}: {json}", + json = serde_json::to_string(&schema).unwrap() + ); + let schema = serde_json::to_value(schema).unwrap(); + + let config = Config::new(CompareMode::Strict).numeric_mode(NumericMode::AssumeFloat); + assert_json_matches!(schema, value, config); + } + } + + #[cfg(feature = "non_strict_integers")] + #[test] + fn test_partial_schema_non_strict_integers() { + for (name, schema, value) in [ + ( + "i8", + i8::schema(), + json!({"type": "integer", "format": "int8"}), + ), + ( + "i16", + i16::schema(), + json!({"type": "integer", "format": "int16"}), + ), + ( + "i32", + i32::schema(), + json!({"type": "integer", "format": "int32"}), + ), + ( + "i64", + i64::schema(), + json!({"type": "integer", "format": "int64"}), + ), + ("i128", i128::schema(), json!({"type": "integer"})), + ("isize", isize::schema(), json!({"type": "integer"})), + ( + "u8", + u8::schema(), + json!({"type": "integer", "format": "uint8", "minimum": 0}), + ), + ( + "u16", + u16::schema(), + json!({"type": "integer", "format": "uint16", "minimum": 0}), + ), + ( + "u32", + u32::schema(), + json!({"type": "integer", "format": "uint32", "minimum": 0}), + ), + ( + "u64", + u64::schema(), + json!({"type": "integer", "format": "uint64", "minimum": 0}), + ), + ] { + println!( + "{name}: {json}", + json = serde_json::to_string(&schema).unwrap() + ); + let schema = serde_json::to_value(schema).unwrap(); + assert_json_eq!(schema, value); + } + } + + #[test] + fn test_partial_schema() { + for (name, schema, value) in [ + ("bool", bool::schema(), json!({"type": "boolean"})), + ("str", str::schema(), json!({"type": "string"})), + ("String", String::schema(), json!({"type": "string"})), + ("char", char::schema(), json!({"type": "string"})), + ( + "f32", + f32::schema(), + json!({"type": "number", "format": "float"}), + ), + ( + "f64", + f64::schema(), + json!({"type": "number", "format": "double"}), + ), + ] { + println!( + "{name}: {json}", + json = serde_json::to_string(&schema).unwrap() + ); + let schema = serde_json::to_value(schema).unwrap(); + assert_json_eq!(schema, value); + } + } +} diff --git a/fastapi/src/openapi.rs b/fastapi/src/openapi.rs new file mode 100644 index 0000000..ce10279 --- /dev/null +++ b/fastapi/src/openapi.rs @@ -0,0 +1,1078 @@ +//! Rust implementation of Openapi Spec V3.1. + +use serde::{ + de::{Error, Expected, Visitor}, + Deserialize, Deserializer, Serialize, Serializer, +}; +use std::fmt::Formatter; + +use self::path::PathsMap; +pub use self::{ + content::{Content, ContentBuilder}, + external_docs::ExternalDocs, + header::{Header, HeaderBuilder}, + info::{Contact, ContactBuilder, Info, InfoBuilder, License, LicenseBuilder}, + path::{HttpMethod, PathItem, Paths, PathsBuilder}, + response::{Response, ResponseBuilder, Responses, ResponsesBuilder}, + schema::{ + AllOf, AllOfBuilder, Array, ArrayBuilder, Components, ComponentsBuilder, Discriminator, + KnownFormat, Object, ObjectBuilder, OneOf, OneOfBuilder, Ref, Schema, SchemaFormat, + ToArray, Type, + }, + security::SecurityRequirement, + server::{Server, ServerBuilder, ServerVariable, ServerVariableBuilder}, + tag::Tag, +}; + +pub mod content; +pub mod encoding; +pub mod example; +pub mod extensions; +pub mod external_docs; +pub mod header; +pub mod info; +pub mod link; +pub mod path; +pub mod request_body; +pub mod response; +pub mod schema; +pub mod security; +pub mod server; +pub mod tag; +pub mod xml; + +builder! { + /// # Examples + /// + /// Create [`OpenApi`] using [`OpenApiBuilder`]. + /// ```rust + /// # use fastapi::openapi::{Info, Paths, Components, OpenApiBuilder}; + /// let openapi = OpenApiBuilder::new() + /// .info(Info::new("My api", "1.0.0")) + /// .paths(Paths::new()) + /// .components(Some( + /// Components::new() + /// )) + /// .build(); + /// ``` + OpenApiBuilder; + + /// Root object of the OpenAPI document. + /// + /// You can use [`OpenApi::new`] function to construct a new [`OpenApi`] instance and then + /// use the fields with mutable access to modify them. This is quite tedious if you are not simply + /// just changing one thing thus you can also use the [`OpenApiBuilder::new`] to use builder to + /// construct a new [`OpenApi`] object. + /// + /// See more details at . + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct OpenApi { + /// OpenAPI document version. + pub openapi: OpenApiVersion, + + /// Provides metadata about the API. + /// + /// See more details at . + pub info: Info, + + /// Optional list of servers that provides the connectivity information to target servers. + /// + /// This is implicitly one server with `url` set to `/`. + /// + /// See more details at . + #[serde(skip_serializing_if = "Option::is_none")] + pub servers: Option>, + + /// Available paths and operations for the API. + /// + /// See more details at . + pub paths: Paths, + + /// Holds various reusable schemas for the OpenAPI document. + /// + /// Few of these elements are security schemas and object schemas. + /// + /// See more details at . + #[serde(skip_serializing_if = "Option::is_none")] + pub components: Option, + + /// Declaration of global security mechanisms that can be used across the API. The individual operations + /// can override the declarations. You can use `SecurityRequirement::default()` if you wish to make security + /// optional by adding it to the list of securities. + /// + /// See more details at . + #[serde(skip_serializing_if = "Option::is_none")] + pub security: Option>, + + /// Optional list of tags can be used to add additional documentation to matching tags of operations. + /// + /// See more details at . + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + + /// Optional global additional documentation reference. + /// + /// See more details at . + #[serde(skip_serializing_if = "Option::is_none")] + pub external_docs: Option, + + /// Schema keyword can be used to override default _`$schema`_ dialect which is by default + /// “”. + /// + /// All the references and individual files could use their own schema dialect. + #[serde(rename = "$schema", default, skip_serializing_if = "String::is_empty")] + pub schema: String, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl OpenApi { + /// Construct a new [`OpenApi`] object. + /// + /// Function accepts two arguments one which is [`Info`] metadata of the API; two which is [`Paths`] + /// containing operations for the API. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi::openapi::{Info, Paths, OpenApi}; + /// # + /// let openapi = OpenApi::new(Info::new("pet api", "0.1.0"), Paths::new()); + /// ``` + pub fn new>(info: Info, paths: P) -> Self { + Self { + info, + paths: paths.into(), + ..Default::default() + } + } + + /// Converts this [`OpenApi`] to JSON String. This method essentially calls [`serde_json::to_string`] method. + pub fn to_json(&self) -> Result { + serde_json::to_string(self) + } + + /// Converts this [`OpenApi`] to pretty JSON String. This method essentially calls [`serde_json::to_string_pretty`] method. + pub fn to_pretty_json(&self) -> Result { + serde_json::to_string_pretty(self) + } + + /// Converts this [`OpenApi`] to YAML String. This method essentially calls [`serde_yaml::to_string`] method. + #[cfg(feature = "yaml")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "yaml")))] + pub fn to_yaml(&self) -> Result { + serde_yaml::to_string(self) + } + + /// Merge `other` [`OpenApi`] moving `self` and returning combined [`OpenApi`]. + /// + /// In functionality wise this is exactly same as calling [`OpenApi::merge`] but but provides + /// leaner API for chaining method calls. + pub fn merge_from(mut self, other: OpenApi) -> OpenApi { + self.merge(other); + self + } + + /// Merge `other` [`OpenApi`] consuming it and resuming it's content. + /// + /// Merge function will take all `self` nonexistent _`servers`, `paths`, `schemas`, `responses`, + /// `security_schemes`, `security_requirements` and `tags`_ from _`other`_ [`OpenApi`]. + /// + /// This function performs a shallow comparison for `paths`, `schemas`, `responses` and + /// `security schemes` which means that only _`name`_ and _`path`_ is used for comparison. When + /// match occurs the whole item will be ignored from merged results. Only items not + /// found will be appended to `self`. + /// + /// For _`servers`_, _`tags`_ and _`security_requirements`_ the whole item will be used for + /// comparison. Items not found from `self` will be appended to `self`. + /// + /// **Note!** `info`, `openapi`, `external_docs` and `schema` will not be merged. + pub fn merge(&mut self, mut other: OpenApi) { + if let Some(other_servers) = &mut other.servers { + let servers = self.servers.get_or_insert(Vec::new()); + other_servers.retain(|server| !servers.contains(server)); + servers.append(other_servers); + } + + if !other.paths.paths.is_empty() { + self.paths.merge(other.paths); + }; + + if let Some(other_components) = &mut other.components { + let components = self.components.get_or_insert(Components::default()); + + other_components + .schemas + .retain(|name, _| !components.schemas.contains_key(name)); + components.schemas.append(&mut other_components.schemas); + + other_components + .responses + .retain(|name, _| !components.responses.contains_key(name)); + components.responses.append(&mut other_components.responses); + + other_components + .security_schemes + .retain(|name, _| !components.security_schemes.contains_key(name)); + components + .security_schemes + .append(&mut other_components.security_schemes); + } + + if let Some(other_security) = &mut other.security { + let security = self.security.get_or_insert(Vec::new()); + other_security.retain(|requirement| !security.contains(requirement)); + security.append(other_security); + } + + if let Some(other_tags) = &mut other.tags { + let tags = self.tags.get_or_insert(Vec::new()); + other_tags.retain(|tag| !tags.contains(tag)); + tags.append(other_tags); + } + } + + /// Nest `other` [`OpenApi`] to this [`OpenApi`]. + /// + /// Nesting performs custom [`OpenApi::merge`] where `other` [`OpenApi`] paths are prepended with given + /// `path` and then appended to _`paths`_ of this [`OpenApi`] instance. Rest of the `other` + /// [`OpenApi`] instance is merged to this [`OpenApi`] with [`OpenApi::merge_from`] method. + /// + /// **If multiple** APIs are being nested with same `path` only the **last** one will be retained. + /// + /// Method accepts two arguments, first is the path to prepend .e.g. _`/user`_. Second argument + /// is the [`OpenApi`] to prepend paths for. + /// + /// # Examples + /// + /// _**Merge `user_api` to `api` nesting `user_api` paths under `/api/v1/user`**_ + /// ```rust + /// # use fastapi::openapi::{OpenApi, OpenApiBuilder}; + /// # use fastapi::openapi::path::{PathsBuilder, PathItemBuilder, PathItem, + /// # HttpMethod, OperationBuilder}; + /// let api = OpenApiBuilder::new() + /// .paths( + /// PathsBuilder::new().path( + /// "/api/v1/status", + /// PathItem::new( + /// HttpMethod::Get, + /// OperationBuilder::new() + /// .description(Some("Get status")) + /// .build(), + /// ), + /// ), + /// ) + /// .build(); + /// let user_api = OpenApiBuilder::new() + /// .paths( + /// PathsBuilder::new().path( + /// "/", + /// PathItem::new(HttpMethod::Post, OperationBuilder::new().build()), + /// ) + /// ) + /// .build(); + /// let nested = api.nest("/api/v1/user", user_api); + /// ``` + pub fn nest, O: Into>(self, path: P, other: O) -> Self { + self.nest_with_path_composer(path, other, |base, path| format!("{base}{path}")) + } + + /// Nest `other` [`OpenApi`] with custom path composer. + /// + /// In most cases you should use [`OpenApi::nest`] instead. + /// Only use this method if you need custom path composition for a specific use case. + /// + /// `composer` is a function that takes two strings, the base path and the path to nest, and returns the composed path for the API Specification. + pub fn nest_with_path_composer< + P: Into, + O: Into, + F: Fn(&str, &str) -> String, + >( + mut self, + path: P, + other: O, + composer: F, + ) -> Self { + let path: String = path.into(); + let mut other_api: OpenApi = other.into(); + + let nested_paths = other_api + .paths + .paths + .into_iter() + .map(|(item_path, item)| { + let path = composer(&path, &item_path); + (path, item) + }) + .collect::>(); + + self.paths.paths.extend(nested_paths); + + // paths are already merged, thus we can ignore them + other_api.paths.paths = PathsMap::new(); + self.merge_from(other_api) + } +} + +impl OpenApiBuilder { + /// Add [`Info`] metadata of the API. + pub fn info>(mut self, info: I) -> Self { + set_value!(self info info.into()) + } + + /// Add iterator of [`Server`]s to configure target servers. + pub fn servers>(mut self, servers: Option) -> Self { + set_value!(self servers servers.map(|servers| servers.into_iter().collect())) + } + + /// Add [`Paths`] to configure operations and endpoints of the API. + pub fn paths>(mut self, paths: P) -> Self { + set_value!(self paths paths.into()) + } + + /// Add [`Components`] to configure reusable schemas. + pub fn components(mut self, components: Option) -> Self { + set_value!(self components components) + } + + /// Add iterator of [`SecurityRequirement`]s that are globally available for all operations. + pub fn security>( + mut self, + security: Option, + ) -> Self { + set_value!(self security security.map(|security| security.into_iter().collect())) + } + + /// Add iterator of [`Tag`]s to add additional documentation for **operations** tags. + pub fn tags>(mut self, tags: Option) -> Self { + set_value!(self tags tags.map(|tags| tags.into_iter().collect())) + } + + /// Add [`ExternalDocs`] for referring additional documentation. + pub fn external_docs(mut self, external_docs: Option) -> Self { + set_value!(self external_docs external_docs) + } + + /// Override default `$schema` dialect for the Open API doc. + /// + /// # Examples + /// + /// _**Override default schema dialect.**_ + /// ```rust + /// # use fastapi::openapi::OpenApiBuilder; + /// let _ = OpenApiBuilder::new() + /// .schema("http://json-schema.org/draft-07/schema#") + /// .build(); + /// ``` + pub fn schema>(mut self, schema: S) -> Self { + set_value!(self schema schema.into()) + } +} + +/// Represents available [OpenAPI versions][version]. +/// +/// [version]: +#[derive(Serialize, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum OpenApiVersion { + /// Will serialize to `3.1.0` the latest released OpenAPI version. + #[serde(rename = "3.1.0")] + #[default] + Version31, +} + +impl<'de> Deserialize<'de> for OpenApiVersion { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct VersionVisitor; + + impl<'v> Visitor<'v> for VersionVisitor { + type Value = OpenApiVersion; + + fn expecting(&self, formatter: &mut Formatter) -> std::fmt::Result { + formatter.write_str("a version string in 3.1.x format") + } + + fn visit_str(self, v: &str) -> Result + where + E: Error, + { + self.visit_string(v.to_string()) + } + + fn visit_string(self, v: String) -> Result + where + E: Error, + { + let version = v + .split('.') + .flat_map(|digit| digit.parse::()) + .collect::>(); + + if version.len() == 3 && version.first() == Some(&3) && version.get(1) == Some(&1) { + Ok(OpenApiVersion::Version31) + } else { + let expected: &dyn Expected = &"3.1.0"; + Err(Error::invalid_value( + serde::de::Unexpected::Str(&v), + expected, + )) + } + } + } + + deserializer.deserialize_string(VersionVisitor) + } +} + +/// Value used to indicate whether reusable schema, parameter or operation is deprecated. +/// +/// The value will serialize to boolean. +#[derive(PartialEq, Eq, Clone, Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[allow(missing_docs)] +pub enum Deprecated { + True, + #[default] + False, +} + +impl Serialize for Deprecated { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bool(matches!(self, Self::True)) + } +} + +impl<'de> Deserialize<'de> for Deprecated { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct BoolVisitor; + impl<'de> Visitor<'de> for BoolVisitor { + type Value = Deprecated; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a bool true or false") + } + + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + match v { + true => Ok(Deprecated::True), + false => Ok(Deprecated::False), + } + } + } + deserializer.deserialize_bool(BoolVisitor) + } +} + +/// Value used to indicate whether parameter or property is required. +/// +/// The value will serialize to boolean. +#[derive(PartialEq, Eq, Clone, Default)] +#[allow(missing_docs)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum Required { + True, + #[default] + False, +} + +impl Serialize for Required { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_bool(matches!(self, Self::True)) + } +} + +impl<'de> Deserialize<'de> for Required { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + struct BoolVisitor; + impl<'de> Visitor<'de> for BoolVisitor { + type Value = Required; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("a bool true or false") + } + + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + match v { + true => Ok(Required::True), + false => Ok(Required::False), + } + } + } + deserializer.deserialize_bool(BoolVisitor) + } +} + +/// A [`Ref`] or some other type `T`. +/// +/// Typically used in combination with [`Components`] and is an union type between [`Ref`] and any +/// other given type such as [`Schema`] or [`Response`]. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(untagged)] +pub enum RefOr { + /// Represents [`Ref`] reference to another OpenAPI object instance. e.g. + /// `$ref: #/components/schemas/Hello` + Ref(Ref), + /// Represents any value that can be added to the [`struct@Components`] e.g. [`enum@Schema`] + /// or [`struct@Response`]. + T(T), +} + +macro_rules! build_fn { + ( $vis:vis $name:ident $( $field:ident ),+ ) => { + #[doc = concat!("Constructs a new [`", stringify!($name),"`] taking all fields values from this object.")] + $vis fn build(self) -> $name { + $name { + $( + $field: self.$field, + )* + } + } + }; +} +pub(crate) use build_fn; + +macro_rules! set_value { + ( $self:ident $field:ident $value:expr ) => {{ + $self.$field = $value; + + $self + }}; +} +pub(crate) use set_value; + +macro_rules! new { + ( $vis:vis $name:ident ) => { + #[doc = concat!("Constructs a new [`", stringify!($name),"`].")] + $vis fn new() -> $name { + $name { + ..Default::default() + } + } + }; +} +pub(crate) use new; + +macro_rules! from { + ( $name:ident $to:ident $( $field:ident ),+ ) => { + impl From<$name> for $to { + fn from(value: $name) -> Self { + Self { + $( $field: value.$field, )* + } + } + } + + impl From<$to> for $name { + fn from(value: $to) -> Self { + value.build() + } + } + }; +} +pub(crate) use from; + +macro_rules! builder { + ( $( #[$builder_meta:meta] )* $builder_name:ident; $(#[$meta:meta])* $vis:vis $key:ident $name:ident $( $tt:tt )* ) => { + builder!( @type_impl $builder_name $( #[$meta] )* $vis $key $name $( $tt )* ); + builder!( @builder_impl $( #[$builder_meta] )* $builder_name $( #[$meta] )* $vis $key $name $( $tt )* ); + }; + + ( @type_impl $builder_name:ident $( #[$meta:meta] )* $vis:vis $key:ident $name:ident + { $( $( #[$field_meta:meta] )* $field_vis:vis $field:ident: $field_ty:ty, )* } + ) => { + $( #[$meta] )* + $vis $key $name { + $( $( #[$field_meta] )* $field_vis $field: $field_ty, )* + } + + impl $name { + #[doc = concat!("Construct a new ", stringify!($builder_name), ".")] + #[doc = ""] + #[doc = concat!("This is effectively same as calling [`", stringify!($builder_name), "::new`]")] + $vis fn builder() -> $builder_name { + $builder_name::new() + } + } + }; + + ( @builder_impl $( #[$builder_meta:meta] )* $builder_name:ident $( #[$meta:meta] )* $vis:vis $key:ident $name:ident + { $( $( #[$field_meta:meta] )* $field_vis:vis $field:ident: $field_ty:ty, )* } + ) => { + #[doc = concat!("Builder for [`", stringify!($name), + "`] with chainable configuration methods to create a new [`", stringify!($name) , "`].")] + $( #[$builder_meta] )* + #[cfg_attr(feature = "debug", derive(Debug))] + $vis $key $builder_name { + $( $field: $field_ty, )* + } + + impl Default for $builder_name { + fn default() -> Self { + let meta_default: $name = $name::default(); + Self { + $( $field: meta_default.$field, )* + } + } + } + + impl $builder_name { + crate::openapi::new!($vis $builder_name); + crate::openapi::build_fn!($vis $name $( $field ),* ); + } + + crate::openapi::from!($name $builder_name $( $field ),* ); + }; +} +use crate::openapi::extensions::Extensions; +pub(crate) use builder; + +#[cfg(test)] +mod tests { + use assert_json_diff::assert_json_eq; + use serde_json::json; + + use crate::openapi::{ + info::InfoBuilder, + path::{OperationBuilder, PathsBuilder}, + }; + + use super::{response::Response, *}; + + #[test] + fn serialize_deserialize_openapi_version_success() -> Result<(), serde_json::Error> { + assert_eq!(serde_json::to_value(&OpenApiVersion::Version31)?, "3.1.0"); + Ok(()) + } + + #[test] + fn serialize_openapi_json_minimal_success() -> Result<(), serde_json::Error> { + let raw_json = include_str!("openapi/testdata/expected_openapi_minimal.json"); + let openapi = OpenApi::new( + InfoBuilder::new() + .title("My api") + .version("1.0.0") + .description(Some("My api description")) + .license(Some( + LicenseBuilder::new() + .name("MIT") + .url(Some("http://mit.licence")) + .build(), + )) + .build(), + Paths::new(), + ); + let serialized = serde_json::to_string_pretty(&openapi)?; + + assert_eq!( + serialized, raw_json, + "expected serialized json to match raw: \nserialized: \n{serialized} \nraw: \n{raw_json}" + ); + Ok(()) + } + + #[test] + fn serialize_openapi_json_with_paths_success() -> Result<(), serde_json::Error> { + let openapi = OpenApi::new( + Info::new("My big api", "1.1.0"), + PathsBuilder::new() + .path( + "/api/v1/users", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new().response("200", Response::new("Get users list")), + ), + ) + .path( + "/api/v1/users", + PathItem::new( + HttpMethod::Post, + OperationBuilder::new().response("200", Response::new("Post new user")), + ), + ) + .path( + "/api/v1/users/{id}", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new().response("200", Response::new("Get user by id")), + ), + ), + ); + + let serialized = serde_json::to_string_pretty(&openapi)?; + let expected = include_str!("./openapi/testdata/expected_openapi_with_paths.json"); + + assert_eq!( + serialized, expected, + "expected serialized json to match raw: \nserialized: \n{serialized} \nraw: \n{expected}" + ); + Ok(()) + } + + #[test] + fn merge_2_openapi_documents() { + let mut api_1 = OpenApi::new( + Info::new("Api", "v1"), + PathsBuilder::new() + .path( + "/api/v1/user", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new().response("200", Response::new("Get user success")), + ), + ) + .build(), + ); + + let api_2 = OpenApiBuilder::new() + .info(Info::new("Api", "v2")) + .paths( + PathsBuilder::new() + .path( + "/api/v1/user", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new() + .response("200", Response::new("This will not get added")), + ), + ) + .path( + "/ap/v2/user", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new() + .response("200", Response::new("Get user success 2")), + ), + ) + .path( + "/api/v2/user", + PathItem::new( + HttpMethod::Post, + OperationBuilder::new() + .response("200", Response::new("Get user success")), + ), + ) + .build(), + ) + .components(Some( + ComponentsBuilder::new() + .schema( + "User2", + ObjectBuilder::new().schema_type(Type::Object).property( + "name", + ObjectBuilder::new().schema_type(Type::String).build(), + ), + ) + .build(), + )) + .build(); + + api_1.merge(api_2); + let value = serde_json::to_value(&api_1).unwrap(); + + assert_eq!( + value, + json!( + { + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "v1" + }, + "paths": { + "/ap/v2/user": { + "get": { + "responses": { + "200": { + "description": "Get user success 2" + } + } + } + }, + "/api/v1/user": { + "get": { + "responses": { + "200": { + "description": "Get user success" + } + } + } + }, + "/api/v2/user": { + "post": { + "responses": { + "200": { + "description": "Get user success" + } + } + } + } + }, + "components": { + "schemas": { + "User2": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + ) + ) + } + + #[test] + fn merge_same_path_diff_methods() { + let mut api_1 = OpenApi::new( + Info::new("Api", "v1"), + PathsBuilder::new() + .path( + "/api/v1/user", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new() + .response("200", Response::new("Get user success 1")), + ), + ) + .extensions(Some(Extensions::from_iter([("x-v1-api", true)]))) + .build(), + ); + + let api_2 = OpenApiBuilder::new() + .info(Info::new("Api", "v2")) + .paths( + PathsBuilder::new() + .path( + "/api/v1/user", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new() + .response("200", Response::new("This will not get added")), + ), + ) + .path( + "/api/v1/user", + PathItem::new( + HttpMethod::Post, + OperationBuilder::new() + .response("200", Response::new("Post user success 1")), + ), + ) + .path( + "/api/v2/user", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new() + .response("200", Response::new("Get user success 2")), + ), + ) + .path( + "/api/v2/user", + PathItem::new( + HttpMethod::Post, + OperationBuilder::new() + .response("200", Response::new("Post user success 2")), + ), + ) + .extensions(Some(Extensions::from_iter([("x-random", "Value")]))) + .build(), + ) + .components(Some( + ComponentsBuilder::new() + .schema( + "User2", + ObjectBuilder::new().schema_type(Type::Object).property( + "name", + ObjectBuilder::new().schema_type(Type::String).build(), + ), + ) + .build(), + )) + .build(); + + api_1.merge(api_2); + let value = serde_json::to_value(&api_1).unwrap(); + + assert_eq!( + value, + json!( + { + "openapi": "3.1.0", + "info": { + "title": "Api", + "version": "v1" + }, + "paths": { + "/api/v2/user": { + "get": { + "responses": { + "200": { + "description": "Get user success 2" + } + } + }, + "post": { + "responses": { + "200": { + "description": "Post user success 2" + } + } + } + }, + "/api/v1/user": { + "get": { + "responses": { + "200": { + "description": "Get user success 1" + } + } + }, + "post": { + "responses": { + "200": { + "description": "Post user success 1" + } + } + } + }, + "x-random": "Value", + "x-v1-api": true, + }, + "components": { + "schemas": { + "User2": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + } + } + } + } + ) + ) + } + + #[test] + fn test_nest_open_apis() { + let api = OpenApiBuilder::new() + .paths( + PathsBuilder::new().path( + "/api/v1/status", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new() + .description(Some("Get status")) + .build(), + ), + ), + ) + .build(); + + let user_api = OpenApiBuilder::new() + .paths( + PathsBuilder::new() + .path( + "/", + PathItem::new( + HttpMethod::Get, + OperationBuilder::new() + .description(Some("Get user details")) + .build(), + ), + ) + .path( + "/foo", + PathItem::new(HttpMethod::Post, OperationBuilder::new().build()), + ), + ) + .build(); + + let nest_merged = api.nest("/api/v1/user", user_api); + let value = serde_json::to_value(nest_merged).expect("should serialize as json"); + let paths = value + .pointer("/paths") + .expect("paths should exits in openapi"); + + assert_json_eq!( + paths, + json!({ + "/api/v1/status": { + "get": { + "description": "Get status", + "responses": {} + } + }, + "/api/v1/user/": { + "get": { + "description": "Get user details", + "responses": {} + } + }, + "/api/v1/user/foo": { + "post": { + "responses": {} + } + } + }) + ) + } + + #[test] + fn openapi_custom_extension() { + let mut api = OpenApiBuilder::new().build(); + let extensions = api.extensions.get_or_insert(Default::default()); + extensions.insert( + String::from("x-tagGroup"), + String::from("anything that serializes to Json").into(), + ); + + let api_json = serde_json::to_value(api).expect("OpenApi must serialize to JSON"); + + assert_json_eq!( + api_json, + json!({ + "info": { + "title": "", + "version": "" + }, + "openapi": "3.1.0", + "paths": {}, + "x-tagGroup": "anything that serializes to Json", + }) + ) + } +} diff --git a/fastapi/src/openapi/content.rs b/fastapi/src/openapi/content.rs new file mode 100644 index 0000000..f40bd48 --- /dev/null +++ b/fastapi/src/openapi/content.rs @@ -0,0 +1,124 @@ +//! Implements content object for request body and response. +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use serde_json::Value; + +use super::builder; +use super::example::Example; +use super::extensions::Extensions; +use super::{encoding::Encoding, set_value, RefOr, Schema}; + +builder! { + ContentBuilder; + + + /// Content holds request body content or response content. + /// + /// [`Content`] implements OpenAPI spec [Media Type Object][media_type] + /// + /// [media_type]: + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[non_exhaustive] + pub struct Content { + /// Schema used in response body or request body. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema: Option>, + + /// Example for request body or response body. + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, + + /// Examples of the request body or response body. [`Content::examples`] should match to + /// media type and specified schema if present. [`Content::examples`] and + /// [`Content::example`] are mutually exclusive. If both are defined `examples` will + /// override value in `example`. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub examples: BTreeMap>, + + /// A map between a property name and its encoding information. + /// + /// The key, being the property name, MUST exist in the [`Content::schema`] as a property, with + /// `schema` being a [`Schema::Object`] and this object containing the same property key in + /// [`Object::properties`](crate::openapi::schema::Object::properties). + /// + /// The encoding object SHALL only apply to `request_body` objects when the media type is + /// multipart or `application/x-www-form-urlencoded`. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub encoding: BTreeMap, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Content { + /// Construct a new [`Content`] object for provided _`schema`_. + pub fn new>>(schema: Option) -> Self { + Self { + schema: schema.map(|schema| schema.into()), + ..Self::default() + } + } +} + +impl ContentBuilder { + /// Add schema. + pub fn schema>>(mut self, schema: Option) -> Self { + set_value!(self schema schema.map(|schema| schema.into())) + } + + /// Add example of schema. + pub fn example(mut self, example: Option) -> Self { + set_value!(self example example) + } + + /// Add iterator of _`(N, V)`_ where `N` is name of example and `V` is [`Example`][example] to + /// [`Content`] of a request body or response body. + /// + /// [`Content::examples`] and [`Content::example`] are mutually exclusive. If both are defined + /// `examples` will override value in `example`. + /// + /// [example]: ../example/Example.html + pub fn examples_from_iter< + E: IntoIterator, + N: Into, + V: Into>, + >( + mut self, + examples: E, + ) -> Self { + self.examples.extend( + examples + .into_iter() + .map(|(name, example)| (name.into(), example.into())), + ); + + self + } + + /// Add an encoding. + /// + /// The `property_name` MUST exist in the [`Content::schema`] as a property, + /// with `schema` being a [`Schema::Object`] and this object containing the same property + /// key in [`Object::properties`](crate::openapi::schema::Object::properties). + /// + /// The encoding object SHALL only apply to `request_body` objects when the media type is + /// multipart or `application/x-www-form-urlencoded`. + pub fn encoding, E: Into>( + mut self, + property_name: S, + encoding: E, + ) -> Self { + self.encoding.insert(property_name.into(), encoding.into()); + self + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} diff --git a/fastapi/src/openapi/encoding.rs b/fastapi/src/openapi/encoding.rs new file mode 100644 index 0000000..d63a5a0 --- /dev/null +++ b/fastapi/src/openapi/encoding.rs @@ -0,0 +1,94 @@ +//! Implements encoding object for content. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::extensions::Extensions; +use super::{builder, path::ParameterStyle, set_value, Header}; + +builder! { + EncodingBuilder; + + /// A single encoding definition applied to a single schema [`Object + /// property`](crate::openapi::schema::Object::properties). + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + #[non_exhaustive] + pub struct Encoding { + /// The Content-Type for encoding a specific property. Default value depends on the property + /// type: for string with format being binary – `application/octet-stream`; for other primitive + /// types – `text/plain`; for object - `application/json`; for array – the default is defined + /// based on the inner type. The value can be a specific media type (e.g. `application/json`), + /// a wildcard media type (e.g. `image/*`), or a comma-separated list of the two types. + #[serde(skip_serializing_if = "Option::is_none")] + pub content_type: Option, + + /// A map allowing additional information to be provided as headers, for example + /// Content-Disposition. Content-Type is described separately and SHALL be ignored in this + /// section. This property SHALL be ignored if the request body media type is not a multipart. + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub headers: BTreeMap, + + /// Describes how a specific property value will be serialized depending on its type. See + /// Parameter Object for details on the style property. The behavior follows the same values as + /// query parameters, including default values. This property SHALL be ignored if the request + /// body media type is not `application/x-www-form-urlencoded`. + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + + /// When this is true, property values of type array or object generate separate parameters for + /// each value of the array, or key-value-pair of the map. For other types of properties this + /// property has no effect. When style is form, the default value is true. For all other + /// styles, the default value is false. This property SHALL be ignored if the request body + /// media type is not `application/x-www-form-urlencoded`. + #[serde(skip_serializing_if = "Option::is_none")] + pub explode: Option, + + /// Determines whether the parameter value SHOULD allow reserved characters, as defined by + /// RFC3986 `:/?#[]@!$&'()*+,;=` to be included without percent-encoding. The default value is + /// false. This property SHALL be ignored if the request body media type is not + /// `application/x-www-form-urlencoded`. + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_reserved: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl EncodingBuilder { + /// Set the content type. See [`Encoding::content_type`]. + pub fn content_type>(mut self, content_type: Option) -> Self { + set_value!(self content_type content_type.map(Into::into)) + } + + /// Add a [`Header`]. See [`Encoding::headers`]. + pub fn header, H: Into
>(mut self, header_name: S, header: H) -> Self { + self.headers.insert(header_name.into(), header.into()); + + self + } + + /// Set the style [`ParameterStyle`]. See [`Encoding::style`]. + pub fn style(mut self, style: Option) -> Self { + set_value!(self style style) + } + + /// Set the explode. See [`Encoding::explode`]. + pub fn explode(mut self, explode: Option) -> Self { + set_value!(self explode explode) + } + + /// Set the allow reserved. See [`Encoding::allow_reserved`]. + pub fn allow_reserved(mut self, allow_reserved: Option) -> Self { + set_value!(self allow_reserved allow_reserved) + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} diff --git a/fastapi/src/openapi/example.rs b/fastapi/src/openapi/example.rs new file mode 100644 index 0000000..648c5c1 --- /dev/null +++ b/fastapi/src/openapi/example.rs @@ -0,0 +1,101 @@ +//! Implements [OpenAPI Example Object][example] can be used to define examples for [`Response`][response]s and +//! [`RequestBody`][request_body]s. +//! +//! [example]: https://spec.openapis.org/oas/latest.html#example-object +//! [response]: response/struct.Response.html +//! [request_body]: request_body/struct.RequestBody.html +use serde::{Deserialize, Serialize}; + +use super::{builder, set_value, RefOr}; + +builder! { + /// # Examples + /// + /// _**Construct a new [`Example`] via builder**_ + /// ```rust + /// # use fastapi::openapi::example::ExampleBuilder; + /// let example = ExampleBuilder::new() + /// .summary("Example string response") + /// .value(Some(serde_json::json!("Example value"))) + /// .build(); + /// ``` + ExampleBuilder; + + /// Implements [OpenAPI Example Object][example]. + /// + /// Example is used on path operations to describe possible response bodies. + /// + /// [example]: https://spec.openapis.org/oas/latest.html#example-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Example { + /// Short description for the [`Example`]. + #[serde(skip_serializing_if = "String::is_empty", default)] + pub summary: String, + + /// Long description for the [`Example`]. Value supports markdown syntax for rich text + /// representation. + #[serde(skip_serializing_if = "String::is_empty", default)] + pub description: String, + + /// Embedded literal example value. [`Example::value`] and [`Example::external_value`] are + /// mutually exclusive. + #[serde(skip_serializing_if = "Option::is_none")] + pub value: Option, + + /// An URI that points to a literal example value. [`Example::external_value`] provides the + /// capability to references an example that cannot be easily included in JSON or YAML. + /// [`Example::value`] and [`Example::external_value`] are mutually exclusive. + #[serde(skip_serializing_if = "String::is_empty", default)] + pub external_value: String, + } +} + +impl Example { + /// Construct a new empty [`Example`]. This is effectively same as calling + /// [`Example::default`]. + pub fn new() -> Self { + Self::default() + } +} + +impl ExampleBuilder { + /// Add or change a short description for the [`Example`]. Setting this to empty `String` + /// will make it not render in the generated OpenAPI document. + pub fn summary>(mut self, summary: S) -> Self { + set_value!(self summary summary.into()) + } + + /// Add or change a long description for the [`Example`]. Markdown syntax is supported for rich + /// text representation. + /// + /// Setting this to empty `String` will make it not render in the generated + /// OpenAPI document. + pub fn description>(mut self, description: D) -> Self { + set_value!(self description description.into()) + } + + /// Add or change embedded literal example value. [`Example::value`] and [`Example::external_value`] + /// are mutually exclusive. + pub fn value(mut self, value: Option) -> Self { + set_value!(self value value) + } + + /// Add or change an URI that points to a literal example value. [`Example::external_value`] + /// provides the capability to references an example that cannot be easily included + /// in JSON or YAML. [`Example::value`] and [`Example::external_value`] are mutually exclusive. + /// + /// Setting this to an empty String will make the field not to render in the generated OpenAPI + /// document. + pub fn external_value>(mut self, external_value: E) -> Self { + set_value!(self external_value external_value.into()) + } +} + +impl From for RefOr { + fn from(example_builder: ExampleBuilder) -> Self { + Self::T(example_builder.build()) + } +} diff --git a/fastapi/src/openapi/extensions.rs b/fastapi/src/openapi/extensions.rs new file mode 100644 index 0000000..5833b9d --- /dev/null +++ b/fastapi/src/openapi/extensions.rs @@ -0,0 +1,130 @@ +//! Implements [OpenAPI Extensions][extensions]. +//! +//! [extensions]: https://spec.openapis.org/oas/latest.html#specification-extensions +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, +}; + +use serde::Serialize; + +use super::builder; + +const EXTENSION_PREFIX: &str = "x-"; + +builder! { + ExtensionsBuilder; + + /// Additional [data for extending][extensions] the OpenAPI specification. + /// + /// [extensions]: https://spec.openapis.org/oas/latest.html#specification-extensions + #[derive(Default, Serialize, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Extensions{ + #[serde(flatten)] + extensions: HashMap, + } +} + +impl Extensions { + /// Merge other [`Extensions`] into _`self`_. + pub fn merge(&mut self, other: Extensions) { + self.extensions.extend(other.extensions); + } +} + +impl Deref for Extensions { + type Target = HashMap; + + fn deref(&self) -> &Self::Target { + &self.extensions + } +} + +impl DerefMut for Extensions { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.extensions + } +} + +impl FromIterator<(K, V)> for Extensions +where + K: Into, + V: Into, +{ + fn from_iter>(iter: T) -> Self { + let iter = iter.into_iter().map(|(k, v)| (k.into(), v.into())); + let extensions = HashMap::from_iter(iter); + Self { extensions } + } +} + +impl From for HashMap { + fn from(value: Extensions) -> Self { + value.extensions + } +} + +impl<'de> serde::de::Deserialize<'de> for Extensions { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let extensions: HashMap = HashMap::deserialize(deserializer)?; + let extensions = extensions + .into_iter() + .filter(|(k, _)| k.starts_with(EXTENSION_PREFIX)) + .collect(); + Ok(Self { extensions }) + } +} + +impl ExtensionsBuilder { + /// Adds a key-value pair to the extensions. Extensions keys are prefixed with `"x-"` if + /// not done already. + pub fn add(mut self, key: K, value: V) -> Self + where + K: Into, + V: Into, + { + let mut key: String = key.into(); + if !key.starts_with(EXTENSION_PREFIX) { + key = format!("{EXTENSION_PREFIX}{key}"); + } + self.extensions.insert(key, value.into()); + self + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn extensions_builder() { + let expected = json!("value"); + let extensions = ExtensionsBuilder::new() + .add("x-some-extension", expected.clone()) + .add("another-extension", expected.clone()) + .build(); + + let value = serde_json::to_value(&extensions).unwrap(); + assert_eq!(value.get("x-some-extension"), Some(&expected)); + assert_eq!(value.get("x-another-extension"), Some(&expected)); + } + + #[test] + fn extensions_from_iter() { + let expected = json!("value"); + let extensions: Extensions = [ + ("x-some-extension", expected.clone()), + ("another-extension", expected.clone()), + ] + .into_iter() + .collect(); + + assert_eq!(extensions.get("x-some-extension"), Some(&expected)); + assert_eq!(extensions.get("another-extension"), Some(&expected)); + } +} diff --git a/fastapi/src/openapi/external_docs.rs b/fastapi/src/openapi/external_docs.rs new file mode 100644 index 0000000..3c78850 --- /dev/null +++ b/fastapi/src/openapi/external_docs.rs @@ -0,0 +1,63 @@ +//! Implements [OpenAPI External Docs Object][external_docs] types. +//! +//! [external_docs]: https://spec.openapis.org/oas/latest.html#xml-object +use serde::{Deserialize, Serialize}; + +use super::extensions::Extensions; +use super::{builder, set_value}; + +builder! { + ExternalDocsBuilder; + + /// Reference of external resource allowing extended documentation. + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct ExternalDocs { + /// Target url for external documentation location. + pub url: String, + /// Additional description supporting markdown syntax of the external documentation. + pub description: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl ExternalDocs { + /// Construct a new [`ExternalDocs`]. + /// + /// Function takes target url argument for the external documentation location. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi::openapi::external_docs::ExternalDocs; + /// let external_docs = ExternalDocs::new("https://pet-api.external.docs"); + /// ``` + pub fn new>(url: S) -> Self { + Self { + url: url.as_ref().to_string(), + ..Default::default() + } + } +} + +impl ExternalDocsBuilder { + /// Add target url for external documentation location. + pub fn url>(mut self, url: I) -> Self { + set_value!(self url url.into()) + } + + /// Add additional description of external documentation. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} diff --git a/fastapi/src/openapi/header.rs b/fastapi/src/openapi/header.rs new file mode 100644 index 0000000..7ee22d2 --- /dev/null +++ b/fastapi/src/openapi/header.rs @@ -0,0 +1,73 @@ +//! Implements [OpenAPI Header Object][header] types. +//! +//! [header]: https://spec.openapis.org/oas/latest.html#header-object + +use serde::{Deserialize, Serialize}; + +use super::{builder, set_value, Object, RefOr, Schema, Type}; + +builder! { + HeaderBuilder; + + /// Implements [OpenAPI Header Object][header] for response headers. + /// + /// [header]: https://spec.openapis.org/oas/latest.html#header-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Header { + /// Schema of header type. + pub schema: RefOr, + + /// Additional description of the header value. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + } +} + +impl Header { + /// Construct a new [`Header`] with custom schema. If you wish to construct a default + /// header with `String` type you can use [`Header::default`] function. + /// + /// # Examples + /// + /// Create new [`Header`] with integer type. + /// ```rust + /// # use fastapi::openapi::header::Header; + /// # use fastapi::openapi::{Object, Type}; + /// let header = Header::new(Object::with_type(Type::Integer)); + /// ``` + /// + /// Create a new [`Header`] with default type `String` + /// ```rust + /// # use fastapi::openapi::header::Header; + /// let header = Header::default(); + /// ``` + pub fn new>>(component: C) -> Self { + Self { + schema: component.into(), + ..Default::default() + } + } +} + +impl Default for Header { + fn default() -> Self { + Self { + description: Default::default(), + schema: Object::with_type(Type::String).into(), + } + } +} + +impl HeaderBuilder { + /// Add schema of header. + pub fn schema>>(mut self, component: I) -> Self { + set_value!(self schema component.into()) + } + + /// Add additional description for header. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } +} diff --git a/fastapi/src/openapi/info.rs b/fastapi/src/openapi/info.rs new file mode 100644 index 0000000..7f130de --- /dev/null +++ b/fastapi/src/openapi/info.rs @@ -0,0 +1,277 @@ +//! Implements [OpenAPI Metadata][info] types. +//! +//! Refer to [`OpenApi`][openapi_trait] trait and [derive documentation][derive] +//! for examples and usage details. +//! +//! [info]: +//! [openapi_trait]: ../../trait.OpenApi.html +//! [derive]: ../../derive.OpenApi.html + +use serde::{Deserialize, Serialize}; + +use super::{builder, extensions::Extensions, set_value}; + +builder! { + /// # Examples + /// + /// Create [`Info`] using [`InfoBuilder`]. + /// ```rust + /// # use fastapi::openapi::{Info, InfoBuilder, ContactBuilder}; + /// let info = InfoBuilder::new() + /// .title("My api") + /// .version("1.0.0") + /// .contact(Some(ContactBuilder::new() + /// .name(Some("Admin Admin")) + /// .email(Some("amdin@petapi.com")) + /// .build() + /// )) + /// .build(); + /// ``` + InfoBuilder; + + /// OpenAPI [Info][info] object represents metadata of the API. + /// + /// You can use [`Info::new`] to construct a new [`Info`] object or alternatively use [`InfoBuilder::new`] + /// to construct a new [`Info`] with chainable configuration methods. + /// + /// [info]: + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Info { + /// Title of the API. + pub title: String, + + /// Optional description of the API. + /// + /// Value supports markdown syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional url for terms of service. + #[serde(skip_serializing_if = "Option::is_none")] + pub terms_of_service: Option, + + /// Contact information of exposed API. + /// + /// See more details at: . + #[serde(skip_serializing_if = "Option::is_none")] + pub contact: Option, + + /// License of the API. + /// + /// See more details at: . + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, + + /// Document version typically the API version. + pub version: String, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Info { + /// Construct a new [`Info`] object. + /// + /// This function accepts two arguments. One which is the title of the API and two the + /// version of the api document typically the API version. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi::openapi::Info; + /// let info = Info::new("Pet api", "1.1.0"); + /// ``` + pub fn new>(title: S, version: S) -> Self { + Self { + title: title.into(), + version: version.into(), + ..Default::default() + } + } +} + +impl InfoBuilder { + /// Add title of the API. + pub fn title>(mut self, title: I) -> Self { + set_value!(self title title.into()) + } + + /// Add version of the api document typically the API version. + pub fn version>(mut self, version: I) -> Self { + set_value!(self version version.into()) + } + + /// Add description of the API. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add url for terms of the API. + pub fn terms_of_service>(mut self, terms_of_service: Option) -> Self { + set_value!(self terms_of_service terms_of_service.map(|terms_of_service| terms_of_service.into())) + } + + /// Add contact information of the API. + pub fn contact(mut self, contact: Option) -> Self { + set_value!(self contact contact) + } + + /// Add license of the API. + pub fn license(mut self, license: Option) -> Self { + set_value!(self license license) + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +builder! { + /// See the [`InfoBuilder`] for combined usage example. + ContactBuilder; + + /// OpenAPI [Contact][contact] information of the API. + /// + /// You can use [`Contact::new`] to construct a new [`Contact`] object or alternatively + /// use [`ContactBuilder::new`] to construct a new [`Contact`] with chainable configuration methods. + /// + /// [contact]: + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Contact { + /// Identifying name of the contact person or organization of the API. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// Url pointing to contact information of the API. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// Email of the contact person or the organization of the API. + #[serde(skip_serializing_if = "Option::is_none")] + pub email: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Contact { + /// Construct a new [`Contact`]. + pub fn new() -> Self { + Default::default() + } +} + +impl ContactBuilder { + /// Add name contact person or organization of the API. + pub fn name>(mut self, name: Option) -> Self { + set_value!(self name name.map(|name| name.into())) + } + + /// Add url pointing to the contact information of the API. + pub fn url>(mut self, url: Option) -> Self { + set_value!(self url url.map(|url| url.into())) + } + + /// Add email of the contact person or organization of the API. + pub fn email>(mut self, email: Option) -> Self { + set_value!(self email email.map(|email| email.into())) + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +builder! { + LicenseBuilder; + + /// OpenAPI [License][license] information of the API. + /// + /// [license]: + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct License { + /// Name of the license used e.g MIT or Apache-2.0. + pub name: String, + + /// Optional url pointing to the license. + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + + /// An [SPDX-Licenses][spdx_licence] expression for the API. The _`identifier`_ field + /// is mutually exclusive of the _`url`_ field. E.g. Apache-2.0 + /// + /// [spdx_licence]: + #[serde(skip_serializing_if = "Option::is_none")] + pub identifier: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl License { + /// Construct a new [`License`] object. + /// + /// Function takes name of the license as an argument e.g MIT. + pub fn new>(name: S) -> Self { + Self { + name: name.into(), + ..Default::default() + } + } +} + +impl LicenseBuilder { + /// Add name of the license used in API. + pub fn name>(mut self, name: S) -> Self { + set_value!(self name name.into()) + } + + /// Add url pointing to the license used in API. + pub fn url>(mut self, url: Option) -> Self { + set_value!(self url url.map(|url| url.into())) + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + /// Set identifier of the licence as [SPDX-Licenses][spdx_licence] expression for the API. + /// The _`identifier`_ field is mutually exclusive of the _`url`_ field. E.g. Apache-2.0 + /// + /// [spdx_licence]: + pub fn identifier>(mut self, identifier: Option) -> Self { + set_value!(self identifier identifier.map(|identifier| identifier.into())) + } +} + +#[cfg(test)] +mod tests { + use super::Contact; + + #[test] + fn contact_new() { + let contact = Contact::new(); + + assert!(contact.name.is_none()); + assert!(contact.url.is_none()); + assert!(contact.email.is_none()); + } +} diff --git a/fastapi/src/openapi/link.rs b/fastapi/src/openapi/link.rs new file mode 100644 index 0000000..365d157 --- /dev/null +++ b/fastapi/src/openapi/link.rs @@ -0,0 +1,143 @@ +//! Implements [Open API Link Object][link_object] for responses. +//! +//! [link_object]: https://spec.openapis.org/oas/latest.html#link-object +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::extensions::Extensions; +use super::{builder, Server}; + +builder! { + LinkBuilder; + + /// Implements [Open API Link Object][link_object] for responses. + /// + /// The `Link` represents possible design time link for a response. It does not guarantee + /// callers ability to invoke it but rather provides known relationship between responses and + /// other operations. + /// + /// For computing links, and providing instructions to execute them, + /// a runtime [expression][expression] is used for accessing values in an operation + /// and using them as parameters while invoking the linked operation. + /// + /// [expression]: https://spec.openapis.org/oas/latest.html#runtime-expressions + /// [link_object]: https://spec.openapis.org/oas/latest.html#link-object + #[derive(Serialize, Deserialize, Clone, PartialEq, Default)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[non_exhaustive] + pub struct Link { + /// A relative or absolute URI reference to an OAS operation. This field is + /// mutually exclusive of the _`operation_id`_ field, and **must** point to an [Operation + /// Object][operation]. + /// Relative _`operation_ref`_ values may be used to locate an existing [Operation + /// Object][operation] in the OpenAPI definition. See the rules for resolving [Relative + /// References][relative_references]. + /// + /// [relative_references]: https://spec.openapis.org/oas/latest.html#relative-references-in-uris + /// [operation]: ../path/struct.Operation.html + #[serde(skip_serializing_if = "String::is_empty", default)] + pub operation_ref: String, + + /// The name of an existing, resolvable OAS operation, as defined with a unique + /// _`operation_id`_. + /// This field is mutually exclusive of the _`operation_ref`_ field. + #[serde(skip_serializing_if = "String::is_empty", default)] + pub operation_id: String, + + /// A map representing parameters to pass to an operation as specified with _`operation_id`_ + /// or identified by _`operation_ref`_. The key is parameter name to be used and value can + /// be any value supported by JSON or an [expression][expression] e.g. `$path.id` + /// + /// [expression]: https://spec.openapis.org/oas/latest.html#runtime-expressions + #[serde(skip_serializing_if = "BTreeMap::is_empty")] + pub parameters: BTreeMap, + + /// A literal value or an [expression][expression] to be used as request body when operation is called. + /// + /// [expression]: https://spec.openapis.org/oas/latest.html#runtime-expressions + #[serde(skip_serializing_if = "Option::is_none")] + pub request_body: Option, + + /// Description of the link. Value supports Markdown syntax. + #[serde(skip_serializing_if = "String::is_empty", default)] + pub description: String, + + /// A [`Server`][server] object to be used by the target operation. + /// + /// [server]: ../server/struct.Server.html + #[serde(skip_serializing_if = "Option::is_none")] + pub server: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl LinkBuilder { + /// Set a relative or absolute URI reference to an OAS operation. This field is + /// mutually exclusive of the _`operation_id`_ field, and **must** point to an [Operation + /// Object][operation]. + /// + /// [operation]: ../path/struct.Operation.html + pub fn operation_ref>(mut self, operation_ref: S) -> Self { + self.operation_ref = operation_ref.into(); + + self + } + + /// Set the name of an existing, resolvable OAS operation, as defined with a unique + /// _`operation_id`_. + /// This field is mutually exclusive of the _`operation_ref`_ field. + pub fn operation_id>(mut self, operation_id: S) -> Self { + self.operation_id = operation_id.into(); + + self + } + + /// Add parameter to be passed to [Operation][operation] upon execution. + /// + /// [operation]: ../path/struct.Operation.html + pub fn parameter, V: Into>( + mut self, + name: N, + value: V, + ) -> Self { + self.parameters.insert(name.into(), value.into()); + + self + } + + /// Set a literal value or an [expression][expression] to be used as request body when operation is called. + /// + /// [expression]: https://spec.openapis.org/oas/latest.html#runtime-expressions + pub fn request_body>(mut self, request_body: Option) -> Self { + self.request_body = request_body.map(|request_body| request_body.into()); + + self + } + + /// Set description of the link. Value supports Markdown syntax. + pub fn description>(mut self, description: S) -> Self { + self.description = description.into(); + + self + } + + /// Set a [`Server`][server] object to be used by the target operation. + /// + /// [server]: ../server/struct.Server.html + pub fn server>(mut self, server: Option) -> Self { + self.server = server.map(|server| server.into()); + + self + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + self.extensions = extensions; + + self + } +} diff --git a/fastapi/src/openapi/path.rs b/fastapi/src/openapi/path.rs new file mode 100644 index 0000000..5615642 --- /dev/null +++ b/fastapi/src/openapi/path.rs @@ -0,0 +1,1015 @@ +//! Implements [OpenAPI Path Object][paths] types. +//! +//! [paths]: https://spec.openapis.org/oas/latest.html#paths-object +use crate::Path; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::{ + builder, + extensions::Extensions, + request_body::RequestBody, + response::{Response, Responses}, + security::SecurityRequirement, + set_value, Deprecated, ExternalDocs, RefOr, Required, Schema, Server, +}; + +#[cfg(not(feature = "preserve_path_order"))] +#[allow(missing_docs)] +#[doc(hidden)] +pub type PathsMap = std::collections::BTreeMap; +#[cfg(feature = "preserve_path_order")] +#[allow(missing_docs)] +#[doc(hidden)] +pub type PathsMap = indexmap::IndexMap; + +builder! { + PathsBuilder; + + /// Implements [OpenAPI Paths Object][paths]. + /// + /// Holds relative paths to matching endpoints and operations. The path is appended to the url + /// from [`Server`] object to construct a full url for endpoint. + /// + /// [paths]: https://spec.openapis.org/oas/latest.html#paths-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Paths { + /// Map of relative paths with [`PathItem`]s holding [`Operation`]s matching + /// api endpoints. + #[serde(flatten)] + pub paths: PathsMap, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Paths { + /// Construct a new [`Paths`] object. + pub fn new() -> Self { + Default::default() + } + + /// Return _`Option`_ of reference to [`PathItem`] by given relative path _`P`_ if one exists + /// in [`Paths::paths`] map. Otherwise will return `None`. + /// + /// # Examples + /// + /// _**Get user path item.**_ + /// ```rust + /// # use fastapi::openapi::path::{Paths, HttpMethod}; + /// # let paths = Paths::new(); + /// let path_item = paths.get_path_item("/api/v1/user"); + /// ``` + pub fn get_path_item>(&self, path: P) -> Option<&PathItem> { + self.paths.get(path.as_ref()) + } + + /// Return _`Option`_ of reference to [`Operation`] from map of paths or `None` if not found. + /// + /// * First will try to find [`PathItem`] by given relative path _`P`_ e.g. `"/api/v1/user"`. + /// * Then tries to find [`Operation`] from [`PathItem`]'s operations by given [`HttpMethod`]. + /// + /// # Examples + /// + /// _**Get user operation from paths.**_ + /// ```rust + /// # use fastapi::openapi::path::{Paths, HttpMethod}; + /// # let paths = Paths::new(); + /// let operation = paths.get_path_operation("/api/v1/user", HttpMethod::Get); + /// ``` + pub fn get_path_operation>( + &self, + path: P, + http_method: HttpMethod, + ) -> Option<&Operation> { + self.paths + .get(path.as_ref()) + .and_then(|path| match http_method { + HttpMethod::Get => path.get.as_ref(), + HttpMethod::Put => path.put.as_ref(), + HttpMethod::Post => path.post.as_ref(), + HttpMethod::Delete => path.delete.as_ref(), + HttpMethod::Options => path.options.as_ref(), + HttpMethod::Head => path.head.as_ref(), + HttpMethod::Patch => path.patch.as_ref(), + HttpMethod::Trace => path.trace.as_ref(), + }) + } + + /// Append path operation to the list of paths. + /// + /// Method accepts three arguments; `path` to add operation for, `http_methods` list of + /// allowed HTTP methods for the [`Operation`] and `operation` to be added under the _`path`_. + /// + /// If _`path`_ already exists, the provided [`Operation`] will be set to existing path item for + /// given list of [`HttpMethod`]s. + pub fn add_path_operation, O: Into>( + &mut self, + path: P, + http_methods: Vec, + operation: O, + ) { + let path = path.as_ref(); + let operation = operation.into(); + if let Some(existing_item) = self.paths.get_mut(path) { + for http_method in http_methods { + match http_method { + HttpMethod::Get => existing_item.get = Some(operation.clone()), + HttpMethod::Put => existing_item.put = Some(operation.clone()), + HttpMethod::Post => existing_item.post = Some(operation.clone()), + HttpMethod::Delete => existing_item.delete = Some(operation.clone()), + HttpMethod::Options => existing_item.options = Some(operation.clone()), + HttpMethod::Head => existing_item.head = Some(operation.clone()), + HttpMethod::Patch => existing_item.patch = Some(operation.clone()), + HttpMethod::Trace => existing_item.trace = Some(operation.clone()), + }; + } + } else { + self.paths.insert( + String::from(path), + PathItem::from_http_methods(http_methods, operation), + ); + } + } + + /// Merge _`other_paths`_ into `self`. On conflicting path the path item operations will be + /// merged into existing [`PathItem`]. Otherwise path with [`PathItem`] will be appended to + /// `self`. All [`Extensions`] will be merged from _`other_paths`_ into `self`. + pub fn merge(&mut self, other_paths: Paths) { + for (path, that) in other_paths.paths { + if let Some(this) = self.paths.get_mut(&path) { + this.merge_operations(that); + } else { + self.paths.insert(path, that); + } + } + + if let Some(other_paths_extensions) = other_paths.extensions { + let paths_extensions = self.extensions.get_or_insert(Extensions::default()); + paths_extensions.merge(other_paths_extensions); + } + } +} + +impl PathsBuilder { + /// Append [`PathItem`] with path to map of paths. If path already exists it will merge [`Operation`]s of + /// [`PathItem`] with already found path item operations. + pub fn path>(mut self, path: I, item: PathItem) -> Self { + let path_string = path.into(); + if let Some(existing_item) = self.paths.get_mut(&path_string) { + existing_item.merge_operations(item); + } else { + self.paths.insert(path_string, item); + } + + self + } + + /// Add extensions to the paths section. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + /// Appends a [`Path`] to map of paths. Method must be called with one generic argument that + /// implements [`trait@Path`] trait. + /// + /// # Examples + /// + /// _**Append `MyPath` content to the paths.**_ + /// ```rust + /// # struct MyPath; + /// # impl fastapi::Path for MyPath { + /// # fn methods() -> Vec { vec![] } + /// # fn path() -> String { String::new() } + /// # fn operation() -> fastapi::openapi::path::Operation { + /// # fastapi::openapi::path::Operation::new() + /// # } + /// # } + /// let paths = fastapi::openapi::path::PathsBuilder::new(); + /// let _ = paths.path_from::(); + /// ``` + pub fn path_from(self) -> Self { + let methods = P::methods(); + let operation = P::operation(); + + // for one operation method avoid clone + let path_item = if methods.len() == 1 { + PathItem::new( + methods + .into_iter() + .next() + .expect("must have one operation method"), + operation, + ) + } else { + methods + .into_iter() + .fold(PathItemBuilder::new(), |path_item, method| { + path_item.operation(method, operation.clone()) + }) + .build() + }; + + self.path(P::path(), path_item) + } +} + +builder! { + PathItemBuilder; + + /// Implements [OpenAPI Path Item Object][path_item] what describes [`Operation`]s available on + /// a single path. + /// + /// [path_item]: https://spec.openapis.org/oas/latest.html#path-item-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct PathItem { + /// Optional summary intended to apply all operations in this [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + + /// Optional description intended to apply all operations in this [`PathItem`]. + /// Description supports markdown syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Alternative [`Server`] array to serve all [`Operation`]s in this [`PathItem`] overriding + /// the global server array. + #[serde(skip_serializing_if = "Option::is_none")] + pub servers: Option>, + + /// List of [`Parameter`]s common to all [`Operation`]s in this [`PathItem`]. Parameters cannot + /// contain duplicate parameters. They can be overridden in [`Operation`] level but cannot be + /// removed there. + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, + + /// Get [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub get: Option, + + /// Put [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub put: Option, + + /// Post [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub post: Option, + + /// Delete [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub delete: Option, + + /// Options [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub options: Option, + + /// Head [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub head: Option, + + /// Patch [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub patch: Option, + + /// Trace [`Operation`] for the [`PathItem`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub trace: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl PathItem { + /// Construct a new [`PathItem`] with provided [`Operation`] mapped to given [`HttpMethod`]. + pub fn new>(http_method: HttpMethod, operation: O) -> Self { + let mut path_item = Self::default(); + match http_method { + HttpMethod::Get => path_item.get = Some(operation.into()), + HttpMethod::Put => path_item.put = Some(operation.into()), + HttpMethod::Post => path_item.post = Some(operation.into()), + HttpMethod::Delete => path_item.delete = Some(operation.into()), + HttpMethod::Options => path_item.options = Some(operation.into()), + HttpMethod::Head => path_item.head = Some(operation.into()), + HttpMethod::Patch => path_item.patch = Some(operation.into()), + HttpMethod::Trace => path_item.trace = Some(operation.into()), + }; + + path_item + } + + /// Constructs a new [`PathItem`] with given [`Operation`] set for provided [`HttpMethod`]s. + pub fn from_http_methods, O: Into>( + http_methods: I, + operation: O, + ) -> Self { + let mut path_item = Self::default(); + let operation = operation.into(); + for method in http_methods { + match method { + HttpMethod::Get => path_item.get = Some(operation.clone()), + HttpMethod::Put => path_item.put = Some(operation.clone()), + HttpMethod::Post => path_item.post = Some(operation.clone()), + HttpMethod::Delete => path_item.delete = Some(operation.clone()), + HttpMethod::Options => path_item.options = Some(operation.clone()), + HttpMethod::Head => path_item.head = Some(operation.clone()), + HttpMethod::Patch => path_item.patch = Some(operation.clone()), + HttpMethod::Trace => path_item.trace = Some(operation.clone()), + }; + } + + path_item + } + + /// Merge all defined [`Operation`]s from given [`PathItem`] to `self` if `self` does not have + /// existing operation. + pub fn merge_operations(&mut self, path_item: PathItem) { + if path_item.get.is_some() && self.get.is_none() { + self.get = path_item.get; + } + if path_item.put.is_some() && self.put.is_none() { + self.put = path_item.put; + } + if path_item.post.is_some() && self.post.is_none() { + self.post = path_item.post; + } + if path_item.delete.is_some() && self.delete.is_none() { + self.delete = path_item.delete; + } + if path_item.options.is_some() && self.options.is_none() { + self.options = path_item.options; + } + if path_item.head.is_some() && self.head.is_none() { + self.head = path_item.head; + } + if path_item.patch.is_some() && self.patch.is_none() { + self.patch = path_item.patch; + } + if path_item.trace.is_some() && self.trace.is_none() { + self.trace = path_item.trace; + } + } +} + +impl PathItemBuilder { + /// Append a new [`Operation`] by [`HttpMethod`] to this [`PathItem`]. Operations can + /// hold only one operation per [`HttpMethod`]. + pub fn operation>(mut self, http_method: HttpMethod, operation: O) -> Self { + match http_method { + HttpMethod::Get => self.get = Some(operation.into()), + HttpMethod::Put => self.put = Some(operation.into()), + HttpMethod::Post => self.post = Some(operation.into()), + HttpMethod::Delete => self.delete = Some(operation.into()), + HttpMethod::Options => self.options = Some(operation.into()), + HttpMethod::Head => self.head = Some(operation.into()), + HttpMethod::Patch => self.patch = Some(operation.into()), + HttpMethod::Trace => self.trace = Some(operation.into()), + }; + + self + } + + /// Add or change summary intended to apply all operations in this [`PathItem`]. + pub fn summary>(mut self, summary: Option) -> Self { + set_value!(self summary summary.map(|summary| summary.into())) + } + + /// Add or change optional description intended to apply all operations in this [`PathItem`]. + /// Description supports markdown syntax. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add list of alternative [`Server`]s to serve all [`Operation`]s in this [`PathItem`] overriding + /// the global server array. + pub fn servers>(mut self, servers: Option) -> Self { + set_value!(self servers servers.map(|servers| servers.into_iter().collect())) + } + + /// Append list of [`Parameter`]s common to all [`Operation`]s to this [`PathItem`]. + pub fn parameters>(mut self, parameters: Option) -> Self { + set_value!(self parameters parameters.map(|parameters| parameters.into_iter().collect())) + } + + /// Add openapi extensions (x-something) to this [`PathItem`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +/// HTTP method of the operation. +/// +/// List of supported HTTP methods +#[derive(Serialize, Deserialize, PartialEq, Eq, Hash, PartialOrd, Ord, Clone)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum HttpMethod { + /// Type mapping for HTTP _GET_ request. + Get, + /// Type mapping for HTTP _POST_ request. + Post, + /// Type mapping for HTTP _PUT_ request. + Put, + /// Type mapping for HTTP _DELETE_ request. + Delete, + /// Type mapping for HTTP _OPTIONS_ request. + Options, + /// Type mapping for HTTP _HEAD_ request. + Head, + /// Type mapping for HTTP _PATCH_ request. + Patch, + /// Type mapping for HTTP _TRACE_ request. + Trace, +} + +builder! { + OperationBuilder; + + /// Implements [OpenAPI Operation Object][operation] object. + /// + /// [operation]: https://spec.openapis.org/oas/latest.html#operation-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Operation { + /// List of tags used for grouping operations. + /// + /// When used with derive [`#[fastapi::path(...)]`][derive_path] attribute macro the default + /// value used will be resolved from handler path provided in `#[openapi(paths(...))]` with + /// [`#[derive(OpenApi)]`][derive_openapi] macro. If path resolves to `None` value `crate` will + /// be used by default. + /// + /// [derive_path]: ../../attr.path.html + /// [derive_openapi]: ../../derive.OpenApi.html + #[serde(skip_serializing_if = "Option::is_none")] + pub tags: Option>, + + /// Short summary what [`Operation`] does. + /// + /// When used with derive [`#[fastapi::path(...)]`][derive_path] attribute macro the value + /// is taken from **first line** of doc comment. + /// + /// [derive_path]: ../../attr.path.html + #[serde(skip_serializing_if = "Option::is_none")] + pub summary: Option, + + /// Long explanation of [`Operation`] behaviour. Markdown syntax is supported. + /// + /// When used with derive [`#[fastapi::path(...)]`][derive_path] attribute macro the + /// doc comment is used as value for description. + /// + /// [derive_path]: ../../attr.path.html + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Unique identifier for the API [`Operation`]. Most typically this is mapped to handler function name. + /// + /// When used with derive [`#[fastapi::path(...)]`][derive_path] attribute macro the handler function + /// name will be used by default. + /// + /// [derive_path]: ../../attr.path.html + #[serde(skip_serializing_if = "Option::is_none")] + pub operation_id: Option, + + /// Additional external documentation for this operation. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_docs: Option, + + /// List of applicable parameters for this [`Operation`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub parameters: Option>, + + /// Optional request body for this [`Operation`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub request_body: Option, + + /// List of possible responses returned by the [`Operation`]. + pub responses: Responses, + + // TODO + #[allow(missing_docs)] + #[serde(skip_serializing_if = "Option::is_none")] + pub callbacks: Option, + + /// Define whether the operation is deprecated or not and thus should be avoided consuming. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated: Option, + + /// Declaration which security mechanisms can be used for for the operation. Only one + /// [`SecurityRequirement`] must be met. + /// + /// Security for the [`Operation`] can be set to optional by adding empty security with + /// [`SecurityRequirement::default`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub security: Option>, + + /// Alternative [`Server`]s for this [`Operation`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub servers: Option>, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Operation { + /// Construct a new API [`Operation`]. + pub fn new() -> Self { + Default::default() + } +} + +impl OperationBuilder { + /// Add or change tags of the [`Operation`]. + pub fn tags, V: Into>(mut self, tags: Option) -> Self { + set_value!(self tags tags.map(|tags| tags.into_iter().map(Into::into).collect())) + } + + /// Append tag to [`Operation`] tags. + pub fn tag>(mut self, tag: S) -> Self { + let tag_string = tag.into(); + match self.tags { + Some(ref mut tags) => tags.push(tag_string), + None => { + self.tags = Some(vec![tag_string]); + } + } + + self + } + + /// Add or change short summary of the [`Operation`]. + pub fn summary>(mut self, summary: Option) -> Self { + set_value!(self summary summary.map(|summary| summary.into())) + } + + /// Add or change description of the [`Operation`]. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change operation id of the [`Operation`]. + pub fn operation_id>(mut self, operation_id: Option) -> Self { + set_value!(self operation_id operation_id.map(|operation_id| operation_id.into())) + } + + /// Add or change parameters of the [`Operation`]. + pub fn parameters, P: Into>( + mut self, + parameters: Option, + ) -> Self { + self.parameters = parameters.map(|parameters| { + if let Some(mut params) = self.parameters { + params.extend(parameters.into_iter().map(|parameter| parameter.into())); + params + } else { + parameters + .into_iter() + .map(|parameter| parameter.into()) + .collect() + } + }); + + self + } + + /// Append parameter to [`Operation`] parameters. + pub fn parameter>(mut self, parameter: P) -> Self { + match self.parameters { + Some(ref mut parameters) => parameters.push(parameter.into()), + None => { + self.parameters = Some(vec![parameter.into()]); + } + } + + self + } + + /// Add or change request body of the [`Operation`]. + pub fn request_body(mut self, request_body: Option) -> Self { + set_value!(self request_body request_body) + } + + /// Add or change responses of the [`Operation`]. + pub fn responses>(mut self, responses: R) -> Self { + set_value!(self responses responses.into()) + } + + /// Append status code and a [`Response`] to the [`Operation`] responses map. + /// + /// * `code` must be valid HTTP status code. + /// * `response` is instances of [`Response`]. + pub fn response, R: Into>>( + mut self, + code: S, + response: R, + ) -> Self { + self.responses + .responses + .insert(code.into(), response.into()); + + self + } + + /// Add or change deprecated status of the [`Operation`]. + pub fn deprecated(mut self, deprecated: Option) -> Self { + set_value!(self deprecated deprecated) + } + + /// Add or change list of [`SecurityRequirement`]s that are available for [`Operation`]. + pub fn securities>( + mut self, + securities: Option, + ) -> Self { + set_value!(self security securities.map(|securities| securities.into_iter().collect())) + } + + /// Append [`SecurityRequirement`] to [`Operation`] security requirements. + pub fn security(mut self, security: SecurityRequirement) -> Self { + if let Some(ref mut securities) = self.security { + securities.push(security); + } else { + self.security = Some(vec![security]); + } + + self + } + + /// Add or change list of [`Server`]s of the [`Operation`]. + pub fn servers>(mut self, servers: Option) -> Self { + set_value!(self servers servers.map(|servers| servers.into_iter().collect())) + } + + /// Append a new [`Server`] to the [`Operation`] servers. + pub fn server(mut self, server: Server) -> Self { + if let Some(ref mut servers) = self.servers { + servers.push(server); + } else { + self.servers = Some(vec![server]); + } + + self + } + + /// Add openapi extensions (x-something) of the [`Operation`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +builder! { + ParameterBuilder; + + /// Implements [OpenAPI Parameter Object][parameter] for [`Operation`]. + /// + /// [parameter]: https://spec.openapis.org/oas/latest.html#parameter-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Parameter { + /// Name of the parameter. + /// + /// * For [`ParameterIn::Path`] this must in accordance to path templating. + /// * For [`ParameterIn::Query`] `Content-Type` or `Authorization` value will be ignored. + pub name: String, + + /// Parameter location. + #[serde(rename = "in")] + pub parameter_in: ParameterIn, + + /// Markdown supported description of the parameter. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Declares whether the parameter is required or not for api. + /// + /// * For [`ParameterIn::Path`] this must and will be [`Required::True`]. + pub required: Required, + + /// Declares the parameter deprecated status. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated: Option, + // pub allow_empty_value: bool, this is going to be removed from further open api spec releases + /// Schema of the parameter. Typically [`Schema::Object`] is used. + #[serde(skip_serializing_if = "Option::is_none")] + pub schema: Option>, + + /// Describes how [`Parameter`] is being serialized depending on [`Parameter::schema`] (type of a content). + /// Default value is based on [`ParameterIn`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub style: Option, + + /// When _`true`_ it will generate separate parameter value for each parameter with _`array`_ and _`object`_ type. + /// This is also _`true`_ by default for [`ParameterStyle::Form`]. + /// + /// With explode _`false`_: + /// ```text + /// color=blue,black,brown + /// ``` + /// + /// With explode _`true`_: + /// ```text + /// color=blue&color=black&color=brown + /// ``` + #[serde(skip_serializing_if = "Option::is_none")] + pub explode: Option, + + /// Defines whether parameter should allow reserved characters defined by + /// [RFC3986](https://tools.ietf.org/html/rfc3986#section-2.2) _`:/?#[]@!$&'()*+,;=`_. + /// This is only applicable with [`ParameterIn::Query`]. Default value is _`false`_. + #[serde(skip_serializing_if = "Option::is_none")] + pub allow_reserved: Option, + + /// Example of [`Parameter`]'s potential value. This examples will override example + /// within [`Parameter::schema`] if defined. + #[serde(skip_serializing_if = "Option::is_none")] + example: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Parameter { + /// Constructs a new required [`Parameter`] with given name. + pub fn new>(name: S) -> Self { + Self { + name: name.into(), + required: Required::True, + ..Default::default() + } + } +} + +impl ParameterBuilder { + /// Add name of the [`Parameter`]. + pub fn name>(mut self, name: I) -> Self { + set_value!(self name name.into()) + } + + /// Add in of the [`Parameter`]. + pub fn parameter_in(mut self, parameter_in: ParameterIn) -> Self { + set_value!(self parameter_in parameter_in) + } + + /// Add required declaration of the [`Parameter`]. If [`ParameterIn::Path`] is + /// defined this is always [`Required::True`]. + pub fn required(mut self, required: Required) -> Self { + self.required = required; + // required must be true, if parameter_in is Path + if self.parameter_in == ParameterIn::Path { + self.required = Required::True; + } + + self + } + + /// Add or change description of the [`Parameter`]. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change [`Parameter`] deprecated declaration. + pub fn deprecated(mut self, deprecated: Option) -> Self { + set_value!(self deprecated deprecated) + } + + /// Add or change [`Parameter`]s schema. + pub fn schema>>(mut self, component: Option) -> Self { + set_value!(self schema component.map(|component| component.into())) + } + + /// Add or change serialization style of [`Parameter`]. + pub fn style(mut self, style: Option) -> Self { + set_value!(self style style) + } + + /// Define whether [`Parameter`]s are exploded or not. + pub fn explode(mut self, explode: Option) -> Self { + set_value!(self explode explode) + } + + /// Add or change whether [`Parameter`] should allow reserved characters. + pub fn allow_reserved(mut self, allow_reserved: Option) -> Self { + set_value!(self allow_reserved allow_reserved) + } + + /// Add or change example of [`Parameter`]'s potential value. + pub fn example(mut self, example: Option) -> Self { + set_value!(self example example) + } + + /// Add openapi extensions (x-something) to the [`Parameter`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +/// In definition of [`Parameter`]. +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum ParameterIn { + /// Declares that parameter is used as query parameter. + Query, + /// Declares that parameter is used as path parameter. + Path, + /// Declares that parameter is used as header value. + Header, + /// Declares that parameter is used as cookie value. + Cookie, +} + +impl Default for ParameterIn { + fn default() -> Self { + Self::Path + } +} + +/// Defines how [`Parameter`] should be serialized. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "camelCase")] +pub enum ParameterStyle { + /// Path style parameters defined by [RFC6570](https://tools.ietf.org/html/rfc6570#section-3.2.7) + /// e.g _`;color=blue`_. + /// Allowed with [`ParameterIn::Path`]. + Matrix, + /// Label style parameters defined by [RFC6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.5) + /// e.g _`.color=blue`_. + /// Allowed with [`ParameterIn::Path`]. + Label, + /// Form style parameters defined by [RFC6570](https://datatracker.ietf.org/doc/html/rfc6570#section-3.2.8) + /// e.g. _`color=blue`_. Default value for [`ParameterIn::Query`] [`ParameterIn::Cookie`]. + /// Allowed with [`ParameterIn::Query`] or [`ParameterIn::Cookie`]. + Form, + /// Default value for [`ParameterIn::Path`] [`ParameterIn::Header`]. e.g. _`blue`_. + /// Allowed with [`ParameterIn::Path`] or [`ParameterIn::Header`]. + Simple, + /// Space separated array values e.g. _`blue%20black%20brown`_. + /// Allowed with [`ParameterIn::Query`]. + SpaceDelimited, + /// Pipe separated array values e.g. _`blue|black|brown`_. + /// Allowed with [`ParameterIn::Query`]. + PipeDelimited, + /// Simple way of rendering nested objects using form parameters .e.g. _`color[B]=150`_. + /// Allowed with [`ParameterIn::Query`]. + DeepObject, +} + +#[cfg(test)] +mod tests { + use super::{HttpMethod, Operation, OperationBuilder}; + use crate::openapi::{security::SecurityRequirement, server::Server, PathItem, PathsBuilder}; + + #[test] + fn test_path_order() { + let paths_list = PathsBuilder::new() + .path( + "/todo", + PathItem::new(HttpMethod::Get, OperationBuilder::new()), + ) + .path( + "/todo", + PathItem::new(HttpMethod::Post, OperationBuilder::new()), + ) + .path( + "/todo/{id}", + PathItem::new(HttpMethod::Delete, OperationBuilder::new()), + ) + .path( + "/todo/{id}", + PathItem::new(HttpMethod::Get, OperationBuilder::new()), + ) + .path( + "/todo/{id}", + PathItem::new(HttpMethod::Put, OperationBuilder::new()), + ) + .path( + "/todo/search", + PathItem::new(HttpMethod::Get, OperationBuilder::new()), + ) + .build(); + + let actual_value = paths_list + .paths + .iter() + .flat_map(|(path, path_item)| { + let mut path_methods = + Vec::<(&str, &HttpMethod)>::with_capacity(paths_list.paths.len()); + if path_item.get.is_some() { + path_methods.push((path, &HttpMethod::Get)); + } + if path_item.put.is_some() { + path_methods.push((path, &HttpMethod::Put)); + } + if path_item.post.is_some() { + path_methods.push((path, &HttpMethod::Post)); + } + if path_item.delete.is_some() { + path_methods.push((path, &HttpMethod::Delete)); + } + if path_item.options.is_some() { + path_methods.push((path, &HttpMethod::Options)); + } + if path_item.head.is_some() { + path_methods.push((path, &HttpMethod::Head)); + } + if path_item.patch.is_some() { + path_methods.push((path, &HttpMethod::Patch)); + } + if path_item.trace.is_some() { + path_methods.push((path, &HttpMethod::Trace)); + } + + path_methods + }) + .collect::>(); + + let get = HttpMethod::Get; + let post = HttpMethod::Post; + let put = HttpMethod::Put; + let delete = HttpMethod::Delete; + + #[cfg(not(feature = "preserve_path_order"))] + { + let expected_value = vec![ + ("/todo", &get), + ("/todo", &post), + ("/todo/search", &get), + ("/todo/{id}", &get), + ("/todo/{id}", &put), + ("/todo/{id}", &delete), + ]; + assert_eq!(actual_value, expected_value); + } + + #[cfg(feature = "preserve_path_order")] + { + let expected_value = vec![ + ("/todo", &get), + ("/todo", &post), + ("/todo/{id}", &get), + ("/todo/{id}", &put), + ("/todo/{id}", &delete), + ("/todo/search", &get), + ]; + assert_eq!(actual_value, expected_value); + } + } + + #[test] + fn operation_new() { + let operation = Operation::new(); + + assert!(operation.tags.is_none()); + assert!(operation.summary.is_none()); + assert!(operation.description.is_none()); + assert!(operation.operation_id.is_none()); + assert!(operation.external_docs.is_none()); + assert!(operation.parameters.is_none()); + assert!(operation.request_body.is_none()); + assert!(operation.responses.responses.is_empty()); + assert!(operation.callbacks.is_none()); + assert!(operation.deprecated.is_none()); + assert!(operation.security.is_none()); + assert!(operation.servers.is_none()); + } + + #[test] + fn operation_builder_security() { + let security_requirement1 = + SecurityRequirement::new("api_oauth2_flow", ["edit:items", "read:items"]); + let security_requirement2 = SecurityRequirement::new("api_oauth2_flow", ["remove:items"]); + let operation = OperationBuilder::new() + .security(security_requirement1) + .security(security_requirement2) + .build(); + + assert!(operation.security.is_some()); + } + + #[test] + fn operation_builder_server() { + let server1 = Server::new("/api"); + let server2 = Server::new("/admin"); + let operation = OperationBuilder::new() + .server(server1) + .server(server2) + .build(); + + assert!(operation.servers.is_some()); + } +} diff --git a/fastapi/src/openapi/request_body.rs b/fastapi/src/openapi/request_body.rs new file mode 100644 index 0000000..f708ab3 --- /dev/null +++ b/fastapi/src/openapi/request_body.rs @@ -0,0 +1,221 @@ +//! Implements [OpenAPI Request Body][request_body] types. +//! +//! [request_body]: https://spec.openapis.org/oas/latest.html#request-body-object +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use super::extensions::Extensions; +use super::{builder, set_value, Content, Required}; + +builder! { + RequestBodyBuilder; + + /// Implements [OpenAPI Request Body][request_body]. + /// + /// [request_body]: https://spec.openapis.org/oas/latest.html#request-body-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct RequestBody { + /// Additional description of [`RequestBody`] supporting markdown syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Map of request body contents mapped by content type e.g. `application/json`. + pub content: BTreeMap, + + /// Determines whether request body is required in the request or not. + #[serde(skip_serializing_if = "Option::is_none")] + pub required: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl RequestBody { + /// Construct a new [`RequestBody`]. + pub fn new() -> Self { + Default::default() + } +} + +impl RequestBodyBuilder { + /// Add description for [`RequestBody`]. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Define [`RequestBody`] required. + pub fn required(mut self, required: Option) -> Self { + set_value!(self required required) + } + + /// Add [`Content`] by content type e.g `application/json` to [`RequestBody`]. + pub fn content>(mut self, content_type: S, content: Content) -> Self { + self.content.insert(content_type.into(), content); + + self + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +/// Trait with convenience functions for documenting request bodies. +/// +/// With a single method call we can add [`Content`] to our [`RequestBodyBuilder`] and +/// [`RequestBody`] that references a [schema][schema] using +/// content-type `"application/json"`. +/// +/// _**Add json request body from schema ref.**_ +/// ```rust +/// use fastapi::openapi::request_body::{RequestBodyBuilder, RequestBodyExt}; +/// +/// let request = RequestBodyBuilder::new().json_schema_ref("EmailPayload").build(); +/// ``` +/// +/// If serialized to JSON, the above will result in a requestBody schema like this. +/// ```json +/// { +/// "content": { +/// "application/json": { +/// "schema": { +/// "$ref": "#/components/schemas/EmailPayload" +/// } +/// } +/// } +/// } +/// ``` +/// +/// [schema]: crate::ToSchema +/// +#[cfg(feature = "openapi_extensions")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "openapi_extensions")))] +pub trait RequestBodyExt { + /// Add [`Content`] to [`RequestBody`] referring to a _`schema`_ + /// with Content-Type `application/json`. + fn json_schema_ref(self, ref_name: &str) -> Self; +} + +#[cfg(feature = "openapi_extensions")] +impl RequestBodyExt for RequestBody { + fn json_schema_ref(mut self, ref_name: &str) -> RequestBody { + self.content.insert( + "application/json".to_string(), + crate::openapi::Content::new(Some(crate::openapi::Ref::from_schema_name(ref_name))), + ); + self + } +} + +#[cfg(feature = "openapi_extensions")] +impl RequestBodyExt for RequestBodyBuilder { + fn json_schema_ref(self, ref_name: &str) -> RequestBodyBuilder { + self.content( + "application/json", + crate::openapi::Content::new(Some(crate::openapi::Ref::from_schema_name(ref_name))), + ) + } +} + +#[cfg(test)] +mod tests { + use assert_json_diff::assert_json_eq; + use serde_json::json; + + use super::{Content, RequestBody, RequestBodyBuilder, Required}; + + #[test] + fn request_body_new() { + let request_body = RequestBody::new(); + + assert!(request_body.content.is_empty()); + assert_eq!(request_body.description, None); + assert!(request_body.required.is_none()); + } + + #[test] + fn request_body_builder() -> Result<(), serde_json::Error> { + let request_body = RequestBodyBuilder::new() + .description(Some("A sample requestBody")) + .required(Some(Required::True)) + .content( + "application/json", + Content::new(Some(crate::openapi::Ref::from_schema_name("EmailPayload"))), + ) + .build(); + let serialized = serde_json::to_string_pretty(&request_body)?; + println!("serialized json:\n {serialized}"); + assert_json_eq!( + request_body, + json!({ + "description": "A sample requestBody", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailPayload" + } + } + }, + "required": true + }) + ); + Ok(()) + } +} + +#[cfg(all(test, feature = "openapi_extensions"))] +#[cfg_attr(doc_cfg, doc(cfg(feature = "openapi_extensions")))] +mod openapi_extensions_tests { + use assert_json_diff::assert_json_eq; + use serde_json::json; + + use crate::openapi::request_body::RequestBodyBuilder; + + use super::RequestBodyExt; + + #[test] + fn request_body_ext() { + let request_body = RequestBodyBuilder::new() + .build() + // build a RequestBody first to test the method + .json_schema_ref("EmailPayload"); + assert_json_eq!( + request_body, + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailPayload" + } + } + } + }) + ); + } + + #[test] + fn request_body_builder_ext() { + let request_body = RequestBodyBuilder::new() + .json_schema_ref("EmailPayload") + .build(); + assert_json_eq!( + request_body, + json!({ + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EmailPayload" + } + } + } + }) + ); + } +} diff --git a/fastapi/src/openapi/response.rs b/fastapi/src/openapi/response.rs new file mode 100644 index 0000000..fdc4a04 --- /dev/null +++ b/fastapi/src/openapi/response.rs @@ -0,0 +1,357 @@ +//! Implements [OpenApi Responses][responses]. +//! +//! [responses]: https://spec.openapis.org/oas/latest.html#responses-object +use std::collections::BTreeMap; + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use crate::openapi::{Ref, RefOr}; +use crate::IntoResponses; + +use super::extensions::Extensions; +use super::link::Link; +use super::{builder, header::Header, set_value, Content}; + +builder! { + ResponsesBuilder; + + /// Implements [OpenAPI Responses Object][responses]. + /// + /// Responses is a map holding api operation responses identified by their status code. + /// + /// [responses]: https://spec.openapis.org/oas/latest.html#responses-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Responses { + /// Map containing status code as a key with represented response as a value. + #[serde(flatten)] + pub responses: BTreeMap>, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Responses { + /// Construct a new [`Responses`]. + pub fn new() -> Self { + Default::default() + } +} + +impl ResponsesBuilder { + /// Add a [`Response`]. + pub fn response, R: Into>>( + mut self, + code: S, + response: R, + ) -> Self { + self.responses.insert(code.into(), response.into()); + + self + } + + /// Add responses from an iterator over a pair of `(status_code, response): (String, Response)`. + pub fn responses_from_iter< + I: IntoIterator, + C: Into, + R: Into>, + >( + mut self, + iter: I, + ) -> Self { + self.responses.extend( + iter.into_iter() + .map(|(code, response)| (code.into(), response.into())), + ); + self + } + + /// Add responses from a type that implements [`IntoResponses`]. + pub fn responses_from_into_responses(mut self) -> Self { + self.responses.extend(I::responses()); + self + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +impl From for BTreeMap> { + fn from(responses: Responses) -> Self { + responses.responses + } +} + +impl FromIterator<(C, R)> for Responses +where + C: Into, + R: Into>, +{ + fn from_iter>(iter: T) -> Self { + Self { + responses: BTreeMap::from_iter( + iter.into_iter() + .map(|(code, response)| (code.into(), response.into())), + ), + ..Default::default() + } + } +} + +builder! { + ResponseBuilder; + + /// Implements [OpenAPI Response Object][response]. + /// + /// Response is api operation response. + /// + /// [response]: https://spec.openapis.org/oas/latest.html#response-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Response { + /// Description of the response. Response support markdown syntax. + pub description: String, + + /// Map of headers identified by their name. `Content-Type` header will be ignored. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub headers: BTreeMap, + + /// Map of response [`Content`] objects identified by response body content type e.g `application/json`. + /// + /// [`Content`]s are stored within [`IndexMap`] to retain their insertion order. Swagger UI + /// will create and show default example according to the first entry in `content` map. + #[serde(skip_serializing_if = "IndexMap::is_empty", default)] + pub content: IndexMap, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + + /// A map of operations links that can be followed from the response. The key of the + /// map is a short name for the link. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub links: BTreeMap>, + } +} + +impl Response { + /// Construct a new [`Response`]. + /// + /// Function takes description as argument. + pub fn new>(description: S) -> Self { + Self { + description: description.into(), + ..Default::default() + } + } +} + +impl ResponseBuilder { + /// Add description. Description supports markdown syntax. + pub fn description>(mut self, description: I) -> Self { + set_value!(self description description.into()) + } + + /// Add [`Content`] of the [`Response`] with content type e.g `application/json`. + pub fn content>(mut self, content_type: S, content: Content) -> Self { + self.content.insert(content_type.into(), content); + + self + } + + /// Add response [`Header`]. + pub fn header>(mut self, name: S, header: Header) -> Self { + self.headers.insert(name.into(), header); + + self + } + + /// Add openapi extensions (x-something) to the [`Header`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + /// Add link that can be followed from the response. + pub fn link, L: Into>>(mut self, name: S, link: L) -> Self { + self.links.insert(name.into(), link.into()); + + self + } +} + +impl From for RefOr { + fn from(builder: ResponseBuilder) -> Self { + Self::T(builder.build()) + } +} + +impl From for RefOr { + fn from(r: Ref) -> Self { + Self::Ref(r) + } +} + +/// Trait with convenience functions for documenting response bodies. +/// +/// With a single method call we can add [`Content`] to our [`ResponseBuilder`] and [`Response`] +/// that references a [schema][schema] using content-type `"application/json"`. +/// +/// _**Add json response from schema ref.**_ +/// ```rust +/// use fastapi::openapi::response::{ResponseBuilder, ResponseExt}; +/// +/// let request = ResponseBuilder::new() +/// .description("A sample response") +/// .json_schema_ref("MyResponsePayload").build(); +/// ``` +/// +/// If serialized to JSON, the above will result in a response schema like this. +/// ```json +/// { +/// "description": "A sample response", +/// "content": { +/// "application/json": { +/// "schema": { +/// "$ref": "#/components/schemas/MyResponsePayload" +/// } +/// } +/// } +/// } +/// ``` +/// +/// [response]: crate::ToResponse +/// [schema]: crate::ToSchema +/// +#[cfg(feature = "openapi_extensions")] +#[cfg_attr(doc_cfg, doc(cfg(feature = "openapi_extensions")))] +pub trait ResponseExt { + /// Add [`Content`] to [`Response`] referring to a _`schema`_ + /// with Content-Type `application/json`. + fn json_schema_ref(self, ref_name: &str) -> Self; +} + +#[cfg(feature = "openapi_extensions")] +impl ResponseExt for Response { + fn json_schema_ref(mut self, ref_name: &str) -> Response { + self.content.insert( + "application/json".to_string(), + Content::new(Some(crate::openapi::Ref::from_schema_name(ref_name))), + ); + self + } +} + +#[cfg(feature = "openapi_extensions")] +impl ResponseExt for ResponseBuilder { + fn json_schema_ref(self, ref_name: &str) -> ResponseBuilder { + self.content( + "application/json", + Content::new(Some(crate::openapi::Ref::from_schema_name(ref_name))), + ) + } +} + +#[cfg(test)] +mod tests { + use super::{Content, ResponseBuilder, Responses}; + use assert_json_diff::assert_json_eq; + use serde_json::json; + + #[test] + fn responses_new() { + let responses = Responses::new(); + + assert!(responses.responses.is_empty()); + } + + #[test] + fn response_builder() -> Result<(), serde_json::Error> { + let request_body = ResponseBuilder::new() + .description("A sample response") + .content( + "application/json", + Content::new(Some(crate::openapi::Ref::from_schema_name( + "MySchemaPayload", + ))), + ) + .build(); + let serialized = serde_json::to_string_pretty(&request_body)?; + println!("serialized json:\n {serialized}"); + assert_json_eq!( + request_body, + json!({ + "description": "A sample response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MySchemaPayload" + } + } + } + }) + ); + Ok(()) + } +} + +#[cfg(all(test, feature = "openapi_extensions"))] +mod openapi_extensions_tests { + use assert_json_diff::assert_json_eq; + use serde_json::json; + + use crate::openapi::ResponseBuilder; + + use super::ResponseExt; + + #[test] + fn response_ext() { + let request_body = ResponseBuilder::new() + .description("A sample response") + .build() + .json_schema_ref("MySchemaPayload"); + + assert_json_eq!( + request_body, + json!({ + "description": "A sample response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MySchemaPayload" + } + } + } + }) + ); + } + + #[test] + fn response_builder_ext() { + let request_body = ResponseBuilder::new() + .description("A sample response") + .json_schema_ref("MySchemaPayload") + .build(); + assert_json_eq!( + request_body, + json!({ + "description": "A sample response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MySchemaPayload" + } + } + } + }) + ); + } +} diff --git a/fastapi/src/openapi/schema.rs b/fastapi/src/openapi/schema.rs new file mode 100644 index 0000000..a030679 --- /dev/null +++ b/fastapi/src/openapi/schema.rs @@ -0,0 +1,2872 @@ +//! Implements [OpenAPI Schema Object][schema] types which can be +//! used to define field properties, enum values, array or object types. +//! +//! [schema]: https://spec.openapis.org/oas/latest.html#schema-object +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use super::extensions::Extensions; +use super::RefOr; +use super::{builder, security::SecurityScheme, set_value, xml::Xml, Deprecated, Response}; +use crate::{ToResponse, ToSchema}; + +macro_rules! component_from_builder { + ( $name:ident ) => { + impl From<$name> for Schema { + fn from(builder: $name) -> Self { + builder.build().into() + } + } + }; +} + +macro_rules! to_array_builder { + () => { + /// Construct a new [`ArrayBuilder`] with this component set to [`ArrayBuilder::items`]. + pub fn to_array_builder(self) -> ArrayBuilder { + ArrayBuilder::from(Array::new(self)) + } + }; +} + +/// Create an _`empty`_ [`Schema`] that serializes to _`null`_. +/// +/// Can be used in places where an item can be serialized as `null`. This is used with unit type +/// enum variants and tuple unit types. +pub fn empty() -> Schema { + Schema::Object( + ObjectBuilder::new() + .schema_type(SchemaType::AnyValue) + .default(Some(serde_json::Value::Null)) + .into(), + ) +} + +builder! { + ComponentsBuilder; + + /// Implements [OpenAPI Components Object][components] which holds supported + /// reusable objects. + /// + /// Components can hold either reusable types themselves or references to other reusable + /// types. + /// + /// [components]: https://spec.openapis.org/oas/latest.html#components-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Components { + /// Map of reusable [OpenAPI Schema Object][schema]s. + /// + /// [schema]: https://spec.openapis.org/oas/latest.html#schema-object + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub schemas: BTreeMap>, + + /// Map of reusable response name, to [OpenAPI Response Object][response]s or [OpenAPI + /// Reference][reference]s to [OpenAPI Response Object][response]s. + /// + /// [response]: https://spec.openapis.org/oas/latest.html#response-object + /// [reference]: https://spec.openapis.org/oas/latest.html#reference-object + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub responses: BTreeMap>, + + /// Map of reusable [OpenAPI Security Scheme Object][security_scheme]s. + /// + /// [security_scheme]: https://spec.openapis.org/oas/latest.html#security-scheme-object + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub security_schemes: BTreeMap, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Components { + /// Construct a new [`Components`]. + pub fn new() -> Self { + Self { + ..Default::default() + } + } + /// Add [`SecurityScheme`] to [`Components`]. + /// + /// Accepts two arguments where first is the name of the [`SecurityScheme`]. This is later when + /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecurityScheme`]. + /// + /// [requirement]: ../security/struct.SecurityRequirement.html + pub fn add_security_scheme, S: Into>( + &mut self, + name: N, + security_scheme: S, + ) { + self.security_schemes + .insert(name.into(), security_scheme.into()); + } + + /// Add iterator of [`SecurityScheme`]s to [`Components`]. + /// + /// Accepts two arguments where first is the name of the [`SecurityScheme`]. This is later when + /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecurityScheme`]. + /// + /// [requirement]: ../security/struct.SecurityRequirement.html + pub fn add_security_schemes_from_iter< + I: IntoIterator, + N: Into, + S: Into, + >( + &mut self, + schemas: I, + ) { + self.security_schemes.extend( + schemas + .into_iter() + .map(|(name, item)| (name.into(), item.into())), + ); + } +} + +impl ComponentsBuilder { + /// Add [`Schema`] to [`Components`]. + /// + /// Accepts two arguments where first is name of the schema and second is the schema itself. + pub fn schema, I: Into>>(mut self, name: S, schema: I) -> Self { + self.schemas.insert(name.into(), schema.into()); + + self + } + + /// Add [`Schema`] to [`Components`]. + /// + /// This is effectively same as calling [`ComponentsBuilder::schema`] but expects to be called + /// with one generic argument that implements [`ToSchema`][trait@ToSchema] trait. + /// + /// # Examples + /// + /// _**Add schema from `Value` type that derives `ToSchema`.**_ + /// + /// ```rust + /// # use fastapi::{ToSchema, openapi::schema::ComponentsBuilder}; + /// #[derive(ToSchema)] + /// struct Value(String); + /// + /// let _ = ComponentsBuilder::new().schema_from::().build(); + /// ``` + pub fn schema_from(mut self) -> Self { + let name = I::name(); + let schema = I::schema(); + self.schemas.insert(name.to_string(), schema); + + self + } + + /// Add [`Schema`]s from iterator. + /// + /// # Examples + /// ```rust + /// # use fastapi::openapi::schema::{ComponentsBuilder, ObjectBuilder, + /// # Type, Schema}; + /// ComponentsBuilder::new().schemas_from_iter([( + /// "Pet", + /// Schema::from( + /// ObjectBuilder::new() + /// .property( + /// "name", + /// ObjectBuilder::new().schema_type(Type::String), + /// ) + /// .required("name") + /// ), + /// )]); + /// ``` + pub fn schemas_from_iter< + I: IntoIterator, + C: Into>, + S: Into, + >( + mut self, + schemas: I, + ) -> Self { + self.schemas.extend( + schemas + .into_iter() + .map(|(name, schema)| (name.into(), schema.into())), + ); + + self + } + + /// Add [`struct@Response`] to [`Components`]. + /// + /// Method accepts tow arguments; `name` of the reusable response and `response` which is the + /// reusable response itself. + pub fn response, R: Into>>( + mut self, + name: S, + response: R, + ) -> Self { + self.responses.insert(name.into(), response.into()); + self + } + + /// Add [`struct@Response`] to [`Components`]. + /// + /// This behaves the same way as [`ComponentsBuilder::schema_from`] but for responses. It + /// allows adding response from type implementing [`trait@ToResponse`] trait. Method is + /// expected to be called with one generic argument that implements the trait. + pub fn response_from<'r, I: ToResponse<'r>>(self) -> Self { + let (name, response) = I::response(); + self.response(name, response) + } + + /// Add multiple [`struct@Response`]s to [`Components`] from iterator. + /// + /// Like the [`ComponentsBuilder::schemas_from_iter`] this allows adding multiple responses by + /// any iterator what returns tuples of (name, response) values. + pub fn responses_from_iter< + I: IntoIterator, + S: Into, + R: Into>, + >( + mut self, + responses: I, + ) -> Self { + self.responses.extend( + responses + .into_iter() + .map(|(name, response)| (name.into(), response.into())), + ); + + self + } + + /// Add [`SecurityScheme`] to [`Components`]. + /// + /// Accepts two arguments where first is the name of the [`SecurityScheme`]. This is later when + /// referenced by [`SecurityRequirement`][requirement]s. Second parameter is the [`SecurityScheme`]. + /// + /// [requirement]: ../security/struct.SecurityRequirement.html + pub fn security_scheme, S: Into>( + mut self, + name: N, + security_scheme: S, + ) -> Self { + self.security_schemes + .insert(name.into(), security_scheme.into()); + + self + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +/// Is super type for [OpenAPI Schema Object][schemas]. Schema is reusable resource what can be +/// referenced from path operations and other components using [`Ref`]. +/// +/// [schemas]: https://spec.openapis.org/oas/latest.html#schema-object +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(untagged, rename_all = "camelCase")] +pub enum Schema { + /// Defines array schema from another schema. Typically used with + /// [`Schema::Object`]. Slice and Vec types are translated to [`Schema::Array`] types. + Array(Array), + /// Defines object schema. Object is either `object` holding **properties** which are other [`Schema`]s + /// or can be a field within the [`Object`]. + Object(Object), + /// Creates a _OneOf_ type [composite Object][composite] schema. This schema + /// is used to map multiple schemas together where API endpoint could return any of them. + /// [`Schema::OneOf`] is created form mixed enum where enum contains various variants. + /// + /// [composite]: https://spec.openapis.org/oas/latest.html#components-object + OneOf(OneOf), + + /// Creates a _AllOf_ type [composite Object][composite] schema. + /// + /// [composite]: https://spec.openapis.org/oas/latest.html#components-object + AllOf(AllOf), + + /// Creates a _AnyOf_ type [composite Object][composite] schema. + /// + /// [composite]: https://spec.openapis.org/oas/latest.html#components-object + AnyOf(AnyOf), +} + +impl Default for Schema { + fn default() -> Self { + Schema::Object(Object::default()) + } +} + +/// OpenAPI [Discriminator][discriminator] object which can be optionally used together with +/// [`OneOf`] composite object. +/// +/// [discriminator]: https://spec.openapis.org/oas/latest.html#discriminator-object +#[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Discriminator { + /// Defines a discriminator property name which must be found within all composite + /// objects. + pub property_name: String, + + /// An object to hold mappings between payload values and schema names or references. + /// This field can only be populated manually. There is no macro support and no + /// validation. + #[serde(skip_serializing_if = "BTreeMap::is_empty", default)] + pub mapping: BTreeMap, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl Discriminator { + /// Construct a new [`Discriminator`] object with property name. + /// + /// # Examples + /// + /// Create a new [`Discriminator`] object for `pet_type` property. + /// ```rust + /// # use fastapi::openapi::schema::Discriminator; + /// let discriminator = Discriminator::new("pet_type"); + /// ``` + pub fn new>(property_name: I) -> Self { + Self { + property_name: property_name.into(), + mapping: BTreeMap::new(), + ..Default::default() + } + } + + /// Construct a new [`Discriminator`] object with property name and mappings. + /// + /// + /// Method accepts two arguments. First _`property_name`_ to use as `discriminator` and + /// _`mapping`_ for custom property name mappings. + /// + /// # Examples + /// + ///_**Construct an ew [`Discriminator`] with custom mapping.**_ + /// + /// ```rust + /// # use fastapi::openapi::schema::Discriminator; + /// let discriminator = Discriminator::with_mapping("pet_type", [ + /// ("cat","#/components/schemas/Cat") + /// ]); + /// ``` + pub fn with_mapping< + P: Into, + M: IntoIterator, + K: Into, + V: Into, + >( + property_name: P, + mapping: M, + ) -> Self { + Self { + property_name: property_name.into(), + mapping: BTreeMap::from_iter( + mapping + .into_iter() + .map(|(key, val)| (key.into(), val.into())), + ), + ..Default::default() + } + } +} + +builder! { + OneOfBuilder; + + /// OneOf [Composite Object][oneof] component holds + /// multiple components together where API endpoint could return any of them. + /// + /// See [`Schema::OneOf`] for more details. + /// + /// [oneof]: https://spec.openapis.org/oas/latest.html#components-object + #[derive(Serialize, Deserialize, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct OneOf { + /// Components of _OneOf_ component. + #[serde(rename = "oneOf")] + pub items: Vec>, + + /// Type of [`OneOf`] e.g. `SchemaType::new(Type::Object)` for `object`. + /// + /// By default this is [`SchemaType::AnyValue`] as the type is defined by items + /// themselves. + #[serde(rename = "type", default = "SchemaType::any", skip_serializing_if = "SchemaType::is_any_value")] + pub schema_type: SchemaType, + + /// Changes the [`OneOf`] title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Description of the [`OneOf`]. Markdown syntax is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Default value which is provided when user has not provided the input in Swagger UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`OneOf::examples`] instead** + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, + + /// Examples shown in UI of the value for richer documentation. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub examples: Vec, + + /// Optional discriminator field can be used to aid deserialization, serialization and validation of a + /// specific schema. + #[serde(skip_serializing_if = "Option::is_none")] + pub discriminator: Option, + + /// Optional extensions `x-something`. + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl OneOf { + /// Construct a new [`OneOf`] component. + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Construct a new [`OneOf`] component with given capacity. + /// + /// OneOf component is then able to contain number of components without + /// reallocating. + /// + /// # Examples + /// + /// Create [`OneOf`] component with initial capacity of 5. + /// ```rust + /// # use fastapi::openapi::schema::OneOf; + /// let one_of = OneOf::with_capacity(5); + /// ``` + pub fn with_capacity(capacity: usize) -> Self { + Self { + items: Vec::with_capacity(capacity), + ..Default::default() + } + } +} + +impl Default for OneOf { + fn default() -> Self { + Self { + items: Default::default(), + schema_type: SchemaType::AnyValue, + title: Default::default(), + description: Default::default(), + default: Default::default(), + example: Default::default(), + examples: Default::default(), + discriminator: Default::default(), + extensions: Default::default(), + } + } +} + +impl OneOfBuilder { + /// Adds a given [`Schema`] to [`OneOf`] [Composite Object][composite]. + /// + /// [composite]: https://spec.openapis.org/oas/latest.html#components-object + pub fn item>>(mut self, component: I) -> Self { + self.items.push(component.into()); + + self + } + + /// Add or change type of the object e.g. to change type to _`string`_ + /// use value `SchemaType::Type(Type::String)`. + pub fn schema_type>(mut self, schema_type: T) -> Self { + set_value!(self schema_type schema_type.into()) + } + + /// Add or change the title of the [`OneOf`]. + pub fn title>(mut self, title: Option) -> Self { + set_value!(self title title.map(|title| title.into())) + } + + /// Add or change optional description for `OneOf` component. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change default value for the object which is provided when user has not provided the input in Swagger UI. + pub fn default(mut self, default: Option) -> Self { + set_value!(self default default) + } + + /// Add or change example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`OneOfBuilder::examples`] instead** + #[deprecated = "Since OpenAPI 3.1 prefer using `examples`"] + pub fn example(mut self, example: Option) -> Self { + set_value!(self example example) + } + + /// Add or change examples shown in UI of the value for richer documentation. + pub fn examples, V: Into>(mut self, examples: I) -> Self { + set_value!(self examples examples.into_iter().map(Into::into).collect()) + } + + /// Add or change discriminator field of the composite [`OneOf`] type. + pub fn discriminator(mut self, discriminator: Option) -> Self { + set_value!(self discriminator discriminator) + } + + /// Add openapi extensions (`x-something`) for [`OneOf`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + to_array_builder!(); +} + +impl From for Schema { + fn from(one_of: OneOf) -> Self { + Self::OneOf(one_of) + } +} + +impl From for RefOr { + fn from(one_of: OneOfBuilder) -> Self { + Self::T(Schema::OneOf(one_of.build())) + } +} + +impl From for ArrayItems { + fn from(value: OneOfBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +component_from_builder!(OneOfBuilder); + +builder! { + AllOfBuilder; + + /// AllOf [Composite Object][allof] component holds + /// multiple components together where API endpoint will return a combination of all of them. + /// + /// See [`Schema::AllOf`] for more details. + /// + /// [allof]: https://spec.openapis.org/oas/latest.html#components-object + #[derive(Serialize, Deserialize, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct AllOf { + /// Components of _AllOf_ component. + #[serde(rename = "allOf")] + pub items: Vec>, + + /// Type of [`AllOf`] e.g. `SchemaType::new(Type::Object)` for `object`. + /// + /// By default this is [`SchemaType::AnyValue`] as the type is defined by items + /// themselves. + #[serde(rename = "type", default = "SchemaType::any", skip_serializing_if = "SchemaType::is_any_value")] + pub schema_type: SchemaType, + + /// Changes the [`AllOf`] title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Description of the [`AllOf`]. Markdown syntax is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Default value which is provided when user has not provided the input in Swagger UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`AllOf::examples`] instead** + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, + + /// Examples shown in UI of the value for richer documentation. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub examples: Vec, + + /// Optional discriminator field can be used to aid deserialization, serialization and validation of a + /// specific schema. + #[serde(skip_serializing_if = "Option::is_none")] + pub discriminator: Option, + + /// Optional extensions `x-something`. + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl AllOf { + /// Construct a new [`AllOf`] component. + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Construct a new [`AllOf`] component with given capacity. + /// + /// AllOf component is then able to contain number of components without + /// reallocating. + /// + /// # Examples + /// + /// Create [`AllOf`] component with initial capacity of 5. + /// ```rust + /// # use fastapi::openapi::schema::AllOf; + /// let one_of = AllOf::with_capacity(5); + /// ``` + pub fn with_capacity(capacity: usize) -> Self { + Self { + items: Vec::with_capacity(capacity), + ..Default::default() + } + } +} + +impl Default for AllOf { + fn default() -> Self { + Self { + items: Default::default(), + schema_type: SchemaType::AnyValue, + title: Default::default(), + description: Default::default(), + default: Default::default(), + example: Default::default(), + examples: Default::default(), + discriminator: Default::default(), + extensions: Default::default(), + } + } +} + +impl AllOfBuilder { + /// Adds a given [`Schema`] to [`AllOf`] [Composite Object][composite]. + /// + /// [composite]: https://spec.openapis.org/oas/latest.html#components-object + pub fn item>>(mut self, component: I) -> Self { + self.items.push(component.into()); + + self + } + + /// Add or change type of the object e.g. to change type to _`string`_ + /// use value `SchemaType::Type(Type::String)`. + pub fn schema_type>(mut self, schema_type: T) -> Self { + set_value!(self schema_type schema_type.into()) + } + + /// Add or change the title of the [`AllOf`]. + pub fn title>(mut self, title: Option) -> Self { + set_value!(self title title.map(|title| title.into())) + } + + /// Add or change optional description for `AllOf` component. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change default value for the object which is provided when user has not provided the input in Swagger UI. + pub fn default(mut self, default: Option) -> Self { + set_value!(self default default) + } + + /// Add or change example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`AllOfBuilder::examples`] instead** + #[deprecated = "Since OpenAPI 3.1 prefer using `examples`"] + pub fn example(mut self, example: Option) -> Self { + set_value!(self example example) + } + + /// Add or change examples shown in UI of the value for richer documentation. + pub fn examples, V: Into>(mut self, examples: I) -> Self { + set_value!(self examples examples.into_iter().map(Into::into).collect()) + } + + /// Add or change discriminator field of the composite [`AllOf`] type. + pub fn discriminator(mut self, discriminator: Option) -> Self { + set_value!(self discriminator discriminator) + } + + /// Add openapi extensions (`x-something`) for [`AllOf`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + to_array_builder!(); +} + +impl From for Schema { + fn from(one_of: AllOf) -> Self { + Self::AllOf(one_of) + } +} + +impl From for RefOr { + fn from(one_of: AllOfBuilder) -> Self { + Self::T(Schema::AllOf(one_of.build())) + } +} + +impl From for ArrayItems { + fn from(value: AllOfBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +component_from_builder!(AllOfBuilder); + +builder! { + AnyOfBuilder; + + /// AnyOf [Composite Object][anyof] component holds + /// multiple components together where API endpoint will return a combination of one or more of them. + /// + /// See [`Schema::AnyOf`] for more details. + /// + /// [anyof]: https://spec.openapis.org/oas/latest.html#components-object + #[derive(Serialize, Deserialize, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct AnyOf { + /// Components of _AnyOf component. + #[serde(rename = "anyOf")] + pub items: Vec>, + + /// Type of [`AnyOf`] e.g. `SchemaType::new(Type::Object)` for `object`. + /// + /// By default this is [`SchemaType::AnyValue`] as the type is defined by items + /// themselves. + #[serde(rename = "type", default = "SchemaType::any", skip_serializing_if = "SchemaType::is_any_value")] + pub schema_type: SchemaType, + + /// Description of the [`AnyOf`]. Markdown syntax is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Default value which is provided when user has not provided the input in Swagger UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`AnyOf::examples`] instead** + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, + + /// Examples shown in UI of the value for richer documentation. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub examples: Vec, + + /// Optional discriminator field can be used to aid deserialization, serialization and validation of a + /// specific schema. + #[serde(skip_serializing_if = "Option::is_none")] + pub discriminator: Option, + + /// Optional extensions `x-something`. + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl AnyOf { + /// Construct a new [`AnyOf`] component. + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Construct a new [`AnyOf`] component with given capacity. + /// + /// AnyOf component is then able to contain number of components without + /// reallocating. + /// + /// # Examples + /// + /// Create [`AnyOf`] component with initial capacity of 5. + /// ```rust + /// # use fastapi::openapi::schema::AnyOf; + /// let one_of = AnyOf::with_capacity(5); + /// ``` + pub fn with_capacity(capacity: usize) -> Self { + Self { + items: Vec::with_capacity(capacity), + ..Default::default() + } + } +} + +impl Default for AnyOf { + fn default() -> Self { + Self { + items: Default::default(), + schema_type: SchemaType::AnyValue, + description: Default::default(), + default: Default::default(), + example: Default::default(), + examples: Default::default(), + discriminator: Default::default(), + extensions: Default::default(), + } + } +} + +impl AnyOfBuilder { + /// Adds a given [`Schema`] to [`AnyOf`] [Composite Object][composite]. + /// + /// [composite]: https://spec.openapis.org/oas/latest.html#components-object + pub fn item>>(mut self, component: I) -> Self { + self.items.push(component.into()); + + self + } + + /// Add or change type of the object e.g. to change type to _`string`_ + /// use value `SchemaType::Type(Type::String)`. + pub fn schema_type>(mut self, schema_type: T) -> Self { + set_value!(self schema_type schema_type.into()) + } + + /// Add or change optional description for `AnyOf` component. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change default value for the object which is provided when user has not provided the input in Swagger UI. + pub fn default(mut self, default: Option) -> Self { + set_value!(self default default) + } + + /// Add or change example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`AllOfBuilder::examples`] instead** + #[deprecated = "Since OpenAPI 3.1 prefer using `examples`"] + pub fn example(mut self, example: Option) -> Self { + set_value!(self example example) + } + + /// Add or change examples shown in UI of the value for richer documentation. + pub fn examples, V: Into>(mut self, examples: I) -> Self { + set_value!(self examples examples.into_iter().map(Into::into).collect()) + } + + /// Add or change discriminator field of the composite [`AnyOf`] type. + pub fn discriminator(mut self, discriminator: Option) -> Self { + set_value!(self discriminator discriminator) + } + + /// Add openapi extensions (`x-something`) for [`AnyOf`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + to_array_builder!(); +} + +impl From for Schema { + fn from(any_of: AnyOf) -> Self { + Self::AnyOf(any_of) + } +} + +impl From for RefOr { + fn from(any_of: AnyOfBuilder) -> Self { + Self::T(Schema::AnyOf(any_of.build())) + } +} + +impl From for ArrayItems { + fn from(value: AnyOfBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +component_from_builder!(AnyOfBuilder); + +#[cfg(not(feature = "preserve_order"))] +type ObjectPropertiesMap = BTreeMap; +#[cfg(feature = "preserve_order")] +type ObjectPropertiesMap = indexmap::IndexMap; + +builder! { + ObjectBuilder; + + /// Implements subset of [OpenAPI Schema Object][schema] which allows + /// adding other [`Schema`]s as **properties** to this [`Schema`]. + /// + /// This is a generic OpenAPI schema object which can used to present `object`, `field` or an `enum`. + /// + /// [schema]: https://spec.openapis.org/oas/latest.html#schema-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Object { + /// Type of [`Object`] e.g. [`Type::Object`] for `object` and [`Type::String`] for + /// `string` types. + #[serde(rename = "type", skip_serializing_if="SchemaType::is_any_value")] + pub schema_type: SchemaType, + + /// Changes the [`Object`] title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Additional format for detailing the schema type. + #[serde(skip_serializing_if = "Option::is_none")] + pub format: Option, + + /// Description of the [`Object`]. Markdown syntax is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Default value which is provided when user has not provided the input in Swagger UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Enum variants of fields that can be represented as `unit` type `enums`. + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, + + /// Vector of required field names. + #[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::new")] + pub required: Vec, + + /// Map of fields with their [`Schema`] types. + /// + /// With **preserve_order** feature flag [`indexmap::IndexMap`] will be used as + /// properties map backing implementation to retain property order of [`ToSchema`][to_schema]. + /// By default [`BTreeMap`] will be used. + /// + /// [to_schema]: crate::ToSchema + #[serde(skip_serializing_if = "ObjectPropertiesMap::is_empty", default = "ObjectPropertiesMap::new")] + pub properties: ObjectPropertiesMap>, + + /// Additional [`Schema`] for non specified fields (Useful for typed maps). + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_properties: Option>>, + + /// Additional [`Schema`] to describe property names of an object such as a map. See more + /// details + #[serde(skip_serializing_if = "Option::is_none")] + pub property_names: Option>, + + /// Changes the [`Object`] deprecated status. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated: Option, + + /// Example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`Object::examples`] instead** + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, + + /// Examples shown in UI of the value for richer documentation. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub examples: Vec, + + /// Write only property will be only sent in _write_ requests like _POST, PUT_. + #[serde(skip_serializing_if = "Option::is_none")] + pub write_only: Option, + + /// Read only property will be only sent in _read_ requests like _GET_. + #[serde(skip_serializing_if = "Option::is_none")] + pub read_only: Option, + + /// Additional [`Xml`] formatting of the [`Object`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub xml: Option, + + /// Must be a number strictly greater than `0`. Numeric value is considered valid if value + /// divided by the _`multiple_of`_ value results an integer. + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "omit_decimal_zero")] + pub multiple_of: Option, + + /// Specify inclusive upper limit for the [`Object`]'s value. Number is considered valid if + /// it is equal or less than the _`maximum`_. + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "omit_decimal_zero")] + pub maximum: Option, + + /// Specify inclusive lower limit for the [`Object`]'s value. Number value is considered + /// valid if it is equal or greater than the _`minimum`_. + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "omit_decimal_zero")] + pub minimum: Option, + + /// Specify exclusive upper limit for the [`Object`]'s value. Number value is considered + /// valid if it is strictly less than _`exclusive_maximum`_. + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "omit_decimal_zero")] + pub exclusive_maximum: Option, + + /// Specify exclusive lower limit for the [`Object`]'s value. Number value is considered + /// valid if it is strictly above the _`exclusive_minimum`_. + #[serde(skip_serializing_if = "Option::is_none", serialize_with = "omit_decimal_zero")] + pub exclusive_minimum: Option, + + /// Specify maximum length for `string` values. _`max_length`_ cannot be a negative integer + /// value. Value is considered valid if content length is equal or less than the _`max_length`_. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_length: Option, + + /// Specify minimum length for `string` values. _`min_length`_ cannot be a negative integer + /// value. Setting this to _`0`_ has the same effect as omitting this field. Value is + /// considered valid if content length is equal or more than the _`min_length`_. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_length: Option, + + /// Define a valid `ECMA-262` dialect regular expression. The `string` content is + /// considered valid if the _`pattern`_ matches the value successfully. + #[serde(skip_serializing_if = "Option::is_none")] + pub pattern: Option, + + /// Specify inclusive maximum amount of properties an [`Object`] can hold. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_properties: Option, + + /// Specify inclusive minimum amount of properties an [`Object`] can hold. Setting this to + /// `0` will have same effect as omitting the attribute. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_properties: Option, + + /// Optional extensions `x-something`. + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + + /// The `content_encoding` keyword specifies the encoding used to store the contents, as specified in + /// [RFC 2054, part 6.1](https://tools.ietf.org/html/rfc2045) and [RFC 4648](RFC 2054, part 6.1). + /// + /// Typically this is either unset for _`string`_ content types which then uses the content + /// encoding of the underlying JSON document. If the content is in _`binary`_ format such as an image or an audio + /// set it to `base64` to encode it as _`Base64`_. + /// + /// See more details at + #[serde(skip_serializing_if = "String::is_empty", default)] + pub content_encoding: String, + + /// The _`content_media_type`_ keyword specifies the MIME type of the contents of a string, + /// as described in [RFC 2046](https://tools.ietf.org/html/rfc2046). + /// + /// See more details at + #[serde(skip_serializing_if = "String::is_empty", default)] + pub content_media_type: String, + } +} + +fn is_false(value: &bool) -> bool { + !*value +} + +impl Object { + /// Initialize a new [`Object`] with default [`SchemaType`]. This effectively same as calling + /// `Object::with_type(SchemaType::Object)`. + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Initialize new [`Object`] with given [`SchemaType`]. + /// + /// Create [`std::string`] object type which can be used to define `string` field of an object. + /// ```rust + /// # use fastapi::openapi::schema::{Object, Type}; + /// let object = Object::with_type(Type::String); + /// ``` + pub fn with_type>(schema_type: T) -> Self { + Self { + schema_type: schema_type.into(), + ..Default::default() + } + } +} + +impl From for Schema { + fn from(s: Object) -> Self { + Self::Object(s) + } +} + +impl From for ArrayItems { + fn from(value: Object) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +impl ToArray for Object {} + +impl ObjectBuilder { + /// Add or change type of the object e.g. to change type to _`string`_ + /// use value `SchemaType::Type(Type::String)`. + pub fn schema_type>(mut self, schema_type: T) -> Self { + set_value!(self schema_type schema_type.into()) + } + + /// Add or change additional format for detailing the schema type. + pub fn format(mut self, format: Option) -> Self { + set_value!(self format format) + } + + /// Add new property to the [`Object`]. + /// + /// Method accepts property name and property component as an arguments. + pub fn property, I: Into>>( + mut self, + property_name: S, + component: I, + ) -> Self { + self.properties + .insert(property_name.into(), component.into()); + + self + } + + /// Add additional [`Schema`] for non specified fields (Useful for typed maps). + pub fn additional_properties>>( + mut self, + additional_properties: Option, + ) -> Self { + set_value!(self additional_properties additional_properties.map(|additional_properties| Box::new(additional_properties.into()))) + } + + /// Add additional [`Schema`] to describe property names of an object such as a map. See more + /// details + pub fn property_names>(mut self, property_name: Option) -> Self { + set_value!(self property_names property_name.map(|property_name| Box::new(property_name.into()))) + } + + /// Add field to the required fields of [`Object`]. + pub fn required>(mut self, required_field: I) -> Self { + self.required.push(required_field.into()); + + self + } + + /// Add or change the title of the [`Object`]. + pub fn title>(mut self, title: Option) -> Self { + set_value!(self title title.map(|title| title.into())) + } + + /// Add or change description of the property. Markdown syntax is supported. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change default value for the object which is provided when user has not provided the input in Swagger UI. + pub fn default(mut self, default: Option) -> Self { + set_value!(self default default) + } + + /// Add or change deprecated status for [`Object`]. + pub fn deprecated(mut self, deprecated: Option) -> Self { + set_value!(self deprecated deprecated) + } + + /// Add or change enum property variants. + pub fn enum_values, E: Into>( + mut self, + enum_values: Option, + ) -> Self { + set_value!(self enum_values + enum_values.map(|values| values.into_iter().map(|enum_value| enum_value.into()).collect())) + } + + /// Add or change example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`Object::examples`] instead** + #[deprecated = "Since OpenAPI 3.1 prefer using `examples`"] + pub fn example(mut self, example: Option) -> Self { + set_value!(self example example) + } + + /// Add or change examples shown in UI of the value for richer documentation. + pub fn examples, V: Into>(mut self, examples: I) -> Self { + set_value!(self examples examples.into_iter().map(Into::into).collect()) + } + + /// Add or change write only flag for [`Object`]. + pub fn write_only(mut self, write_only: Option) -> Self { + set_value!(self write_only write_only) + } + + /// Add or change read only flag for [`Object`]. + pub fn read_only(mut self, read_only: Option) -> Self { + set_value!(self read_only read_only) + } + + /// Add or change additional [`Xml`] formatting of the [`Object`]. + pub fn xml(mut self, xml: Option) -> Self { + set_value!(self xml xml) + } + + /// Set or change _`multiple_of`_ validation flag for `number` and `integer` type values. + pub fn multiple_of>(mut self, multiple_of: Option) -> Self { + set_value!(self multiple_of multiple_of.map(|multiple_of| multiple_of.into())) + } + + /// Set or change inclusive maximum value for `number` and `integer` values. + pub fn maximum>(mut self, maximum: Option) -> Self { + set_value!(self maximum maximum.map(|max| max.into())) + } + + /// Set or change inclusive minimum value for `number` and `integer` values. + pub fn minimum>(mut self, minimum: Option) -> Self { + set_value!(self minimum minimum.map(|min| min.into())) + } + + /// Set or change exclusive maximum value for `number` and `integer` values. + pub fn exclusive_maximum>( + mut self, + exclusive_maximum: Option, + ) -> Self { + set_value!(self exclusive_maximum exclusive_maximum.map(|exclusive_maximum| exclusive_maximum.into())) + } + + /// Set or change exclusive minimum value for `number` and `integer` values. + pub fn exclusive_minimum>( + mut self, + exclusive_minimum: Option, + ) -> Self { + set_value!(self exclusive_minimum exclusive_minimum.map(|exclusive_minimum| exclusive_minimum.into())) + } + + /// Set or change maximum length for `string` values. + pub fn max_length(mut self, max_length: Option) -> Self { + set_value!(self max_length max_length) + } + + /// Set or change minimum length for `string` values. + pub fn min_length(mut self, min_length: Option) -> Self { + set_value!(self min_length min_length) + } + + /// Set or change a valid regular expression for `string` value to match. + pub fn pattern>(mut self, pattern: Option) -> Self { + set_value!(self pattern pattern.map(|pattern| pattern.into())) + } + + /// Set or change maximum number of properties the [`Object`] can hold. + pub fn max_properties(mut self, max_properties: Option) -> Self { + set_value!(self max_properties max_properties) + } + + /// Set or change minimum number of properties the [`Object`] can hold. + pub fn min_properties(mut self, min_properties: Option) -> Self { + set_value!(self min_properties min_properties) + } + + /// Add openapi extensions (`x-something`) for [`Object`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + /// Set of change [`Object::content_encoding`]. Typically left empty but could be `base64` for + /// example. + pub fn content_encoding>(mut self, content_encoding: S) -> Self { + set_value!(self content_encoding content_encoding.into()) + } + + /// Set of change [`Object::content_media_type`]. Value must be valid MIME type e.g. + /// `application/json`. + pub fn content_media_type>(mut self, content_media_type: S) -> Self { + set_value!(self content_media_type content_media_type.into()) + } + + to_array_builder!(); +} + +component_from_builder!(ObjectBuilder); + +impl From for RefOr { + fn from(builder: ObjectBuilder) -> Self { + Self::T(Schema::Object(builder.build())) + } +} + +impl From> for Schema { + fn from(value: RefOr) -> Self { + match value { + RefOr::Ref(_) => { + panic!("Invalid type `RefOr::Ref` provided, cannot convert to RefOr::T") + } + RefOr::T(value) => value, + } + } +} + +impl From for ArrayItems { + fn from(value: ObjectBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +/// AdditionalProperties is used to define values of map fields of the [`Schema`]. +/// +/// The value can either be [`RefOr`] or _`bool`_. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(untagged)] +pub enum AdditionalProperties { + /// Use when value type of the map is a known [`Schema`] or [`Ref`] to the [`Schema`]. + RefOr(RefOr), + /// Use _`AdditionalProperties::FreeForm(true)`_ when any value is allowed in the map. + FreeForm(bool), +} + +impl From> for AdditionalProperties { + fn from(value: RefOr) -> Self { + Self::RefOr(value) + } +} + +impl From for AdditionalProperties { + fn from(value: ObjectBuilder) -> Self { + Self::RefOr(RefOr::T(Schema::Object(value.build()))) + } +} + +impl From for AdditionalProperties { + fn from(value: ArrayBuilder) -> Self { + Self::RefOr(RefOr::T(Schema::Array(value.build()))) + } +} + +impl From for AdditionalProperties { + fn from(value: Ref) -> Self { + Self::RefOr(RefOr::Ref(value)) + } +} + +impl From for AdditionalProperties { + fn from(value: RefBuilder) -> Self { + Self::RefOr(RefOr::Ref(value.build())) + } +} + +impl From for AdditionalProperties { + fn from(value: Schema) -> Self { + Self::RefOr(RefOr::T(value)) + } +} + +impl From for AdditionalProperties { + fn from(value: AllOfBuilder) -> Self { + Self::RefOr(RefOr::T(Schema::AllOf(value.build()))) + } +} + +builder! { + RefBuilder; + + /// Implements [OpenAPI Reference Object][reference] that can be used to reference + /// reusable components such as [`Schema`]s or [`Response`]s. + /// + /// [reference]: https://spec.openapis.org/oas/latest.html#reference-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Ref { + /// Reference location of the actual component. + #[serde(rename = "$ref")] + pub ref_location: String, + + /// A description which by default should override that of the referenced component. + /// Description supports markdown syntax. If referenced object type does not support + /// description this field does not have effect. + #[serde(skip_serializing_if = "String::is_empty", default)] + pub description: String, + + /// A short summary which by default should override that of the referenced component. If + /// referenced component does not support summary field this does not have effect. + #[serde(skip_serializing_if = "String::is_empty", default)] + pub summary: String, + } +} + +impl Ref { + /// Construct a new [`Ref`] with custom ref location. In most cases this is not necessary + /// and [`Ref::from_schema_name`] could be used instead. + pub fn new>(ref_location: I) -> Self { + Self { + ref_location: ref_location.into(), + ..Default::default() + } + } + + /// Construct a new [`Ref`] from provided schema name. This will create a [`Ref`] that + /// references the the reusable schemas. + pub fn from_schema_name>(schema_name: I) -> Self { + Self::new(format!("#/components/schemas/{}", schema_name.into())) + } + + /// Construct a new [`Ref`] from provided response name. This will create a [`Ref`] that + /// references the reusable response. + pub fn from_response_name>(response_name: I) -> Self { + Self::new(format!("#/components/responses/{}", response_name.into())) + } + + to_array_builder!(); +} + +impl RefBuilder { + /// Add or change reference location of the actual component. + pub fn ref_location(mut self, ref_location: String) -> Self { + set_value!(self ref_location ref_location) + } + + /// Add or change reference location of the actual component automatically formatting the $ref + /// to `#/components/schemas/...` format. + pub fn ref_location_from_schema_name>(mut self, schema_name: S) -> Self { + set_value!(self ref_location format!("#/components/schemas/{}", schema_name.into())) + } + + // TODO: REMOVE THE unnecessary description Option wrapping. + + /// Add or change description which by default should override that of the referenced component. + /// Description supports markdown syntax. If referenced object type does not support + /// description this field does not have effect. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(Into::into).unwrap_or_default()) + } + + /// Add or change short summary which by default should override that of the referenced component. If + /// referenced component does not support summary field this does not have effect. + pub fn summary>(mut self, summary: S) -> Self { + set_value!(self summary summary.into()) + } +} + +impl From for RefOr { + fn from(builder: RefBuilder) -> Self { + Self::Ref(builder.build()) + } +} + +impl From for ArrayItems { + fn from(value: RefBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +impl From for RefOr { + fn from(r: Ref) -> Self { + Self::Ref(r) + } +} + +impl From for ArrayItems { + fn from(value: Ref) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +impl From for RefOr { + fn from(t: T) -> Self { + Self::T(t) + } +} + +impl Default for RefOr { + fn default() -> Self { + Self::T(Schema::Object(Object::new())) + } +} + +impl ToArray for RefOr {} + +impl From for RefOr { + fn from(object: Object) -> Self { + Self::T(Schema::Object(object)) + } +} + +impl From for RefOr { + fn from(array: Array) -> Self { + Self::T(Schema::Array(array)) + } +} + +fn omit_decimal_zero( + maybe_value: &Option, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + match maybe_value { + Some(crate::fastapi::Number::Float(float)) => { + if float.fract() == 0.0 && *float >= i64::MIN as f64 && *float <= i64::MAX as f64 { + serializer.serialize_i64(float.trunc() as i64) + } else { + serializer.serialize_f64(*float) + } + } + Some(crate::fastapi::Number::Int(int)) => serializer.serialize_i64(*int as i64), + Some(crate::fastapi::Number::UInt(uint)) => serializer.serialize_u64(*uint as u64), + None => serializer.serialize_none(), + } +} + +/// Represents [`Array`] items in [JSON Schema Array][json_schema_array]. +/// +/// [json_schema_array]: +#[derive(Serialize, Deserialize, Clone, PartialEq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(untagged)] +pub enum ArrayItems { + /// Defines [`Array::items`] as [`RefOr::T(Schema)`]. This is the default for [`Array`]. + RefOrSchema(Box>), + /// Defines [`Array::items`] as `false` indicating that no extra items are allowed to the + /// [`Array`]. This can be used together with [`Array::prefix_items`] to disallow [additional + /// items][additional_items] in [`Array`]. + /// + /// [additional_items]: + #[serde(with = "array_items_false")] + False, +} + +mod array_items_false { + use serde::de::Visitor; + + pub fn serialize(serializer: S) -> Result { + serializer.serialize_bool(false) + } + + pub fn deserialize<'de, D: serde::Deserializer<'de>>(deserializer: D) -> Result<(), D::Error> { + struct ItemsFalseVisitor; + + impl<'de> Visitor<'de> for ItemsFalseVisitor { + type Value = (); + fn visit_bool(self, v: bool) -> Result + where + E: serde::de::Error, + { + if !v { + Ok(()) + } else { + Err(serde::de::Error::custom(format!( + "invalid boolean value: {v}, expected false" + ))) + } + } + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("expected boolean false") + } + } + + deserializer.deserialize_bool(ItemsFalseVisitor) + } +} + +impl Default for ArrayItems { + fn default() -> Self { + Self::RefOrSchema(Box::new(Object::with_type(SchemaType::AnyValue).into())) + } +} + +impl From> for ArrayItems { + fn from(value: RefOr) -> Self { + Self::RefOrSchema(Box::new(value)) + } +} + +builder! { + ArrayBuilder; + + /// Array represents [`Vec`] or [`slice`] type of items. + /// + /// See [`Schema::Array`] for more details. + #[non_exhaustive] + #[derive(Serialize, Deserialize, Clone, PartialEq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Array { + /// Type will always be [`SchemaType::Array`]. + #[serde(rename = "type")] + pub schema_type: SchemaType, + + /// Changes the [`Array`] title. + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + + /// Items of the [`Array`]. + pub items: ArrayItems, + + /// Prefix items of [`Array`] is used to define item validation of tuples according [JSON schema + /// item validation][item_validation]. + /// + /// [item_validation]: + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub prefix_items: Vec, + + /// Description of the [`Array`]. Markdown syntax is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Marks the [`Array`] deprecated. + #[serde(skip_serializing_if = "Option::is_none")] + pub deprecated: Option, + + /// Example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`Array::examples`] instead** + #[serde(skip_serializing_if = "Option::is_none")] + pub example: Option, + + /// Examples shown in UI of the value for richer documentation. + #[serde(skip_serializing_if = "Vec::is_empty", default)] + pub examples: Vec, + + /// Default value which is provided when user has not provided the input in Swagger UI. + #[serde(skip_serializing_if = "Option::is_none")] + pub default: Option, + + /// Max length of the array. + #[serde(skip_serializing_if = "Option::is_none")] + pub max_items: Option, + + /// Min length of the array. + #[serde(skip_serializing_if = "Option::is_none")] + pub min_items: Option, + + /// Setting this to `true` will validate successfully if all elements of this [`Array`] are + /// unique. + #[serde(default, skip_serializing_if = "is_false")] + pub unique_items: bool, + + /// Xml format of the array. + #[serde(skip_serializing_if = "Option::is_none")] + pub xml: Option, + + /// The `content_encoding` keyword specifies the encoding used to store the contents, as specified in + /// [RFC 2054, part 6.1](https://tools.ietf.org/html/rfc2045) and [RFC 4648](RFC 2054, part 6.1). + /// + /// Typically this is either unset for _`string`_ content types which then uses the content + /// encoding of the underlying JSON document. If the content is in _`binary`_ format such as an image or an audio + /// set it to `base64` to encode it as _`Base64`_. + /// + /// See more details at + #[serde(skip_serializing_if = "String::is_empty", default)] + pub content_encoding: String, + + /// The _`content_media_type`_ keyword specifies the MIME type of the contents of a string, + /// as described in [RFC 2046](https://tools.ietf.org/html/rfc2046). + /// + /// See more details at + #[serde(skip_serializing_if = "String::is_empty", default)] + pub content_media_type: String, + + /// Optional extensions `x-something`. + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Default for Array { + fn default() -> Self { + Self { + title: Default::default(), + schema_type: Type::Array.into(), + unique_items: bool::default(), + items: Default::default(), + prefix_items: Vec::default(), + description: Default::default(), + deprecated: Default::default(), + example: Default::default(), + examples: Default::default(), + default: Default::default(), + max_items: Default::default(), + min_items: Default::default(), + xml: Default::default(), + extensions: Default::default(), + content_encoding: Default::default(), + content_media_type: Default::default(), + } + } +} + +impl Array { + /// Construct a new [`Array`] component from given [`Schema`]. + /// + /// # Examples + /// + /// _**Create a `String` array component**_. + /// ```rust + /// # use fastapi::openapi::schema::{Schema, Array, Type, Object}; + /// let string_array = Array::new(Object::with_type(Type::String)); + /// ``` + pub fn new>>(component: I) -> Self { + Self { + items: ArrayItems::RefOrSchema(Box::new(component.into())), + ..Default::default() + } + } + + /// Construct a new nullable [`Array`] component from given [`Schema`]. + /// + /// # Examples + /// + /// _**Create a nullable `String` array component**_. + /// ```rust + /// # use fastapi::openapi::schema::{Schema, Array, Type, Object}; + /// let string_array = Array::new_nullable(Object::with_type(Type::String)); + /// ``` + pub fn new_nullable>>(component: I) -> Self { + Self { + items: ArrayItems::RefOrSchema(Box::new(component.into())), + schema_type: SchemaType::from_iter([Type::Array, Type::Null]), + ..Default::default() + } + } +} + +impl ArrayBuilder { + /// Set [`Schema`] type for the [`Array`]. + pub fn items>(mut self, items: I) -> Self { + set_value!(self items items.into()) + } + + /// Add prefix items of [`Array`] to define item validation of tuples according [JSON schema + /// item validation][item_validation]. + /// + /// [item_validation]: + pub fn prefix_items, S: Into>(mut self, items: I) -> Self { + self.prefix_items = items + .into_iter() + .map(|item| item.into()) + .collect::>(); + + self + } + + /// Change type of the array e.g. to change type to _`string`_ + /// use value `SchemaType::Type(Type::String)`. + /// + /// # Examples + /// + /// _**Make nullable string array.**_ + /// ```rust + /// # use fastapi::openapi::schema::{ArrayBuilder, SchemaType, Type, Object}; + /// let _ = ArrayBuilder::new() + /// .schema_type(SchemaType::from_iter([Type::Array, Type::Null])) + /// .items(Object::with_type(Type::String)) + /// .build(); + /// ``` + pub fn schema_type>(mut self, schema_type: T) -> Self { + set_value!(self schema_type schema_type.into()) + } + + /// Add or change the title of the [`Array`]. + pub fn title>(mut self, title: Option) -> Self { + set_value!(self title title.map(|title| title.into())) + } + + /// Add or change description of the property. Markdown syntax is supported. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change deprecated status for [`Array`]. + pub fn deprecated(mut self, deprecated: Option) -> Self { + set_value!(self deprecated deprecated) + } + + /// Add or change example shown in UI of the value for richer documentation. + /// + /// **Deprecated since 3.0.x. Prefer [`Array::examples`] instead** + #[deprecated = "Since OpenAPI 3.1 prefer using `examples`"] + pub fn example(mut self, example: Option) -> Self { + set_value!(self example example) + } + + /// Add or change examples shown in UI of the value for richer documentation. + pub fn examples, V: Into>(mut self, examples: I) -> Self { + set_value!(self examples examples.into_iter().map(Into::into).collect()) + } + + /// Add or change default value for the object which is provided when user has not provided the input in Swagger UI. + pub fn default(mut self, default: Option) -> Self { + set_value!(self default default) + } + + /// Set maximum allowed length for [`Array`]. + pub fn max_items(mut self, max_items: Option) -> Self { + set_value!(self max_items max_items) + } + + /// Set minimum allowed length for [`Array`]. + pub fn min_items(mut self, min_items: Option) -> Self { + set_value!(self min_items min_items) + } + + /// Set or change whether [`Array`] should enforce all items to be unique. + pub fn unique_items(mut self, unique_items: bool) -> Self { + set_value!(self unique_items unique_items) + } + + /// Set [`Xml`] formatting for [`Array`]. + pub fn xml(mut self, xml: Option) -> Self { + set_value!(self xml xml) + } + + /// Set of change [`Object::content_encoding`]. Typically left empty but could be `base64` for + /// example. + pub fn content_encoding>(mut self, content_encoding: S) -> Self { + set_value!(self content_encoding content_encoding.into()) + } + + /// Set of change [`Object::content_media_type`]. Value must be valid MIME type e.g. + /// `application/json`. + pub fn content_media_type>(mut self, content_media_type: S) -> Self { + set_value!(self content_media_type content_media_type.into()) + } + + /// Add openapi extensions (`x-something`) for [`Array`]. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } + + to_array_builder!(); +} + +component_from_builder!(ArrayBuilder); + +impl From for Schema { + fn from(array: Array) -> Self { + Self::Array(array) + } +} + +impl From for ArrayItems { + fn from(value: ArrayBuilder) -> Self { + Self::RefOrSchema(Box::new(value.into())) + } +} + +impl From for RefOr { + fn from(array: ArrayBuilder) -> Self { + Self::T(Schema::Array(array.build())) + } +} + +impl ToArray for Array {} + +/// This convenience trait allows quick way to wrap any `RefOr` with [`Array`] schema. +pub trait ToArray +where + RefOr: From, + Self: Sized, +{ + /// Wrap this `RefOr` with [`Array`]. + fn to_array(self) -> Array { + Array::new(self) + } +} + +/// Represents type of [`Schema`]. +/// +/// This is a collection type for [`Type`] that can be represented as a single value +/// or as [`slice`] of [`Type`]s. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(untagged)] +pub enum SchemaType { + /// Single type known from OpenAPI spec 3.0 + Type(Type), + /// Multiple types rendered as [`slice`] + Array(Vec), + /// Type that is considered typeless. _`AnyValue`_ will omit the type definition from the schema + /// making it to accept any type possible. + AnyValue, +} + +impl Default for SchemaType { + fn default() -> Self { + Self::Type(Type::default()) + } +} + +impl From for SchemaType { + fn from(value: Type) -> Self { + SchemaType::new(value) + } +} + +impl FromIterator for SchemaType { + fn from_iter>(iter: T) -> Self { + Self::Array(iter.into_iter().collect()) + } +} + +impl SchemaType { + /// Instantiate new [`SchemaType`] of given [`Type`] + /// + /// Method accepts one argument `type` to create [`SchemaType`] for. + /// + /// # Examples + /// + /// _**Create string [`SchemaType`]**_ + /// ```rust + /// # use fastapi::openapi::schema::{SchemaType, Type}; + /// let ty = SchemaType::new(Type::String); + /// ``` + pub fn new(r#type: Type) -> Self { + Self::Type(r#type) + } + + //// Instantiate new [`SchemaType::AnyValue`]. + /// + /// This is same as calling [`SchemaType::AnyValue`] but in a function form `() -> SchemaType` + /// allowing it to be used as argument for _serde's_ _`default = "..."`_. + pub fn any() -> Self { + SchemaType::AnyValue + } + + /// Check whether this [`SchemaType`] is any value _(typeless)_ returning true on any value + /// schema type. + pub fn is_any_value(&self) -> bool { + matches!(self, Self::AnyValue) + } +} + +/// Represents data type fragment of [`Schema`]. +/// +/// [`Type`] is used to create a [`SchemaType`] that defines the type of the [`Schema`]. +/// [`SchemaType`] can be created from a single [`Type`] or multiple [`Type`]s according to the +/// OpenAPI 3.1 spec. Since the OpenAPI 3.1 is fully compatible with JSON schema the definition of +/// the _**type**_ property comes from [JSON Schema type](https://json-schema.org/understanding-json-schema/reference/type). +/// +/// # Examples +/// _**Create nullable string [`SchemaType`]**_ +/// ```rust +/// # use std::iter::FromIterator; +/// # use fastapi::openapi::schema::{Type, SchemaType}; +/// let _: SchemaType = [Type::String, Type::Null].into_iter().collect(); +/// ``` +/// _**Create string [`SchemaType`]**_ +/// ```rust +/// # use fastapi::openapi::schema::{Type, SchemaType}; +/// let _ = SchemaType::new(Type::String); +/// ``` +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq, Default)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "lowercase")] +pub enum Type { + /// Used with [`Object`] and [`ObjectBuilder`] to describe schema that has _properties_ + /// describing fields. + #[default] + Object, + /// Indicates string type of content. Used with [`Object`] and [`ObjectBuilder`] on a `string` + /// field. + String, + /// Indicates integer type of content. Used with [`Object`] and [`ObjectBuilder`] on a `number` + /// field. + Integer, + /// Indicates floating point number type of content. Used with + /// [`Object`] and [`ObjectBuilder`] on a `number` field. + Number, + /// Indicates boolean type of content. Used with [`Object`] and [`ObjectBuilder`] on + /// a `bool` field. + Boolean, + /// Used with [`Array`] and [`ArrayBuilder`]. Indicates array type of content. + Array, + /// Null type. Used together with other type to indicate nullable values. + Null, +} + +/// Additional format for [`SchemaType`] to fine tune the data type used. If the **format** is not +/// supported by the UI it may default back to [`SchemaType`] alone. +/// Format is an open value, so you can use any formats, even not those defined by the +/// OpenAPI Specification. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "lowercase", untagged)] +pub enum SchemaFormat { + /// Use to define additional detail about the value. + KnownFormat(KnownFormat), + /// Can be used to provide additional detail about the value when [`SchemaFormat::KnownFormat`] + /// is not suitable. + Custom(String), +} + +/// Known schema format modifier property to provide fine detail of the primitive type. +/// +/// Known format is defined in and +/// as +/// well as by few known data types that are enabled by specific feature flag e.g. _`uuid`_. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "kebab-case")] +pub enum KnownFormat { + /// 8 bit integer. + #[cfg(feature = "non_strict_integers")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "non_strict_integers")))] + Int8, + /// 16 bit integer. + #[cfg(feature = "non_strict_integers")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "non_strict_integers")))] + Int16, + /// 32 bit integer. + Int32, + /// 64 bit integer. + Int64, + /// 8 bit unsigned integer. + #[cfg(feature = "non_strict_integers")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "non_strict_integers")))] + UInt8, + /// 16 bit unsigned integer. + #[cfg(feature = "non_strict_integers")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "non_strict_integers")))] + UInt16, + /// 32 bit unsigned integer. + #[cfg(feature = "non_strict_integers")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "non_strict_integers")))] + UInt32, + /// 64 bit unsigned integer. + #[cfg(feature = "non_strict_integers")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "non_strict_integers")))] + UInt64, + /// floating point number. + Float, + /// double (floating point) number. + Double, + /// base64 encoded chars. + Byte, + /// binary data (octet). + Binary, + /// ISO-8601 full time format [RFC3339](https://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14). + Time, + /// ISO-8601 full date [RFC3339](https://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14). + Date, + /// ISO-8601 full date time [RFC3339](https://xml2rfc.ietf.org/public/rfc/html/rfc3339.html#anchor14). + DateTime, + /// duration format from [RFC3339 Appendix-A](https://datatracker.ietf.org/doc/html/rfc3339#appendix-A). + Duration, + /// Hint to UI to obscure input. + Password, + /// Used with [`String`] values to indicate value is in UUID format. + /// + /// **uuid** feature need to be enabled. + #[cfg(feature = "uuid")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "uuid")))] + Uuid, + /// Used with [`String`] values to indicate value is in ULID format. + #[cfg(feature = "ulid")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "ulid")))] + Ulid, + /// Used with [`String`] values to indicate value is in Url format according to + /// [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986). + #[cfg(feature = "url")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "url")))] + Uri, + /// A string instance is valid against this attribute if it is a valid URI Reference + /// (either a URI or a relative-reference) according to + /// [RFC3986](https://datatracker.ietf.org/doc/html/rfc3986). + #[cfg(feature = "url")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "url")))] + UriReference, + /// A string instance is valid against this attribute if it is a + /// valid IRI, according to [RFC3987](https://datatracker.ietf.org/doc/html/rfc3987). + #[cfg(feature = "url")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "url")))] + Iri, + /// A string instance is valid against this attribute if it is a valid IRI Reference + /// (either an IRI or a relative-reference) + /// according to [RFC3987](https://datatracker.ietf.org/doc/html/rfc3987). + #[cfg(feature = "url")] + #[cfg_attr(doc_cfg, doc(cfg(feature = "url")))] + IriReference, + /// As defined in "Mailbox" rule [RFC5321](https://datatracker.ietf.org/doc/html/rfc5321#section-4.1.2). + Email, + /// As defined by extended "Mailbox" rule [RFC6531](https://datatracker.ietf.org/doc/html/rfc6531#section-3.3). + IdnEmail, + /// As defined by [RFC1123](https://datatracker.ietf.org/doc/html/rfc1123#section-2.1), including host names + /// produced using the Punycode algorithm + /// specified in [RFC5891](https://datatracker.ietf.org/doc/html/rfc5891#section-4.4). + Hostname, + /// As defined by either [RFC1123](https://datatracker.ietf.org/doc/html/rfc1123#section-2.1) as for hostname, + /// or an internationalized hostname as defined by [RFC5890](https://datatracker.ietf.org/doc/html/rfc5890#section-2.3.2.3). + IdnHostname, + /// An IPv4 address according to [RFC2673](https://datatracker.ietf.org/doc/html/rfc2673#section-3.2). + Ipv4, + /// An IPv6 address according to [RFC4291](https://datatracker.ietf.org/doc/html/rfc4291#section-2.2). + Ipv6, + /// A string instance is a valid URI Template if it is according to + /// [RFC6570](https://datatracker.ietf.org/doc/html/rfc6570). + /// + /// _**Note!**_ There are no separate IRL template. + UriTemplate, + /// A valid JSON string representation of a JSON Pointer according to [RFC6901](https://datatracker.ietf.org/doc/html/rfc6901#section-5). + JsonPointer, + /// A valid relative JSON Pointer according to [draft-handrews-relative-json-pointer-01](https://datatracker.ietf.org/doc/html/draft-handrews-relative-json-pointer-01). + RelativeJsonPointer, + /// Regular expression, which SHOULD be valid according to the + /// [ECMA-262](https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#ref-ecma262). + Regex, +} + +#[cfg(test)] +mod tests { + use assert_json_diff::assert_json_eq; + use serde_json::{json, Value}; + + use super::*; + use crate::openapi::*; + + #[test] + fn create_schema_serializes_json() -> Result<(), serde_json::Error> { + let openapi = OpenApiBuilder::new() + .info(Info::new("My api", "1.0.0")) + .paths(Paths::new()) + .components(Some( + ComponentsBuilder::new() + .schema("Person", Ref::new("#/components/PersonModel")) + .schema( + "Credential", + Schema::from( + ObjectBuilder::new() + .property( + "id", + ObjectBuilder::new() + .schema_type(Type::Integer) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) + .description(Some("Id of credential")) + .default(Some(json!(1i32))), + ) + .property( + "name", + ObjectBuilder::new() + .schema_type(Type::String) + .description(Some("Name of credential")), + ) + .property( + "status", + ObjectBuilder::new() + .schema_type(Type::String) + .default(Some(json!("Active"))) + .description(Some("Credential status")) + .enum_values(Some([ + "Active", + "NotActive", + "Locked", + "Expired", + ])), + ) + .property( + "history", + Array::new(Ref::from_schema_name("UpdateHistory")), + ) + .property("tags", Object::with_type(Type::String).to_array()), + ), + ) + .build(), + )) + .build(); + + let serialized = serde_json::to_string_pretty(&openapi)?; + println!("serialized json:\n {serialized}"); + + let value = serde_json::to_value(&openapi)?; + let credential = get_json_path(&value, "components.schemas.Credential.properties"); + let person = get_json_path(&value, "components.schemas.Person"); + + assert!( + credential.get("id").is_some(), + "could not find path: components.schemas.Credential.properties.id" + ); + assert!( + credential.get("status").is_some(), + "could not find path: components.schemas.Credential.properties.status" + ); + assert!( + credential.get("name").is_some(), + "could not find path: components.schemas.Credential.properties.name" + ); + assert!( + credential.get("history").is_some(), + "could not find path: components.schemas.Credential.properties.history" + ); + assert_eq!( + credential + .get("id") + .unwrap_or(&serde_json::value::Value::Null) + .to_string(), + r#"{"default":1,"description":"Id of credential","format":"int32","type":"integer"}"#, + "components.schemas.Credential.properties.id did not match" + ); + assert_eq!( + credential + .get("name") + .unwrap_or(&serde_json::value::Value::Null) + .to_string(), + r#"{"description":"Name of credential","type":"string"}"#, + "components.schemas.Credential.properties.name did not match" + ); + assert_eq!( + credential + .get("status") + .unwrap_or(&serde_json::value::Value::Null) + .to_string(), + r#"{"default":"Active","description":"Credential status","enum":["Active","NotActive","Locked","Expired"],"type":"string"}"#, + "components.schemas.Credential.properties.status did not match" + ); + assert_eq!( + credential + .get("history") + .unwrap_or(&serde_json::value::Value::Null) + .to_string(), + r###"{"items":{"$ref":"#/components/schemas/UpdateHistory"},"type":"array"}"###, + "components.schemas.Credential.properties.history did not match" + ); + assert_eq!( + person.to_string(), + r###"{"$ref":"#/components/PersonModel"}"###, + "components.schemas.Person.ref did not match" + ); + + Ok(()) + } + + // Examples taken from https://spec.openapis.org/oas/latest.html#model-with-map-dictionary-properties + #[test] + fn test_property_order() { + let json_value = ObjectBuilder::new() + .property( + "id", + ObjectBuilder::new() + .schema_type(Type::Integer) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) + .description(Some("Id of credential")) + .default(Some(json!(1i32))), + ) + .property( + "name", + ObjectBuilder::new() + .schema_type(Type::String) + .description(Some("Name of credential")), + ) + .property( + "status", + ObjectBuilder::new() + .schema_type(Type::String) + .default(Some(json!("Active"))) + .description(Some("Credential status")) + .enum_values(Some(["Active", "NotActive", "Locked", "Expired"])), + ) + .property( + "history", + Array::new(Ref::from_schema_name("UpdateHistory")), + ) + .property("tags", Object::with_type(Type::String).to_array()) + .build(); + + #[cfg(not(feature = "preserve_order"))] + assert_eq!( + json_value.properties.keys().collect::>(), + vec!["history", "id", "name", "status", "tags"] + ); + + #[cfg(feature = "preserve_order")] + assert_eq!( + json_value.properties.keys().collect::>(), + vec!["id", "name", "status", "history", "tags"] + ); + } + + // Examples taken from https://spec.openapis.org/oas/latest.html#model-with-map-dictionary-properties + #[test] + fn test_additional_properties() { + let json_value = ObjectBuilder::new() + .additional_properties(Some(ObjectBuilder::new().schema_type(Type::String))) + .build(); + assert_json_eq!( + json_value, + json!({ + "type": "object", + "additionalProperties": { + "type": "string" + } + }) + ); + + let json_value = ObjectBuilder::new() + .additional_properties(Some(ArrayBuilder::new().items(ArrayItems::RefOrSchema( + Box::new(ObjectBuilder::new().schema_type(Type::Number).into()), + )))) + .build(); + assert_json_eq!( + json_value, + json!({ + "type": "object", + "additionalProperties": { + "items": { + "type": "number", + }, + "type": "array", + } + }) + ); + + let json_value = ObjectBuilder::new() + .additional_properties(Some(Ref::from_schema_name("ComplexModel"))) + .build(); + assert_json_eq!( + json_value, + json!({ + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/ComplexModel" + } + }) + ) + } + + #[test] + fn test_object_with_title() { + let json_value = ObjectBuilder::new().title(Some("SomeName")).build(); + assert_json_eq!( + json_value, + json!({ + "type": "object", + "title": "SomeName" + }) + ); + } + + #[test] + fn derive_object_with_examples() { + let expected = r#"{"type":"object","examples":[{"age":20,"name":"bob the cat"}]}"#; + let json_value = ObjectBuilder::new() + .examples([Some(json!({"age": 20, "name": "bob the cat"}))]) + .build(); + + let value_string = serde_json::to_string(&json_value).unwrap(); + assert_eq!( + value_string, expected, + "value string != expected string, {value_string} != {expected}" + ); + } + + fn get_json_path<'a>(value: &'a Value, path: &str) -> &'a Value { + path.split('.').fold(value, |acc, fragment| { + acc.get(fragment).unwrap_or(&serde_json::value::Value::Null) + }) + } + + #[test] + fn test_array_new() { + let array = Array::new( + ObjectBuilder::new().property( + "id", + ObjectBuilder::new() + .schema_type(Type::Integer) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) + .description(Some("Id of credential")) + .default(Some(json!(1i32))), + ), + ); + + assert!(matches!(array.schema_type, SchemaType::Type(Type::Array))); + } + + #[test] + fn test_array_builder() { + let array: Array = ArrayBuilder::new() + .items( + ObjectBuilder::new().property( + "id", + ObjectBuilder::new() + .schema_type(Type::Integer) + .format(Some(SchemaFormat::KnownFormat(KnownFormat::Int32))) + .description(Some("Id of credential")) + .default(Some(json!(1i32))), + ), + ) + .build(); + + assert!(matches!(array.schema_type, SchemaType::Type(Type::Array))); + } + + #[test] + fn reserialize_deserialized_schema_components() { + let components = ComponentsBuilder::new() + .schemas_from_iter(vec![( + "Comp", + Schema::from( + ObjectBuilder::new() + .property("name", ObjectBuilder::new().schema_type(Type::String)) + .required("name"), + ), + )]) + .responses_from_iter(vec![( + "200", + ResponseBuilder::new().description("Okay").build(), + )]) + .security_scheme( + "TLS", + SecurityScheme::MutualTls { + description: None, + extensions: None, + }, + ) + .build(); + + let serialized_components = serde_json::to_string(&components).unwrap(); + + let deserialized_components: Components = + serde_json::from_str(serialized_components.as_str()).unwrap(); + + assert_eq!( + serialized_components, + serde_json::to_string(&deserialized_components).unwrap() + ) + } + + #[test] + fn reserialize_deserialized_object_component() { + let prop = ObjectBuilder::new() + .property("name", ObjectBuilder::new().schema_type(Type::String)) + .required("name") + .build(); + + let serialized_components = serde_json::to_string(&prop).unwrap(); + let deserialized_components: Object = + serde_json::from_str(serialized_components.as_str()).unwrap(); + + assert_eq!( + serialized_components, + serde_json::to_string(&deserialized_components).unwrap() + ) + } + + #[test] + fn reserialize_deserialized_property() { + let prop = ObjectBuilder::new().schema_type(Type::String).build(); + + let serialized_components = serde_json::to_string(&prop).unwrap(); + let deserialized_components: Object = + serde_json::from_str(serialized_components.as_str()).unwrap(); + + assert_eq!( + serialized_components, + serde_json::to_string(&deserialized_components).unwrap() + ) + } + + #[test] + fn serialize_deserialize_array_within_ref_or_t_object_builder() { + let ref_or_schema = RefOr::T(Schema::Object( + ObjectBuilder::new() + .property( + "test", + RefOr::T(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("element", RefOr::Ref(Ref::new("#/test"))) + .build(), + ))) + .build(), + )), + ) + .build(), + )); + + let json_str = serde_json::to_string(&ref_or_schema).expect(""); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).expect(""); + + let json_de_str = serde_json::to_string(&deserialized).expect(""); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn serialize_deserialize_one_of_within_ref_or_t_object_builder() { + let ref_or_schema = RefOr::T(Schema::Object( + ObjectBuilder::new() + .property( + "test", + RefOr::T(Schema::OneOf( + OneOfBuilder::new() + .item(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("element", RefOr::Ref(Ref::new("#/test"))) + .build(), + ))) + .build(), + )) + .item(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("foobar", RefOr::Ref(Ref::new("#/foobar"))) + .build(), + ))) + .build(), + )) + .build(), + )), + ) + .build(), + )); + + let json_str = serde_json::to_string(&ref_or_schema).expect(""); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).expect(""); + + let json_de_str = serde_json::to_string(&deserialized).expect(""); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn serialize_deserialize_all_of_of_within_ref_or_t_object_builder() { + let ref_or_schema = RefOr::T(Schema::Object( + ObjectBuilder::new() + .property( + "test", + RefOr::T(Schema::AllOf( + AllOfBuilder::new() + .item(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("element", RefOr::Ref(Ref::new("#/test"))) + .build(), + ))) + .build(), + )) + .item(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("foobar", RefOr::Ref(Ref::new("#/foobar"))) + .build(), + ))) + .build(), + )), + ) + .build(), + )); + + let json_str = serde_json::to_string(&ref_or_schema).expect(""); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).expect(""); + + let json_de_str = serde_json::to_string(&deserialized).expect(""); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn deserialize_reserialize_one_of_default_type() { + let a = OneOfBuilder::new() + .item(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("element", RefOr::Ref(Ref::new("#/test"))) + .build(), + ))) + .build(), + )) + .item(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("foobar", RefOr::Ref(Ref::new("#/foobar"))) + .build(), + ))) + .build(), + )) + .build(); + + let serialized_json = serde_json::to_string(&a).expect("should serialize to json"); + let b: OneOf = serde_json::from_str(&serialized_json).expect("should deserialize OneOf"); + let reserialized_json = serde_json::to_string(&b).expect("reserialized json"); + + println!("{serialized_json}"); + println!("{reserialized_json}",); + assert_eq!(serialized_json, reserialized_json); + } + + #[test] + fn serialize_deserialize_any_of_of_within_ref_or_t_object_builder() { + let ref_or_schema = RefOr::T(Schema::Object( + ObjectBuilder::new() + .property( + "test", + RefOr::T(Schema::AnyOf( + AnyOfBuilder::new() + .item(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("element", RefOr::Ref(Ref::new("#/test"))) + .build(), + ))) + .build(), + )) + .item(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("foobar", RefOr::Ref(Ref::new("#/foobar"))) + .build(), + ))) + .build(), + )), + ) + .build(), + )); + + let json_str = serde_json::to_string(&ref_or_schema).expect(""); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).expect(""); + + let json_de_str = serde_json::to_string(&deserialized).expect(""); + println!("----------------------------"); + println!("{json_de_str}"); + assert!(json_str.contains("\"anyOf\"")); + assert_eq!(json_str, json_de_str); + } + + #[test] + fn serialize_deserialize_schema_array_ref_or_t() { + let ref_or_schema = RefOr::T(Schema::Array( + ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("element", RefOr::Ref(Ref::new("#/test"))) + .build(), + ))) + .build(), + )); + + let json_str = serde_json::to_string(&ref_or_schema).expect(""); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).expect(""); + + let json_de_str = serde_json::to_string(&deserialized).expect(""); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn serialize_deserialize_schema_array_builder() { + let ref_or_schema = ArrayBuilder::new() + .items(RefOr::T(Schema::Object( + ObjectBuilder::new() + .property("element", RefOr::Ref(Ref::new("#/test"))) + .build(), + ))) + .build(); + + let json_str = serde_json::to_string(&ref_or_schema).expect(""); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).expect(""); + + let json_de_str = serde_json::to_string(&deserialized).expect(""); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn serialize_deserialize_schema_with_additional_properties() { + let schema = Schema::Object( + ObjectBuilder::new() + .property( + "map", + ObjectBuilder::new() + .additional_properties(Some(AdditionalProperties::FreeForm(true))), + ) + .build(), + ); + + let json_str = serde_json::to_string(&schema).unwrap(); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).unwrap(); + + let json_de_str = serde_json::to_string(&deserialized).unwrap(); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn serialize_deserialize_schema_with_additional_properties_object() { + let schema = Schema::Object( + ObjectBuilder::new() + .property( + "map", + ObjectBuilder::new().additional_properties(Some( + ObjectBuilder::new().property("name", Object::with_type(Type::String)), + )), + ) + .build(), + ); + + let json_str = serde_json::to_string(&schema).unwrap(); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: RefOr = serde_json::from_str(&json_str).unwrap(); + + let json_de_str = serde_json::to_string(&deserialized).unwrap(); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn serialize_discriminator_with_mapping() { + let mut discriminator = Discriminator::new("type"); + discriminator.mapping = [("int".to_string(), "#/components/schemas/MyInt".to_string())] + .into_iter() + .collect::>(); + let one_of = OneOfBuilder::new() + .item(Ref::from_schema_name("MyInt")) + .discriminator(Some(discriminator)) + .build(); + let json_value = serde_json::to_value(one_of).unwrap(); + + assert_json_eq!( + json_value, + json!({ + "oneOf": [ + { + "$ref": "#/components/schemas/MyInt" + } + ], + "discriminator": { + "propertyName": "type", + "mapping": { + "int": "#/components/schemas/MyInt" + } + } + }) + ); + } + + #[test] + fn serialize_deserialize_object_with_multiple_schema_types() { + let object = ObjectBuilder::new() + .schema_type(SchemaType::from_iter([Type::Object, Type::Null])) + .build(); + + let json_str = serde_json::to_string(&object).unwrap(); + println!("----------------------------"); + println!("{json_str}"); + + let deserialized: Object = serde_json::from_str(&json_str).unwrap(); + + let json_de_str = serde_json::to_string(&deserialized).unwrap(); + println!("----------------------------"); + println!("{json_de_str}"); + + assert_eq!(json_str, json_de_str); + } + + #[test] + fn object_with_extensions() { + let expected = json!("value"); + let extensions = extensions::ExtensionsBuilder::new() + .add("x-some-extension", expected.clone()) + .build(); + let json_value = ObjectBuilder::new().extensions(Some(extensions)).build(); + + let value = serde_json::to_value(&json_value).unwrap(); + assert_eq!(value.get("x-some-extension"), Some(&expected)); + } + + #[test] + fn array_with_extensions() { + let expected = json!("value"); + let extensions = extensions::ExtensionsBuilder::new() + .add("x-some-extension", expected.clone()) + .build(); + let json_value = ArrayBuilder::new().extensions(Some(extensions)).build(); + + let value = serde_json::to_value(&json_value).unwrap(); + assert_eq!(value.get("x-some-extension"), Some(&expected)); + } + + #[test] + fn oneof_with_extensions() { + let expected = json!("value"); + let extensions = extensions::ExtensionsBuilder::new() + .add("x-some-extension", expected.clone()) + .build(); + let json_value = OneOfBuilder::new().extensions(Some(extensions)).build(); + + let value = serde_json::to_value(&json_value).unwrap(); + assert_eq!(value.get("x-some-extension"), Some(&expected)); + } + + #[test] + fn allof_with_extensions() { + let expected = json!("value"); + let extensions = extensions::ExtensionsBuilder::new() + .add("x-some-extension", expected.clone()) + .build(); + let json_value = AllOfBuilder::new().extensions(Some(extensions)).build(); + + let value = serde_json::to_value(&json_value).unwrap(); + assert_eq!(value.get("x-some-extension"), Some(&expected)); + } + + #[test] + fn anyof_with_extensions() { + let expected = json!("value"); + let extensions = extensions::ExtensionsBuilder::new() + .add("x-some-extension", expected.clone()) + .build(); + let json_value = AnyOfBuilder::new().extensions(Some(extensions)).build(); + + let value = serde_json::to_value(&json_value).unwrap(); + assert_eq!(value.get("x-some-extension"), Some(&expected)); + } +} diff --git a/fastapi/src/openapi/security.rs b/fastapi/src/openapi/security.rs new file mode 100644 index 0000000..235c6d9 --- /dev/null +++ b/fastapi/src/openapi/security.rs @@ -0,0 +1,1321 @@ +//! Implements [OpenAPI Security Schema][security] types. +//! +//! Refer to [`SecurityScheme`] for usage and more details. +//! +//! [security]: https://spec.openapis.org/oas/latest.html#security-scheme-object +use std::{collections::BTreeMap, iter}; + +use serde::{Deserialize, Serialize}; + +use super::{builder, extensions::Extensions}; + +/// OpenAPI [security requirement][security] object. +/// +/// Security requirement holds list of required [`SecurityScheme`] *names* and possible *scopes* required +/// to execute the operation. They can be defined in [`#[fastapi::path(...)]`][path] or in `#[openapi(...)]` +/// of [`OpenApi`][openapi]. +/// +/// Applying the security requirement to [`OpenApi`][openapi] will make it globally +/// available to all operations. When applied to specific [`#[fastapi::path(...)]`][path] will only +/// make the security requirements available for that operation. Only one of the requirements must be +/// satisfied. +/// +/// [security]: https://spec.openapis.org/oas/latest.html#security-requirement-object +/// [path]: ../../attr.path.html +/// [openapi]: ../../derive.OpenApi.html +#[non_exhaustive] +#[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct SecurityRequirement { + #[serde(flatten)] + value: BTreeMap>, +} + +impl SecurityRequirement { + /// Construct a new [`SecurityRequirement`]. + /// + /// Accepts name for the security requirement which must match to the name of available [`SecurityScheme`]. + /// Second parameter is [`IntoIterator`] of [`Into`] scopes needed by the [`SecurityRequirement`]. + /// Scopes must match to the ones defined in [`SecurityScheme`]. + /// + /// # Examples + /// + /// Create new security requirement with scopes. + /// ```rust + /// # use fastapi::openapi::security::SecurityRequirement; + /// SecurityRequirement::new("api_oauth2_flow", ["edit:items", "read:items"]); + /// ``` + /// + /// You can also create an empty security requirement with `Default::default()`. + /// ```rust + /// # use fastapi::openapi::security::SecurityRequirement; + /// SecurityRequirement::default(); + /// ``` + /// + /// If you have more than one name in the security requirement you can use + /// [`SecurityRequirement::add`]. + pub fn new, S: IntoIterator, I: Into>( + name: N, + scopes: S, + ) -> Self { + Self { + value: BTreeMap::from_iter(iter::once_with(|| { + ( + Into::::into(name), + scopes + .into_iter() + .map(|scope| Into::::into(scope)) + .collect::>(), + ) + })), + } + } + + /// Allows to add multiple names to security requirement. + /// + /// Accepts name for the security requirement which must match to the name of available [`SecurityScheme`]. + /// Second parameter is [`IntoIterator`] of [`Into`] scopes needed by the [`SecurityRequirement`]. + /// Scopes must match to the ones defined in [`SecurityScheme`]. + /// + /// # Examples + /// + /// Make both API keys required: + /// ```rust + /// # use fastapi::openapi::security::{SecurityRequirement, HttpAuthScheme, HttpBuilder, SecurityScheme}; + /// # use fastapi::{openapi, Modify, OpenApi}; + /// # use serde::Serialize; + /// #[derive(Debug, Serialize)] + /// struct Foo; + /// + /// impl Modify for Foo { + /// fn modify(&self, openapi: &mut openapi::OpenApi) { + /// if let Some(schema) = openapi.components.as_mut() { + /// schema.add_security_scheme( + /// "api_key1", + /// SecurityScheme::Http( + /// HttpBuilder::new() + /// .scheme(HttpAuthScheme::Bearer) + /// .bearer_format("JWT") + /// .build(), + /// ), + /// ); + /// schema.add_security_scheme( + /// "api_key2", + /// SecurityScheme::Http( + /// HttpBuilder::new() + /// .scheme(HttpAuthScheme::Bearer) + /// .bearer_format("JWT") + /// .build(), + /// ), + /// ); + /// } + /// } + /// } + /// + /// #[derive(Default, OpenApi)] + /// #[openapi( + /// modifiers(&Foo), + /// security( + /// ("api_key1" = ["edit:items", "read:items"], "api_key2" = ["edit:items", "read:items"]), + /// ) + /// )] + /// struct ApiDoc; + /// ``` + pub fn add, S: IntoIterator, I: Into>( + mut self, + name: N, + scopes: S, + ) -> Self { + self.value.insert( + Into::::into(name), + scopes.into_iter().map(Into::::into).collect(), + ); + + self + } +} + +/// OpenAPI [security scheme][security] for path operations. +/// +/// [security]: https://spec.openapis.org/oas/latest.html#security-scheme-object +/// +/// # Examples +/// +/// Create implicit oauth2 flow security schema for path operations. +/// ```rust +/// # use fastapi::openapi::security::{SecurityScheme, OAuth2, Implicit, Flow, Scopes}; +/// SecurityScheme::OAuth2( +/// OAuth2::with_description([Flow::Implicit( +/// Implicit::new( +/// "https://localhost/auth/dialog", +/// Scopes::from_iter([ +/// ("edit:items", "edit my items"), +/// ("read:items", "read my items") +/// ]), +/// ), +/// )], "my oauth2 flow") +/// ); +/// ``` +/// +/// Create JWT header authentication. +/// ```rust +/// # use fastapi::openapi::security::{SecurityScheme, HttpAuthScheme, HttpBuilder}; +/// SecurityScheme::Http( +/// HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build() +/// ); +/// ``` +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum SecurityScheme { + /// Oauth flow authentication. + #[serde(rename = "oauth2")] + OAuth2(OAuth2), + /// Api key authentication sent in *`header`*, *`cookie`* or *`query`*. + ApiKey(ApiKey), + /// Http authentication such as *`bearer`* or *`basic`*. + Http(Http), + /// Open id connect url to discover OAuth2 configuration values. + OpenIdConnect(OpenIdConnect), + /// Authentication is done via client side certificate. + /// + /// OpenApi 3.1 type + #[serde(rename = "mutualTLS")] + MutualTls { + #[allow(missing_docs)] + #[serde(skip_serializing_if = "Option::is_none")] + description: Option, + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + extensions: Option, + }, +} + +/// Api key authentication [`SecurityScheme`]. +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(tag = "in", rename_all = "lowercase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum ApiKey { + /// Create api key which is placed in HTTP header. + Header(ApiKeyValue), + /// Create api key which is placed in query parameters. + Query(ApiKeyValue), + /// Create api key which is placed in cookie value. + Cookie(ApiKeyValue), +} + +/// Value object for [`ApiKey`]. +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ApiKeyValue { + /// Name of the [`ApiKey`] parameter. + pub name: String, + + /// Description of the the [`ApiKey`] [`SecurityScheme`]. Supports markdown syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl ApiKeyValue { + /// Constructs new api key value. + /// + /// # Examples + /// + /// Create new api key security schema with name `api_key`. + /// ```rust + /// # use fastapi::openapi::security::ApiKeyValue; + /// let api_key = ApiKeyValue::new("api_key"); + /// ``` + pub fn new>(name: S) -> Self { + Self { + name: name.into(), + description: None, + extensions: Default::default(), + } + } + + /// Construct a new api key with optional description supporting markdown syntax. + /// + /// # Examples + /// + /// Create new api key security schema with name `api_key` with description. + /// ```rust + /// # use fastapi::openapi::security::ApiKeyValue; + /// let api_key = ApiKeyValue::with_description("api_key", "my api_key token"); + /// ``` + pub fn with_description>(name: S, description: S) -> Self { + Self { + name: name.into(), + description: Some(description.into()), + extensions: Default::default(), + } + } +} + +builder! { + HttpBuilder; + + /// Http authentication [`SecurityScheme`] builder. + /// + /// Methods can be chained to configure _bearer_format_ or to add _description_. + #[non_exhaustive] + #[derive(Serialize, Deserialize, Clone, Default, PartialEq, Eq)] + #[serde(rename_all = "camelCase")] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Http { + /// Http authorization scheme in HTTP `Authorization` header value. + pub scheme: HttpAuthScheme, + + /// Optional hint to client how the bearer token is formatted. Valid only with [`HttpAuthScheme::Bearer`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub bearer_format: Option, + + /// Optional description of [`Http`] [`SecurityScheme`] supporting markdown syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Http { + /// Create new http authentication security schema. + /// + /// Accepts one argument which defines the scheme of the http authentication. + /// + /// # Examples + /// + /// Create http security schema with basic authentication. + /// ```rust + /// # use fastapi::openapi::security::{SecurityScheme, Http, HttpAuthScheme}; + /// SecurityScheme::Http(Http::new(HttpAuthScheme::Basic)); + /// ``` + pub fn new(scheme: HttpAuthScheme) -> Self { + Self { + scheme, + bearer_format: None, + description: None, + extensions: Default::default(), + } + } +} + +impl HttpBuilder { + /// Add or change http authentication scheme used. + /// + /// # Examples + /// + /// Create new [`Http`] [`SecurityScheme`] via [`HttpBuilder`]. + /// ```rust + /// # use fastapi::openapi::security::{HttpBuilder, HttpAuthScheme}; + /// let http = HttpBuilder::new().scheme(HttpAuthScheme::Basic).build(); + /// ``` + pub fn scheme(mut self, scheme: HttpAuthScheme) -> Self { + self.scheme = scheme; + + self + } + /// Add or change informative bearer format for http security schema. + /// + /// This is only applicable to [`HttpAuthScheme::Bearer`]. + /// + /// # Examples + /// + /// Add JTW bearer format for security schema. + /// ```rust + /// # use fastapi::openapi::security::{HttpBuilder, HttpAuthScheme}; + /// HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build(); + /// ``` + pub fn bearer_format>(mut self, bearer_format: S) -> Self { + if self.scheme == HttpAuthScheme::Bearer { + self.bearer_format = Some(bearer_format.into()); + } + + self + } + + /// Add or change optional description supporting markdown syntax. + pub fn description>(mut self, description: Option) -> Self { + self.description = description.map(|description| description.into()); + + self + } +} + +/// Implements types according [RFC7235](https://datatracker.ietf.org/doc/html/rfc7235#section-5.1). +/// +/// Types are maintained at . +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +#[serde(rename_all = "lowercase")] +#[allow(missing_docs)] +pub enum HttpAuthScheme { + Basic, + Bearer, + Digest, + Hoba, + Mutual, + Negotiate, + OAuth, + #[serde(rename = "scram-sha-1")] + ScramSha1, + #[serde(rename = "scram-sha-256")] + ScramSha256, + Vapid, +} + +impl Default for HttpAuthScheme { + fn default() -> Self { + Self::Basic + } +} + +/// Open id connect [`SecurityScheme`]. +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct OpenIdConnect { + /// Url of the [`OpenIdConnect`] to discover OAuth2 connect values. + pub open_id_connect_url: String, + + /// Description of [`OpenIdConnect`] [`SecurityScheme`] supporting markdown syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl OpenIdConnect { + /// Construct a new open id connect security schema. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi::openapi::security::OpenIdConnect; + /// OpenIdConnect::new("https://localhost/openid"); + /// ``` + pub fn new>(open_id_connect_url: S) -> Self { + Self { + open_id_connect_url: open_id_connect_url.into(), + description: None, + extensions: Default::default(), + } + } + + /// Construct a new [`OpenIdConnect`] [`SecurityScheme`] with optional description + /// supporting markdown syntax. + /// + /// # Examples + /// + /// ```rust + /// # use fastapi::openapi::security::OpenIdConnect; + /// OpenIdConnect::with_description("https://localhost/openid", "my pet api open id connect"); + /// ``` + pub fn with_description>(open_id_connect_url: S, description: S) -> Self { + Self { + open_id_connect_url: open_id_connect_url.into(), + description: Some(description.into()), + extensions: Default::default(), + } + } +} + +/// OAuth2 [`Flow`] configuration for [`SecurityScheme`]. +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct OAuth2 { + /// Map of supported OAuth2 flows. + pub flows: BTreeMap, + + /// Optional description for the [`OAuth2`] [`Flow`] [`SecurityScheme`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl OAuth2 { + /// Construct a new OAuth2 security schema configuration object. + /// + /// Oauth flow accepts slice of [`Flow`] configuration objects and can be optionally provided with description. + /// + /// # Examples + /// + /// Create new OAuth2 flow with multiple authentication flows. + /// ```rust + /// # use fastapi::openapi::security::{OAuth2, Flow, Password, AuthorizationCode, Scopes}; + /// OAuth2::new([Flow::Password( + /// Password::with_refresh_url( + /// "https://localhost/oauth/token", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// "https://localhost/refresh/token" + /// )), + /// Flow::AuthorizationCode( + /// AuthorizationCode::new( + /// "https://localhost/authorization/token", + /// "https://localhost/token/url", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ])), + /// ), + /// ]); + /// ``` + pub fn new>(flows: I) -> Self { + Self { + flows: BTreeMap::from_iter( + flows + .into_iter() + .map(|auth_flow| (String::from(auth_flow.get_type_as_str()), auth_flow)), + ), + extensions: None, + description: None, + } + } + + /// Construct a new OAuth2 flow with optional description supporting markdown syntax. + /// + /// # Examples + /// + /// Create new OAuth2 flow with multiple authentication flows with description. + /// ```rust + /// # use fastapi::openapi::security::{OAuth2, Flow, Password, AuthorizationCode, Scopes}; + /// OAuth2::with_description([Flow::Password( + /// Password::with_refresh_url( + /// "https://localhost/oauth/token", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// "https://localhost/refresh/token" + /// )), + /// Flow::AuthorizationCode( + /// AuthorizationCode::new( + /// "https://localhost/authorization/token", + /// "https://localhost/token/url", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]) + /// ), + /// ), + /// ], "my oauth2 flow"); + /// ``` + pub fn with_description, S: Into>( + flows: I, + description: S, + ) -> Self { + Self { + flows: BTreeMap::from_iter( + flows + .into_iter() + .map(|auth_flow| (String::from(auth_flow.get_type_as_str()), auth_flow)), + ), + extensions: None, + description: Some(description.into()), + } + } +} + +/// [`OAuth2`] flow configuration object. +/// +/// See more details at . +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(untagged)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub enum Flow { + /// Define implicit [`Flow`] type. See [`Implicit::new`] for usage details. + /// + /// Soon to be deprecated by . + Implicit(Implicit), + /// Define password [`Flow`] type. See [`Password::new`] for usage details. + Password(Password), + /// Define client credentials [`Flow`] type. See [`ClientCredentials::new`] for usage details. + ClientCredentials(ClientCredentials), + /// Define authorization code [`Flow`] type. See [`AuthorizationCode::new`] for usage details. + AuthorizationCode(AuthorizationCode), +} + +impl Flow { + fn get_type_as_str(&self) -> &str { + match self { + Self::Implicit(_) => "implicit", + Self::Password(_) => "password", + Self::ClientCredentials(_) => "clientCredentials", + Self::AuthorizationCode(_) => "authorizationCode", + } + } +} + +/// Implicit [`Flow`] configuration for [`OAuth2`]. +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Implicit { + /// Authorization token url for the flow. + pub authorization_url: String, + + /// Optional refresh token url for the flow. + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_url: Option, + + /// Scopes required by the flow. + #[serde(flatten)] + pub scopes: Scopes, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl Implicit { + /// Construct a new implicit oauth2 flow. + /// + /// Accepts two arguments: one which is authorization url and second map of scopes. Scopes can + /// also be an empty map. + /// + /// # Examples + /// + /// Create new implicit flow with scopes. + /// ```rust + /// # use fastapi::openapi::security::{Implicit, Scopes}; + /// Implicit::new( + /// "https://localhost/auth/dialog", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// ); + /// ``` + /// + /// Create new implicit flow without any scopes. + /// ```rust + /// # use fastapi::openapi::security::{Implicit, Scopes}; + /// Implicit::new( + /// "https://localhost/auth/dialog", + /// Scopes::new(), + /// ); + /// ``` + pub fn new>(authorization_url: S, scopes: Scopes) -> Self { + Self { + authorization_url: authorization_url.into(), + refresh_url: None, + scopes, + extensions: Default::default(), + } + } + + /// Construct a new implicit oauth2 flow with refresh url for getting refresh tokens. + /// + /// This is essentially same as [`Implicit::new`] but allows defining `refresh_url` for the [`Implicit`] + /// oauth2 flow. + /// + /// # Examples + /// + /// Create a new implicit oauth2 flow with refresh token. + /// ```rust + /// # use fastapi::openapi::security::{Implicit, Scopes}; + /// Implicit::with_refresh_url( + /// "https://localhost/auth/dialog", + /// Scopes::new(), + /// "https://localhost/refresh-token" + /// ); + /// ``` + pub fn with_refresh_url>( + authorization_url: S, + scopes: Scopes, + refresh_url: S, + ) -> Self { + Self { + authorization_url: authorization_url.into(), + refresh_url: Some(refresh_url.into()), + scopes, + extensions: Default::default(), + } + } +} + +/// Authorization code [`Flow`] configuration for [`OAuth2`]. +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct AuthorizationCode { + /// Url for authorization token. + pub authorization_url: String, + /// Token url for the flow. + pub token_url: String, + + /// Optional refresh token url for the flow. + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_url: Option, + + /// Scopes required by the flow. + #[serde(flatten)] + pub scopes: Scopes, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl AuthorizationCode { + /// Construct a new authorization code oauth flow. + /// + /// Accepts three arguments: one which is authorization url, two a token url and + /// three a map of scopes for oauth flow. + /// + /// # Examples + /// + /// Create new authorization code flow with scopes. + /// ```rust + /// # use fastapi::openapi::security::{AuthorizationCode, Scopes}; + /// AuthorizationCode::new( + /// "https://localhost/auth/dialog", + /// "https://localhost/token", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// ); + /// ``` + /// + /// Create new authorization code flow without any scopes. + /// ```rust + /// # use fastapi::openapi::security::{AuthorizationCode, Scopes}; + /// AuthorizationCode::new( + /// "https://localhost/auth/dialog", + /// "https://localhost/token", + /// Scopes::new(), + /// ); + /// ``` + pub fn new, T: Into>( + authorization_url: A, + token_url: T, + scopes: Scopes, + ) -> Self { + Self { + authorization_url: authorization_url.into(), + token_url: token_url.into(), + refresh_url: None, + scopes, + extensions: Default::default(), + } + } + + /// Construct a new [`AuthorizationCode`] OAuth2 flow with additional refresh token url. + /// + /// This is essentially same as [`AuthorizationCode::new`] but allows defining extra parameter `refresh_url` + /// for fetching refresh token. + /// + /// # Examples + /// + /// Create [`AuthorizationCode`] OAuth2 flow with refresh url. + /// ```rust + /// # use fastapi::openapi::security::{AuthorizationCode, Scopes}; + /// AuthorizationCode::with_refresh_url( + /// "https://localhost/auth/dialog", + /// "https://localhost/token", + /// Scopes::new(), + /// "https://localhost/refresh-token" + /// ); + /// ``` + pub fn with_refresh_url>( + authorization_url: S, + token_url: S, + scopes: Scopes, + refresh_url: S, + ) -> Self { + Self { + authorization_url: authorization_url.into(), + token_url: token_url.into(), + refresh_url: Some(refresh_url.into()), + scopes, + extensions: Default::default(), + } + } +} + +/// Password [`Flow`] configuration for [`OAuth2`]. +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Password { + /// Token url for this OAuth2 flow. OAuth2 standard requires TLS. + pub token_url: String, + + /// Optional refresh token url. + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_url: Option, + + /// Scopes required by the flow. + #[serde(flatten)] + pub scopes: Scopes, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl Password { + /// Construct a new password oauth flow. + /// + /// Accepts two arguments: one which is a token url and + /// two a map of scopes for oauth flow. + /// + /// # Examples + /// + /// Create new password flow with scopes. + /// ```rust + /// # use fastapi::openapi::security::{Password, Scopes}; + /// Password::new( + /// "https://localhost/token", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// ); + /// ``` + /// + /// Create new password flow without any scopes. + /// ```rust + /// # use fastapi::openapi::security::{Password, Scopes}; + /// Password::new( + /// "https://localhost/token", + /// Scopes::new(), + /// ); + /// ``` + pub fn new>(token_url: S, scopes: Scopes) -> Self { + Self { + token_url: token_url.into(), + refresh_url: None, + scopes, + extensions: Default::default(), + } + } + + /// Construct a new password oauth flow with additional refresh url. + /// + /// This is essentially same as [`Password::new`] but allows defining third parameter for `refresh_url` + /// for fetching refresh tokens. + /// + /// # Examples + /// + /// Create new password flow with refresh url. + /// ```rust + /// # use fastapi::openapi::security::{Password, Scopes}; + /// Password::with_refresh_url( + /// "https://localhost/token", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// "https://localhost/refres-token" + /// ); + /// ``` + pub fn with_refresh_url>(token_url: S, scopes: Scopes, refresh_url: S) -> Self { + Self { + token_url: token_url.into(), + refresh_url: Some(refresh_url.into()), + scopes, + extensions: Default::default(), + } + } +} + +/// Client credentials [`Flow`] configuration for [`OAuth2`]. +#[non_exhaustive] +#[derive(Serialize, Deserialize, Clone, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct ClientCredentials { + /// Token url used for [`ClientCredentials`] flow. OAuth2 standard requires TLS. + pub token_url: String, + + /// Optional refresh token url. + #[serde(skip_serializing_if = "Option::is_none")] + pub refresh_url: Option, + + /// Scopes required by the flow. + #[serde(flatten)] + pub scopes: Scopes, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, +} + +impl ClientCredentials { + /// Construct a new client credentials oauth flow. + /// + /// Accepts two arguments: one which is a token url and + /// two a map of scopes for oauth flow. + /// + /// # Examples + /// + /// Create new client credentials flow with scopes. + /// ```rust + /// # use fastapi::openapi::security::{ClientCredentials, Scopes}; + /// ClientCredentials::new( + /// "https://localhost/token", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// ); + /// ``` + /// + /// Create new client credentials flow without any scopes. + /// ```rust + /// # use fastapi::openapi::security::{ClientCredentials, Scopes}; + /// ClientCredentials::new( + /// "https://localhost/token", + /// Scopes::new(), + /// ); + /// ``` + pub fn new>(token_url: S, scopes: Scopes) -> Self { + Self { + token_url: token_url.into(), + refresh_url: None, + scopes, + extensions: Default::default(), + } + } + + /// Construct a new client credentials oauth flow with additional refresh url. + /// + /// This is essentially same as [`ClientCredentials::new`] but allows defining third parameter for + /// `refresh_url`. + /// + /// # Examples + /// + /// Create new client credentials for with refresh url. + /// ```rust + /// # use fastapi::openapi::security::{ClientCredentials, Scopes}; + /// ClientCredentials::with_refresh_url( + /// "https://localhost/token", + /// Scopes::from_iter([ + /// ("edit:items", "edit my items"), + /// ("read:items", "read my items") + /// ]), + /// "https://localhost/refresh-url" + /// ); + /// ``` + pub fn with_refresh_url>(token_url: S, scopes: Scopes, refresh_url: S) -> Self { + Self { + token_url: token_url.into(), + refresh_url: Some(refresh_url.into()), + scopes, + extensions: Default::default(), + } + } +} + +/// [`OAuth2`] flow scopes object defines required permissions for oauth flow. +/// +/// Scopes must be given to oauth2 flow but depending on need one of few initialization methods +/// could be used. +/// +/// * Create empty map of scopes you can use [`Scopes::new`]. +/// * Create map with only one scope you can use [`Scopes::one`]. +/// * Create multiple scopes from iterator with [`Scopes::from_iter`]. +/// +/// # Examples +/// +/// Create empty map of scopes. +/// ```rust +/// # use fastapi::openapi::security::Scopes; +/// let scopes = Scopes::new(); +/// ``` +/// +/// Create [`Scopes`] holding one scope. +/// ```rust +/// # use fastapi::openapi::security::Scopes; +/// let scopes = Scopes::one("edit:item", "edit pets"); +/// ``` +/// +/// Create map of scopes from iterator. +/// ```rust +/// # use fastapi::openapi::security::Scopes; +/// let scopes = Scopes::from_iter([ +/// ("edit:items", "edit my items"), +/// ("read:items", "read my items") +/// ]); +/// ``` +#[derive(Default, Serialize, Deserialize, Clone, PartialEq, Eq)] +#[cfg_attr(feature = "debug", derive(Debug))] +pub struct Scopes { + scopes: BTreeMap, +} + +impl Scopes { + /// Construct new [`Scopes`] with empty map of scopes. This is useful if oauth flow does not need + /// any permission scopes. + /// + /// # Examples + /// + /// Create empty map of scopes. + /// ```rust + /// # use fastapi::openapi::security::Scopes; + /// let scopes = Scopes::new(); + /// ``` + pub fn new() -> Self { + Self { + ..Default::default() + } + } + + /// Construct new [`Scopes`] with holding one scope. + /// + /// * `scope` Is be the permission required. + /// * `description` Short description about the permission. + /// + /// # Examples + /// + /// Create map of scopes with one scope item. + /// ```rust + /// # use fastapi::openapi::security::Scopes; + /// let scopes = Scopes::one("edit:item", "edit items"); + /// ``` + pub fn one>(scope: S, description: S) -> Self { + Self { + scopes: BTreeMap::from_iter(iter::once_with(|| (scope.into(), description.into()))), + } + } +} + +impl FromIterator<(I, I)> for Scopes +where + I: Into, +{ + fn from_iter>(iter: T) -> Self { + Self { + scopes: iter + .into_iter() + .map(|(key, value)| (key.into(), value.into())) + .collect(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! test_fn { + ($name:ident: $schema:expr; $expected:literal) => { + #[test] + fn $name() { + let value = serde_json::to_value($schema).unwrap(); + let expected_value: serde_json::Value = serde_json::from_str($expected).unwrap(); + + assert_eq!( + value, + expected_value, + "testing serializing \"{}\": \nactual:\n{}\nexpected:\n{}", + stringify!($name), + value, + expected_value + ); + + println!("{}", &serde_json::to_string_pretty(&$schema).unwrap()); + } + }; + } + + test_fn! { + security_scheme_correct_http_bearer_json: + SecurityScheme::Http( + HttpBuilder::new().scheme(HttpAuthScheme::Bearer).bearer_format("JWT").build() + ); + r###"{ + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" +}"### + } + + test_fn! { + security_scheme_correct_basic_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::Basic)); + r###"{ + "type": "http", + "scheme": "basic" +}"### + } + + test_fn! { + security_scheme_correct_digest_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::Digest)); + r###"{ + "type": "http", + "scheme": "digest" +}"### + } + + test_fn! { + security_scheme_correct_hoba_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::Hoba)); + r###"{ + "type": "http", + "scheme": "hoba" +}"### + } + + test_fn! { + security_scheme_correct_mutual_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::Mutual)); + r###"{ + "type": "http", + "scheme": "mutual" +}"### + } + + test_fn! { + security_scheme_correct_negotiate_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::Negotiate)); + r###"{ + "type": "http", + "scheme": "negotiate" +}"### + } + + test_fn! { + security_scheme_correct_oauth_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::OAuth)); + r###"{ + "type": "http", + "scheme": "oauth" +}"### + } + + test_fn! { + security_scheme_correct_scram_sha1_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::ScramSha1)); + r###"{ + "type": "http", + "scheme": "scram-sha-1" +}"### + } + + test_fn! { + security_scheme_correct_scram_sha256_auth: + SecurityScheme::Http(Http::new(HttpAuthScheme::ScramSha256)); + r###"{ + "type": "http", + "scheme": "scram-sha-256" +}"### + } + + test_fn! { + security_scheme_correct_api_key_cookie_auth: + SecurityScheme::ApiKey(ApiKey::Cookie(ApiKeyValue::new(String::from("api_key")))); + r###"{ + "type": "apiKey", + "name": "api_key", + "in": "cookie" +}"### + } + + test_fn! { + security_scheme_correct_api_key_header_auth: + SecurityScheme::ApiKey(ApiKey::Header(ApiKeyValue::new("api_key"))); + r###"{ + "type": "apiKey", + "name": "api_key", + "in": "header" +}"### + } + + test_fn! { + security_scheme_correct_api_key_query_auth: + SecurityScheme::ApiKey(ApiKey::Query(ApiKeyValue::new(String::from("api_key")))); + r###"{ + "type": "apiKey", + "name": "api_key", + "in": "query" +}"### + } + + test_fn! { + security_scheme_correct_open_id_connect_auth: + SecurityScheme::OpenIdConnect(OpenIdConnect::new("https://localhost/openid")); + r###"{ + "type": "openIdConnect", + "openIdConnectUrl": "https://localhost/openid" +}"### + } + + test_fn! { + security_scheme_correct_oauth2_implicit: + SecurityScheme::OAuth2( + OAuth2::with_description([Flow::Implicit( + Implicit::new( + "https://localhost/auth/dialog", + Scopes::from_iter([ + ("edit:items", "edit my items"), + ("read:items", "read my items") + ]), + ), + )], "my oauth2 flow") + ); + r###"{ + "type": "oauth2", + "flows": { + "implicit": { + "authorizationUrl": "https://localhost/auth/dialog", + "scopes": { + "edit:items": "edit my items", + "read:items": "read my items" + } + } + }, + "description": "my oauth2 flow" +}"### + } + + test_fn! { + security_scheme_correct_oauth2_password: + SecurityScheme::OAuth2( + OAuth2::with_description([Flow::Password( + Password::with_refresh_url( + "https://localhost/oauth/token", + Scopes::from_iter([ + ("edit:items", "edit my items"), + ("read:items", "read my items") + ]), + "https://localhost/refresh/token" + ), + )], "my oauth2 flow") + ); + r###"{ + "type": "oauth2", + "flows": { + "password": { + "tokenUrl": "https://localhost/oauth/token", + "refreshUrl": "https://localhost/refresh/token", + "scopes": { + "edit:items": "edit my items", + "read:items": "read my items" + } + } + }, + "description": "my oauth2 flow" +}"### + } + + test_fn! { + security_scheme_correct_oauth2_client_credentials: + SecurityScheme::OAuth2( + OAuth2::new([Flow::ClientCredentials( + ClientCredentials::with_refresh_url( + "https://localhost/oauth/token", + Scopes::from_iter([ + ("edit:items", "edit my items"), + ("read:items", "read my items") + ]), + "https://localhost/refresh/token" + ), + )]) + ); + r###"{ + "type": "oauth2", + "flows": { + "clientCredentials": { + "tokenUrl": "https://localhost/oauth/token", + "refreshUrl": "https://localhost/refresh/token", + "scopes": { + "edit:items": "edit my items", + "read:items": "read my items" + } + } + } +}"### + } + + test_fn! { + security_scheme_correct_oauth2_authorization_code: + SecurityScheme::OAuth2( + OAuth2::new([Flow::AuthorizationCode( + AuthorizationCode::with_refresh_url( + "https://localhost/authorization/token", + "https://localhost/token/url", + Scopes::from_iter([ + ("edit:items", "edit my items"), + ("read:items", "read my items") + ]), + "https://localhost/refresh/token" + ), + )]) + ); + r###"{ + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://localhost/authorization/token", + "tokenUrl": "https://localhost/token/url", + "refreshUrl": "https://localhost/refresh/token", + "scopes": { + "edit:items": "edit my items", + "read:items": "read my items" + } + } + } +}"### + } + + test_fn! { + security_scheme_correct_oauth2_authorization_code_no_scopes: + SecurityScheme::OAuth2( + OAuth2::new([Flow::AuthorizationCode( + AuthorizationCode::with_refresh_url( + "https://localhost/authorization/token", + "https://localhost/token/url", + Scopes::new(), + "https://localhost/refresh/token" + ), + )]) + ); + r###"{ + "type": "oauth2", + "flows": { + "authorizationCode": { + "authorizationUrl": "https://localhost/authorization/token", + "tokenUrl": "https://localhost/token/url", + "refreshUrl": "https://localhost/refresh/token", + "scopes": {} + } + } +}"### + } + + test_fn! { + security_scheme_correct_mutual_tls: + SecurityScheme::MutualTls { + description: Some(String::from("authorization is performed with client side certificate")), + extensions: None, + }; + r###"{ + "type": "mutualTLS", + "description": "authorization is performed with client side certificate" +}"### + } +} diff --git a/fastapi/src/openapi/server.rs b/fastapi/src/openapi/server.rs new file mode 100644 index 0000000..0481024 --- /dev/null +++ b/fastapi/src/openapi/server.rs @@ -0,0 +1,262 @@ +//! Implements [OpenAPI Server Object][server] types to configure target servers. +//! +//! OpenAPI will implicitly add [`Server`] with `url = "/"` to [`OpenApi`][openapi] when no servers +//! are defined. +//! +//! [`Server`] can be used to alter connection url for _**path operations**_. It can be a +//! relative path e.g `/api/v1` or valid http url e.g. `http://alternative.api.com/api/v1`. +//! +//! Relative path will append to the **sever address** so the connection url for _**path operations**_ +//! will become `server address + relative path`. +//! +//! Optionally it also supports parameter substitution with `{variable}` syntax. +//! +//! See [`Modify`][modify] trait for details how add servers to [`OpenApi`][openapi]. +//! +//! # Examples +//! +//! Create new server with relative path. +//! ```rust +//! # use fastapi::openapi::server::Server; +//! Server::new("/api/v1"); +//! ``` +//! +//! Create server with custom url using a builder. +//! ```rust +//! # use fastapi::openapi::server::ServerBuilder; +//! ServerBuilder::new().url("https://alternative.api.url.test/api").build(); +//! ``` +//! +//! Create server with builder and variable substitution. +//! ```rust +//! # use fastapi::openapi::server::{ServerBuilder, ServerVariableBuilder}; +//! ServerBuilder::new().url("/api/{version}/{username}") +//! .parameter("version", ServerVariableBuilder::new() +//! .enum_values(Some(["v1", "v2"])) +//! .default_value("v1")) +//! .parameter("username", ServerVariableBuilder::new() +//! .default_value("the_user")).build(); +//! ``` +//! +//! [server]: https://spec.openapis.org/oas/latest.html#server-object +//! [openapi]: ../struct.OpenApi.html +//! [modify]: ../../trait.Modify.html +use std::{collections::BTreeMap, iter}; + +use serde::{Deserialize, Serialize}; + +use super::extensions::Extensions; +use super::{builder, set_value}; + +builder! { + ServerBuilder; + + /// Represents target server object. It can be used to alter server connection for + /// _**path operations**_. + /// + /// By default OpenAPI will implicitly implement [`Server`] with `url = "/"` if no servers is provided to + /// the [`OpenApi`][openapi]. + /// + /// [openapi]: ../struct.OpenApi.html + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Server { + /// Target url of the [`Server`]. It can be valid http url or relative path. + /// + /// Url also supports variable substitution with `{variable}` syntax. The substitutions + /// then can be configured with [`Server::variables`] map. + pub url: String, + + /// Optional description describing the target server url. Description supports markdown syntax. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Optional map of variable name and its substitution value used in [`Server::url`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub variables: Option>, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Server { + /// Construct a new [`Server`] with given url. Url can be valid http url or context path of the url. + /// + /// If url is valid http url then all path operation request's will be forwarded to the selected [`Server`]. + /// + /// If url is path of url e.g. `/api/v1` then the url will be appended to the servers address and the + /// operations will be forwarded to location `server address + url`. + /// + /// + /// # Examples + /// + /// Create new server with url path. + /// ```rust + /// # use fastapi::openapi::server::Server; + /// Server::new("/api/v1"); + /// ``` + /// + /// Create new server with alternative server. + /// ```rust + /// # use fastapi::openapi::server::Server; + /// Server::new("https://alternative.pet-api.test/api/v1"); + /// ``` + pub fn new>(url: S) -> Self { + Self { + url: url.into(), + ..Default::default() + } + } +} + +impl ServerBuilder { + /// Add url to the target [`Server`]. + pub fn url>(mut self, url: U) -> Self { + set_value!(self url url.into()) + } + + /// Add or change description of the [`Server`]. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add parameter to [`Server`] which is used to substitute values in [`Server::url`]. + /// + /// * `name` Defines name of the parameter which is being substituted within the url. If url has + /// `{username}` substitution then the name should be `username`. + /// * `parameter` Use [`ServerVariableBuilder`] to define how the parameter is being substituted + /// within the url. + pub fn parameter, V: Into>( + mut self, + name: N, + variable: V, + ) -> Self { + match self.variables { + Some(ref mut variables) => { + variables.insert(name.into(), variable.into()); + } + None => { + self.variables = Some(BTreeMap::from_iter(iter::once(( + name.into(), + variable.into(), + )))) + } + } + + self + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +builder! { + ServerVariableBuilder; + + /// Implements [OpenAPI Server Variable][server_variable] used to substitute variables in [`Server::url`]. + /// + /// [server_variable]: https://spec.openapis.org/oas/latest.html#server-variable-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct ServerVariable { + /// Default value used to substitute parameter if no other value is being provided. + #[serde(rename = "default")] + pub default_value: String, + + /// Optional description describing the variable of substitution. Markdown syntax is supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Enum values can be used to limit possible options for substitution. If enum values is used + /// the [`ServerVariable::default_value`] must contain one of the enum values. + #[serde(rename = "enum", skip_serializing_if = "Option::is_none")] + pub enum_values: Option>, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl ServerVariableBuilder { + /// Add default value for substitution. + pub fn default_value>(mut self, default_value: S) -> Self { + set_value!(self default_value default_value.into()) + } + + /// Add or change description of substituted parameter. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add or change possible values used to substitute parameter. + pub fn enum_values, V: Into>( + mut self, + enum_values: Option, + ) -> Self { + set_value!(self enum_values enum_values + .map(|enum_values| enum_values.into_iter().map(|value| value.into()).collect())) + } + + /// Add openapi extensions (x-something) of the API. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + macro_rules! test_fn { + ($name:ident: $schema:expr; $expected:literal) => { + #[test] + fn $name() { + let value = serde_json::to_value($schema).unwrap(); + let expected_value: serde_json::Value = serde_json::from_str($expected).unwrap(); + + assert_eq!( + value, + expected_value, + "testing serializing \"{}\": \nactual:\n{}\nexpected:\n{}", + stringify!($name), + value, + expected_value + ); + + println!("{}", &serde_json::to_string_pretty(&$schema).unwrap()); + } + }; + } + + test_fn! { + create_server_with_builder_and_variable_substitution: + ServerBuilder::new().url("/api/{version}/{username}") + .parameter("version", ServerVariableBuilder::new() + .enum_values(Some(["v1", "v2"])) + .description(Some("api version")) + .default_value("v1")) + .parameter("username", ServerVariableBuilder::new() + .default_value("the_user")).build(); + r###"{ + "url": "/api/{version}/{username}", + "variables": { + "version": { + "enum": ["v1", "v2"], + "default": "v1", + "description": "api version" + }, + "username": { + "default": "the_user" + } + } +}"### + } +} diff --git a/fastapi/src/openapi/tag.rs b/fastapi/src/openapi/tag.rs new file mode 100644 index 0000000..9ea2d18 --- /dev/null +++ b/fastapi/src/openapi/tag.rs @@ -0,0 +1,68 @@ +//! Implements [OpenAPI Tag Object][tag] types. +//! +//! [tag]: https://spec.openapis.org/oas/latest.html#tag-object +use serde::{Deserialize, Serialize}; + +use super::{builder, extensions::Extensions, external_docs::ExternalDocs, set_value}; + +builder! { + TagBuilder; + + /// Implements [OpenAPI Tag Object][tag]. + /// + /// Tag can be used to provide additional metadata for tags used by path operations. + /// + /// [tag]: https://spec.openapis.org/oas/latest.html#tag-object + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + #[serde(rename_all = "camelCase")] + pub struct Tag { + /// Name of the tag. Should match to tag of **operation**. + pub name: String, + + /// Additional description for the tag shown in the document. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + + /// Additional external documentation for the tag. + #[serde(skip_serializing_if = "Option::is_none")] + pub external_docs: Option, + + /// Optional extensions "x-something". + #[serde(skip_serializing_if = "Option::is_none", flatten)] + pub extensions: Option, + } +} + +impl Tag { + /// Construct a new [`Tag`] with given name. + pub fn new>(name: S) -> Self { + Self { + name: name.as_ref().to_string(), + ..Default::default() + } + } +} + +impl TagBuilder { + /// Add name of the tag. + pub fn name>(mut self, name: I) -> Self { + set_value!(self name name.into()) + } + + /// Add additional description for the tag. + pub fn description>(mut self, description: Option) -> Self { + set_value!(self description description.map(|description| description.into())) + } + + /// Add additional external documentation for the tag. + pub fn external_docs(mut self, external_docs: Option) -> Self { + set_value!(self external_docs external_docs) + } + + /// Add openapi extensions (x-something) to the tag. + pub fn extensions(mut self, extensions: Option) -> Self { + set_value!(self extensions extensions) + } +} diff --git a/fastapi/src/openapi/testdata/expected_openapi_minimal.json b/fastapi/src/openapi/testdata/expected_openapi_minimal.json new file mode 100644 index 0000000..ce3fccb --- /dev/null +++ b/fastapi/src/openapi/testdata/expected_openapi_minimal.json @@ -0,0 +1,13 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "My api", + "description": "My api description", + "license": { + "name": "MIT", + "url": "http://mit.licence" + }, + "version": "1.0.0" + }, + "paths": {} +} \ No newline at end of file diff --git a/fastapi/src/openapi/testdata/expected_openapi_with_paths.json b/fastapi/src/openapi/testdata/expected_openapi_with_paths.json new file mode 100644 index 0000000..c18ab68 --- /dev/null +++ b/fastapi/src/openapi/testdata/expected_openapi_with_paths.json @@ -0,0 +1,34 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "My big api", + "version": "1.1.0" + }, + "paths": { + "/api/v1/users": { + "get": { + "responses": { + "200": { + "description": "Get users list" + } + } + }, + "post": { + "responses": { + "200": { + "description": "Post new user" + } + } + } + }, + "/api/v1/users/{id}": { + "get": { + "responses": { + "200": { + "description": "Get user by id" + } + } + } + } + } +} \ No newline at end of file diff --git a/fastapi/src/openapi/xml.rs b/fastapi/src/openapi/xml.rs new file mode 100644 index 0000000..a54b0a2 --- /dev/null +++ b/fastapi/src/openapi/xml.rs @@ -0,0 +1,119 @@ +//! Implements [OpenAPI Xml Object][xml_object] types. +//! +//! [xml_object]: https://spec.openapis.org/oas/latest.html#xml-object +use std::borrow::Cow; + +use serde::{Deserialize, Serialize}; + +use super::{builder, set_value}; + +builder! { + /// # Examples + /// + /// Create [`Xml`] with [`XmlBuilder`]. + /// ```rust + /// # use fastapi::openapi::xml::XmlBuilder; + /// let xml = XmlBuilder::new() + /// .name(Some("some_name")) + /// .prefix(Some("prefix")) + /// .build(); + /// ``` + XmlBuilder; + /// Implements [OpenAPI Xml Object][xml_object]. + /// + /// Can be used to modify xml output format of specific [OpenAPI Schema Object][schema_object] which are + /// implemented in [`schema`][schema] module. + /// + /// [xml_object]: https://spec.openapis.org/oas/latest.html#xml-object + /// [schema_object]: https://spec.openapis.org/oas/latest.html#schema-object + /// [schema]: ../schema/index.html + #[non_exhaustive] + #[derive(Serialize, Deserialize, Default, Clone, PartialEq, Eq)] + #[cfg_attr(feature = "debug", derive(Debug))] + pub struct Xml { + /// Used to replace the name of attribute or type used in schema property. + /// When used with [`Xml::wrapped`] attribute the name will be used as a wrapper name + /// for wrapped array instead of the item or type name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option>, + + /// Valid uri definition of namespace used in xml. + #[serde(skip_serializing_if = "Option::is_none")] + pub namespace: Option>, + + /// Prefix for xml element [`Xml::name`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub prefix: Option>, + + /// Flag deciding will this attribute translate to element attribute instead of xml element. + #[serde(skip_serializing_if = "Option::is_none")] + pub attribute: Option, + + /// Flag only usable with array definition. If set to true the output xml will wrap the array of items + /// `` instead of unwrapped ``. + #[serde(skip_serializing_if = "Option::is_none")] + pub wrapped: Option, + } +} + +impl Xml { + /// Construct a new [`Xml`] object. + pub fn new() -> Self { + Self { + ..Default::default() + } + } +} + +impl XmlBuilder { + /// Add [`Xml::name`] to xml object. + /// + /// Builder style chainable consuming add name method. + pub fn name>>(mut self, name: Option) -> Self { + set_value!(self name name.map(|name| name.into())) + } + + /// Add [`Xml::namespace`] to xml object. + /// + /// Builder style chainable consuming add namespace method. + pub fn namespace>>(mut self, namespace: Option) -> Self { + set_value!(self namespace namespace.map(|namespace| namespace.into())) + } + + /// Add [`Xml::prefix`] to xml object. + /// + /// Builder style chainable consuming add prefix method. + pub fn prefix>>(mut self, prefix: Option) -> Self { + set_value!(self prefix prefix.map(|prefix| prefix.into())) + } + + /// Mark [`Xml`] object as attribute. See [`Xml::attribute`]. + /// + /// Builder style chainable consuming add attribute method. + pub fn attribute(mut self, attribute: Option) -> Self { + set_value!(self attribute attribute) + } + + /// Mark [`Xml`] object wrapped. See [`Xml::wrapped`]. + /// + /// Builder style chainable consuming add wrapped method. + pub fn wrapped(mut self, wrapped: Option) -> Self { + set_value!(self wrapped wrapped) + } +} + +#[cfg(test)] +mod tests { + use super::Xml; + + #[test] + fn xml_new() { + let xml = Xml::new(); + + assert!(xml.name.is_none()); + assert!(xml.namespace.is_none()); + assert!(xml.prefix.is_none()); + assert!(xml.attribute.is_none()); + assert!(xml.wrapped.is_none()); + } +} diff --git a/scripts/coverage.sh b/scripts/coverage.sh old mode 100644 new mode 100755 diff --git a/scripts/doc.sh b/scripts/doc.sh old mode 100644 new mode 100755 diff --git a/scripts/test.sh b/scripts/test.sh old mode 100644 new mode 100755 diff --git a/scripts/update-swagger-ui.sh b/scripts/update-swagger-ui.sh old mode 100644 new mode 100755 diff --git a/scripts/validate-examples.sh b/scripts/validate-examples.sh old mode 100644 new mode 100755