├── .github ├── FUNDING.yml └── workflows │ ├── cd.yml │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── docs └── logo.svg ├── examples ├── github-create-release.yaml ├── github-upload-release-asset.yaml ├── postman-echo-delete.yaml ├── postman-echo-get.yaml ├── postman-echo-patch.yaml ├── postman-echo-post-file.yaml ├── postman-echo-post.yaml ├── postman-echo-put.yaml ├── postman-echo-stream.yaml ├── request.yaml ├── request_html.yaml ├── request_with_advanced_template.yaml ├── request_with_env.yaml ├── request_with_file.yaml ├── request_with_params.yaml ├── request_with_query_params.yaml └── request_yaml.yaml ├── rustfmt.toml └── src ├── build_args.rs ├── dialog.rs ├── display.rs ├── editor.rs ├── execute.rs ├── http_utils.rs ├── import.rs ├── main.rs ├── manifests ├── config.rs └── mod.rs ├── match_params.rs ├── match_prompts.rs ├── progress_component.rs ├── requests.rs ├── template.rs └── validators.rs /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [ecyrbe] 4 | custom: ["https://www.paypal.me/ecyrbe"] 5 | -------------------------------------------------------------------------------- /.github/workflows/cd.yml: -------------------------------------------------------------------------------- 1 | name: CD 2 | on: 3 | push: 4 | tags: 5 | - v* 6 | jobs: 7 | release: 8 | name: build linux 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | job: 13 | - os: ubuntu-latest 14 | target: x86_64-unknown-linux-gnu 15 | outputs: 16 | release: ${{ fromJson(steps.release.outputs.release).id }} 17 | env: 18 | ASSET: apix-${{github.ref_name}}-${{ matrix.job.target }}.tar.gz 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | runs-on: ${{ matrix.job.os }} 21 | steps: 22 | - name: checkout 23 | uses: actions/checkout@v2 24 | with: 25 | fetch-depth: 0 26 | - name: install toolchain 27 | uses: actions-rs/toolchain@v1 28 | with: 29 | toolchain: stable 30 | target: ${{ matrix.job.target }} 31 | profile: minimal 32 | default: true 33 | - name: install git-cliff 34 | uses: actions-rs/cargo@v1 35 | with: 36 | command: install 37 | args: git-cliff 38 | - name: build apix 39 | uses: actions-rs/cargo@v1 40 | env: 41 | RUSTFLAGS: -C link-arg=-s 42 | with: 43 | command: build 44 | args: --release 45 | # eat your own food. use apix to create a release 46 | - name: create release 47 | id: release 48 | run: |- 49 | git-cliff -l > changelog.txt 50 | export CHANGELOG=`cat changelog.txt` 51 | chmod +x target/release/apix 52 | cd examples 53 | ../target/release/apix exec github-create-release > release.json 54 | content=`cat release.json` 55 | content="${content//'%'/'%25'}" 56 | content="${content//$'\n'/'%0A'}" 57 | content="${content//$'\r'/'%0D'}" 58 | echo "::set-output name=release::$content" 59 | - name: create tarball 60 | run: |- 61 | cp README.md target/release/README.md 62 | cp LICENSE target/release/LICENSE 63 | cd target/release 64 | tar -zcvf ${{ env.ASSET }} apix LICENSE README.md 65 | - name: upload tarball 66 | env: 67 | RELEASE_ID: ${{ fromJson(steps.release.outputs.release).id }} 68 | run: |- 69 | cd examples 70 | ../target/release/apix exec github-upload-release-asset 71 | build: 72 | strategy: 73 | fail-fast: false 74 | matrix: 75 | job: 76 | - os: windows-latest 77 | target: x86_64-pc-windows-gnu 78 | ext: .zip 79 | compress: 7z -y a 80 | apix: apix.exe 81 | - os: windows-latest 82 | target: x86_64-pc-windows-msvc 83 | ext: .zip 84 | compress: 7z -y a 85 | apix: apix.exe 86 | - os: macos-latest 87 | target: x86_64-apple-darwin 88 | ext: .tar.gz 89 | compress: tar -zcvf 90 | apix: apix 91 | runs-on: ${{ matrix.job.os }} 92 | needs: 93 | - release 94 | env: 95 | ASSET: apix-${{github.ref_name}}-${{ matrix.job.target }}${{ matrix.job.ext }} 96 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 97 | steps: 98 | - name: checkout 99 | uses: actions/checkout@v2 100 | - name: install toolchain 101 | uses: actions-rs/toolchain@v1 102 | with: 103 | toolchain: stable 104 | profile: minimal 105 | target: ${{ matrix.job.target }} 106 | default: true 107 | - name: build apix 108 | uses: actions-rs/cargo@v1 109 | env: 110 | RUSTFLAGS: -C link-arg=-s 111 | with: 112 | command: build 113 | args: --release 114 | - name: create artifact 115 | run: |- 116 | cp README.md target/release/README.md 117 | cp LICENSE target/release/LICENSE 118 | cd target/release 119 | chmod +x ${{ matrix.job.apix }} 120 | ${{ matrix.job.compress }} ${{ env.ASSET}} ${{ matrix.job.apix }} LICENSE README.md 121 | - name: upload artifact 122 | env: 123 | RELEASE_ID: ${{ needs.release.outputs.release }} 124 | run: |- 125 | cd examples 126 | ../target/release/${{ matrix.job.apix }} exec github-upload-release-asset 127 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | paths: 7 | - src/** 8 | - Cargo.lock 9 | - Cargo.toml 10 | pull_request: 11 | branches: 12 | - main 13 | paths: 14 | - src/** 15 | - Cargo.lock 16 | - Cargo.toml 17 | env: 18 | CARGO_TERM_COLOR: always 19 | jobs: 20 | ci: 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | config: 25 | - do: check format clippy test 26 | os: ubuntu-latest 27 | - do: check test 28 | os: windows-latest 29 | - do: check test 30 | os: macos-latest 31 | runs-on: ${{ matrix.config.os }} 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v2 35 | - name: install toolchain 36 | uses: actions-rs/toolchain@v1 37 | with: 38 | toolchain: stable 39 | profile: minimal 40 | components: clippy, rustfmt 41 | default: true 42 | - name: check code 43 | if: contains(matrix.config.do, 'check') 44 | uses: actions-rs/cargo@v1 45 | with: 46 | command: check 47 | - name: check format 48 | if: contains(matrix.config.do, 'format') 49 | uses: actions-rs/cargo@v1 50 | with: 51 | command: fmt 52 | args: --all -- --check 53 | - name: static code analysis 54 | if: contains(matrix.config.do, 'clippy') 55 | uses: actions-rs/cargo@v1 56 | with: 57 | command: clippy 58 | args: --all -- -D warnings 59 | - name: test 60 | if: contains(matrix.config.do, 'test') 61 | uses: actions-rs/cargo@v1 62 | with: 63 | command: test 64 | args: --verbose 65 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Generated by Cargo 2 | # will have compiled files and executables 3 | /target/ 4 | 5 | # These are backup files generated by rustfmt 6 | **/*.rs.bk 7 | /open-api*/ 8 | 9 | # Added by cargo 10 | 11 | /target 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | ## [unreleased] 5 | 6 | ### Documentation 7 | 8 | - Add proxy documentation 9 | 10 | ### Features 11 | 12 | - Add output file option to metadata 13 | - Add proxy support 14 | - Add more args to requests without impl 15 | 16 | ### Miscellaneous Tasks 17 | 18 | - Add lock 19 | - Only run ci if code or deps have changed 20 | 21 | ## [0.5.2.alpha] - 2022-01-15 22 | 23 | ### Bug Fixes 24 | 25 | - Use cargo action to install git cliff 26 | - Support multiline 27 | - Export changelog env 28 | 29 | ### Miscellaneous Tasks 30 | 31 | - Auto generate changelog 32 | - Fetch all tags 33 | 34 | ## [0.5.1.alpha] - 2022-01-15 35 | 36 | ### Miscellaneous Tasks 37 | 38 | - Strip binary 39 | 40 | ## [0.5.0.alpha] - 2022-01-15 41 | 42 | ### Bug Fixes 43 | 44 | - Use binary mode when no content type in response 45 | 46 | ### Features 47 | 48 | - Add separator and format json response 49 | - Allow to pipe output when downloading files 50 | - Add output file option to override default output 51 | 52 | ### Miscellaneous Tasks 53 | 54 | - Add postman echo examples 55 | - Bump version of many dependencies 56 | - Bump to version 0.5.0.alpha 57 | 58 | ### Refactor 59 | 60 | - Use option body 61 | 62 | ### Refeactor 63 | 64 | - Put args description in it's own file 65 | 66 | ## [0.4.3.alpha] - 2022-01-14 67 | 68 | ### Refactor 69 | 70 | - Declare trait for matching clap params 71 | 72 | ## [0.4.2.alpha] - 2022-01-14 73 | 74 | ### Bug Fixes 75 | 76 | - Use env var directly since on windows syntax is different 77 | 78 | ## [0.4.1.alpha] - 2022-01-14 79 | 80 | ### Bug Fixes 81 | 82 | - Remove duplicated env 83 | 84 | ## [0.4.0.alpha] - 2022-01-14 85 | 86 | ### Bug Fixes 87 | 88 | - Use env github token 89 | 90 | ### Features 91 | 92 | - Allow params to also be passed in command line 93 | 94 | ### Miscellaneous Tasks 95 | 96 | - Update workflow to use exec instead of post 97 | - Fix env 98 | 99 | ## [0.3.1] - 2022-01-14 100 | 101 | ### Bug Fixes 102 | 103 | - Use needs to share output betwwen jobs 104 | - Store share output betwwen jobs 105 | - Always upload artifact 106 | 107 | ### Miscellaneous Tasks 108 | 109 | - Add windows and macos builds 110 | - Fix build 111 | 112 | ## [0.3.1.alpha5] - 2022-01-13 113 | 114 | ### Bug Fixes 115 | 116 | - Add content-length for files 117 | 118 | ## [0.3.1.alpha4] - 2022-01-13 119 | 120 | ### Bug Fixes 121 | 122 | - Do not upload all build directory 123 | 124 | ## [0.3.1.alpha3] - 2022-01-13 125 | 126 | ### Bug Fixes 127 | 128 | - Use github template syntax 129 | 130 | ## [0.3.1.alpha2] - 2022-01-13 131 | 132 | ### Bug Fixes 133 | 134 | - Add readme.md not release.md 135 | 136 | ## [0.3.1.alpha1] - 2022-01-13 137 | 138 | ### Bug Fixes 139 | 140 | - Use options parameters 141 | 142 | ### Features 143 | 144 | - Auto detect pipe to disable coloring 145 | 146 | ### Miscellaneous Tasks 147 | 148 | - Add release upload 149 | 150 | ## [0.3.0-test] - 2022-01-12 151 | 152 | ### Bug Fixes 153 | 154 | - Clippy 155 | - Automatic fix 156 | - Fix clippy errors 157 | 158 | ### Documentation 159 | 160 | - Update readme 161 | 162 | ### Features 163 | 164 | - Impl basic get resource 165 | - Use clock emoji for spinners 166 | 167 | ### Miscellaneous Tasks 168 | 169 | - Improve pipeline 170 | - Fix pipeline 171 | - Add github create release example 172 | - Add create release workflow 173 | 174 | ## [0.3.0] - 2022-01-09 175 | 176 | ### Bug Fixes 177 | 178 | - Use match for set 179 | - Use anyhow everywhere 180 | - Better error display 181 | - Use new arg macro 182 | - Fix print bug when body result is empty 183 | - Upload bar orverride fix 184 | - Add progress component to main 185 | 186 | ### Documentation 187 | 188 | - Add some basic docs 189 | - Update readme 190 | - Update readme 191 | 192 | ### Features 193 | 194 | - Add basic config, theming and requests 195 | - Implement full configuration management 196 | - Improve output 197 | - Use default headers 198 | - Use singleton config 199 | - Improve manifests 200 | - Add support for tera templates 201 | - Add parameter input in terminal 202 | - Add support for process environnement variables 203 | - Context can use templates 204 | - Add support for default values from schema 205 | - Remove definitions 206 | - Set theme back to bat default 207 | - Implement chunk based file upload 208 | - Add upload progressbar 209 | - Add support for downloading files 210 | - Show percent file transfert 211 | - Replace static lazy with once cell 212 | - Add support for queries 213 | - Add dialog for creating requests 214 | - Add dynamic prompts for creating requests 215 | - Add a request editor 216 | - Allow to execute request by name 217 | 218 | ### Miscellaneous Tasks 219 | 220 | - Add disclaimer 221 | - Update clap 222 | - Update .gitignore 223 | - Disable imports 224 | - Add basic examples 225 | - Reorder deps 226 | - Add more examples 227 | - Add yaml example 228 | - Add ci 229 | - Add badges 230 | - Add logo 231 | - Add logo to readme 232 | - Update logo 233 | - Update readme 234 | - Improve readme 235 | - Bump version 236 | 237 | ### Refactor 238 | 239 | - Split cli into multiple files 240 | - Split request 241 | - Split config manifest 242 | - Use inline destructuring 243 | - Refactor execution 244 | - Split execute request 245 | - Rename and split request exec 246 | - Use filter to convert string to option 247 | - Split logic into upload/download component 248 | - Split progress bar in it's own file 249 | 250 | ### Styling 251 | 252 | - Convert to 2 space tabs 253 | 254 | ### Testing 255 | 256 | - Add tests for config 257 | - Add tests for languages 258 | - Use mocks for language tests 259 | - DRY tests 260 | - Use test_case library for improved DRY tests 261 | - Add tests 262 | - Add render tests 263 | 264 | 265 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | ecyrbe@gmail.com. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "apix" 3 | version = "0.6.0" 4 | edition = "2021" 5 | 6 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 7 | 8 | [dependencies] 9 | anyhow = "1.0.52" 10 | atty = "0.2.14" 11 | bat = "0.18.3" 12 | clap = { version = "3.0.7", features=["regex", "cargo"] } 13 | clap_complete = "3.0.3" 14 | cmd_lib = "1.3.0" 15 | chrono = "0.4.19" 16 | comfy-table = "5.0.0" 17 | dialoguer = "0.9.0" 18 | dirs = "4.0.0" 19 | futures = "0.3.19" 20 | indexmap = { version = "1.8.0", features=["serde"]} 21 | indicatif = "0.16.2" 22 | jsonschema = "0.13.3" 23 | once_cell = "1.9.0" 24 | regex = "1.5.4" 25 | reqwest = { version="0.11.9", features = ["gzip","json","socks","stream"] } 26 | serde = "1.0.133" 27 | serde_json = { version = "1.0.74", features=["preserve_order"] } 28 | serde_yaml = "0.8.23" 29 | strum = "0.23.0" 30 | strum_macros = "0.23.1" 31 | term_size = "0.3.2" 32 | tokio = { version = "1.15.0", features = ["full"] } 33 | tokio-util = { version = "0.6.9", features = ["full"] } 34 | tera = "1.15.0" 35 | url = "2.2.2" 36 | whoami = "1.2.1" 37 | 38 | [dev-dependencies] 39 | test-case = "1.2.1" 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 ecyrbe 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Apix logo 4 | 5 |

6 | 7 |

8 | APIX is a modern HTTP client for the command line. 9 |

10 | 11 |

12 | langue rust 13 | build status 14 | code size 15 | 16 | MIT license 17 | 18 |

19 | 20 | ```yaml 21 | 🚨 WARNING: 🚧 Apix is still in alpha/proof of concept state! 🚧 22 | ``` 23 | 24 | Apix brings ideas from tools like `Git`,`Kubernetes`, `Helm` ,`Httpie`. 25 | Indeed it's is not just a simple HTTP client, Apix is : 26 | - **Pretty** as it uses [Bat](https://github.com/sharkdp/bat) to pretty print requests and responses 27 | ```bash 28 | > apix get https://apix.io/json 29 | { 30 | "id": 0, 31 | "test": "hello" 32 | } 33 | ``` 34 | - **Beatifull** as it uses [Indicatif](https://docs.rs/indicatif/latest/indicatif/index.html) to show modern command line progress bars when uploading or downloading files 35 | ```bash 36 | > apix get https://apix.io/test.mp4 37 | Downloading File test.mp4 38 | 🕘 [00:00:28] [████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░] 14.98MiB/298.06MiB (549.87KiB/s, 8m) 39 | ``` 40 | - **Friendly** to use as it uses [Dialoguer](https://docs.rs/dialoguer/latest/dialoguer/index.html) for interactive prompt to guide you when creating requests or executing them 41 | ```bash 42 | > apix exec -f request.yaml 43 | ✔ todoId · 1 44 | ? email ("ecyrbe@gmail.com") › bad\gmail.com 45 | ✘ Invalid input: 46 | cause 0: "bad\gmail.com" is not a "email" 47 | ``` 48 | - **Powerfull** as it uses [Tera](https://tera.netlify.app/) template engine to allow your requests to do complex things if you want them to (see [examples](/examples)) 49 | - **Easy** to use in the command line as it uses [Clap](https://docs.rs/clap/latest/clap/) autocompletion 50 | - **Reusable** as it stores your requests in your file system in a readable format for later use (yaml) 51 | - **Helping you not forget** as it stores the response of the requests so you can consult them at any time 52 | - **Helping you test APIs** as it allows you to create request stories (chain requests, use results of a request to make another one) 53 | - **Team player** as it allows you to naturally share your request collections with git 54 | 55 | Coming soon: 56 | - **Secure** as it handles secrets stored in hashi corp vault 57 | - **Enterprise friend** as it allows you to import OpenAPI definition files into apix requests files 58 | 59 | ## help 60 | 61 | ```bash 62 | apix 0.3.0 63 | 64 | 65 | USAGE: 66 | apix [OPTIONS] 67 | 68 | OPTIONS: 69 | -h, --help Print help information 70 | -v, --verbose print full request and response 71 | -V, --version Print version information 72 | 73 | SUBCOMMANDS: 74 | completions generate shell completions 75 | config configuration settings 76 | ctl apix control interface for handling multiple APIs 77 | delete delete an http resource 78 | exec execute a request from the current API context 79 | get get an http resource 80 | head get an http resource header 81 | help Print this message or the help of the given subcommand(s) 82 | history show history of requests sent (require project) 83 | init initialise a new API context in the current directory by using git 84 | patch patch an http resource 85 | post post to an http resource 86 | put put to an http resource 87 | ``` 88 | 89 | ## make simple http requests 90 | 91 | Even if Apix allows you to use advanced mode by coupling it to a git repository and interpret openapi declarations (swagger), you also can use Apix as a replacement for curl, wget, httpie ... 92 | 93 | Apix will colorize the output according to http **content-type** header information. 94 | 95 | By default Apix will assume you are doing an API request using json. 96 | 97 | ```bash 98 | > apix get https://jsonplaceholder.typicode.com/todos?_limit=1 99 | or 100 | > apix get https://jsonplaceholder.typicode.com/todos --query _limit:1 101 | 102 | [ 103 | { 104 | "userId": 1, 105 | "id": 1, 106 | "title": "delectus aut autem", 107 | "completed": false 108 | } 109 | ] 110 | ``` 111 | you can also ask for verbose mode where apix will show you the full sended http request and response : 112 | ```bash 113 | > apix get -v https://jsonplaceholder.typicode.com/todos -q_limit:1 114 | GET /todos?_limit=3 HTTP/1.1 115 | host: jsonplaceholder.typicode.com 116 | user-agent: apix/0.1.0 117 | accept: application/json 118 | accept-encoding: gzip 119 | content-type: application/json 120 | 121 | HTTP/1.1 200 OK 122 | date: Sun, 21 Nov 2021 17:29:22 GMT 123 | content-type: application/json; charset=utf-8 124 | transfer-encoding: chunked 125 | connection: keep-alive 126 | access-control-allow-credentials: true 127 | cache-control: max-age=43200 128 | pragma: no-cache 129 | expires: -1 130 | etag: W/"136-fTr038fftlG9yIOWHGimupdrQDg" 131 | 132 | [ 133 | { 134 | "userId": 1, 135 | "id": 1, 136 | "title": "delectus aut autem", 137 | "completed": false 138 | } 139 | ] 140 | ``` 141 | 142 | ## Context 143 | 144 | Apix handle contexts gracefully. Contexts are named resources to handle: 145 | - API bindings (based on openAPI) 146 | - Session authentification 147 | - Session cookies 148 | - variables bindings that can be used with templates 149 | 150 | ### Create a new context 151 | 152 | ```bash 153 | apix ctl init MyContext 154 | ``` 155 | 156 | ### Switching to another context 157 | 158 | ```bash 159 | apix ctl switch OtherContext 160 | ``` 161 | 162 | ### List all contexts 163 | 164 | ```bash 165 | apix ctl get contexts 166 | ``` 167 | 168 | ### Delete a context 169 | 170 | ```bash 171 | apix ctl delete MyContext 172 | ``` 173 | 174 | ## apix commands 175 | 176 | ### apix get 177 | 178 | ```bash 179 | apix-get 180 | 181 | get an http resource 182 | 183 | USAGE: 184 | apix get [OPTIONS] 185 | 186 | ARGS: 187 | url to request, can be a 'Tera' template 188 | 189 | OPTIONS: 190 | -b, --body set body to send with request, can be a 'Tera' template 191 | -c, --cookie set cookie name:value to send with request 192 | -e, --env set variable name:value for 'Tera' template rendering 193 | -f, --file set body from file to send with request, can be a 'Tera' template 194 | -h, --help Print help information 195 | -H, --header
set header name:value to send with request 196 | -i, --insecure allow insecure connections when using https 197 | -q, --query set query name:value to send with request 198 | -v, --verbose print full request and response 199 | ``` 200 | ## Proxy 201 | 202 | Apix uses system proxy by default. System proxy is taken from `HTTP_PROXY` and `HTTPS_PROXY` environment variables. 203 | You can ovverride system proxy manually, either with request annotations on manifest files or on the command line. 204 | Apix support `HTTP`, `HTTPS` and `SOCKS5` proxies. 205 | 206 | Here are the options available for configuring the proxy on the command line : 207 | ```bash 208 | -x, --proxy set proxy to use for request 209 | --proxy-login set proxy login to use for request 210 | --proxy-password set proxy password to use for request 211 | ``` 212 | Here are the options available for configuting the proxy on manifests 213 | ```yaml 214 | metadata: 215 | annotations: 216 | apix.io/proxy-url: 217 | apix.io/proxy-login: 218 | apix.io/proxy-password: 219 | ``` 220 | _example URL for proxies_: 221 | | HTTP | HTTPS | SOCKS5 | 222 | | --------------------- | ---------------------- | ----------------------- | 223 | | http://localhost:3128 | https://localhost:3128 | socks5://localhost:1080 | 224 | 225 | 226 | # Persistance 227 | 228 | | type | persist mode | gitignore | description | 229 | | :------: | :----------: | :-------: | :-------------------------------------: | 230 | | config | file | no | from cli config | 231 | | requests | file | no | 232 | | params | file | yes | auto saved from dialoguer | 233 | | results | file | yes | auto saved after execution | 234 | | cookies | file | yes | auto saved after execution, auto reused | 235 | | storage | file | yes | saved from request response | 236 | | secrets | web | N/A | from hashi corp vault | 237 | -------------------------------------------------------------------------------- /docs/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 16 | 36 | 38 | 42 | 50 | A 60 | 68 | P 78 | 86 | I 96 | 104 | X 114 | 115 | 116 | -------------------------------------------------------------------------------- /examples/github-create-release.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apix.io/v1 3 | metadata: 4 | name: github-create-release 5 | labels: 6 | app: apix 7 | apix.io/api: github 8 | annotations: 9 | apix.io/created-by: ecyrbe 10 | apix.io/created-at: "2022-01-12T20:00:09.830263999+00:00" 11 | kind: Request 12 | spec: 13 | request: 14 | method: POST 15 | url: "{{ env.GITHUB_API_URL }}/repos/{{ env.GITHUB_REPOSITORY }}/releases" 16 | headers: 17 | accept: application/vnd.github.v3+json 18 | authorization: Bearer {{ env.GITHUB_TOKEN }} 19 | body: 20 | tag_name: "{{ env.GITHUB_REF_NAME }}" 21 | target_commitish: "{{ env.GITHUB_SHA }}" 22 | name: "Release {{ env.GITHUB_REF_NAME }}" 23 | body: "{{ env.CHANGELOG }}" 24 | draft: false 25 | prerelease: false 26 | generate_release_notes: true 27 | -------------------------------------------------------------------------------- /examples/github-upload-release-asset.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apix.io/v1 3 | metadata: 4 | name: github-upload-release-asset 5 | labels: 6 | app: apix 7 | apix.io/api: github 8 | annotations: 9 | apix.io/created-by: ecyrbe 10 | apix.io/created-at: "2022-01-12T20:00:09.830263999+00:00" 11 | apix.io/body-file: "../target/release/{{ env.ASSET }}" 12 | kind: Request 13 | spec: 14 | request: 15 | method: POST 16 | url: https://uploads.github.com/repos/{{ env.GITHUB_REPOSITORY }}/releases/{{ env.RELEASE_ID }}/assets 17 | headers: 18 | accept: application/vnd.github.v3+json 19 | content-type: application/gzip 20 | authorization: Bearer {{ env.GITHUB_TOKEN }} 21 | queries: 22 | name: "{{ env.ASSET }}" 23 | -------------------------------------------------------------------------------- /examples/postman-echo-delete.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec postman-echo-post 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: postman-echo-delete 6 | kind: Request 7 | spec: 8 | request: 9 | method: delete 10 | url: https://postman-echo.com/delete 11 | headers: 12 | accept: application/json 13 | queries: 14 | param1: value1 15 | param2: value2 16 | -------------------------------------------------------------------------------- /examples/postman-echo-get.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec postman-echo-get 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: postman-echo-get 6 | kind: Request 7 | spec: 8 | request: 9 | method: get 10 | url: https://postman-echo.com/get 11 | headers: 12 | accept: application/json 13 | queries: 14 | param1: value1 15 | param2: value2 16 | -------------------------------------------------------------------------------- /examples/postman-echo-patch.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec postman-echo-patch 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: postman-echo-patch 6 | kind: Request 7 | spec: 8 | request: 9 | method: patch 10 | url: https://postman-echo.com/patch 11 | headers: 12 | accept: application/json 13 | queries: 14 | param1: value1 15 | param2: value2 16 | body: 17 | body1: value1 18 | body2: value2 19 | body3: 20 | - item1 21 | - item2 22 | -------------------------------------------------------------------------------- /examples/postman-echo-post-file.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec postman-echo-post-file 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: postman-echo-post-file 6 | annotations: 7 | apix.io/body-file: ../docs/logo.svg 8 | kind: Request 9 | spec: 10 | request: 11 | method: post 12 | url: https://postman-echo.com/post 13 | headers: 14 | accept: application/json 15 | content-type: image/svg+xml 16 | queries: 17 | param1: value1 18 | param2: value2 19 | -------------------------------------------------------------------------------- /examples/postman-echo-post.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec postman-echo-post 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: postman-echo-post 6 | kind: Request 7 | spec: 8 | request: 9 | method: post 10 | url: https://postman-echo.com/post 11 | headers: 12 | accept: application/json 13 | queries: 14 | param1: value1 15 | param2: value2 16 | body: 17 | body1: value1 18 | body2: value2 19 | body3: 20 | - item1 21 | - item2 22 | -------------------------------------------------------------------------------- /examples/postman-echo-put.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec postman-echo-post 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: postman-echo-put 6 | kind: Request 7 | spec: 8 | request: 9 | method: put 10 | url: https://postman-echo.com/put 11 | headers: 12 | accept: application/json 13 | queries: 14 | param1: value1 15 | param2: value2 16 | body: 17 | body1: value1 18 | body2: value2 19 | body3: 20 | - item1 21 | - item2 22 | -------------------------------------------------------------------------------- /examples/postman-echo-stream.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec postman-echo-stream 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: postman-echo-stream 6 | annotations: 7 | apix.io/created-by: ecyrbe 8 | apix.io/created-at: "2022-01-12T20:00:09.830263999+00:00" 9 | apix.io/output-file: stream.json 10 | kind: Request 11 | spec: 12 | request: 13 | method: get 14 | url: https://postman-echo.com/stream/100 15 | headers: 16 | accept: application/json 17 | queries: 18 | param1: value1 19 | param2: value2 20 | -------------------------------------------------------------------------------- /examples/request.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request 6 | annotations: 7 | apix.io/convert-body-string-to-json: "true" 8 | kind: Request 9 | spec: 10 | parameters: 11 | - name: todo 12 | required: true 13 | schema: 14 | type: integer 15 | default: 1 16 | - name: email 17 | required: true 18 | schema: 19 | type: string 20 | format: email 21 | default: ecyrbe@gmail.com 22 | - name: content 23 | required: true 24 | schema: 25 | type: object 26 | default: 27 | title: "My todo" 28 | description: "My todo description" 29 | done: false 30 | context: 31 | # you can create new variables in the context 32 | url: https://jsonplaceholder.typicode.com/todos 33 | # you can use variables from parameters in the context 34 | next: "{{ parameters.todo + 1 }}" 35 | # you can use variables from introspection in the context 36 | annotation: '{{ manifest.metadata.annotations["apix.io/convert-body-string-to-json"] }}' 37 | # you cannot use variables created in the context 38 | # error: "{{ next + 1 }}" # variable next is not defined in context itself 39 | request: 40 | method: post 41 | # you can use variables from the context 42 | url: "{{ context.url }}" 43 | headers: 44 | accept: application/json 45 | # you can use variables from the process environnement 46 | # you can use variables from the context 47 | # you can use variables from the parameters 48 | # you can use variables from the introspection 49 | # you can use variables from the tera only in scoped content (ie: variable declared in url can't be used in body) 50 | # you can use filters to transform the variable 51 | body: |- 52 | {% set my_var = "test" %} 53 | { 54 | "user": "{{ env.USERNAME }}", 55 | "todo": {{ parameters.todo }}, 56 | "next": {{ context.next }}, 57 | "hello": "{{ parameters.email }}", 58 | "annotation": "{{ context.annotation }}", 59 | "content": {{ parameters.content | json_encode }}, 60 | "test": "{{ my_var }}" 61 | } 62 | -------------------------------------------------------------------------------- /examples/request_html.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request_html 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request_html 6 | kind: Request 7 | spec: 8 | request: 9 | method: get 10 | url: https://google.com 11 | headers: 12 | accept: text/html 13 | -------------------------------------------------------------------------------- /examples/request_with_advanced_template.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request_with_advanced_template 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request_with_advanced_template 6 | annotations: 7 | apix.io/convert-body-string-to-json: "true" 8 | kind: Request 9 | spec: 10 | context: 11 | # you can create new variables in the context 12 | url: https://jsonplaceholder.typicode.com/users 13 | info: 14 | email: ecyrbe@gmail.com 15 | name: ecyrbe 16 | birthday: "01/01/1989" 17 | request: 18 | method: post 19 | # you can use variables from the context and parameters 20 | url: "{{ context.url }}" 21 | headers: 22 | accept: application/json 23 | content-type: application/json 24 | # you can loop over anything in the context, env, parameters, introspection 25 | # you can use if conditions to filter the loop 26 | body: |- 27 | { 28 | {% for name,value in context.info -%} 29 | {% if name != 'birthday' -%} 30 | "{{ name }}": "{{ value }}", 31 | {%- endif %} 32 | {%- endfor %} 33 | "name": "{{ env.USERNAME }}" 34 | } 35 | -------------------------------------------------------------------------------- /examples/request_with_env.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request_with_env 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request_with_env 6 | kind: Request 7 | spec: 8 | context: 9 | # you can create new variables in the context 10 | url: https://jsonplaceholder.typicode.com/users 11 | user: USERNAME 12 | request: 13 | method: post 14 | # you can use variables from the context and parameters 15 | url: "{{ context.url }}" 16 | headers: 17 | accept: application/json 18 | content-type: application/json 19 | # you can use variables from the process environnement with simplified syntax 20 | # you can use variables from the process environnement with tera get_env function 21 | body: 22 | name: "{{ env.USERNAME }}" 23 | dynamicName: "{{ env[context.user] }}" 24 | functionName: "{{ get_env(name='USERNAME') }}" 25 | dynamicFunctionName: "{{ get_env(name=context.user) }}" 26 | -------------------------------------------------------------------------------- /examples/request_with_file.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request_with_file 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request_with_file 6 | annotations: 7 | # you can upload files to body instead of using the body field 8 | # but templating is not supported for files 9 | # you should then use external files to upload large binary files 10 | apix.io/body-file: "test.json" 11 | kind: Request 12 | spec: 13 | request: 14 | method: post 15 | url: https://jsonplaceholder.typicode.com/users 16 | headers: 17 | accept: application/json 18 | content-type: application/json 19 | -------------------------------------------------------------------------------- /examples/request_with_params.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request_with_params 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request_with_params 6 | kind: Request 7 | spec: 8 | parameters: 9 | - name: todoId 10 | required: true 11 | schema: 12 | type: integer 13 | default: 1 14 | minimum: 1 15 | maximum: 200 16 | context: 17 | # you can create new variables in the context 18 | url: https://jsonplaceholder.typicode.com/todos 19 | request: 20 | method: get 21 | # you can use variables from the context and parameters 22 | url: "{{ context.url }}/{{ parameters.todoId }}" 23 | headers: 24 | accept: application/json 25 | -------------------------------------------------------------------------------- /examples/request_with_query_params.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request_with_query_params 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request_with_query_params 6 | kind: Request 7 | spec: 8 | parameters: 9 | - name: max_results 10 | required: true 11 | schema: 12 | type: integer 13 | default: 1 14 | minimum: 1 15 | maximum: 200 16 | context: 17 | # you can create new variables in the context 18 | url: https://jsonplaceholder.typicode.com/todos 19 | request: 20 | method: get 21 | # you can use variables from the context and parameters 22 | url: "{{ context.url }}" 23 | headers: 24 | accept: application/json 25 | queries: 26 | _limit: "{{ parameters.max_results }}" 27 | -------------------------------------------------------------------------------- /examples/request_yaml.yaml: -------------------------------------------------------------------------------- 1 | # example to execute this request: 2 | # apix exec request_yaml 3 | apiVersion: apix.io/v1 4 | metadata: 5 | name: request_yaml 6 | kind: Request 7 | spec: 8 | request: 9 | method: get 10 | url: https://raw.githubusercontent.com/ecyrbe/apix-rust/main/examples/request_yaml.yaml 11 | headers: 12 | accept: text/x-yaml 13 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | tab_spaces = 2 2 | max_width = 120 3 | -------------------------------------------------------------------------------- /src/build_args.rs: -------------------------------------------------------------------------------- 1 | use super::match_params::RequestParam; 2 | use super::validators::{validate_param, validate_url}; 3 | use clap::{crate_authors, crate_version, App, AppSettings, Arg, ValueHint}; 4 | use clap_complete::Shell; 5 | use once_cell::sync::Lazy; 6 | 7 | pub fn build_request_args() -> impl Iterator> { 8 | static ARGS: Lazy<[Arg<'static>; 17]> = Lazy::new(|| { 9 | [ 10 | Arg::new("url") 11 | .help("url to request, can be a 'Tera' template") 12 | .required(true) 13 | .value_hint(ValueHint::Url) 14 | .validator(validate_url), 15 | Arg::new("header") 16 | .short('H') 17 | .long("header") 18 | .help("set header name:value to send with request") 19 | .multiple_occurrences(true) 20 | .takes_value(true) 21 | .validator(|param| validate_param(param, RequestParam::Header)), 22 | Arg::new("cookie") 23 | .short('c') 24 | .long("cookie") 25 | .help("set cookie name:value to send with request") 26 | .multiple_occurrences(true) 27 | .takes_value(true) 28 | .validator(|param| validate_param(param, RequestParam::Cookie)), 29 | Arg::new("query") 30 | .short('q') 31 | .long("query") 32 | .help("set query name:value to send with request") 33 | .multiple_occurrences(true) 34 | .takes_value(true) 35 | .validator(|param| validate_param(param, RequestParam::Query)), 36 | Arg::new("body") 37 | .short('b') 38 | .long("body") 39 | .help("set body to send with request, can be a 'Tera' template") 40 | .takes_value(true) 41 | .conflicts_with("file"), 42 | Arg::new("file") 43 | .short('f') 44 | .long("file") 45 | .help("set body from file to send with request, can be a 'Tera' template") 46 | .takes_value(true) 47 | .conflicts_with("body") 48 | .value_hint(ValueHint::FilePath), 49 | Arg::new("param") 50 | .short('p') 51 | .long("param") 52 | .help("set parameter name:value for 'Tera' template rendering") 53 | .multiple_occurrences(true) 54 | .takes_value(true) 55 | .validator(|param| validate_param(param, RequestParam::Param)), 56 | Arg::new("proxy") 57 | .help("set proxy url to use for request") 58 | .short('x') 59 | .long("proxy") 60 | .value_hint(ValueHint::Url) 61 | .takes_value(true), 62 | Arg::new("proxy-login") 63 | .help("set proxy login to use for request") 64 | .long("proxy-login") 65 | .takes_value(true), 66 | Arg::new("proxy-password") 67 | .help("set proxy password to use for request") 68 | .long("proxy-password") 69 | .takes_value(true), 70 | Arg::new("follow") 71 | .help("follow http redirects") 72 | .short('F') 73 | .long("follow"), 74 | Arg::new("max-redirects") 75 | .help("set max http redirects to follow") 76 | .long("max-redirects") 77 | .takes_value(true), 78 | Arg::new("timeout") 79 | .help("set request timeout in seconds") 80 | .long("timeout") 81 | .takes_value(true), 82 | Arg::new("user-agent") 83 | .help("set user agent to send with request") 84 | .long("user-agent") 85 | .takes_value(true), 86 | Arg::new("certificate") 87 | .help("add a custom certificate authority") 88 | .long("certificate") 89 | .takes_value(true) 90 | .multiple_occurrences(true) 91 | .value_hint(ValueHint::FilePath), 92 | Arg::new("bind-address") 93 | .help("set bind address for request") 94 | .long("bind-address") 95 | .takes_value(true), 96 | Arg::new("insecure") 97 | .help("allow insecure connections when using https") 98 | .long("insecure"), 99 | ] 100 | }); 101 | ARGS.iter() 102 | } 103 | 104 | pub fn build_exec_args() -> impl Iterator> { 105 | static EXEC_ARGS: Lazy<[Arg<'static>; 6]> = Lazy::new(|| { 106 | [ 107 | Arg::new("name").help("name of the request to execute").index(1), 108 | Arg::new("file") 109 | .help("Execute a manifest file request directly") 110 | .short('f') 111 | .long("file") 112 | .takes_value(true) 113 | .value_hint(ValueHint::FilePath) 114 | .conflicts_with("name"), 115 | Arg::new("param") 116 | .help("Set a parameter for the request") 117 | .short('p') 118 | .long("param") 119 | .multiple_occurrences(true) 120 | .takes_value(true) 121 | .validator(|param| validate_param(param, RequestParam::Param)), 122 | Arg::new("proxy") 123 | .help("set proxy to use for request") 124 | .short('x') 125 | .long("proxy") 126 | .takes_value(true), 127 | Arg::new("proxy-login") 128 | .help("set proxy login to use for request") 129 | .long("proxy-login") 130 | .takes_value(true), 131 | Arg::new("proxy-password") 132 | .help("set proxy password to use for request") 133 | .long("proxy-password") 134 | .takes_value(true), 135 | ] 136 | }); 137 | EXEC_ARGS.iter() 138 | } 139 | 140 | pub fn build_create_request_args() -> impl Iterator> { 141 | static CREATE_ARGS: Lazy<[Arg<'static>; 10]> = Lazy::new(|| { 142 | [ 143 | Arg::new("name").help("name of request to create").index(1), 144 | Arg::new("method") 145 | .help("method of request to create") 146 | .possible_values(["GET", "POST", "PUT", "DELETE"]) 147 | .ignore_case(true) 148 | .index(2), 149 | Arg::new("url") 150 | .help("url to request, can be a 'Tera' template") 151 | .validator(validate_url) 152 | .index(3), 153 | Arg::new("header") 154 | .short('H') 155 | .long("header") 156 | .help("set header name:value to send with request") 157 | .multiple_occurrences(true) 158 | .takes_value(true) 159 | .validator(|param| validate_param(param, RequestParam::Header)), 160 | Arg::new("cookie") 161 | .short('c') 162 | .long("cookie") 163 | .help("set cookie name:value to send with request") 164 | .multiple_occurrences(true) 165 | .takes_value(true) 166 | .validator(|param| validate_param(param, RequestParam::Cookie)), 167 | Arg::new("query") 168 | .short('q') 169 | .long("query") 170 | .help("set query name:value to send with request") 171 | .multiple_occurrences(true) 172 | .takes_value(true) 173 | .validator(|param| validate_param(param, RequestParam::Query)), 174 | Arg::new("body") 175 | .short('b') 176 | .long("body") 177 | .help("set body to send with request, can be a 'Tera' template") 178 | .takes_value(true) 179 | .conflicts_with("file"), 180 | Arg::new("file") 181 | .short('f') 182 | .long("file") 183 | .help("set body from file to send with request, can be a 'Tera' template") 184 | .takes_value(true) 185 | .conflicts_with("body") 186 | .value_hint(ValueHint::FilePath), 187 | Arg::new("param") 188 | .short('p') 189 | .long("param") 190 | .help("set parameter name:value for 'Tera' template rendering") 191 | .multiple_occurrences(true) 192 | .takes_value(true) 193 | .validator(|param| validate_param(param, RequestParam::Param)), 194 | Arg::new("insecure") 195 | .help("allow insecure connections when using https") 196 | .short('i') 197 | .long("insecure"), 198 | ] 199 | }); 200 | CREATE_ARGS.iter() 201 | } 202 | 203 | pub fn build_cli() -> App<'static> { 204 | App::new("apix") 205 | .setting(AppSettings::SubcommandRequiredElseHelp) 206 | .version(crate_version!()) 207 | .author(crate_authors!()) 208 | .args([ 209 | Arg::new("verbose") 210 | .help("print full request and response") 211 | .short('v') 212 | .long("verbose") 213 | .global(true), 214 | Arg::new("output-file") 215 | .help("output file") 216 | .short('o') 217 | .long("output-file") 218 | .takes_value(true) 219 | .value_hint(ValueHint::FilePath) 220 | .global(true), 221 | ]) 222 | .subcommands([ 223 | App::new("completions").about("generate shell completions").arg( 224 | Arg::new("shell") 225 | .help("shell to target for completions") 226 | .possible_values(Shell::possible_values()) 227 | .required(true), 228 | ), 229 | App::new("config") 230 | .setting(AppSettings::SubcommandRequiredElseHelp) 231 | .about("configuration settings") 232 | .subcommands([ 233 | App::new("list"), 234 | App::new("set").about("set configuration value").args([ 235 | Arg::new("name") 236 | .help("name of configuration value to set") 237 | .required(true) 238 | .index(1), 239 | Arg::new("value") 240 | .help("value to set configuration value to") 241 | .required(true) 242 | .index(2), 243 | ]), 244 | App::new("get").about("get a configuration value").arg( 245 | Arg::new("name") 246 | .help("name of configuration value to get") 247 | .required(true), 248 | ), 249 | App::new("delete").about("delete a configuration value").arg( 250 | Arg::new("name") 251 | .help("name of configuration value to delete") 252 | .required(true), 253 | ), 254 | ]), 255 | App::new("init").about("initialise a new API context in the current directory by using git"), 256 | App::new("history").about("show history of requests sent (require project)"), 257 | App::new("get").about("get an http resource").args(build_request_args()), 258 | App::new("head") 259 | .about("get an http resource header") 260 | .args(build_request_args()), 261 | App::new("post") 262 | .about("post to an http resource") 263 | .args(build_request_args()), 264 | App::new("delete") 265 | .about("delete an http resource") 266 | .args(build_request_args()), 267 | App::new("put") 268 | .about("put to an http resource") 269 | .args(build_request_args()), 270 | App::new("patch") 271 | .about("patch an http resource") 272 | .args(build_request_args()), 273 | App::new("exec") 274 | .about("execute a request from the current API context") 275 | .args(build_exec_args()), 276 | App::new("ctl") 277 | .setting(AppSettings::SubcommandRequiredElseHelp) 278 | .about("apix control interface for handling multiple APIs") 279 | .subcommands([ 280 | App::new("switch").about("switch API context"), 281 | App::new("apply").about("apply an apix manifest into current project"), 282 | App::new("create") 283 | .setting(AppSettings::SubcommandRequiredElseHelp) 284 | .about("create a new apix manifest") 285 | .subcommands([ 286 | App::new("request") 287 | .about("create a new request") 288 | .args(build_create_request_args()), 289 | App::new("story").about("create a new story"), 290 | // .args(build_create_story_args()), 291 | ]), 292 | App::new("edit") 293 | .about("edit an existing apix resource with current terminal EDITOR") 294 | .args([ 295 | Arg::new("resource") 296 | .help("resource type to edit") 297 | .possible_values(["resource", "context", "story", "request", "config"]) 298 | .index(1), 299 | Arg::new("name").help("name of apix resource to edit").index(2), 300 | Arg::new("file") 301 | .help("Edit a resource file directly") 302 | .short('f') 303 | .long("file") 304 | .takes_value(true) 305 | .value_hint(ValueHint::FilePath) 306 | .conflicts_with_all(&["resource", "name"]), 307 | ]), 308 | App::new("get").about("get information about an apix resource").args([ 309 | Arg::new("resource") 310 | .possible_values(["resource", "context", "story", "request"]) 311 | .index(1), 312 | Arg::new("name").help("name of apix resource to edit").index(2), 313 | ]), 314 | App::new("delete").about("delete an existing named resource").args([ 315 | Arg::new("resource") 316 | .help("resource type to delete") 317 | .possible_values(["resource", "context", "story", "request"]) 318 | .required(true) 319 | .index(1), 320 | Arg::new("name") 321 | .help("name of apix resource to delete") 322 | .required(true) 323 | .index(2), 324 | ]), 325 | App::new("import") 326 | .about("import an OpenAPI description file in yaml or json") 327 | .arg( 328 | Arg::new("url") 329 | .help("Filename or URL to openApi description to import") 330 | .required(true), 331 | ), 332 | ]), 333 | ]) 334 | } 335 | -------------------------------------------------------------------------------- /src/dialog.rs: -------------------------------------------------------------------------------- 1 | use super::manifests::ApixParameter; 2 | use anyhow::Result; 3 | use dialoguer::{theme::ColorfulTheme, Input, Password}; 4 | use jsonschema::{Draft, JSONSchema}; 5 | use serde_json::Value; 6 | 7 | fn input_to_value(input: &str) -> Value { 8 | match serde_json::from_str(input) { 9 | Ok(value) => value, 10 | _ => Value::String(input.to_string()), 11 | } 12 | } 13 | 14 | pub trait Dialog { 15 | fn ask(&self) -> Result; 16 | } 17 | 18 | impl Dialog for ApixParameter { 19 | fn ask(&self) -> Result { 20 | let value_schema = self.schema.as_ref().unwrap(); 21 | let schema = JSONSchema::options() 22 | .with_draft(Draft::Draft7) 23 | .compile(value_schema) 24 | .map_err(|err| anyhow::anyhow!("{}", err))?; 25 | if self.password { 26 | let input = Password::with_theme(&ColorfulTheme::default()) 27 | .with_prompt(&self.name) 28 | .interact()?; 29 | 30 | Ok(Value::String(input)) 31 | } else { 32 | // check if schema has a default value 33 | let default = value_schema.as_object().and_then(|obj| obj.get("default")); 34 | let theme = ColorfulTheme::default(); 35 | let mut input = Input::with_theme(&theme); 36 | input.with_prompt(&self.name); 37 | if let Some(default) = default { 38 | input.default(serde_json::to_string(default)?); 39 | } 40 | let value = input 41 | .validate_with(|input: &String| { 42 | let value = input_to_value(input); 43 | let result = schema.validate(&value); 44 | if let Err(errors) = result { 45 | let mut msg: Vec = vec!["Invalid input:".to_string()]; 46 | for (index, cause) in errors.enumerate() { 47 | msg.push(format!("cause {}: {}", index, cause)); 48 | } 49 | return Err(msg.join("\n")); 50 | } 51 | Ok(()) 52 | }) 53 | .interact_text()?; 54 | 55 | Ok(input_to_value(&value)) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/display.rs: -------------------------------------------------------------------------------- 1 | use std::path::PathBuf; 2 | 3 | use super::http_utils::Language; 4 | use anyhow::Result; 5 | use bat::{Input, PrettyPrinter}; 6 | use reqwest::{Request, Response}; 7 | use serde_json::Value; 8 | use term_size::dimensions_stdout; 9 | use url::Position; 10 | 11 | pub trait HttpDisplay { 12 | fn print(&self, theme: &str, enable_color: bool) -> Result<()>; 13 | } 14 | 15 | pub fn print_separator() { 16 | if let Some((width, _)) = dimensions_stdout() { 17 | println!("{}", "─".repeat(width)); 18 | } 19 | } 20 | 21 | pub fn pretty_print(content: String, theme: &str, language: &str, enable_color: bool) -> Result<()> { 22 | match language { 23 | "json" => { 24 | let json: Value = serde_json::from_str(&content)?; 25 | let formatted = serde_json::to_string_pretty(&json)?; 26 | PrettyPrinter::new() 27 | .input(Input::from_reader(formatted.as_bytes())) 28 | .language(language) 29 | .colored_output(enable_color) 30 | .theme(theme) 31 | .print() 32 | .map_err(|err| anyhow::anyhow!("Failed to print result: {:#}", err))?; 33 | } 34 | _ => { 35 | PrettyPrinter::new() 36 | .input(Input::from_reader(content.as_bytes())) 37 | .language(language) 38 | .colored_output(enable_color) 39 | .theme(theme) 40 | .print() 41 | .map_err(|err| anyhow::anyhow!("Failed to print result: {:#}", err))?; 42 | } 43 | } 44 | Ok(()) 45 | } 46 | 47 | pub fn pretty_print_file(path: PathBuf, theme: &str, language: &str, enable_color: bool) -> Result<()> { 48 | PrettyPrinter::new() 49 | .input_file(path) 50 | .language(language) 51 | .colored_output(enable_color) 52 | .theme(theme) 53 | .grid(true) 54 | .header(true) 55 | .line_numbers(true) 56 | .print() 57 | .map_err(|err| anyhow::anyhow!("Failed to print result: {:#}", err))?; 58 | Ok(()) 59 | } 60 | 61 | impl HttpDisplay for Request { 62 | fn print(&self, theme: &str, enable_color: bool) -> Result<()> { 63 | let mut output = format!( 64 | "{method} {endpoint} {protocol:?}\nhost: {host}\n", 65 | method = self.method(), 66 | endpoint = &self.url()[Position::BeforePath..], 67 | protocol = self.version(), 68 | host = self 69 | .url() 70 | .host_str() 71 | .ok_or_else(|| anyhow::anyhow!("invalid host in URL: {}", self.url()))? 72 | ); 73 | for (key, value) in self.headers() { 74 | output.push_str(&format!("{}: {}\n", key.as_str(), value.to_str()?)); 75 | } 76 | pretty_print(output, theme, "yaml", enable_color)?; 77 | 78 | // pretty print body if present and it has a content type that match a language 79 | if let (Some(body), Some(language)) = (self.body(), self.get_language()) { 80 | println!(); 81 | if let Some(bytes) = body.as_bytes() { 82 | PrettyPrinter::new() 83 | .input(Input::from_reader(bytes)) 84 | .language(language) 85 | .colored_output(enable_color) 86 | .theme(theme) 87 | .print() 88 | .map_err(|err| anyhow::anyhow!("Failed to print result: {:#}", err))?; 89 | } 90 | } 91 | Ok(()) 92 | } 93 | } 94 | 95 | impl HttpDisplay for Response { 96 | fn print(&self, theme: &str, enable_color: bool) -> Result<()> { 97 | let mut output = format!( 98 | "{protocol:?} {status}\n", 99 | protocol = self.version(), 100 | status = self.status() 101 | ); 102 | for (key, value) in self.headers() { 103 | output.push_str(&format!("{}: {}\n", key.as_str(), value.to_str()?)); 104 | } 105 | pretty_print(output, theme, "yaml", enable_color)?; 106 | Ok(()) 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/editor.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use std::ffi::OsString; 3 | 4 | // get the user default editor 5 | fn get_default_editor() -> OsString { 6 | if let Some(prog) = std::env::var_os("VISUAL") { 7 | return prog; 8 | } 9 | if let Some(prog) = std::env::var_os("EDITOR") { 10 | return prog; 11 | } 12 | if cfg!(windows) { 13 | "notepad.exe".into() 14 | } else { 15 | "vi".into() 16 | } 17 | } 18 | 19 | // edit file with default editor 20 | pub fn edit_file(file: &str) -> Result<()> { 21 | let editor = get_default_editor(); 22 | std::process::Command::new(&editor).arg(file).spawn()?.wait()?; 23 | Ok(()) 24 | } 25 | -------------------------------------------------------------------------------- /src/execute.rs: -------------------------------------------------------------------------------- 1 | use crate::manifests::ApixRequest; 2 | use crate::requests::{make_request, AdvancedBody, RequestOptions}; 3 | 4 | use super::dialog::Dialog; 5 | use super::template::{MapTemplate, StringTemplate, ValueTemplate}; 6 | use super::{ApixKind, ApixManifest}; 7 | use anyhow::Result; 8 | use indexmap::IndexMap; 9 | use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; 10 | use serde_json::Value; 11 | use std::collections::HashMap; 12 | use std::str::FromStr; 13 | use tera::{Context, Tera}; 14 | 15 | struct RequestTemplate<'a> { 16 | request: &'a ApixRequest, 17 | engine: Tera, 18 | context: Context, 19 | file: &'a str, 20 | annotations: IndexMap, 21 | } 22 | 23 | #[derive(Debug, Clone)] 24 | struct RequestParams<'a> { 25 | url: String, 26 | method: String, 27 | headers: HeaderMap, 28 | queries: IndexMap, 29 | body: Option, 30 | options: RequestOptions<'a>, 31 | } 32 | 33 | // ask for all parameters in manifest request 34 | fn ask_for_required_parameters( 35 | request: &ApixRequest, 36 | params: &Option>, 37 | ) -> Result, anyhow::Error> { 38 | match params { 39 | Some(params) => request 40 | .parameters 41 | .iter() 42 | .filter(|param| param.required || params.get(¶m.name).is_some()) 43 | .map(|parameter| { 44 | if let Some(param) = params.get(¶meter.name) { 45 | Ok((parameter.name.clone(), Value::String(param.clone()))) 46 | } else { 47 | Ok((parameter.name.clone(), parameter.ask()?)) 48 | } 49 | }) 50 | .collect(), 51 | None => request 52 | .parameters 53 | .iter() 54 | .filter(|param| param.required) 55 | .map(|parameter| Ok((parameter.name.clone(), parameter.ask()?))) 56 | .collect(), 57 | } 58 | } 59 | 60 | impl<'a> RequestTemplate<'a> { 61 | fn new(manifest: &'a ApixManifest, file: &'a str, params: &Option>) -> Result { 62 | match manifest.kind() { 63 | ApixKind::Request(request) => { 64 | let parameters = Value::Object(ask_for_required_parameters(request, params)?); 65 | let env: HashMap = std::env::vars().collect(); 66 | let mut engine = Tera::default(); 67 | let mut context = Context::new(); 68 | 69 | context.insert("manifest", &manifest); 70 | context.insert("parameters", ¶meters); 71 | context.insert("env", &env); 72 | 73 | let annotations = engine.render_map( 74 | &format!("{}#/annotations", file), 75 | manifest.get_annotations().unwrap_or(&IndexMap::::new()), 76 | &context, 77 | )?; 78 | 79 | Ok(Self { 80 | request, 81 | engine, 82 | context, 83 | file, 84 | annotations, 85 | }) 86 | } 87 | _ => Err(anyhow::anyhow!("Request manifest expected")), 88 | } 89 | } 90 | 91 | fn render_context(&mut self) -> Result<&mut Self> { 92 | let rendered_context = self.engine.render_value( 93 | &format!("{}#/context", self.file), 94 | &Value::Object(serde_json::Map::from_iter(self.request.context.clone().into_iter())), 95 | &self.context, 96 | )?; 97 | self.context.insert("context", &rendered_context); 98 | Ok(self) 99 | } 100 | 101 | fn render_options(&mut self, options: &RequestOptions<'a>) -> RequestOptions<'a> { 102 | let output_filename = self.annotations.get("apix.io/output-file").map(String::to_owned); 103 | let proxy_url = self.annotations.get("apix.io/proxy-url").map(String::to_owned); 104 | let proxy_login = self.annotations.get("apix.io/proxy-login").map(String::to_owned); 105 | let proxy_password = self.annotations.get("apix.io/proxy-password").map(String::to_owned); 106 | let options = options.clone(); 107 | RequestOptions { 108 | output_filename: options.output_filename.or(output_filename), 109 | proxy_url: options.proxy_url.or(proxy_url), 110 | proxy_login: options.proxy_login.or(proxy_login), 111 | proxy_password: options.proxy_password.or(proxy_password), 112 | ..options 113 | } 114 | } 115 | 116 | fn render_url(&mut self) -> Result { 117 | self 118 | .engine 119 | .add_raw_template(&format!("{}#/url", self.file), &self.request.request.url)?; 120 | let url = self.engine.render(&format!("{}#/url", self.file), &self.context)?; 121 | Ok(url) 122 | } 123 | 124 | fn render_method(&mut self) -> Result { 125 | self 126 | .engine 127 | .add_raw_template(&format!("{}#/method", self.file), &self.request.request.method)?; 128 | let method = self.engine.render(&format!("{}#/method", self.file), &self.context)?; 129 | Ok(method) 130 | } 131 | 132 | fn render_headers(&mut self) -> Result { 133 | let headers = HeaderMap::from_iter( 134 | self 135 | .engine 136 | .render_map( 137 | &format!("{}#/headers", self.file), 138 | &self.request.request.headers, 139 | &self.context, 140 | )? 141 | .iter() 142 | .map(|(key, value)| { 143 | ( 144 | HeaderName::from_str(key).unwrap(), 145 | HeaderValue::from_str(value).unwrap(), 146 | ) 147 | }), 148 | ); 149 | Ok(headers) 150 | } 151 | 152 | fn render_queries(&mut self) -> Result> { 153 | let queries = self.engine.render_map( 154 | &format!("{}#/queries", self.file), 155 | &self.request.request.queries, 156 | &self.context, 157 | )?; 158 | Ok(queries) 159 | } 160 | 161 | fn render_body(&mut self) -> Result> { 162 | match ( 163 | self.request.request.body.as_ref(), 164 | self.annotations.get("apix.io/convert-body-to-json"), 165 | self.annotations.get("apix.io/body-file"), 166 | ) { 167 | (Some(Value::String(body)), Some(convert_to_json), _) if convert_to_json == "true" => { 168 | let string_body = self 169 | .engine 170 | .render_string(&format!("{}#/body", self.file), body, &self.context)?; 171 | // try to parse as json or return original string if it fails 172 | Ok(Some(AdvancedBody::Json( 173 | serde_json::from_str(&string_body).or::(Ok(Value::String(string_body)))?, 174 | ))) 175 | } 176 | (Some(body), _, _) => Ok(Some(AdvancedBody::Json(self.engine.render_value( 177 | &format!("{}#/body", self.file), 178 | body, 179 | &self.context, 180 | )?))), 181 | (None, _, Some(filepath)) => Ok(Some(AdvancedBody::File(filepath.to_owned()))), 182 | (None, _, None) => Ok(None), 183 | } 184 | } 185 | 186 | fn render_request_params(&mut self, options: &RequestOptions<'a>) -> Result { 187 | let url = self.render_url()?; 188 | let method = self.render_method()?; 189 | let headers = self.render_headers()?; 190 | let queries = self.render_queries()?; 191 | let body = self.render_body()?; 192 | let options = self.render_options(options); 193 | Ok(RequestParams { 194 | url, 195 | method, 196 | headers, 197 | queries, 198 | body, 199 | options, 200 | }) 201 | } 202 | } 203 | 204 | pub async fn handle_execute( 205 | file: &str, 206 | manifest: &ApixManifest, 207 | params: Option>, 208 | options: RequestOptions<'_>, 209 | ) -> Result<()> { 210 | let mut template = RequestTemplate::new(manifest, file, ¶ms)?; 211 | let params = template.render_context()?.render_request_params(&options)?; 212 | make_request( 213 | ¶ms.url, 214 | ¶ms.method, 215 | Some(¶ms.headers), 216 | Some(¶ms.queries), 217 | params.body, 218 | params.options, 219 | ) 220 | .await 221 | } 222 | -------------------------------------------------------------------------------- /src/http_utils.rs: -------------------------------------------------------------------------------- 1 | use reqwest::{header::CONTENT_TYPE, Request, Response}; 2 | 3 | pub trait HttpHeaders { 4 | fn headers(&self) -> &reqwest::header::HeaderMap; 5 | } 6 | 7 | impl HttpHeaders for Request { 8 | #[inline] 9 | fn headers(&self) -> &reqwest::header::HeaderMap { 10 | self.headers() 11 | } 12 | } 13 | 14 | impl HttpHeaders for Response { 15 | #[inline] 16 | fn headers(&self) -> &reqwest::header::HeaderMap { 17 | self.headers() 18 | } 19 | } 20 | 21 | pub trait Language { 22 | fn get_language(&self) -> Option<&'static str>; 23 | } 24 | 25 | impl Language for T 26 | where 27 | T: HttpHeaders, 28 | { 29 | fn get_language(&self) -> Option<&'static str> { 30 | match self.headers().get(CONTENT_TYPE) { 31 | Some(header) => match header.to_str() { 32 | Ok(content_type) if content_type.contains("json") => Some("json"), 33 | Ok(content_type) if content_type.contains("xml") => Some("xml"), 34 | Ok(content_type) if content_type.contains("html") => Some("html"), 35 | Ok(content_type) if content_type.contains("css") => Some("css"), 36 | Ok(content_type) if content_type.contains("javascript") => Some("js"), 37 | Ok(content_type) if content_type.contains("yaml") => Some("yaml"), 38 | Ok(content_type) if content_type.contains("text") => Some("txt"), 39 | _ => Some("binary"), 40 | }, 41 | _ => Some("binary"), 42 | } 43 | } 44 | } 45 | 46 | //test get language for HttpHeaders 47 | #[cfg(test)] 48 | mod test_get_language { 49 | use super::*; 50 | use reqwest::header::CONTENT_TYPE; 51 | use test_case::test_case; 52 | 53 | // Mock HttpHeaders 54 | struct MockHttpHeaders { 55 | headers: reqwest::header::HeaderMap, 56 | } 57 | 58 | // Mock HttpHeaders impl 59 | impl HttpHeaders for MockHttpHeaders { 60 | fn headers(&self) -> &reqwest::header::HeaderMap { 61 | &self.headers 62 | } 63 | } 64 | 65 | // Mock HttpHeaders impl 66 | impl MockHttpHeaders { 67 | fn new() -> MockHttpHeaders { 68 | MockHttpHeaders { 69 | headers: reqwest::header::HeaderMap::new(), 70 | } 71 | } 72 | 73 | fn set_content_type(&mut self, value: &str) { 74 | self 75 | .headers 76 | .insert(CONTENT_TYPE, reqwest::header::HeaderValue::from_str(value).unwrap()); 77 | } 78 | 79 | fn from_content_type(value: &str) -> MockHttpHeaders { 80 | let mut headers = MockHttpHeaders::new(); 81 | headers.set_content_type(value); 82 | headers 83 | } 84 | } 85 | 86 | // test get language for all test cases 87 | #[test_case("application/json" => "json")] 88 | #[test_case("application/xml" => "xml")] 89 | #[test_case("text/html" => "html")] 90 | #[test_case("text/css" => "css")] 91 | #[test_case("text/javascript" => "js")] 92 | #[test_case("text/x-yaml" => "yaml")] 93 | #[test_case("text/plain" => "txt")] 94 | #[test_case("application/octet-stream" => "binary")] 95 | #[test_case("application/pdf" => "binary")] 96 | fn test_get_language(content_type: &str) -> &str { 97 | MockHttpHeaders::from_content_type(content_type).get_language().unwrap() 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/import.rs: -------------------------------------------------------------------------------- 1 | // use crate::manifests::{ApixApi, ApixManifest, ApixParameter, ApixRequest, ApixTemplate, Json}; 2 | // use anyhow::Result; 3 | // use indexmap::IndexMap; 4 | // use openapiv3::{OpenAPI, PathItem, ReferenceOr}; 5 | // use regex::Regex; 6 | // use tokio::fs::File; 7 | // use tokio::io::AsyncWriteExt; 8 | 9 | // pub enum OpenApiType { 10 | // JSON, 11 | // YAML, 12 | // } 13 | 14 | // fn is_method(method: &str) -> bool { 15 | // ["get", "post", "put", "delete", "patch", "options", "head"] 16 | // .contains(&method.to_lowercase().as_ref()) 17 | // } 18 | 19 | // // get parameter name from reference 20 | // // example: #/components/parameters/id -> id 21 | // fn get_reference_name(reference: &str) -> String { 22 | // reference.split('/').last().unwrap_or_default().to_string() 23 | // } 24 | 25 | // trait Replacable { 26 | // fn replace(&self, pattern: &str, replacement: &str) -> String; 27 | // } 28 | 29 | // impl Replacable for String { 30 | // fn replace(&self, pattern: &str, replacement: &str) -> String { 31 | // let re = Regex::new(pattern).unwrap(); 32 | // re.replace_all(self, replacement).to_string() 33 | // } 34 | // } 35 | 36 | // trait ReferencableParameter { 37 | // fn get_parameter(&self, name: &str) -> Option; 38 | 39 | // fn resolve_parameter<'a>( 40 | // &'a self, 41 | // parameter: &'a ReferenceOr, 42 | // ) -> Option; 43 | // } 44 | 45 | // trait ReferencableSchema { 46 | // fn get_schema(&self, name: &str) -> Json; 47 | 48 | // fn resolve_schema<'a>( 49 | // &'a self, 50 | // schema: &'a ReferenceOr, 51 | // ) -> Json; 52 | // } 53 | 54 | // trait ReferencableBody { 55 | // fn get_body(&self, name: &str) -> Option; 56 | 57 | // fn resolve_body<'a>( 58 | // &'a self, 59 | // body: &'a ReferenceOr, 60 | // ) -> Option; 61 | // } 62 | 63 | // impl ReferencableParameter for OpenAPI { 64 | // fn get_parameter(&self, name: &str) -> Option { 65 | // match self.components.as_ref() { 66 | // Some(components) => match components.parameters.get(name)? { 67 | // ReferenceOr::Reference { reference } => { 68 | // self.get_parameter(&get_reference_name(reference)) 69 | // } 70 | // ReferenceOr::Item(parameter) => Some(parameter.clone()), 71 | // }, 72 | // None => None, 73 | // } 74 | // } 75 | 76 | // fn resolve_parameter<'a>( 77 | // &'a self, 78 | // parameter: &'a ReferenceOr, 79 | // ) -> Option { 80 | // match parameter { 81 | // ReferenceOr::Reference { reference } => { 82 | // self.get_parameter(&get_reference_name(reference)) 83 | // } 84 | // ReferenceOr::Item(parameter) => Some(parameter.clone()), 85 | // } 86 | // } 87 | // } 88 | 89 | // impl ReferencableSchema for OpenAPI { 90 | // fn get_schema(&self, name: &str) -> Json { 91 | // match self.components.as_ref() { 92 | // Some(components) => match components.schemas.get(name)? { 93 | // ReferenceOr::Reference { reference } => { 94 | // self.get_schema(&get_reference_name(reference)) 95 | // } 96 | // ReferenceOr::Item(schema) => Some(schema.clone()), 97 | // }, 98 | // None => None, 99 | // } 100 | // } 101 | // fn resolve_schema<'a>( 102 | // &'a self, 103 | // schema: &'a ReferenceOr, 104 | // ) -> Option { 105 | // match schema { 106 | // ReferenceOr::Reference { reference } => self.get_schema(&get_reference_name(reference)), 107 | // ReferenceOr::Item(schema) => Some(schema.clone()), 108 | // } 109 | // } 110 | // } 111 | 112 | // impl ReferencableBody for OpenAPI { 113 | // fn get_body(&self, name: &str) -> Option { 114 | // match self.components.as_ref() { 115 | // Some(components) => match components.request_bodies.get(name)? { 116 | // ReferenceOr::Reference { reference } => { 117 | // self.get_body(&get_reference_name(reference)) 118 | // } 119 | // ReferenceOr::Item(body) => Some(body.clone()), 120 | // }, 121 | // None => None, 122 | // } 123 | // } 124 | // fn resolve_body<'a>( 125 | // &'a self, 126 | // body: &'a ReferenceOr, 127 | // ) -> Option { 128 | // match body { 129 | // ReferenceOr::Reference { reference } => self.get_body(&get_reference_name(reference)), 130 | // ReferenceOr::Item(body) => Some(body.clone()), 131 | // } 132 | // } 133 | // } 134 | 135 | // pub fn openapi_operation_to_apix_request(operation: &openapiv3::Operation) -> Option { 136 | // todo!() 137 | // } 138 | 139 | // trait ToApixParameter { 140 | // fn to_apix_parameter(&self, api: &OpenAPI) -> Option; 141 | // } 142 | 143 | // impl ToApixParameter for openapiv3::Parameter { 144 | // fn to_apix_parameter(&self, api: &OpenAPI) -> Option { 145 | // let data = self.parameter_data_ref(); 146 | // Some(ApixParameter::new( 147 | // data.name.clone(), 148 | // data.required, 149 | // data.description.clone(), 150 | // match &data.format { 151 | // openapiv3::ParameterSchemaOrContent::Schema(schema) => api.resolve_schema(&schema), 152 | // _ => return None, 153 | // }, 154 | // )) 155 | // } 156 | // } 157 | 158 | // trait ToApixParameters { 159 | // fn to_apix_parameters(&self, api: &OpenAPI) -> Result>; 160 | // } 161 | 162 | // impl ToApixParameters for PathItem { 163 | // fn to_apix_parameters(&self, api: &OpenAPI) -> Result> { 164 | // let parameters = self 165 | // .parameters 166 | // .iter() 167 | // .filter_map(|maybe_ref_parameter| { 168 | // Some( 169 | // api.resolve_parameter(maybe_ref_parameter)? 170 | // .to_apix_parameter(api)?, 171 | // ) 172 | // }) 173 | // .collect(); 174 | // Ok(parameters) 175 | // } 176 | // } 177 | 178 | // trait ToApixRequest { 179 | // fn to_apix_request( 180 | // &self, 181 | // method: &str, 182 | // operation: &openapiv3::Operation, 183 | // ) -> Option; 184 | // } 185 | 186 | // impl ToApixRequest for OpenAPI { 187 | // fn to_apix_request( 188 | // &self, 189 | // method: &str, 190 | // operation: &openapiv3::Operation, 191 | // ) -> Option { 192 | // let mut request = ApixRequest::new( 193 | // IndexMap::new(), 194 | // operation 195 | // .parameters 196 | // .iter() 197 | // .filter_map(|maybe_ref_parameter| { 198 | // Some( 199 | // self.resolve_parameter(maybe_ref_parameter)? 200 | // .to_apix_parameter(self)?, 201 | // ) 202 | // }) 203 | // .collect(), 204 | // ApixTemplate::new(), 205 | // ); 206 | // request.parameters = parameters; 207 | // request.body = operation.request_body.clone().map(|body| { 208 | // let body = api.resolve_body(&body)?; 209 | // ApixBody::new( 210 | // body.description.clone(), 211 | // body.content.clone(), 212 | // body.required, 213 | // ) 214 | // }); 215 | // Some(request) 216 | // } 217 | // } 218 | 219 | // trait ToApixApiManifest { 220 | // fn to_apix_api(&self) -> Result; 221 | // } 222 | 223 | // impl ToApixApiManifest for OpenAPI { 224 | // fn to_apix_api(&self) -> Result { 225 | // //compute api name 226 | // let name = &self.info.title; 227 | // // create apixApi based on openapi 228 | // let url: Option = { 229 | // let mut url = String::new(); 230 | // for server in self.servers.iter() { 231 | // if server.url.starts_with("http://") || server.url.starts_with("https://") { 232 | // url = server.url.to_string(); 233 | // break; 234 | // } 235 | // } 236 | // Some(url) 237 | // }; 238 | // let api = ApixApi::new( 239 | // url.unwrap_or_default(), 240 | // self.info.version.clone(), 241 | // self.info.description.clone(), 242 | // ); 243 | // Ok(ApixManifest::new_api(name.clone(), Some(api))) 244 | // } 245 | // } 246 | 247 | // trait ToApixRequestsManifest { 248 | // fn to_apix_requests(&self) -> Result>; 249 | // } 250 | 251 | // impl ToApixRequestsManifest for OpenAPI { 252 | // fn to_apix_requests(&self) -> Result> { 253 | // let mut apix_requests = Vec::new(); 254 | // for (path, path_item) in self.paths.iter() { 255 | // match path_item { 256 | // ReferenceOr::Item(path_item) => { 257 | // for (method, operation) in path_item.iter() { 258 | // if let Some(apix_request) = self.to_apix_request(method, operation) { 259 | // apix_requests 260 | // .push(ApixManifest::new_request(path.clone(), apix_request)); 261 | // } 262 | // } 263 | // } 264 | // ReferenceOr::Reference { .. } => {} 265 | // } 266 | // } 267 | // Ok(apix_requests) 268 | // } 269 | // } 270 | 271 | // // return an apix API and a vector of ApixManifest 272 | // pub fn openapi_to_apix(api: &OpenAPI) -> Result<(ApixManifest, Vec)> { 273 | // let apix_api = api.to_apix_api()?; 274 | // let apix_requests = api.to_apix_requests()?; 275 | // Ok((apix_api, apix_requests)) 276 | // } 277 | 278 | // pub async fn import_api(api_description: String, api_type: OpenApiType) -> Result<()> { 279 | // let api: OpenAPI = load_api(api_description, api_type)?; 280 | // // convert to apix 281 | // let (api, requests) = openapi_to_apix(&api)?; 282 | // // write apixApi to current directory with name of api 283 | // let mut file = File::create(format!("{}.index.yaml", &api.name())).await?; 284 | // file.write_all(serde_yaml::to_string(&api).unwrap().as_bytes()) 285 | // .await?; 286 | // // write each request to current directory with name of request 287 | // for request in requests { 288 | // let mut file = File::create(format!("{}.{}.yaml", &api.name(), &request.name())).await?; 289 | // file.write_all(serde_yaml::to_string(&request).unwrap().as_bytes()) 290 | // .await?; 291 | // } 292 | // Ok(()) 293 | // } 294 | 295 | // fn load_api(api_description: String, api_type: OpenApiType) -> Result { 296 | // match api_type { 297 | // OpenApiType::JSON => { 298 | // let open_api: OpenAPI = serde_json::from_str(&api_description)?; 299 | // Ok(open_api) 300 | // } 301 | // OpenApiType::YAML => { 302 | // let open_api: OpenAPI = serde_yaml::from_str(&api_description)?; 303 | // Ok(open_api) 304 | // } 305 | // } 306 | // } 307 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod build_args; 2 | mod dialog; 3 | mod display; 4 | mod editor; 5 | mod execute; 6 | mod http_utils; 7 | mod import; 8 | mod manifests; 9 | mod match_params; 10 | mod match_prompts; 11 | mod progress_component; 12 | mod requests; 13 | mod template; 14 | mod validators; 15 | use anyhow::{anyhow, Result}; 16 | use build_args::build_cli; 17 | use clap::App; 18 | use clap_complete::{generate, Generator, Shell}; 19 | use cmd_lib::run_cmd; 20 | use comfy_table::{ContentArrangement, Table}; 21 | use display::{pretty_print, pretty_print_file}; 22 | use editor::edit_file; 23 | use execute::handle_execute; 24 | use indexmap::indexmap; 25 | use manifests::{ApixConfiguration, ApixKind, ApixManifest, ApixRequest, ApixRequestTemplate}; 26 | use match_params::{MatchParams, RequestParam}; 27 | use match_prompts::MatchPrompts; 28 | use requests::RequestOptions; 29 | use std::io; 30 | use std::io::Write; 31 | use std::string::ToString; 32 | use validators::validate_url; 33 | 34 | fn print_completions(gen: G, app: &mut App) { 35 | generate(gen, app, app.get_name().to_string(), &mut io::stdout()); 36 | } 37 | 38 | async fn handle_import(_url: &str) -> Result<()> { 39 | // let open_api = reqwest::get(url).await?.text().await?; 40 | // let result = import::import_api(open_api, import::OpenApiType::YAML) 41 | // .await 42 | // .map_err(|e| anyhow::anyhow!("Invalid Open Api description\n{:#}", e))?; 43 | // println!("api {}", serde_json::to_string(&result)?); 44 | Ok(()) 45 | } 46 | 47 | #[tokio::main] 48 | async fn main() -> Result<()> { 49 | let is_output_terminal = atty::is(atty::Stream::Stdout); 50 | let matches = build_cli().get_matches(); 51 | // read config file 52 | let theme = ApixConfiguration::once().get("theme").unwrap().clone(); 53 | match matches.subcommand() { 54 | Some(("completions", matches)) => { 55 | if let Ok(generator) = matches.value_of_t::("shell") { 56 | let mut app = build_cli(); 57 | print_completions(generator, &mut app); 58 | } 59 | } 60 | Some(("init", _)) => { 61 | run_cmd! {git --version}.map_err(|_| anyhow!("git command not found"))?; 62 | // create .gitignore 63 | let mut gitignore = 64 | std::fs::File::create(".gitignore").map_err(|e| anyhow!("Failed to create .gitignore\ncause: {}", e))?; 65 | gitignore 66 | .write_all(b".apix/context.yaml\n") 67 | .map_err(|e| anyhow!("Failed to write to .gitignore\ncause: {}", e))?; 68 | gitignore 69 | .flush() 70 | .map_err(|e| anyhow!("Failed to save .gitignore\ncause: {}", e))?; 71 | // init git 72 | run_cmd! { 73 | git init 74 | git add .gitignore 75 | git commit -m "Apix init commit" 76 | } 77 | .map_err(|e| anyhow!("Failed to init apix repository\ncause: {}", e))?; 78 | } 79 | Some(("config", matches)) => match matches.subcommand() { 80 | Some(("list", _)) => { 81 | pretty_print( 82 | serde_yaml::to_string(ApixConfiguration::once())?, 83 | &theme, 84 | "yaml", 85 | is_output_terminal, 86 | )?; 87 | } 88 | Some(("set", matches)) => { 89 | if let (Some(key), Some(value)) = (matches.value_of("name"), matches.value_of("value")) { 90 | if let Some(old_value) = ApixConfiguration::once().set(key.to_string(), value.to_string()) { 91 | println!("Replaced config key"); 92 | pretty_print( 93 | format!("-{}: {}\n+{}: {}\n", key, old_value, key, value), 94 | &theme, 95 | "diff", 96 | is_output_terminal, 97 | )?; 98 | } else { 99 | println!("Set config key"); 100 | pretty_print(format!("{}: {}\n", key, value), &theme, "yaml", is_output_terminal)?; 101 | } 102 | ApixConfiguration::once().save()?; 103 | } 104 | } 105 | Some(("get", matches)) => { 106 | let key = matches.value_of("name").unwrap(); 107 | if let Some(value) = ApixConfiguration::once().get(key) { 108 | pretty_print(format!("{}: {}\n", key, value), &theme, "yaml", is_output_terminal)?; 109 | } 110 | } 111 | Some(("delete", matches)) => { 112 | let key = matches.value_of("name").unwrap(); 113 | if let Some(value) = ApixConfiguration::once().delete(key) { 114 | println!("Deleted config key"); 115 | pretty_print(format!("{}: {}\n", key, value), &theme, "yaml", is_output_terminal)?; 116 | ApixConfiguration::once().save()?; 117 | } 118 | } 119 | _ => {} 120 | }, 121 | Some(("history", _submatches)) => {} 122 | Some(("exec", matches)) => { 123 | if let Some(file) = matches.value_of("file") { 124 | let content = std::fs::read_to_string(file)?; 125 | let manifest: ApixManifest = serde_yaml::from_str(&content)?; 126 | handle_execute( 127 | file, 128 | &manifest, 129 | matches.match_params(RequestParam::Param), 130 | RequestOptions { 131 | verbose: matches.is_present("verbose"), 132 | theme: &theme, 133 | is_output_terminal, 134 | output_filename: matches.value_of("output-file").map(str::to_string), 135 | proxy_url: matches.value_of("proxy").map(str::to_string), 136 | proxy_login: matches.value_of("proxy-login").map(str::to_string), 137 | proxy_password: matches.value_of("proxy-password").map(str::to_string), 138 | }, 139 | ) 140 | .await?; 141 | } else if let Ok(name) = matches.match_or_input("name", "Request name") { 142 | match ApixManifest::find_manifest("request", &name) { 143 | Some((path, manifest)) => { 144 | let path = path.to_str().ok_or_else(|| anyhow!("Invalid path"))?; 145 | handle_execute( 146 | path, 147 | &manifest, 148 | matches.match_params(RequestParam::Param), 149 | RequestOptions { 150 | verbose: matches.is_present("verbose"), 151 | theme: &theme, 152 | is_output_terminal, 153 | output_filename: matches.value_of("output-file").map(str::to_string), 154 | proxy_url: matches.value_of("proxy").map(str::to_string), 155 | proxy_login: matches.value_of("proxy-login").map(str::to_string), 156 | proxy_password: matches.value_of("proxy-password").map(str::to_string), 157 | }, 158 | ) 159 | .await?; 160 | } 161 | None => { 162 | println!("No request where found with name {}", name); 163 | } 164 | } 165 | } 166 | } 167 | Some(("ctl", matches)) => match matches.subcommand() { 168 | Some(("apply", _submatches)) => {} 169 | Some(("create", matches)) => match matches.subcommand() { 170 | Some(("request", matches)) => { 171 | let name = matches.match_or_input("name", "Request Name")?; 172 | let methods = ["GET", "POST", "PUT", "DELETE"]; 173 | let method = matches.match_or_select("method", "Request method", &methods)?; 174 | let url = matches.match_or_validate_input("url", "Request url", |url: &String| { 175 | validate_url(&url.to_owned()).map(|_| ()) 176 | })?; 177 | let headers = matches.match_or_input_multiples("header", "Add request headers?")?; 178 | let queries = matches.match_or_input_multiples("query", "Add request query parameters?")?; 179 | 180 | let body = matches 181 | .match_or_optional_input("body", "Add a request body?")? 182 | .map(serde_json::Value::String); 183 | 184 | let filename = format!("{}.yaml", &name); 185 | let request_manifest = ApixManifest::new_request( 186 | "test".to_string(), 187 | name, 188 | ApixRequest::new( 189 | vec![], 190 | indexmap! {}, 191 | ApixRequestTemplate::new(method, url, headers, queries, body), 192 | ), 193 | ); 194 | let request_manifest_yaml = serde_yaml::to_string(&request_manifest)?; 195 | // save to file with name of request 196 | std::fs::write(filename, request_manifest_yaml)?; 197 | } 198 | Some(("story", _submatches)) => {} 199 | _ => {} 200 | }, 201 | Some(("switch", _submatches)) => {} 202 | Some(("edit", matches)) => { 203 | if let Some(filename) = matches.value_of("file") { 204 | edit_file(filename)?; 205 | } else { 206 | let resource = matches.match_or_select("resource", "Resource type", &["request", "story"])?; 207 | let name = matches.match_or_input("name", "Resource name")?; 208 | match ApixManifest::find_manifest_filename(&resource, &name) { 209 | Some(filename) => { 210 | edit_file(&filename)?; 211 | } 212 | None => { 213 | println!("No resource of type {} where found with name {}", resource, name); 214 | } 215 | } 216 | } 217 | } 218 | Some(("get", matches)) => { 219 | if let Some(kind) = matches.value_of("resource") { 220 | if let Some(name) = matches.value_of("name") { 221 | if let Some((path, _)) = ApixManifest::find_manifest(kind, name) { 222 | pretty_print_file(path, &theme, "yaml", is_output_terminal)?; 223 | } else { 224 | println!("No resource of type {} where found with name {}", kind, name); 225 | } 226 | } else if let Ok(manifests) = ApixManifest::find_manifests_by_kind(kind) { 227 | let mut manifests = manifests.peekable(); 228 | let found = manifests.peek().is_some(); 229 | if found { 230 | if !is_output_terminal { 231 | for (path, _) in manifests { 232 | pretty_print_file(path, &theme, "yaml", false)?; 233 | } 234 | } else { 235 | let mut table = Table::new(); 236 | table 237 | .load_preset("││──├─┼┤│─┼├┤┬┴╭╮╰╯") 238 | .set_content_arrangement(ContentArrangement::Dynamic); 239 | match kind { 240 | "request" => { 241 | table.set_header(["Name", "Method", "Url", "Last Modified"]); 242 | for (_, manifest) in manifests { 243 | let request = manifest.kind().as_request().unwrap(); 244 | table.add_row(vec![ 245 | manifest.name(), 246 | &request.request.method.to_uppercase(), 247 | &request.request.url, 248 | &manifest 249 | .get_annotation("apix.io/created-at") 250 | .map(String::clone) 251 | .unwrap_or_default(), 252 | ]); 253 | } 254 | println!("{table}"); 255 | } 256 | "story" => { 257 | table.set_header(["Name", "Stories", "Last Modified"]); 258 | for (_, manifest) in manifests { 259 | let story = manifest.kind().as_story().unwrap(); 260 | table.add_row(vec![ 261 | manifest.name(), 262 | &story.stories.len().to_string(), 263 | &manifest 264 | .get_annotation("apix.io/created-at") 265 | .map(String::clone) 266 | .unwrap_or_default(), 267 | ]); 268 | } 269 | println!("{table}"); 270 | } 271 | _ => {} 272 | } 273 | } 274 | } else { 275 | println!("No resources of type {} where found", kind); 276 | } 277 | } else { 278 | println!("No resources of type {} where found", kind); 279 | } 280 | } 281 | } 282 | Some(("delete", _submatches)) => {} 283 | Some(("import", matches)) => { 284 | if let Some(url) = matches.value_of("url") { 285 | handle_import(url).await?; 286 | } 287 | } 288 | _ => {} 289 | }, 290 | Some((method, matches)) => { 291 | if let Some(url) = matches.value_of("url") { 292 | requests::make_request( 293 | url, 294 | method, 295 | matches.match_headers().as_ref(), 296 | matches.match_params(RequestParam::Query).as_ref(), 297 | matches.match_body(), 298 | RequestOptions { 299 | verbose: matches.is_present("verbose"), 300 | theme: &theme, 301 | is_output_terminal, 302 | output_filename: matches.value_of("output-file").map(str::to_string), 303 | proxy_url: matches.value_of("proxy").map(str::to_string), 304 | proxy_login: matches.value_of("proxy-login").map(str::to_string), 305 | proxy_password: matches.value_of("proxy-password").map(str::to_string), 306 | }, 307 | ) 308 | .await?; 309 | } 310 | } 311 | _ => {} 312 | } 313 | Ok(()) 314 | } 315 | -------------------------------------------------------------------------------- /src/manifests/config.rs: -------------------------------------------------------------------------------- 1 | use super::{ApixKind, ApixManifest, ApixManifestV1, ApixMetadata}; 2 | use anyhow::Result; 3 | use indexmap::{indexmap, IndexMap}; 4 | use once_cell::sync::Lazy; 5 | use serde::{Deserialize, Serialize}; 6 | use serde_yaml; 7 | use std::{fs, ops::DerefMut}; 8 | 9 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 10 | pub struct ApixConfiguration { 11 | #[serde(flatten, default)] 12 | pub index: IndexMap, 13 | } 14 | 15 | impl Default for ApixConfiguration { 16 | fn default() -> Self { 17 | let mut instance = Self { index: IndexMap::new() }; 18 | instance.set_defaults(); 19 | instance 20 | } 21 | } 22 | 23 | impl ApixConfiguration { 24 | pub fn once() -> &'static mut ApixConfiguration { 25 | static mut CONFIG: Lazy = Lazy::new(|| ApixConfiguration::load().unwrap()); 26 | unsafe { CONFIG.deref_mut() } 27 | } 28 | 29 | // private function to create apix directory if it does not exist 30 | fn create_apix_dir_if_not_exists() -> Result { 31 | let apix_dir = dirs::home_dir() 32 | .expect("Could not find HOME path, login as a user to use Apix") 33 | .join(".apix"); 34 | fs::create_dir_all(&apix_dir)?; 35 | 36 | Ok(apix_dir) 37 | } 38 | 39 | // private function to load apix configuration from file when given a path 40 | fn load_from_path(path: &std::path::Path) -> Result { 41 | if let Ok(content) = fs::read_to_string(path) { 42 | Self::load_from_string(&content, &format!("config file {:?}", &path)) 43 | } else { 44 | Ok(Self::default()) 45 | } 46 | } 47 | 48 | // private function to load apix configuration from string when given a content 49 | fn load_from_string(content: &str, err_msg: &str) -> Result { 50 | if !content.is_empty() { 51 | let manifest: ApixManifest = 52 | serde_yaml::from_str(content).map_err(|e| anyhow::anyhow!("Could not parse {}: {:#}", &err_msg, e))?; 53 | return match manifest.kind() { 54 | ApixKind::Configuration(conf) => { 55 | let mut config = conf.clone(); 56 | config.set_defaults(); 57 | Ok(config) 58 | } 59 | _ => Err(anyhow::anyhow!("Invalid {}", &err_msg)), 60 | }; 61 | } 62 | Ok(Self::default()) 63 | } 64 | 65 | // private method to save apix configuration to file when given a path 66 | fn save_to_path(&self, path: &std::path::Path) -> Result<()> { 67 | let manifest = ApixManifest::new_configuration(Some(self.clone())); 68 | let file = serde_yaml::to_string(&manifest)?; 69 | fs::write(path, file)?; 70 | Ok(()) 71 | } 72 | 73 | // private method to set default values for apix configuration 74 | fn set_defaults(&mut self) { 75 | if self.get("theme").is_none() { 76 | self.set("theme".to_string(), "Monokai Extended".to_string()); 77 | } 78 | } 79 | 80 | // public function to load apix configuration from apix directory 81 | pub fn load() -> Result { 82 | let filename = Self::create_apix_dir_if_not_exists()?.join("config.yml"); 83 | Self::load_from_path(&filename) 84 | } 85 | 86 | // public method to save apix configuration to apix directory 87 | pub fn save(&self) -> Result<()> { 88 | let filename = Self::create_apix_dir_if_not_exists()?.join("config.yml"); 89 | self.save_to_path(&filename) 90 | } 91 | 92 | // public method to get apix configuration value by key 93 | pub fn get(&self, key: &str) -> Option<&String> { 94 | self.index.get(key) 95 | } 96 | 97 | // public method to set apix configuration value by key 98 | pub fn set(&mut self, key: String, value: String) -> Option { 99 | self.index.insert(key, value) 100 | } 101 | 102 | // public method to remove apix configuration value by key 103 | pub fn delete(&mut self, key: &str) -> Option { 104 | self.index.remove(key) 105 | } 106 | } 107 | 108 | impl ApixManifest { 109 | // There is only one configuration file per user, hence the name is hardcoded 110 | pub fn new_configuration(config: Option) -> Self { 111 | ApixManifest::V1(ApixManifestV1 { 112 | metadata: ApixMetadata { 113 | name: "configuration".to_string(), 114 | labels: indexmap! { "app".to_string() => "apix".to_string() }, 115 | annotations: indexmap! { 116 | "apix.io/created-by".to_string() => whoami::username(), 117 | "apix.io/created-at".to_string() => chrono::Utc::now().to_rfc3339(), 118 | }, 119 | extensions: IndexMap::new(), 120 | }, 121 | kind: ApixKind::Configuration(config.unwrap_or_default()), 122 | }) 123 | } 124 | } 125 | 126 | #[cfg(test)] 127 | mod tests { 128 | use super::*; 129 | 130 | // static error message 131 | static ERROR_MSG: &str = "config string"; 132 | 133 | // test apix has default config 134 | #[test] 135 | fn test_default_config() { 136 | let config = ApixConfiguration::default(); 137 | assert_eq!(config.get("theme").unwrap(), "Monokai Extended"); 138 | } 139 | // test ApixConfig default deserialize 140 | #[test] 141 | fn test_default_config_deserialize() { 142 | let config = r#" 143 | apiVersion: "apix.io/v1" 144 | kind: "Configuration" 145 | metadata: 146 | name: "configuration" 147 | labels: 148 | app: "apix" 149 | spec: 150 | rust: "rust" 151 | "#; 152 | let config = ApixConfiguration::load_from_string(config, ERROR_MSG).unwrap(); 153 | assert_eq!(config.get("theme").unwrap(), "Monokai Extended"); 154 | assert_eq!(config.get("rust").unwrap(), "rust"); 155 | } 156 | // test ApixConfig deserialize 157 | #[test] 158 | fn test_config_deserialize() { 159 | let config = r#" 160 | apiVersion: "apix.io/v1" 161 | kind: "Configuration" 162 | metadata: 163 | name: "configuration" 164 | labels: 165 | app: "apix" 166 | spec: 167 | theme: "Coldark-Dark" 168 | rust: "rust" 169 | "#; 170 | let config = ApixConfiguration::load_from_string(config, ERROR_MSG).unwrap(); 171 | assert_eq!(config.get("theme").unwrap(), "Coldark-Dark"); 172 | assert_eq!(config.get("rust").unwrap(), "rust"); 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /src/manifests/mod.rs: -------------------------------------------------------------------------------- 1 | use std::path::{Path, PathBuf}; 2 | 3 | pub use self::config::ApixConfiguration; 4 | pub mod config; 5 | 6 | use anyhow::Result; 7 | use indexmap::{indexmap, IndexMap}; 8 | use serde::{Deserialize, Serialize}; 9 | use serde_json::{json, Value}; 10 | use strum_macros::Display as EnumDisplay; 11 | 12 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] 13 | pub struct ApixApi { 14 | pub url: String, 15 | pub version: String, 16 | pub description: Option, 17 | } 18 | 19 | impl ApixApi { 20 | #[allow(dead_code)] 21 | pub fn new(url: String, version: String, description: Option) -> Self { 22 | Self { 23 | url, 24 | version, 25 | description, 26 | } 27 | } 28 | } 29 | 30 | fn default_schema() -> Option { 31 | Some(json!({ "type": "string" })) 32 | } 33 | 34 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 35 | pub struct ApixParameter { 36 | pub name: String, 37 | #[serde(default)] 38 | pub required: bool, 39 | #[serde(default)] 40 | pub password: bool, 41 | pub description: Option, 42 | #[serde(default = "default_schema", skip_serializing_if = "Option::is_none")] 43 | pub schema: Option, 44 | } 45 | 46 | impl ApixParameter { 47 | #[allow(dead_code)] 48 | pub fn new(name: String, required: bool, password: bool, description: Option, schema: Option) -> Self { 49 | Self { 50 | name, 51 | required, 52 | password, 53 | description, 54 | schema, 55 | } 56 | } 57 | } 58 | 59 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 60 | pub struct ApixStep { 61 | name: String, 62 | #[serde(default, skip_serializing_if = "Option::is_none")] 63 | description: Option, 64 | #[serde(default, skip_serializing_if = "IndexMap::is_empty")] 65 | context: IndexMap, 66 | #[serde(default, skip_serializing_if = "Option::is_none", rename = "if")] 67 | if_: Option, 68 | request: ApixRequestTemplate, 69 | } 70 | 71 | /** 72 | * exemple of a story in yaml 73 | * 74 | * ```yaml 75 | * name: get_user 76 | * description: Get a user by retriving a token first 77 | * context: 78 | * dev: 79 | * url: "https://dev.apix.io" 80 | * prod: 81 | * url: "https://prod.apix.io" 82 | * steps: 83 | * - name: "get_token" 84 | * description: "Get a token" 85 | * request: 86 | * method: "GET" 87 | * url: "{{story.variables.url}}/token" 88 | * headers: 89 | * Authorization: "Basic {{parameters.credentials}}" 90 | * Accept: "application/json" 91 | * - name: "get_user" 92 | * description: "Get a user" 93 | * request: 94 | * method: "GET" 95 | * url: "{{story.variables.url}}/user/{{parameters.user}}" 96 | * headers: 97 | * Authorization: "Bearer {{steps.get_token.response.body.token}}" 98 | * Accept: "application/json" 99 | */ 100 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 101 | pub struct ApixStory { 102 | name: String, 103 | #[serde(default, skip_serializing_if = "Option::is_none")] 104 | needs: Option, 105 | #[serde(default, skip_serializing_if = "Option::is_none")] 106 | description: Option, 107 | #[serde(default, skip_serializing_if = "IndexMap::is_empty")] 108 | context: IndexMap>, 109 | steps: Vec, 110 | } 111 | 112 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 113 | pub struct ApixStories { 114 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 115 | pub parameters: Vec, 116 | pub stories: Vec, 117 | } 118 | 119 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 120 | pub struct ApixRequestTemplate { 121 | pub method: String, 122 | pub url: String, 123 | #[serde(default, skip_serializing_if = "IndexMap::is_empty")] 124 | pub headers: IndexMap, 125 | #[serde(default, skip_serializing_if = "IndexMap::is_empty")] 126 | pub queries: IndexMap, 127 | #[serde(default, skip_serializing_if = "Option::is_none")] 128 | pub body: Option, 129 | } 130 | 131 | impl ApixRequestTemplate { 132 | pub fn new( 133 | method: String, 134 | url: String, 135 | headers: IndexMap, 136 | queries: IndexMap, 137 | body: Option, 138 | ) -> Self { 139 | Self { 140 | method, 141 | url, 142 | headers, 143 | queries, 144 | body, 145 | } 146 | } 147 | } 148 | 149 | // exemple of an ApixRequest for a GET request in yaml 150 | // 151 | // parameters: 152 | // - name: param 153 | // required: true 154 | // description: param description 155 | // schema: 156 | // type: string 157 | // template: 158 | // method: GET 159 | // url: /api/v1/resources/{param} 160 | // headers: 161 | // Accept: application/json 162 | 163 | // exemple of an ApixRequest for a POST request with body template in yaml 164 | // 165 | // parameters: 166 | // - name: param 167 | // required: true 168 | // description: param description 169 | // schema: 170 | // type: string 171 | // template: 172 | // method: POST 173 | // url: /api/v1/resources 174 | // headers: 175 | // Accept: application/json 176 | // Content-Type: application/json 177 | // body: |- 178 | // { 179 | // "param": {{param}} 180 | // } 181 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 182 | pub struct ApixRequest { 183 | #[serde(default, skip_serializing_if = "Vec::is_empty")] 184 | pub parameters: Vec, 185 | #[serde(default, skip_serializing_if = "IndexMap::is_empty")] 186 | pub context: IndexMap, 187 | pub request: ApixRequestTemplate, 188 | } 189 | 190 | impl ApixRequest { 191 | pub fn new(parameters: Vec, context: IndexMap, request: ApixRequestTemplate) -> Self { 192 | Self { 193 | parameters, 194 | context, 195 | request, 196 | } 197 | } 198 | } 199 | 200 | #[allow(clippy::large_enum_variant)] 201 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, EnumDisplay)] 202 | #[serde(tag = "kind", content = "spec")] 203 | pub enum ApixKind { 204 | Api(ApixApi), 205 | Configuration(ApixConfiguration), 206 | Request(ApixRequest), 207 | Story(ApixStories), 208 | None, 209 | } 210 | 211 | impl Default for ApixKind { 212 | fn default() -> Self { 213 | ApixKind::None 214 | } 215 | } 216 | 217 | impl ApixKind { 218 | #[allow(dead_code)] 219 | pub fn as_api(&self) -> Option<&ApixApi> { 220 | match self { 221 | ApixKind::Api(api) => Some(api), 222 | _ => None, 223 | } 224 | } 225 | 226 | #[allow(dead_code)] 227 | pub fn as_configuration(&self) -> Option<&ApixConfiguration> { 228 | match self { 229 | ApixKind::Configuration(configuration) => Some(configuration), 230 | _ => None, 231 | } 232 | } 233 | 234 | #[allow(dead_code)] 235 | pub fn as_request(&self) -> Option<&ApixRequest> { 236 | match self { 237 | ApixKind::Request(request) => Some(request), 238 | _ => None, 239 | } 240 | } 241 | 242 | #[allow(dead_code)] 243 | pub fn as_story(&self) -> Option<&ApixStories> { 244 | match self { 245 | ApixKind::Story(story) => Some(story), 246 | _ => None, 247 | } 248 | } 249 | } 250 | 251 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 252 | pub struct ApixMetadata { 253 | name: String, 254 | #[serde(default, skip_serializing_if = "IndexMap::is_empty")] 255 | labels: IndexMap, 256 | #[serde(default, skip_serializing_if = "IndexMap::is_empty")] 257 | annotations: IndexMap, 258 | #[serde(flatten)] 259 | extensions: IndexMap, 260 | } 261 | 262 | #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)] 263 | #[serde(rename_all = "camelCase")] 264 | pub struct ApixManifestV1 { 265 | metadata: ApixMetadata, 266 | #[serde(flatten)] 267 | kind: ApixKind, 268 | } 269 | 270 | #[allow(clippy::large_enum_variant)] 271 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] 272 | #[serde(tag = "apiVersion")] 273 | pub enum ApixManifest { 274 | #[serde(rename = "apix.io/v1")] 275 | V1(ApixManifestV1), 276 | None, 277 | } 278 | 279 | impl Default for ApixManifest { 280 | fn default() -> Self { 281 | ApixManifest::None 282 | } 283 | } 284 | 285 | impl ApixManifest { 286 | pub fn find_manifests() -> Result> { 287 | let current_dir = std::env::current_dir()?; 288 | let manifests = std::fs::read_dir(current_dir)?.filter_map(|entry| { 289 | if let Ok(entry) = entry { 290 | let path = entry.path(); 291 | if path.is_file() { 292 | match path.extension() { 293 | Some(ext) if ext == "yaml" || ext == "yml" => { 294 | if let Ok(manifest) = ApixManifest::from_file(&path) { 295 | return Some((path, manifest)); 296 | } 297 | } 298 | _ => {} 299 | } 300 | } 301 | } 302 | None 303 | }); 304 | Ok(manifests) 305 | } 306 | 307 | pub fn find_manifests_by_kind(kind: &str) -> Result + '_> { 308 | Self::find_manifests().map(move |manifests| { 309 | manifests.filter(move |(_, manifest)| match manifest { 310 | ApixManifest::V1(manifestv1) => manifestv1.kind.to_string().to_lowercase() == kind, 311 | _ => false, 312 | }) 313 | }) 314 | } 315 | 316 | pub fn find_manifest(kind: &str, name: &str) -> Option<(PathBuf, ApixManifest)> { 317 | Self::find_manifests() 318 | .ok() 319 | .map(|mut manifests| { 320 | manifests.find(|(_, manifest)| match manifest { 321 | ApixManifest::V1(manifest) => { 322 | manifest.kind.to_string().to_lowercase() == kind && manifest.metadata.name == name 323 | } 324 | _ => false, 325 | }) 326 | }) 327 | .flatten() 328 | } 329 | 330 | pub fn find_manifest_filename(kind: &str, name: &str) -> Option { 331 | Self::find_manifest(kind, name) 332 | .map(|(path, _)| path.to_str().map(str::to_string)) 333 | .flatten() 334 | } 335 | 336 | #[allow(dead_code)] 337 | pub fn new_api(name: String, api: Option) -> Self { 338 | ApixManifest::V1(ApixManifestV1 { 339 | metadata: ApixMetadata { 340 | name, 341 | labels: indexmap! { "app".to_string() => "apix".to_string()}, 342 | annotations: indexmap! { 343 | "apix.io/created-by".to_string() => whoami::username(), 344 | "apix.io/created-at".to_string() => chrono::Utc::now().to_rfc3339(), 345 | }, 346 | extensions: IndexMap::new(), 347 | }, 348 | kind: ApixKind::Api(api.unwrap_or_default()), 349 | }) 350 | } 351 | 352 | pub fn new_request(api: String, name: String, request: ApixRequest) -> Self { 353 | ApixManifest::V1(ApixManifestV1 { 354 | metadata: ApixMetadata { 355 | name, 356 | labels: indexmap! { 357 | "app".to_string() => "apix".to_string(), 358 | "apix.io/api".to_string() => api, 359 | }, 360 | annotations: indexmap! { 361 | "apix.io/created-by".to_string() => whoami::username(), 362 | "apix.io/created-at".to_string() => chrono::Utc::now().to_rfc3339(), 363 | }, 364 | extensions: IndexMap::new(), 365 | }, 366 | kind: ApixKind::Request(request), 367 | }) 368 | } 369 | 370 | #[allow(dead_code)] 371 | pub fn new_stories(api: String, name: String, stories: ApixStories) -> Self { 372 | ApixManifest::V1(ApixManifestV1 { 373 | metadata: ApixMetadata { 374 | name, 375 | labels: indexmap! { 376 | "app".to_string() => "apix".to_string(), 377 | "apix.io/api".to_string() => api, 378 | }, 379 | annotations: indexmap! { 380 | "apix.io/created-by".to_string() => whoami::username(), 381 | "apix.io/created-at".to_string() => chrono::Utc::now().to_rfc3339(), 382 | }, 383 | extensions: IndexMap::new(), 384 | }, 385 | kind: ApixKind::Story(stories), 386 | }) 387 | } 388 | 389 | pub fn from_file(path: &Path) -> Result { 390 | let content = std::fs::read_to_string(path)?; 391 | let manifest = serde_yaml::from_str::(&content)?; 392 | Ok(manifest) 393 | } 394 | 395 | #[allow(dead_code)] 396 | pub fn name(&self) -> &str { 397 | match self { 398 | ApixManifest::V1(manifest) => &manifest.metadata.name, 399 | ApixManifest::None => "", 400 | } 401 | } 402 | 403 | #[allow(dead_code)] 404 | pub fn version(&self) -> &str { 405 | match self { 406 | ApixManifest::V1(_) => "apix.io/v1", 407 | ApixManifest::None => "", 408 | } 409 | } 410 | 411 | pub fn kind(&self) -> &ApixKind { 412 | match self { 413 | ApixManifest::V1(manifest) => &manifest.kind, 414 | ApixManifest::None => &ApixKind::None, 415 | } 416 | } 417 | 418 | #[allow(dead_code)] 419 | pub fn get_metadata(&self, key: &str) -> Option<&String> { 420 | match self { 421 | ApixManifest::V1(manifest) => manifest.metadata.extensions.get(key), 422 | ApixManifest::None => None, 423 | } 424 | } 425 | 426 | #[allow(dead_code)] 427 | pub fn insert_metadata(&mut self, key: String, value: String) { 428 | match self { 429 | ApixManifest::V1(manifest) => { 430 | manifest.metadata.extensions.insert(key, value); 431 | } 432 | ApixManifest::None => (), 433 | } 434 | } 435 | 436 | #[allow(dead_code)] 437 | pub fn get_annotation(&self, key: &str) -> Option<&String> { 438 | match self { 439 | ApixManifest::V1(manifest) => manifest.metadata.annotations.get(key), 440 | ApixManifest::None => None, 441 | } 442 | } 443 | 444 | #[allow(dead_code)] 445 | pub fn get_annotations(&self) -> Option<&IndexMap> { 446 | match self { 447 | ApixManifest::V1(manifest) => Some(&manifest.metadata.annotations), 448 | ApixManifest::None => None, 449 | } 450 | } 451 | 452 | #[allow(dead_code)] 453 | pub fn get_label(&self, key: &str) -> Option<&String> { 454 | match self { 455 | ApixManifest::V1(manifest) => manifest.metadata.labels.get(key), 456 | ApixManifest::None => None, 457 | } 458 | } 459 | } 460 | -------------------------------------------------------------------------------- /src/match_params.rs: -------------------------------------------------------------------------------- 1 | use super::requests::AdvancedBody; 2 | use anyhow::Result; 3 | use indexmap::IndexMap; 4 | use once_cell::sync::Lazy; 5 | use regex::Regex; 6 | use reqwest::header::{HeaderMap, HeaderName, HeaderValue}; 7 | use std::str::FromStr; 8 | use strum_macros::Display; 9 | 10 | #[derive(Display, Debug)] 11 | #[strum(serialize_all = "snake_case")] 12 | pub enum RequestParam { 13 | Header, 14 | Cookie, 15 | Query, 16 | Param, 17 | } 18 | 19 | #[derive(Debug)] 20 | struct HeaderTuple(HeaderName, HeaderValue); 21 | 22 | impl FromStr for HeaderTuple { 23 | type Err = anyhow::Error; 24 | fn from_str(header_string: &str) -> Result { 25 | static RE: Lazy = Lazy::new(|| Regex::new("^([\\w-]+):(.*)$").unwrap()); 26 | 27 | let header_split = RE.captures(header_string).ok_or_else(|| { 28 | anyhow::anyhow!( 29 | "Bad header format: \"{}\", should be of the form \":\"", 30 | header_string 31 | ) 32 | })?; 33 | Ok(HeaderTuple( 34 | HeaderName::from_str(&header_split[1])?, 35 | HeaderValue::from_str(&header_split[2])?, 36 | )) 37 | } 38 | } 39 | 40 | #[derive(Debug)] 41 | struct StringTuple(String, String); 42 | 43 | impl FromStr for StringTuple { 44 | type Err = anyhow::Error; 45 | fn from_str(query_string: &str) -> Result { 46 | static RE: Lazy = Lazy::new(|| Regex::new("^([\\w-]+):(.*)$").unwrap()); 47 | 48 | let query = query_string.to_string(); 49 | let header_split = RE.captures(&query).ok_or_else(|| { 50 | anyhow::anyhow!( 51 | "Bad query format: \"{}\", should be of the form \":\"", 52 | query_string 53 | ) 54 | })?; 55 | Ok(StringTuple(header_split[1].to_string(), header_split[2].to_string())) 56 | } 57 | } 58 | 59 | pub trait MatchParams { 60 | fn match_headers(&self) -> Option; 61 | fn match_params(&self, param_type: RequestParam) -> Option>; 62 | fn match_body(&self) -> Option; 63 | } 64 | 65 | impl MatchParams for clap::ArgMatches { 66 | fn match_headers(&self) -> Option { 67 | if let Ok(header_tuples) = self.values_of_t::("header") { 68 | let headers = header_tuples.iter().map(|tuple| (tuple.0.clone(), tuple.1.clone())); 69 | Some(HeaderMap::from_iter(headers)) 70 | } else { 71 | None 72 | } 73 | } 74 | 75 | fn match_params(&self, param_type: RequestParam) -> Option> { 76 | if let Ok(param_tuples) = self.values_of_t::(¶m_type.to_string()) { 77 | let params = param_tuples.iter().map(|tuple| (tuple.0.clone(), tuple.1.clone())); 78 | Some(IndexMap::from_iter(params)) 79 | } else { 80 | None 81 | } 82 | } 83 | 84 | fn match_body(&self) -> Option { 85 | if let Some(body) = self.value_of("body") { 86 | Some(AdvancedBody::String(body.to_string())) 87 | } else { 88 | self.value_of("file").map(|file| AdvancedBody::File(file.to_string())) 89 | } 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use super::*; 96 | use clap::{arg, App}; 97 | 98 | // test match headers 99 | #[test] 100 | fn test_match_headers() { 101 | let matches = App::new("test") 102 | .arg(arg!(--header "Header to add").takes_value(true)) 103 | .get_matches_from(vec!["test", "--header", "foo:bar"]); 104 | let headers = matches.match_headers(); 105 | assert!(headers.is_some()); 106 | let headers = headers.unwrap(); 107 | assert_eq!(headers.get("foo"), Some(&"bar".parse::().unwrap())); 108 | } 109 | 110 | // test match queries 111 | #[test] 112 | fn test_match_queries() { 113 | let matches = App::new("test") 114 | .arg(arg!(--query "Query to add").takes_value(true)) 115 | .get_matches_from(vec!["test", "--query", "foo:bar"]); 116 | let queries = matches.match_params(RequestParam::Query); 117 | assert!(queries.is_some()); 118 | let queries = queries.unwrap(); 119 | assert_eq!(queries.get("foo"), Some(&"bar".to_string())); 120 | } 121 | 122 | // test match params 123 | #[test] 124 | fn test_match_params() { 125 | let matches = App::new("test") 126 | .arg(arg!(--param "Param to add").takes_value(true)) 127 | .get_matches_from(vec!["test", "--param", "foo:bar"]); 128 | let params = matches.match_params(RequestParam::Param); 129 | assert!(params.is_some()); 130 | let params = params.unwrap(); 131 | assert_eq!(params.get("foo"), Some(&"bar".to_string())); 132 | } 133 | 134 | // test match body 135 | #[test] 136 | fn test_match_body() { 137 | let matches = App::new("test") 138 | .arg(arg!(--body "Body to add").takes_value(true)) 139 | .get_matches_from(vec!["test", "--body", "foo"]); 140 | let body = matches.match_body(); 141 | assert!(body.is_some()); 142 | assert_eq!(body.unwrap().to_string().unwrap(), "foo".to_string()); 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/match_prompts.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use clap::ArgMatches; 3 | use dialoguer::{theme::ColorfulTheme, Confirm, Input, Select}; 4 | use indexmap::IndexMap; 5 | pub trait MatchPrompts { 6 | fn match_or_input(&self, name: &str, msg: &str) -> Result; 7 | fn match_or_validate_input Result<()>>( 8 | &self, 9 | name: &str, 10 | msg: &str, 11 | validator: V, 12 | ) -> Result; 13 | fn match_or_input_multiples(&self, name: &str, msg: &str) -> Result>; 14 | fn match_or_optional_input(&self, name: &str, msg: &str) -> Result>; 15 | fn match_or_select(&self, name: &str, msg: &str, options: &[T]) -> Result; 16 | } 17 | 18 | impl MatchPrompts for ArgMatches { 19 | fn match_or_input(&self, name: &str, msg: &str) -> Result { 20 | match self.value_of(name) { 21 | Some(value) => Ok(value.to_string()), 22 | None => Ok( 23 | Input::with_theme(&ColorfulTheme::default()) 24 | .with_prompt(msg) 25 | .interact_text()?, 26 | ), 27 | } 28 | } 29 | fn match_or_validate_input(&self, name: &str, msg: &str, validator: V) -> Result 30 | where 31 | V: FnMut(&String) -> Result<()>, 32 | { 33 | match self.value_of(name) { 34 | Some(value) => Ok(value.to_string()), 35 | None => Ok( 36 | Input::with_theme(&ColorfulTheme::default()) 37 | .with_prompt(msg) 38 | .validate_with(validator) 39 | .interact_text()?, 40 | ), 41 | } 42 | } 43 | 44 | fn match_or_input_multiples(&self, name: &str, msg: &str) -> Result> { 45 | match self.values_of(name) { 46 | Some(values) => { 47 | let mut map = IndexMap::new(); 48 | for value in values { 49 | let mut parts = value.splitn(2, ':'); 50 | let key = parts 51 | .next() 52 | .ok_or_else(|| anyhow::anyhow!("No key found in '{}'", value))?; 53 | let value = parts 54 | .next() 55 | .ok_or_else(|| anyhow::anyhow!("No value found in '{}'", value))?; 56 | map.insert(key.to_string(), value.to_string()); 57 | } 58 | Ok(map) 59 | } 60 | None => { 61 | let mut map = IndexMap::new(); 62 | loop { 63 | let add = Confirm::with_theme(&ColorfulTheme::default()) 64 | .with_prompt(msg) 65 | .interact()?; 66 | if add { 67 | let key = Input::with_theme(&ColorfulTheme::default()) 68 | .with_prompt(format!("{} name", name)) 69 | .interact_text()?; 70 | let value = Input::with_theme(&ColorfulTheme::default()) 71 | .with_prompt(format!("{} value", name)) 72 | .interact_text()?; 73 | map.insert(key, value); 74 | } else { 75 | break; 76 | } 77 | } 78 | Ok(map) 79 | } 80 | } 81 | } 82 | 83 | fn match_or_optional_input(&self, name: &str, msg: &str) -> Result> { 84 | match self.value_of(name) { 85 | Some(value) => Ok(Some(value.to_string())), 86 | None => { 87 | if Confirm::with_theme(&ColorfulTheme::default()) 88 | .with_prompt(msg) 89 | .interact()? 90 | { 91 | Ok(Some( 92 | Input::with_theme(&ColorfulTheme::default()) 93 | .with_prompt(name) 94 | .interact_text()?, 95 | )) 96 | } else { 97 | Ok(None) 98 | } 99 | } 100 | } 101 | } 102 | 103 | fn match_or_select(&self, name: &str, msg: &str, options: &[T]) -> Result { 104 | match self.value_of(name) { 105 | Some(value) => Ok(value.to_string()), 106 | None => { 107 | let select = Select::with_theme(&ColorfulTheme::default()) 108 | .with_prompt(msg) 109 | .items(options) 110 | .interact()?; 111 | Ok(options[select].to_string()) 112 | } 113 | } 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/progress_component.rs: -------------------------------------------------------------------------------- 1 | use indicatif::{ProgressBar, ProgressStyle}; 2 | 3 | pub struct FileProgress { 4 | path: String, 5 | progress: ProgressBar, 6 | } 7 | 8 | pub enum FileProgressComponent { 9 | Download(FileProgress), 10 | Upload(FileProgress), 11 | } 12 | 13 | impl FileProgress { 14 | fn new(path: String, size_hint: u64) -> Self { 15 | let progress = ProgressBar::new(size_hint); 16 | progress.set_style(ProgressStyle::default_bar().template( 17 | "{msg} - {percent}%\n{spinner:.green} [{elapsed_precise}] {wide_bar:.cyan/blue} {bytes}/{total_bytes} ({bytes_per_sec}, {eta})", 18 | ).tick_chars("🕐🕑🕒🕓🕔🕕🕖🕗🕘🕙🕚🕛")); 19 | progress.set_draw_rate(6); 20 | Self { path, progress } 21 | } 22 | } 23 | 24 | impl FileProgressComponent { 25 | pub fn new_download(path: String, size_hint: u64) -> Self { 26 | let progress = FileProgress::new(path, size_hint); 27 | FileProgressComponent::Download(progress) 28 | } 29 | pub fn new_upload(path: String, size_hint: u64) -> Self { 30 | let progress = FileProgress::new(path, size_hint); 31 | FileProgressComponent::Upload(progress) 32 | } 33 | pub fn update_progress(&self, bytes: u64) { 34 | match self { 35 | FileProgressComponent::Download(component) => { 36 | component 37 | .progress 38 | .set_message(format!("Downloading File {}", component.path)); 39 | component.progress.inc(bytes); 40 | if component.progress.is_finished() { 41 | component.progress.finish_with_message("Download Complete"); 42 | } 43 | } 44 | FileProgressComponent::Upload(component) => { 45 | component 46 | .progress 47 | .set_message(format!("Uploading File {}", component.path)); 48 | component.progress.inc(bytes); 49 | if component.progress.is_finished() { 50 | component.progress.finish_with_message("Upload Complete"); 51 | } 52 | } 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /src/requests.rs: -------------------------------------------------------------------------------- 1 | use super::display::{pretty_print, print_separator, HttpDisplay}; 2 | use super::http_utils::Language; 3 | use super::progress_component::FileProgressComponent; 4 | use anyhow::Result; 5 | use futures::stream::TryStreamExt; 6 | use indexmap::IndexMap; 7 | use once_cell::sync::Lazy; 8 | use reqwest::{ 9 | header::{HeaderMap, HeaderValue, ACCEPT, ACCEPT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, USER_AGENT}, 10 | Body, Client, Method, 11 | }; 12 | use serde_json::Value; 13 | use std::fs::File; 14 | use std::str::FromStr; 15 | use tokio::fs::File as AsyncFile; 16 | use tokio_util::codec::{BytesCodec, FramedRead}; 17 | use tokio_util::compat::FuturesAsyncReadCompatExt; 18 | use url::Url; 19 | 20 | static APP_USER_AGENT: &str = concat!(env!("CARGO_PKG_NAME"), "/", env!("CARGO_PKG_VERSION"),); 21 | 22 | static DEFAULT_HEADERS: Lazy = Lazy::new(|| { 23 | HeaderMap::from_iter([ 24 | (USER_AGENT, HeaderValue::from_str(APP_USER_AGENT).unwrap()), 25 | (ACCEPT, HeaderValue::from_static("application/json")), 26 | (ACCEPT_ENCODING, HeaderValue::from_static("gzip")), 27 | (CONTENT_TYPE, HeaderValue::from_static("application/json")), 28 | ]) 29 | }); 30 | 31 | fn merge_with_defaults(headers: &HeaderMap) -> HeaderMap { 32 | let mut merged = DEFAULT_HEADERS.clone(); 33 | for (key, value) in headers { 34 | merged.insert(key.clone(), value.clone()); 35 | } 36 | merged 37 | } 38 | 39 | #[derive(Debug, Clone)] 40 | pub enum AdvancedBody { 41 | Json(Value), 42 | String(String), 43 | File(String), 44 | } 45 | 46 | impl AdvancedBody { 47 | #[allow(dead_code)] 48 | pub fn to_string(&self) -> Result { 49 | match self { 50 | AdvancedBody::Json(value) => Ok(serde_json::to_string(value)?), 51 | AdvancedBody::String(value) => Ok(value.to_string()), 52 | AdvancedBody::File(path) => Ok(std::fs::read_to_string(path)?), 53 | } 54 | } 55 | } 56 | 57 | #[derive(Debug, Clone)] 58 | pub struct RequestOptions<'a> { 59 | pub verbose: bool, 60 | pub theme: &'a str, 61 | pub is_output_terminal: bool, 62 | pub output_filename: Option, 63 | pub proxy_url: Option, 64 | pub proxy_login: Option, 65 | pub proxy_password: Option, 66 | } 67 | 68 | pub async fn make_request( 69 | url: &str, 70 | method: &str, 71 | headers: Option<&HeaderMap>, 72 | queries: Option<&IndexMap>, 73 | body: Option, 74 | options: RequestOptions<'_>, 75 | ) -> Result<()> { 76 | let mut client_builder = Client::builder(); 77 | if let Some(proxy_url) = options.proxy_url { 78 | let mut proxy = reqwest::Proxy::all(&proxy_url)?; 79 | if let (Some(proxy_login), Some(proxy_password)) = (options.proxy_login, options.proxy_password) { 80 | proxy = proxy.basic_auth(&proxy_login, &proxy_password); 81 | } 82 | client_builder = client_builder.proxy(proxy); 83 | } 84 | let client = client_builder.gzip(true).build()?; 85 | let mut builder = client.request(Method::from_str(&method.to_uppercase())?, url); 86 | if let Some(headers) = headers { 87 | builder = builder.headers(merge_with_defaults(headers)) 88 | } else { 89 | builder = builder.headers(DEFAULT_HEADERS.clone()) 90 | } 91 | if let Some(query) = queries { 92 | builder = builder.query(query); 93 | } 94 | match body { 95 | Some(AdvancedBody::String(body)) => { 96 | builder = builder.body(body); 97 | } 98 | Some(AdvancedBody::File(file_path)) => { 99 | let file = 100 | File::open(&file_path).map_err(|e| anyhow::anyhow!("Could not open File '{}'\nCause: {}", &file_path, e))?; 101 | let file_size = file.metadata()?.len(); 102 | let progress_bar = FileProgressComponent::new_upload(file_path, file_size); 103 | let async_file = AsyncFile::from_std(file); 104 | let stream = FramedRead::new(async_file, BytesCodec::new()).inspect_ok(move |bytes| { 105 | progress_bar.update_progress(bytes.len() as u64); 106 | }); 107 | builder = builder 108 | .header(CONTENT_LENGTH, file_size) 109 | .body(Body::wrap_stream(stream)); 110 | } 111 | Some(AdvancedBody::Json(body)) => { 112 | builder = builder.json(&body); 113 | } 114 | None => {} 115 | } 116 | let req = builder.build()?; 117 | if options.verbose { 118 | req.print(options.theme, options.is_output_terminal)?; 119 | println!(); 120 | print_separator(); 121 | } 122 | let result = client.execute(req).await?; 123 | if options.verbose { 124 | result.print(options.theme, options.is_output_terminal)?; 125 | println!(); 126 | } 127 | let language = result.get_language(); 128 | if let Some("binary") = language { 129 | let url = Url::parse(url)?; 130 | let filename = if let Some(output_filename) = options.output_filename { 131 | output_filename 132 | } else { 133 | url 134 | .path_segments() 135 | .and_then(|segments| segments.last()) 136 | .unwrap_or("unknown.bin") 137 | .to_owned() 138 | }; 139 | 140 | let progress_bar = FileProgressComponent::new_download(filename.to_owned(), result.content_length().unwrap_or(0)); 141 | let mut stream = result 142 | .bytes_stream() 143 | .inspect_ok(move |bytes| { 144 | progress_bar.update_progress(bytes.len() as u64); 145 | }) 146 | .map_err(|e| futures::io::Error::new(futures::io::ErrorKind::Other, e)) 147 | .into_async_read() 148 | .compat(); 149 | if !options.is_output_terminal { 150 | tokio::io::copy(&mut stream, &mut tokio::io::stdout()).await?; 151 | } else { 152 | let mut file = AsyncFile::create(filename).await?; 153 | tokio::io::copy(&mut stream, &mut file).await?; 154 | } 155 | } else { 156 | let response_body = result.text().await?; 157 | if !response_body.is_empty() { 158 | if let Some(output_filename) = options.output_filename { 159 | let mut file = AsyncFile::create(output_filename).await?; 160 | tokio::io::copy(&mut response_body.as_bytes(), &mut file).await?; 161 | } else { 162 | pretty_print( 163 | response_body, 164 | options.theme, 165 | language.unwrap_or_default(), 166 | options.is_output_terminal, 167 | )?; 168 | println!(); 169 | } 170 | } 171 | } 172 | Ok(()) 173 | } 174 | -------------------------------------------------------------------------------- /src/template.rs: -------------------------------------------------------------------------------- 1 | use indexmap::IndexMap; 2 | use serde_json::Value; 3 | use tera::{Context, Error, Tera}; 4 | 5 | pub trait ValueTemplate { 6 | fn render_value(&mut self, name: &str, value: &Value, context: &Context) -> Result; 7 | } 8 | 9 | impl ValueTemplate for Tera { 10 | fn render_value(&mut self, name: &str, value: &Value, context: &Context) -> Result { 11 | match value { 12 | Value::Object(obj) => { 13 | let mut new_obj = serde_json::Map::new(); 14 | for (key, val) in obj { 15 | new_obj.insert( 16 | key.clone(), 17 | self.render_value(&format!("{}.{}", name, key), val, context)?, 18 | ); 19 | } 20 | Ok(Value::Object(new_obj)) 21 | } 22 | Value::Array(arr) => { 23 | let mut new_arr = Vec::new(); 24 | for (index, val) in arr.iter().enumerate() { 25 | new_arr.push(self.render_value(&format!("{}.{}", name, index), val, context)?); 26 | } 27 | Ok(Value::Array(new_arr)) 28 | } 29 | Value::String(content) => { 30 | self.add_raw_template(name, content)?; 31 | let new_content = self.render(name, context)?; 32 | Ok(Value::String(new_content)) 33 | } 34 | _ => Ok(value.clone()), 35 | } 36 | } 37 | } 38 | 39 | pub trait MapTemplate { 40 | fn render_map( 41 | &mut self, 42 | name: &str, 43 | map: &IndexMap, 44 | context: &Context, 45 | ) -> Result, Error>; 46 | } 47 | 48 | impl MapTemplate for Tera { 49 | fn render_map( 50 | &mut self, 51 | name: &str, 52 | map: &IndexMap, 53 | context: &Context, 54 | ) -> Result, Error> { 55 | let mut new_map = IndexMap::new(); 56 | for (key, val) in map { 57 | let template_name = format!("{}.{}", name, key); 58 | self.add_raw_template(&template_name, val)?; 59 | let new_content = self.render(&template_name, context)?; 60 | new_map.insert(key.clone(), new_content); 61 | } 62 | Ok(new_map) 63 | } 64 | } 65 | 66 | pub trait StringTemplate { 67 | fn render_string(&mut self, name: &str, content: &str, context: &Context) -> Result; 68 | } 69 | 70 | impl StringTemplate for Tera { 71 | fn render_string(&mut self, name: &str, content: &str, context: &Context) -> Result { 72 | self.add_raw_template(name, content)?; 73 | self.render(name, context) 74 | } 75 | } 76 | 77 | #[cfg(test)] 78 | mod tests { 79 | use super::*; 80 | use serde_json::json; 81 | use tera::{Context, Tera}; 82 | 83 | #[test] 84 | fn test_render_value_object() { 85 | let mut tera = Tera::default(); 86 | let mut context = Context::new(); 87 | context.insert("context", &json!({ "test": "test" })); 88 | let rendered = tera 89 | .render_value( 90 | "test", 91 | &json!({"test": "{{context.test}}","another": "{{context.test}}"}), 92 | &context, 93 | ) 94 | .expect("render value object"); 95 | assert_eq!(rendered, json!({"test":"test","another":"test"})); 96 | } 97 | 98 | #[test] 99 | fn test_render_value_array() { 100 | let mut tera = Tera::default(); 101 | let mut context = Context::new(); 102 | context.insert("context", &json!({ "test": "test" })); 103 | let rendered = tera 104 | .render_value("test", &json!(["{{context.test}}", "{{context.test}}"]), &context) 105 | .expect("render value array"); 106 | assert_eq!(rendered, json!(["test", "test"])); 107 | } 108 | 109 | #[test] 110 | fn test_render_map() { 111 | let mut tera = Tera::default(); 112 | let mut context = Context::new(); 113 | context.insert("context", &json!({ "test": "test" })); 114 | let rendered = tera 115 | .render_map( 116 | "test", 117 | &IndexMap::from_iter(vec![("test".to_string(), "{{context.test}}".to_string())]), 118 | &context, 119 | ) 120 | .expect("render map"); 121 | assert_eq!( 122 | rendered, 123 | IndexMap::::from_iter(vec![("test".to_string(), "test".to_string())]) 124 | ); 125 | } 126 | 127 | #[test] 128 | fn test_render_string() { 129 | let mut tera = Tera::default(); 130 | let mut context = Context::new(); 131 | context.insert("context", &json!({ "test": "test" })); 132 | let rendered = tera 133 | .render_string("test", "{{context.test}} {{context.test}}", &context) 134 | .expect("render string"); 135 | assert_eq!(rendered, "test test"); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/validators.rs: -------------------------------------------------------------------------------- 1 | use super::match_params::RequestParam; 2 | use anyhow::Result; 3 | use once_cell::sync::Lazy; 4 | use regex::Regex; 5 | use url::Url; 6 | 7 | pub fn validate_url(str_url: &str) -> Result { 8 | let url = Url::parse(str_url)?; 9 | if !["https", "http"].contains(&url.scheme()) { 10 | Err(anyhow::anyhow!("Apix only supports http(s) protocols for now",)) 11 | } else { 12 | Ok(url) 13 | } 14 | } 15 | 16 | pub fn validate_param(param: &str, request_type: RequestParam) -> Result<()> { 17 | static RE: Lazy = Lazy::new(|| Regex::new("^([\\w-]+):(.*)$").unwrap()); 18 | if RE.is_match(param) { 19 | Ok(()) 20 | } else { 21 | Err(anyhow::anyhow!( 22 | "Bad {} format: \"{}\", should be of the form \":\"", 23 | request_type, 24 | param 25 | )) 26 | } 27 | } 28 | 29 | #[cfg(test)] 30 | mod tests { 31 | use super::*; 32 | use test_case::test_case; 33 | use url::Url; 34 | 35 | // test validate url with test_case 36 | #[test_case("https://www.google.com")] 37 | #[test_case("http://www.google.com")] 38 | #[test_case("ftp://www.google.com" => panics )] 39 | fn test_validate_url(url: &str) { 40 | assert_eq!(validate_url(url).unwrap(), Url::parse(url).unwrap()); 41 | } 42 | 43 | // test validate param with test_case 44 | #[test_case("name:value")] 45 | #[test_case("name-value" => panics)] 46 | fn test_validate_param(param: &str) { 47 | assert_eq!(validate_param(param, RequestParam::Header).unwrap(), ()); 48 | } 49 | } 50 | --------------------------------------------------------------------------------