├── .clippy.toml ├── .gitattributes ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── release.yml │ ├── test.yml │ └── web.yml ├── .gitignore ├── .rustfmt.toml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── RELEASE.md ├── cli.sh ├── crates ├── cli │ ├── Cargo.toml │ ├── src │ │ ├── bin.rs │ │ ├── commands.rs │ │ ├── commands │ │ │ ├── collections.rs │ │ │ ├── db.rs │ │ │ ├── generate.rs │ │ │ ├── history.rs │ │ │ ├── import.rs │ │ │ ├── new.rs │ │ │ ├── new.yml │ │ │ ├── request.rs │ │ │ └── show.rs │ │ ├── completions.rs │ │ └── lib.rs │ └── tests │ │ ├── common.rs │ │ ├── slumber.yml │ │ ├── test_generate.rs │ │ ├── test_history.rs │ │ └── test_request.rs ├── config │ ├── Cargo.toml │ └── src │ │ ├── input.rs │ │ ├── lib.rs │ │ ├── mime.rs │ │ └── theme.rs ├── core │ ├── Cargo.toml │ ├── proptest-regressions │ │ └── template │ │ │ └── parse.txt │ └── src │ │ ├── collection.rs │ │ ├── collection │ │ ├── cereal.rs │ │ ├── models.rs │ │ └── recipe_tree.rs │ │ ├── database.rs │ │ ├── database │ │ ├── convert.rs │ │ ├── migrations.rs │ │ └── tests.rs │ │ ├── http.rs │ │ ├── http │ │ ├── content_type.rs │ │ ├── curl.rs │ │ ├── models.rs │ │ ├── query.rs │ │ └── tests.rs │ │ ├── lib.rs │ │ ├── template.rs │ │ ├── template │ │ ├── cereal.rs │ │ ├── error.rs │ │ ├── parse.rs │ │ ├── prompt.rs │ │ ├── render.rs │ │ └── tests.rs │ │ ├── test_util.rs │ │ └── util.rs ├── import │ ├── Cargo.toml │ └── src │ │ ├── insomnia.rs │ │ ├── lib.rs │ │ ├── openapi.rs │ │ ├── openapi │ │ └── resolve.rs │ │ └── rest.rs ├── tui │ ├── Cargo.toml │ └── src │ │ ├── context.rs │ │ ├── http.rs │ │ ├── http │ │ └── tests.rs │ │ ├── input.rs │ │ ├── lib.rs │ │ ├── message.rs │ │ ├── state.rs │ │ ├── test_util.rs │ │ ├── util.rs │ │ ├── view.rs │ │ └── view │ │ ├── common.rs │ │ ├── common │ │ ├── actions.rs │ │ ├── button.rs │ │ ├── header_table.rs │ │ ├── list.rs │ │ ├── modal.rs │ │ ├── scrollbar.rs │ │ ├── table.rs │ │ ├── tabs.rs │ │ ├── template_preview.rs │ │ ├── text_box.rs │ │ └── text_window.rs │ │ ├── component.rs │ │ ├── component │ │ ├── exchange_pane.rs │ │ ├── help.rs │ │ ├── history.rs │ │ ├── internal.rs │ │ ├── misc.rs │ │ ├── primary.rs │ │ ├── profile_select.rs │ │ ├── queryable_body.rs │ │ ├── recipe_list.rs │ │ ├── recipe_pane.rs │ │ ├── recipe_pane │ │ │ ├── authentication.rs │ │ │ ├── body.rs │ │ │ ├── persistence.rs │ │ │ ├── recipe.rs │ │ │ └── table.rs │ │ ├── request_view.rs │ │ ├── response_view.rs │ │ └── root.rs │ │ ├── context.rs │ │ ├── debug.rs │ │ ├── draw.rs │ │ ├── event.rs │ │ ├── state.rs │ │ ├── state │ │ ├── fixed_select.rs │ │ └── select.rs │ │ ├── styles.rs │ │ ├── test_util.rs │ │ ├── util.rs │ │ └── util │ │ ├── highlight.rs │ │ └── persistence.rs └── util │ ├── Cargo.toml │ └── src │ ├── lib.rs │ ├── paths.rs │ └── test_util.rs ├── docs ├── .gitignore ├── book.toml └── src │ ├── SUMMARY.md │ ├── api │ ├── configuration │ │ ├── index.md │ │ ├── input_bindings.md │ │ ├── mime.md │ │ └── theme.md │ └── request_collection │ │ ├── authentication.md │ │ ├── chain.md │ │ ├── chain_source.md │ │ ├── content_type.md │ │ ├── index.md │ │ ├── profile.md │ │ ├── query_parameters.md │ │ ├── recipe_body.md │ │ ├── request_recipe.md │ │ └── template.md │ ├── cli │ ├── examples.md │ └── subcommands.md │ ├── getting_started.md │ ├── images │ ├── editor.gif │ ├── export.gif │ ├── query_jq.gif │ └── query_pipe.gif │ ├── install.md │ ├── integration │ └── neovim-integration.md │ ├── introduction.md │ ├── troubleshooting │ ├── logs.md │ ├── lost_history.md │ ├── shell_completions.md │ └── tls.md │ └── user_guide │ ├── cli.md │ ├── database.md │ ├── import.md │ ├── inheritance.md │ ├── key_concepts.md │ ├── templates │ ├── chains.md │ ├── index.md │ └── selector.md │ └── tui │ ├── editor.md │ ├── filter_query.md │ └── index.md ├── gifs.py ├── oranda.json ├── rust-toolchain.toml ├── slumber.yml ├── src └── main.rs ├── static ├── demo.gif ├── favicon.ico ├── favicon.sh └── slumber.png ├── tapes ├── demo.tape ├── editor.tape ├── export.tape ├── query_jq.tape └── query_pipe.tape ├── test_data ├── insomnia.json ├── insomnia_imported.yml ├── invalid_utf8.bin ├── openapiv3_petstore.yml ├── openapiv3_petstore_imported.yml ├── regression.yml ├── rest_http_bin.http ├── rest_imported.yml └── rest_pets.json └── tui.sh /.clippy.toml: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/.clippy.toml -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | static/*.gif filter=lfs diff=lfs merge=lfs -text 2 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: LucasPickering 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: "" 5 | labels: bug 6 | assignees: "" 7 | --- 8 | 9 | **Did you [search](https://github.com/LucasPickering/slumber/issues) for existing issues already?** 10 | 11 | **Describe the bug** 12 | _A clear and concise description of what the bug is_ 13 | 14 | **To Reproduce** 15 | _Steps to reproduce the behavior_ 16 | 17 | 1. 18 | 19 | **Expected behavior** 20 | _A clear and concise description of what you expected to happen_ 21 | 22 | **Screenshots** 23 | _If applicable, add screenshots to help explain your problem_ 24 | 25 | **Version (please complete the following information):** 26 | 27 | - OS: 28 | - Terminal: 29 | - Slumber Version: 30 | 31 | **Additional context** 32 | Add any other context about the problem here. 33 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: "" 5 | labels: "" 6 | assignees: "" 7 | --- 8 | 9 | **Did you [search](https://github.com/LucasPickering/slumber/issues) for existing issues already?** 10 | 11 | **Is your feature request related to a problem? Please describe.** 12 | _A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]_ 13 | 14 | **Describe the solution you'd like** 15 | _A clear and concise description of what you want to happen_ 16 | 17 | **Describe alternatives you've considered** 18 | _A clear and concise description of any alternative solutions or features you've considered_ 19 | 20 | **Additional context** 21 | _Add any other context or screenshots about the feature request here_ 22 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | _Describe the change. If there is an associated issue, please include the issue link (e.g. "Closes #xxx"). For UI changes, please also include screenshots._ 4 | 5 | ## Known Risks 6 | 7 | _What issues could potentially go wrong with this change? Is it a breaking change? What have you done to mitigate any potential risks?_ 8 | 9 | ## QA 10 | 11 | _How did you test this?_ 12 | 13 | ## Checklist 14 | 15 | - [ ] Have you read `CONTRIBUTING.md` already? 16 | - [ ] Did you update `CHANGELOG.md`? 17 | - Only user-facing changes belong in the changelog. Internal changes such as refactors should only be included if they'll impact users, e.g. via performance improvement. 18 | - [ ] Did you remove all TODOs? 19 | - If there are unresolved issues, please open a follow-on issue and link to it in a comment so future work can be tracked 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | # This uses the toolchain defined in rust-toolchain 10 | jobs: 11 | fmt: 12 | name: "Rustfmt" 13 | runs-on: ubuntu-latest 14 | env: 15 | # Rustfmt requires a nightly toolchain because we use unstable rules. The 16 | # chosen version is fairly arbitrary 17 | TOOLCHAIN: nightly-2025-02-25 18 | steps: 19 | - uses: actions/checkout@v4 20 | 21 | - uses: actions-rust-lang/setup-rust-toolchain@v1 22 | with: 23 | toolchain: ${{env.TOOLCHAIN}} 24 | components: rustfmt 25 | cache: true 26 | 27 | - name: Rustfmt Check 28 | run: cargo fmt -- --check 29 | 30 | lint: 31 | name: Check/Lint - ${{ matrix.platform.name }} 32 | strategy: 33 | fail-fast: false 34 | matrix: 35 | # Run linting on every platform to make sure we didn't break any builds. 36 | # This is a subset of the Rust targets we support, just one per OS. 37 | platform: 38 | - name: Linux 39 | os: ubuntu-latest 40 | target: x86_64-unknown-linux-gnu 41 | - name: Windows 42 | os: windows-latest 43 | target: x86_64-pc-windows-msvc 44 | - name: macOS 45 | os: macOS-latest 46 | target: aarch64-apple-darwin 47 | runs-on: ${{ matrix.platform.os }} 48 | steps: 49 | - uses: actions/checkout@v4 50 | 51 | - name: Cache Rust files 52 | uses: swatinem/rust-cache@v2 53 | with: 54 | key: ${{ matrix.platform.target }} 55 | 56 | - name: Install toolchain 57 | run: rustup target add ${{ matrix.platform.target }} 58 | 59 | - name: Run Clippy 60 | run: cargo clippy --target ${{ matrix.platform.target }} --all-targets --all-features 61 | 62 | doc: 63 | name: Check Docs 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | 68 | - name: Cache Rust files 69 | uses: swatinem/rust-cache@v2 70 | 71 | - name: Doc 72 | run: cargo doc --no-deps --all-features --document-private-items 73 | env: 74 | RUSTDOCFLAGS: -D warnings 75 | 76 | test: 77 | name: Test - ${{ matrix.platform.name }} 78 | strategy: 79 | fail-fast: false 80 | matrix: 81 | # Run tests on every platform. This is a subset of the Rust targets we 82 | # support, just one per OS. 83 | platform: 84 | - name: Linux 85 | os: ubuntu-latest 86 | target: x86_64-unknown-linux-gnu 87 | - name: Windows 88 | os: windows-latest 89 | target: x86_64-pc-windows-msvc 90 | - name: macOS 91 | os: macOS-latest 92 | target: aarch64-apple-darwin 93 | runs-on: ${{ matrix.platform.os }} 94 | steps: 95 | - uses: actions/checkout@v4 96 | 97 | - name: Cache Rust files 98 | uses: swatinem/rust-cache@v2 99 | with: 100 | key: ${{ matrix.platform.target }} 101 | 102 | - name: Install toolchain 103 | run: rustup target add ${{ matrix.platform.target }} 104 | 105 | - name: Run tests 106 | run: cargo test --workspace --no-fail-fast 107 | env: 108 | RUST_BACKTRACE: 1 109 | -------------------------------------------------------------------------------- /.github/workflows/web.yml: -------------------------------------------------------------------------------- 1 | # Workflow to build your docs with oranda (and mdbook) 2 | # and deploy them to Github Pages 3 | name: Web 4 | 5 | # We're going to push to the gh-pages branch, so we need that permission 6 | permissions: 7 | contents: write 8 | 9 | # What situations do we want to build docs in? 10 | # All of these work independently and can be removed / commented out 11 | # if you don't want oranda/mdbook running in that situation 12 | on: 13 | # Check that a PR didn't break docs! 14 | # 15 | # Note that the "Deploy to Github Pages" step won't run in this mode, 16 | # so this won't have any side-effects. But it will tell you if a PR 17 | # completely broke oranda/mdbook. Sadly we don't provide previews (yet)! 18 | pull_request: 19 | 20 | # Deploy website when release is published. This is a manual edit. Eventually 21 | # hopefully we can configure oranda to do generate it like this 22 | # https://github.com/axodotdev/oranda/issues/646 23 | push: 24 | tags: 25 | - "**[0-9]+.[0-9]+.[0-9]+*" 26 | 27 | # Run manually 28 | workflow_dispatch: 29 | 30 | # Alright, let's do it! 31 | jobs: 32 | web: 33 | name: Build and deploy site and docs 34 | runs-on: ubuntu-latest 35 | env: 36 | ORANDA_VERSION: v0.6.5 37 | steps: 38 | # Setup 39 | - uses: actions/checkout@v4 40 | with: 41 | fetch-depth: 0 42 | lfs: true 43 | - uses: dtolnay/rust-toolchain@stable 44 | - uses: swatinem/rust-cache@v2 45 | 46 | # If you use any mdbook plugins, here's the place to install them! 47 | - name: Install mdbook plugins 48 | run: | 49 | cargo install mdbook-toc@0.14.2 50 | 51 | # Install and run oranda (and mdbook) 52 | # This will write all output to ./public/ (including copying mdbook's output to there) 53 | - name: Install and run oranda 54 | run: | 55 | curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/oranda/releases/download/${{ env.ORANDA_VERSION }}/oranda-installer.sh | sh 56 | oranda build 57 | 58 | - name: Check HTML for broken internal links 59 | uses: untitaker/hyperlink@0.1.42 60 | with: 61 | args: ./public 62 | 63 | # Deploy to our gh-pages branch (creating it if it doesn't exist) 64 | # the "public" dir that oranda made above will become the root dir 65 | # of this branch. 66 | # 67 | # Note that once the gh-pages branch exists, you must 68 | # go into repo's settings > pages and set "deploy from branch: gh-pages" 69 | # the other defaults work fine. 70 | - name: Deploy to Github Pages 71 | uses: JamesIves/github-pages-deploy-action@v4.6.8 72 | # ONLY if we're on master (so no PRs or feature branches allowed!) 73 | if: ${{ github.ref == 'refs/heads/master' }} 74 | with: 75 | branch: gh-pages 76 | # Gotta tell the action where to find oranda's output 77 | folder: public 78 | token: ${{ secrets.GITHUB_TOKEN }} 79 | single-commit: true 80 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | data/ 2 | /target/ 3 | /.vscode/ 4 | /.zed/ 5 | .DS_Store 6 | response.json 7 | *.sqlite 8 | expand.rs 9 | *.prof 10 | 11 | # Generated by `oranda generate ci` 12 | public/ 13 | *.swp 14 | -------------------------------------------------------------------------------- /.rustfmt.toml: -------------------------------------------------------------------------------- 1 | max_width = 80 2 | # these two are only available on nightly (F) 3 | imports_granularity = "crate" 4 | wrap_comments = true 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = {workspace = true} 3 | description = "Terminal-based HTTP client" 4 | edition = {workspace = true} 5 | homepage = {workspace = true} 6 | keywords = {workspace = true} 7 | license = {workspace = true} 8 | name = "slumber" 9 | repository = {workspace = true} 10 | rust-version = {workspace = true} 11 | version = {workspace = true} 12 | 13 | [workspace] 14 | members = ["crates/*"] 15 | 16 | [workspace.package] 17 | authors = ["Lucas Pickering "] 18 | edition = "2024" 19 | homepage = "https://slumber.lucaspickering.me" 20 | keywords = ["rest", "http", "terminal", "tui"] 21 | license = "MIT" 22 | repository = "https://github.com/LucasPickering/slumber" 23 | version = "3.1.2" 24 | # Keep in sync w/ rust-toolchain.toml 25 | rust-version = "1.86.0" 26 | 27 | # Dependencies used in multiple crates get de-duplicated here 28 | [workspace.dependencies] 29 | anyhow = "1.0.0" 30 | async-trait = "0.1.81" 31 | bytes = {version = "1.6.1", default-features = false} 32 | chrono = {version = "0.4.31", default-features = false} 33 | crossterm = {version = "0.28.0", default-features = false, features = ["events"]} 34 | derive_more = {version = "1.0.0", default-features = false} 35 | dialoguer = {version = "0.11.0", default-features = false} 36 | dirs = "5.0.1" 37 | env-lock = "0.1.0" 38 | futures = "0.3.28" 39 | indexmap = {version = "2.0.0", default-features = false} 40 | itertools = "0.13.0" 41 | mime = "0.3.17" 42 | pretty_assertions = "1.4.0" 43 | ratatui = {version = "0.28.0", default-features = false} 44 | reqwest = {version = "0.12.5", default-features = false} 45 | rstest = {version = "0.24.0", default-features = false} 46 | serde = {version = "1.0.204", default-features = false} 47 | serde_json = {version = "1.0.120", default-features = false, features = ["preserve_order"]} 48 | serde_test = "1.0.176" 49 | serde_yaml = {version = "0.9.0", default-features = false} 50 | slumber_cli = {path = "./crates/cli", version = "3.1.2" } 51 | slumber_config = {path = "./crates/config", version = "3.1.2" } 52 | slumber_core = {path = "./crates/core", version = "3.1.2" } 53 | slumber_import = {path = "./crates/import", version = "3.1.2" } 54 | slumber_tui = {path = "./crates/tui", version = "3.1.2" } 55 | slumber_util = {path = "./crates/util", version = "3.1.2" } 56 | strum = {version = "0.26.3", default-features = false} 57 | thiserror = "2.0.12" 58 | tokio = {version = "1.39.2", default-features = false} 59 | tracing = "0.1.40" 60 | uuid = {version = "1.10.0", default-features = false} 61 | winnow = "0.6.16" 62 | wiremock = {version = "0.6.1", default-features = false} 63 | 64 | [workspace.lints.rust] 65 | unsafe_code = "forbid" 66 | 67 | [workspace.lints.clippy] 68 | all = {level = "deny", priority = -1} 69 | pedantic = {level = "warn", priority = -1} 70 | 71 | allow_attributes = "deny" 72 | cast_possible_truncation = "allow" 73 | cast_possible_wrap = "allow" 74 | cast_precision_loss = "allow" 75 | cast_sign_loss = "allow" 76 | dbg_macro = "warn" 77 | default_trait_access = "allow" 78 | doc_markdown = "allow" 79 | explicit_deref_methods = "allow" 80 | map_unwrap_or = "allow" 81 | match_same_arms = "allow" 82 | missing_errors_doc = "allow" 83 | missing_panics_doc = "allow" 84 | must_use_candidate = "allow" 85 | needless_pass_by_value = "allow" 86 | similar_names = "allow" 87 | too_many_lines = "allow" 88 | unused_self = "allow" 89 | used_underscore_binding = "allow" 90 | 91 | [dependencies] 92 | anyhow = {workspace = true, features = ["backtrace"]} 93 | slumber_cli = {workspace = true, optional = true} 94 | slumber_tui = {workspace = true, optional = true} 95 | slumber_util = {workspace = true} 96 | tokio = {workspace = true, features = ["macros", "rt"]} 97 | tracing = {workspace = true} 98 | tracing-subscriber = {version = "0.3.17", default-features = false, features = ["ansi", "fmt", "registry"]} 99 | 100 | [features] 101 | default = ["cli", "tui"] 102 | # TUI and CLI can be disabled in dev to speed compilation while not in use 103 | cli = ["dep:slumber_cli"] 104 | tui = ["dep:slumber_tui"] 105 | 106 | # The profile that 'cargo dist' will build with 107 | [profile.dist] 108 | inherits = "release" 109 | lto = "thin" 110 | 111 | [package.metadata.release] 112 | pre-release-hook = ["python", "./gifs.py", "--check"] 113 | pre-release-replacements = [ 114 | {file = "CHANGELOG.md", search = "## \\[Unreleased\\] - ReleaseDate", replace = "## [Unreleased] - ReleaseDate\n\n## [{{version}}] - {{date}}"}, 115 | ] 116 | 117 | # Config for 'dist' 118 | [workspace.metadata.dist] 119 | # The preferred dist version to use in CI (Cargo.toml SemVer syntax) 120 | cargo-dist-version = "0.28.0" 121 | # CI backends to support 122 | ci = "github" 123 | # The installers to generate for each app 124 | installers = ["shell", "powershell", "homebrew"] 125 | # A GitHub repo to push Homebrew formulas to 126 | tap = "LucasPickering/homebrew-tap" 127 | # Target platforms to build apps for (Rust target-triple syntax) 128 | targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", "x86_64-pc-windows-msvc"] 129 | # Publish jobs to run in CI 130 | publish-jobs = ["homebrew"] 131 | # Which actions to run on pull requests 132 | pr-run-mode = "plan" 133 | # Whether to install an updater program 134 | install-updater = false 135 | # Path that installers should place binaries in 136 | install-path = "CARGO_HOME" 137 | 138 | [workspace.metadata.dist.github-custom-runners] 139 | global = "ubuntu-22.04" 140 | x86_64-unknown-linux-gnu = "ubuntu-22.04" 141 | x86_64-unknown-linux-musl = "ubuntu-22.04" 142 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2023 Lucas Pickering 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Slumber 2 | 3 | [![Test CI](https://github.com/github/docs/actions/workflows/test.yml/badge.svg)](https://github.com/LucasPickering/slumber/actions) 4 | [![crates.io](https://img.shields.io/crates/v/slumber.svg)](https://crates.io/crates/slumber) 5 | [![Sponsor](https://img.shields.io/github/sponsors/LucasPickering?logo=github)](https://github.com/sponsors/LucasPickering) 6 | 7 | - [Home Page](https://slumber.lucaspickering.me) 8 | - [Installation](https://slumber.lucaspickering.me/artifacts/) 9 | - [Docs](https://slumber.lucaspickering.me/book/) 10 | - [Changelog](https://slumber.lucaspickering.me/changelog/) 11 | 12 | ![Slumber example](/static/demo.gif) 13 | 14 | Slumber is a TUI (terminal user interface) HTTP client. Define, execute, and share configurable HTTP requests. Slumber is built on some basic principles: 15 | 16 | - It will remain free to use forever 17 | - You own your data: all configuration and data is stored locally and can be checked into version control 18 | - It will never be [enshittified](https://en.wikipedia.org/wiki/Enshittification) 19 | 20 | ## Features 21 | 22 | - Usable as a TUI or CLI 23 | - Source-first configuration, for easy persistence and sharing 24 | - [Import from external formats (e.g. Insomnia)](https://slumber.lucaspickering.me/book/user_guide/import.html) 25 | - [Build requests dynamically from other requests, files, and shell commands](https://slumber.lucaspickering.me/book/user_guide/templates/index.html) 26 | - [Browse response data using JSONPath selectors](https://slumber.lucaspickering.me/book/user_guide/tui/filter_query.html) 27 | - Switch between different environments easily using [profiles](https://slumber.lucaspickering.me/book/api/request_collection/profile.html) 28 | - And more! 29 | 30 | ## Examples 31 | 32 | Slumber is based around **collections**. A collection is a group of request **recipes**, which are templates for the requests you want to run. A simple collection could be: 33 | 34 | ```yaml 35 | # slumber.yml 36 | requests: 37 | get: !request 38 | method: GET 39 | url: https://httpbin.org/get 40 | 41 | post: !request 42 | method: POST 43 | url: https://httpbin.org/post 44 | body: !json { "id": 3, "name": "Slumber" } 45 | ``` 46 | 47 | Create this file, then run the TUI with `slumber`. 48 | 49 | For a more extensive example, see [the docs](https://slumber.lucaspickering.me/book/getting_started.html). 50 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Release Process 2 | 3 | It's easy! 4 | 5 | - Make sure `CHANGELOG.md` has the latest release notes under `[Unreleased] - ReleaseDate` 6 | - Regenerate all GIFs with `./gifs.py` (and commit changes) 7 | - Look at the GIFs and make sure they're correct! 8 | - `cargo release --workspace ` 9 | - If it looks good, add `--execute` 10 | 11 | Everything else is automated :) 12 | -------------------------------------------------------------------------------- /cli.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run the CLI, for development 4 | 5 | RUST_LOG=${RUST_LOG:-slumber=${LOG:-DEBUG}} RUST_BACKTRACE=1 \ 6 | cargo run --no-default-features --features cli \ 7 | -- $@ 8 | -------------------------------------------------------------------------------- /crates/cli/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = {workspace = true} 3 | description = "Command line interface for Slumber. Not intended for external use." 4 | edition = {workspace = true} 5 | homepage = {workspace = true} 6 | keywords = {workspace = true} 7 | license = {workspace = true} 8 | name = "slumber_cli" 9 | repository = {workspace = true} 10 | rust-version = {workspace = true} 11 | version = {workspace = true} 12 | 13 | [[bin]] 14 | name = "slumber_cli" 15 | path = "src/bin.rs" 16 | 17 | [dependencies] 18 | anyhow = {workspace = true} 19 | async-trait = {workspace = true} 20 | chrono = {workspace = true} 21 | clap = {version = "4.4.2", features = ["derive"]} 22 | clap_complete = {version = "4.5.29", features = ["unstable-dynamic"]} 23 | dialoguer = {workspace = true, features = ["password"]} 24 | indexmap = {workspace = true} 25 | itertools = {workspace = true} 26 | reqwest = {workspace = true} 27 | serde = {workspace = true} 28 | serde_yaml = {workspace = true} 29 | slumber_config = {workspace = true} 30 | slumber_core = {workspace = true} 31 | slumber_import = {workspace = true} 32 | slumber_util = {workspace = true} 33 | tokio = {workspace = true, features = ["rt", "macros"]} 34 | tracing = {workspace = true} 35 | 36 | [dev-dependencies] 37 | assert_cmd = "2.0.16" 38 | env-lock = {workspace = true} 39 | predicates = {version = "3.1.3", default-features = false} 40 | pretty_assertions = {workspace = true} 41 | rstest = {workspace = true} 42 | serde_json = {workspace = true} 43 | slumber_core = {workspace = true, features = ["test"]} 44 | slumber_util = {workspace = true, features = ["test"]} 45 | uuid = {workspace = true} 46 | wiremock = {workspace = true} 47 | 48 | [lints] 49 | workspace = true 50 | 51 | [package.metadata.release] 52 | tag = false 53 | 54 | [package.metadata.dist] 55 | dist = false 56 | -------------------------------------------------------------------------------- /crates/cli/src/bin.rs: -------------------------------------------------------------------------------- 1 | //! Test-only binary for CLI integration tests. Unfortunately I can't figure out 2 | //! how to make this compile only in `cfg(test)`, so its dependencies (tokio) 3 | //! can't be in dev-dependencies. This doesn't actually add anything to the 4 | //! final dependency tree though. 5 | 6 | use slumber_cli::Args; 7 | use std::process::ExitCode; 8 | 9 | #[tokio::main(flavor = "current_thread")] 10 | async fn main() -> ExitCode { 11 | let args = Args::parse(); 12 | args.subcommand 13 | .expect("Subcommand required for CLI tests") 14 | .execute(args.global) 15 | .await 16 | .unwrap_or_else(|error| { 17 | eprintln!("{error}"); 18 | error 19 | .chain() 20 | .skip(1) 21 | .for_each(|cause| eprintln!(" {cause}")); 22 | ExitCode::FAILURE 23 | }) 24 | } 25 | -------------------------------------------------------------------------------- /crates/cli/src/commands.rs: -------------------------------------------------------------------------------- 1 | pub mod collections; 2 | pub mod db; 3 | pub mod generate; 4 | pub mod history; 5 | pub mod import; 6 | pub mod new; 7 | pub mod request; 8 | pub mod show; 9 | -------------------------------------------------------------------------------- /crates/cli/src/commands/collections.rs: -------------------------------------------------------------------------------- 1 | use crate::{GlobalArgs, Subcommand}; 2 | use clap::Parser; 3 | use slumber_core::database::Database; 4 | use std::{path::PathBuf, process::ExitCode}; 5 | 6 | /// View and modify request collection metadata 7 | #[derive(Clone, Debug, Parser)] 8 | pub struct CollectionsCommand { 9 | #[command(subcommand)] 10 | subcommand: CollectionsSubcommand, 11 | } 12 | 13 | #[derive(Clone, Debug, clap::Subcommand)] 14 | enum CollectionsSubcommand { 15 | /// List all known request collections 16 | #[command(visible_alias = "ls")] 17 | List, 18 | /// Move all data from one collection to another. 19 | /// 20 | /// The data from the source collection will be merged into the target 21 | /// collection, then all traces of the source collection will be deleted! 22 | Migrate { 23 | /// The path the collection to migrate *from* 24 | from: PathBuf, 25 | /// The path the collection to migrate *into* 26 | to: PathBuf, 27 | }, 28 | } 29 | 30 | impl Subcommand for CollectionsCommand { 31 | async fn execute(self, _global: GlobalArgs) -> anyhow::Result { 32 | let database = Database::load()?; 33 | match self.subcommand { 34 | CollectionsSubcommand::List => { 35 | for path in database.collections()? { 36 | println!("{}", path.display()); 37 | } 38 | } 39 | CollectionsSubcommand::Migrate { from, to } => { 40 | database.merge_collections(&from, &to)?; 41 | println!("Migrated {} into {}", from.display(), to.display()); 42 | } 43 | } 44 | Ok(ExitCode::SUCCESS) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /crates/cli/src/commands/db.rs: -------------------------------------------------------------------------------- 1 | use crate::{GlobalArgs, Subcommand}; 2 | use anyhow::Context; 3 | use clap::Parser; 4 | use slumber_core::database::Database; 5 | use std::process::ExitCode; 6 | use tokio::process::Command; 7 | 8 | /// Access the local Slumber database file. 9 | /// 10 | /// This is an advanced command; most users never need to manually view or 11 | /// modify the database file. By default this executes `sqlite3` and thus 12 | /// requires `sqlite3` to be installed. You can customize which binary to invoke 13 | /// with `--shell`. Read more about the Slumber database: 14 | /// 15 | /// https://slumber.lucaspickering.me/book/user_guide/database.html 16 | /// 17 | /// This is simply an alias to make it easy to run your preferred SQLite shell 18 | /// against the Slumber database. These two commands are equivalent: 19 | /// 20 | /// slumber db -s -- 21 | /// 22 | /// <...args> 23 | /// 24 | /// Where `` is the path to the database file. 25 | /// 26 | /// EXAMPLES: 27 | /// 28 | /// Open a shell to the database: 29 | /// 30 | /// slumber db 31 | /// 32 | /// Run a single query and exit: 33 | /// 34 | /// slumber db 'select 1' 35 | #[derive(Clone, Debug, Parser)] 36 | #[clap(verbatim_doc_comment)] 37 | pub struct DbCommand { 38 | /// Program to execute 39 | #[clap(short = 'x', long, default_value = "sqlite3")] 40 | exec: String, 41 | /// Additional arguments to forward to the invoked program. Positional 42 | /// arguments can be passed like so: 43 | /// 44 | /// slumber db 'select 1' 45 | /// 46 | /// However if you want to pass flags that begin with "-", you have to 47 | /// precede the forwarded arguments with "--" to separate them from 48 | /// arguments intended for `slumber`. 49 | /// 50 | /// slumber db -- -cmd 'select 1' 51 | #[clap(num_args = 1.., verbatim_doc_comment)] 52 | args: Vec, 53 | } 54 | 55 | impl Subcommand for DbCommand { 56 | async fn execute(self, _: GlobalArgs) -> anyhow::Result { 57 | // Open a shell 58 | let path = Database::path(); 59 | let exit_status = Command::new(self.exec) 60 | .arg(&path) 61 | .args(self.args) 62 | .spawn() 63 | .with_context(|| format!("Error opening database file {path:?}"))? 64 | .wait() 65 | .await?; 66 | 67 | // Forward exit code if we can, otherwise just match success/failure 68 | if let Some(code) = 69 | exit_status.code().and_then(|code| u8::try_from(code).ok()) 70 | { 71 | Ok(code.into()) 72 | } else if exit_status.success() { 73 | Ok(ExitCode::SUCCESS) 74 | } else { 75 | Ok(ExitCode::FAILURE) 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /crates/cli/src/commands/generate.rs: -------------------------------------------------------------------------------- 1 | use crate::{GlobalArgs, Subcommand, commands::request::BuildRequestCommand}; 2 | use clap::{Parser, ValueEnum}; 3 | use std::process::ExitCode; 4 | 5 | /// Render a request and generate an equivalent for a third-party client 6 | #[derive(Clone, Debug, Parser)] 7 | #[clap(visible_alias = "gen")] 8 | pub struct GenerateCommand { 9 | format: GenerateFormat, 10 | #[clap(flatten)] 11 | build_request: BuildRequestCommand, 12 | /// Execute triggered sub-requests. By default, if a request dependency is 13 | /// triggered (e.g. if it is expired), an error will be thrown instead 14 | #[clap(long)] 15 | execute_triggers: bool, 16 | } 17 | 18 | /// Third-party client to generate for 19 | #[derive(Clone, Debug, ValueEnum)] 20 | pub enum GenerateFormat { 21 | Curl, 22 | } 23 | 24 | impl Subcommand for GenerateCommand { 25 | async fn execute(self, global: GlobalArgs) -> anyhow::Result { 26 | match self.format { 27 | GenerateFormat::Curl => { 28 | let (_, http_engine, seed, template_context) = self 29 | .build_request 30 | .build_seed(global, self.execute_triggers)?; 31 | let command = http_engine 32 | .build_curl(seed, &template_context) 33 | .await 34 | .map_err(|error| { 35 | // If the build failed because triggered requests are 36 | // disabled, replace it with a custom error message 37 | if error.has_trigger_disabled_error() { 38 | error.source.context( 39 | "Triggered requests are disabled by default; \ 40 | pass `--execute-triggers` to enable", 41 | ) 42 | } else { 43 | error.source 44 | } 45 | })?; 46 | println!("{command}"); 47 | } 48 | } 49 | Ok(ExitCode::SUCCESS) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /crates/cli/src/commands/import.rs: -------------------------------------------------------------------------------- 1 | use crate::{GlobalArgs, Subcommand}; 2 | use anyhow::Context; 3 | use clap::{Parser, ValueEnum}; 4 | use std::{ 5 | fs::File, 6 | io::{self, Write}, 7 | path::PathBuf, 8 | process::ExitCode, 9 | }; 10 | 11 | /// Generate a Slumber request collection from an external format 12 | /// 13 | /// See docs for more info on formats: 14 | /// https://slumber.lucaspickering.me/book/cli/import.html 15 | #[derive(Clone, Debug, Parser)] 16 | pub struct ImportCommand { 17 | /// Input format 18 | format: Format, 19 | /// Collection to import 20 | input_file: PathBuf, 21 | /// Destination for the new slumber collection file [default: stdout] 22 | output_file: Option, 23 | } 24 | 25 | #[derive(Copy, Clone, Debug, ValueEnum)] 26 | #[expect(rustdoc::bare_urls)] 27 | enum Format { 28 | /// Insomnia export format (JSON or YAML) 29 | Insomnia, 30 | /// OpenAPI v3.0 (JSON or YAML) v3.1 not supported but may work 31 | Openapi, 32 | /// VSCode `.rest` or JetBrains `.http` format [aliases: vscode, jetbrains] 33 | // Use visible_alias (and remove from doc comment) after 34 | // https://github.com/clap-rs/clap/pull/5480 35 | #[value(alias = "vscode", alias = "jetbrains")] 36 | Rest, 37 | } 38 | 39 | impl Subcommand for ImportCommand { 40 | async fn execute(self, _global: GlobalArgs) -> anyhow::Result { 41 | // Load the input 42 | let collection = match self.format { 43 | Format::Insomnia => { 44 | slumber_import::from_insomnia(&self.input_file)? 45 | } 46 | Format::Openapi => slumber_import::from_openapi(&self.input_file)?, 47 | Format::Rest => slumber_import::from_rest(&self.input_file)?, 48 | }; 49 | 50 | // Write the output 51 | let mut writer: Box = match self.output_file { 52 | Some(output_file) => Box::new( 53 | File::options() 54 | .create(true) 55 | .truncate(true) 56 | .write(true) 57 | .open(&output_file) 58 | .context(format!( 59 | "Error opening collection output file \ 60 | {output_file:?}" 61 | ))?, 62 | ), 63 | None => Box::new(io::stdout()), 64 | }; 65 | serde_yaml::to_writer(&mut writer, &collection)?; 66 | 67 | Ok(ExitCode::SUCCESS) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /crates/cli/src/commands/new.yml: -------------------------------------------------------------------------------- 1 | # For basic usage info, see: 2 | # https://slumber.lucaspickering.me/book/getting_started.html 3 | # For all collection options, see: 4 | # https://slumber.lucaspickering.me/book/api/request_collection/index.html 5 | 6 | # Profiles are groups of data you can easily switch between. A common usage is 7 | # to define profiles for various environments of a REST service 8 | profiles: 9 | example: 10 | name: Example Profile 11 | data: 12 | host: https://httpbin.org 13 | 14 | # Chains allow you to use dynamic data in your request templates 15 | chains: 16 | example: 17 | source: !request 18 | recipe: example1 19 | selector: $.data 20 | 21 | requests: 22 | example1: !request 23 | name: Example Request 1 24 | method: GET 25 | url: "{{host}}/anything" 26 | 27 | example_folder: !folder 28 | name: Example Folder 29 | requests: 30 | example2: !request 31 | name: Example Request 2 32 | method: POST 33 | url: "{{host}}/anything" 34 | body: !json { "data": "{{chains.example}}" } 35 | -------------------------------------------------------------------------------- /crates/cli/src/commands/show.rs: -------------------------------------------------------------------------------- 1 | use crate::{GlobalArgs, Subcommand}; 2 | use clap::Parser; 3 | use serde::Serialize; 4 | use slumber_config::Config; 5 | use slumber_core::database::Database; 6 | use slumber_util::paths; 7 | use std::process::ExitCode; 8 | 9 | /// Print meta information about Slumber (config, collections, etc.) 10 | #[derive(Clone, Debug, Parser)] 11 | pub struct ShowCommand { 12 | #[command(subcommand)] 13 | target: ShowTarget, 14 | } 15 | 16 | #[derive(Copy, Clone, Debug, clap::Subcommand)] 17 | enum ShowTarget { 18 | /// Print the path of all directories/files that Slumber uses 19 | Paths, 20 | /// Print loaded configuration 21 | Config, 22 | /// Print current request collection 23 | Collection, 24 | } 25 | 26 | impl Subcommand for ShowCommand { 27 | async fn execute(self, global: GlobalArgs) -> anyhow::Result { 28 | match self.target { 29 | ShowTarget::Paths => { 30 | println!("Config: {}", Config::path().display()); 31 | println!("Database: {}", Database::path().display()); 32 | println!("Log file: {}", paths::log_file().display()); 33 | println!( 34 | "Collection: {}", 35 | global 36 | .collection_file() 37 | .map(|file| file.to_string()) 38 | .unwrap_or_else(|error| error.to_string()) 39 | ); 40 | } 41 | ShowTarget::Config => { 42 | let config = Config::load()?; 43 | println!("{}", to_yaml(&config)); 44 | } 45 | ShowTarget::Collection => { 46 | let collection_file = global.collection_file()?; 47 | let collection = collection_file.load()?; 48 | println!("{}", to_yaml(&collection)); 49 | } 50 | } 51 | Ok(ExitCode::SUCCESS) 52 | } 53 | } 54 | 55 | fn to_yaml(value: &T) -> String { 56 | // Panic is intentional, indicates a wonky bug 57 | serde_yaml::to_string(value).expect("Error serializing") 58 | } 59 | -------------------------------------------------------------------------------- /crates/cli/src/completions.rs: -------------------------------------------------------------------------------- 1 | //! Shell completion utilities 2 | //! 3 | //! To test this locally: 4 | //! - `cargo install --path .` (current version of Slumber must be in $PATH) 5 | //! - `COMPLETE= slumber` and pipe that to `source` 6 | //! 7 | //! That will enable completions for the current shell 8 | 9 | use clap_complete::CompletionCandidate; 10 | use slumber_core::{ 11 | collection::{Collection, CollectionFile, ProfileId}, 12 | database::Database, 13 | }; 14 | use std::{ffi::OsStr, ops::Deref}; 15 | 16 | /// Provide completions for profile IDs 17 | pub fn complete_profile(current: &OsStr) -> Vec { 18 | let Ok(collection) = load_collection() else { 19 | return Vec::new(); 20 | }; 21 | 22 | get_candidates( 23 | collection.profiles.keys().map(ProfileId::to_string), 24 | current, 25 | ) 26 | } 27 | 28 | /// Provide completions for recipe IDs 29 | pub fn complete_recipe(current: &OsStr) -> Vec { 30 | let Ok(collection) = load_collection() else { 31 | return Vec::new(); 32 | }; 33 | 34 | get_candidates( 35 | collection 36 | .recipes 37 | .iter() 38 | // Include recipe IDs only. Folder IDs are never passed to the CLI 39 | .filter_map(|(_, node)| Some(node.recipe()?.id.to_string())), 40 | current, 41 | ) 42 | } 43 | 44 | /// Provide completions for request IDs 45 | pub fn complete_request_id(current: &OsStr) -> Vec { 46 | let Ok(database) = Database::load() else { 47 | return Vec::new(); 48 | }; 49 | let Ok(exchanges) = database.get_all_requests() else { 50 | return Vec::new(); 51 | }; 52 | get_candidates( 53 | exchanges 54 | .into_iter() 55 | .map(|exchange| exchange.id.to_string()), 56 | current, 57 | ) 58 | } 59 | 60 | fn load_collection() -> anyhow::Result { 61 | // For now we just lean on the default collection paths. In the future we 62 | // should be able to look for a --file arg in the command and use that path 63 | let collection_file = CollectionFile::new(None)?; 64 | collection_file.load() 65 | } 66 | 67 | fn get_candidates>( 68 | iter: impl Iterator, 69 | current: &OsStr, 70 | ) -> Vec { 71 | let Some(current) = current.to_str() else { 72 | return Vec::new(); 73 | }; 74 | // Only include IDs prefixed by the input we've gotten so far 75 | iter.map(T::into) 76 | .filter(|value| value.starts_with(current)) 77 | .map(|value| CompletionCandidate::new(value.deref())) 78 | .collect() 79 | } 80 | -------------------------------------------------------------------------------- /crates/cli/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Command line interface for Slumber. 2 | //! 3 | //! **This crate is not semver compliant**. The version is locked to the root 4 | //! `slumber` crate version. If you choose to depend directly on this crate, you 5 | //! do so at your own risk of breakage. 6 | 7 | mod commands; 8 | mod completions; 9 | 10 | use crate::commands::{ 11 | collections::CollectionsCommand, db::DbCommand, generate::GenerateCommand, 12 | history::HistoryCommand, import::ImportCommand, new::NewCommand, 13 | request::RequestCommand, show::ShowCommand, 14 | }; 15 | use clap::{CommandFactory, Parser}; 16 | use clap_complete::CompleteEnv; 17 | use slumber_core::collection::CollectionFile; 18 | use std::{path::PathBuf, process::ExitCode}; 19 | 20 | const COMMAND_NAME: &str = "slumber"; 21 | 22 | /// Configurable HTTP client with both TUI and CLI interfaces 23 | /// 24 | /// If subcommand is omitted, start the TUI. 25 | /// 26 | /// 27 | #[derive(Debug, Parser)] 28 | #[clap(author, version, about, name = COMMAND_NAME)] 29 | pub struct Args { 30 | #[command(flatten)] 31 | pub global: GlobalArgs, 32 | #[command(subcommand)] 33 | pub subcommand: Option, 34 | } 35 | 36 | impl Args { 37 | /// Check if we're in shell completion mode, which is set via the `COMPLETE` 38 | /// env var. If so, this will print completions then exit the process 39 | pub fn complete() { 40 | CompleteEnv::with_factory(Args::command).complete(); 41 | } 42 | 43 | /// Alias for [clap::Parser::parse] 44 | pub fn parse() -> Self { 45 | ::parse() 46 | } 47 | } 48 | 49 | /// Arguments that are available to all subcommands and the TUI 50 | #[derive(Debug, Parser)] 51 | pub struct GlobalArgs { 52 | /// Collection file, which defines profiles, recipes, etc. If omitted, 53 | /// check the current and all parent directories for the following files 54 | /// (in this order): slumber.yml, slumber.yaml, .slumber.yml, 55 | /// .slumber.yaml. If a directory is passed, apply the same search 56 | /// logic from the given directory rather than the current. 57 | #[clap(long, short)] 58 | pub file: Option, 59 | } 60 | 61 | impl GlobalArgs { 62 | /// Get the path to the active collection file. Return an error if there is 63 | /// no collection file present, or if the user specified an invalid file. 64 | fn collection_file(&self) -> anyhow::Result { 65 | CollectionFile::new(self.file.clone()) 66 | } 67 | } 68 | 69 | /// A CLI subcommand 70 | #[derive(Clone, Debug, clap::Subcommand)] 71 | pub enum CliCommand { 72 | Collections(CollectionsCommand), 73 | Db(DbCommand), 74 | Generate(GenerateCommand), 75 | History(HistoryCommand), 76 | Import(ImportCommand), 77 | New(NewCommand), 78 | Request(RequestCommand), 79 | Show(ShowCommand), 80 | } 81 | 82 | impl CliCommand { 83 | /// Execute this CLI subcommand 84 | pub async fn execute(self, global: GlobalArgs) -> anyhow::Result { 85 | match self { 86 | Self::Collections(command) => command.execute(global).await, 87 | Self::Db(command) => command.execute(global).await, 88 | Self::Generate(command) => command.execute(global).await, 89 | Self::History(command) => command.execute(global).await, 90 | Self::Import(command) => command.execute(global).await, 91 | Self::New(command) => command.execute(global).await, 92 | Self::Request(command) => command.execute(global).await, 93 | Self::Show(command) => command.execute(global).await, 94 | } 95 | } 96 | } 97 | 98 | /// An executable subcommand. This trait isn't strictly necessary because we do 99 | /// static dispatch via the command enum, but it's helpful to enforce a 100 | /// consistent interface for each subcommand. 101 | trait Subcommand { 102 | /// Execute the subcommand 103 | async fn execute(self, global: GlobalArgs) -> anyhow::Result; 104 | } 105 | -------------------------------------------------------------------------------- /crates/cli/tests/common.rs: -------------------------------------------------------------------------------- 1 | #![allow(unused)] 2 | 3 | use assert_cmd::Command; 4 | use slumber_core::collection::CollectionFile; 5 | use slumber_util::{TempDir, paths::DATA_DIRECTORY_ENV_VARIABLE, temp_dir}; 6 | use std::{ 7 | env, 8 | ops::Deref, 9 | path::{Path, PathBuf}, 10 | }; 11 | 12 | /// Get a command to run Slumber. This will also return the data directory that 13 | /// will be used for the database. Most tests can just ignore this. 14 | pub fn slumber() -> (Command, TempDir) { 15 | let data_dir = temp_dir(); 16 | let mut command = Command::cargo_bin("slumber_cli").unwrap(); 17 | command 18 | .current_dir(tests_dir()) 19 | .env(DATA_DIRECTORY_ENV_VARIABLE, data_dir.deref()); 20 | (command, data_dir) 21 | } 22 | 23 | fn tests_dir() -> PathBuf { 24 | Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/") 25 | } 26 | 27 | /// Path to the CLI test collection file 28 | pub fn collection_file() -> CollectionFile { 29 | CollectionFile::new(Some(tests_dir().join("slumber.yml"))).unwrap() 30 | } 31 | -------------------------------------------------------------------------------- /crates/cli/tests/slumber.yml: -------------------------------------------------------------------------------- 1 | # Collection for CLI integration tests 2 | 3 | profiles: 4 | profile1: 5 | name: Profile 1 6 | default: true 7 | data: 8 | host: http://server 9 | username: username1 10 | profile2: 11 | name: Profile 2 12 | data: 13 | host: http://server 14 | username: username2 15 | 16 | chains: 17 | # chain that triggers a request 18 | trigger: 19 | source: !request 20 | recipe: getUser 21 | trigger: !always 22 | content_type: json 23 | selector: $.username 24 | 25 | requests: 26 | getUser: !request 27 | method: GET 28 | url: "{{host}}/users/{{username}}" 29 | 30 | jsonBody: !request 31 | method: POST 32 | url: "{{host}}/json" 33 | body: !json { "username": "{{username}}", "name": "Frederick Smidgen" } 34 | 35 | chained: !request 36 | method: GET 37 | url: "{{host}}/chained/{{chains.trigger}}" 38 | -------------------------------------------------------------------------------- /crates/cli/tests/test_generate.rs: -------------------------------------------------------------------------------- 1 | //! Test the `slumber generate` subcommand 2 | 3 | mod common; 4 | 5 | use serde_json::json; 6 | use slumber_core::database::Database; 7 | use wiremock::{Mock, MockServer, ResponseTemplate, matchers}; 8 | 9 | /// Test generating a curl command with: 10 | /// - URL 11 | /// - Query params 12 | /// - Headers 13 | #[test] 14 | fn test_generate_curl() { 15 | let (mut command, _) = common::slumber(); 16 | command.args(["generate", "curl", "getUser"]); 17 | command 18 | .assert() 19 | .success() 20 | .stdout("curl -XGET --url 'http://server/users/username1'\n"); 21 | } 22 | 23 | /// Make sure the profile option is reflected correctly 24 | #[test] 25 | fn test_generate_curl_profile() { 26 | let (mut command, _) = common::slumber(); 27 | command.args(["generate", "curl", "getUser", "-p", "profile2"]); 28 | command 29 | .assert() 30 | .success() 31 | .stdout("curl -XGET --url 'http://server/users/username2'\n"); 32 | } 33 | 34 | /// Make sure field overrides are applied correctly 35 | #[test] 36 | fn test_generate_curl_override() { 37 | let (mut command, _) = common::slumber(); 38 | command.args(["generate", "curl", "getUser", "-o", "username=username3"]); 39 | command 40 | .assert() 41 | .success() 42 | .stdout("curl -XGET --url 'http://server/users/username3'\n"); 43 | } 44 | 45 | /// Test failure when a downstream request is needed but cannot be triggered 46 | #[test] 47 | fn test_generate_curl_trigger_error() { 48 | let (mut command, _) = common::slumber(); 49 | command.args(["generate", "curl", "chained"]); 50 | command.assert().failure().stderr( 51 | "Triggered requests are disabled by default; pass `--execute-triggers` to enable 52 | Error rendering URL 53 | Resolving chain `trigger` 54 | Triggering upstream recipe `getUser` 55 | Triggered request execution not allowed in this context 56 | ", 57 | ); 58 | } 59 | 60 | /// Test upstream requests can be triggered with `--execute-triggers` 61 | #[tokio::test] 62 | async fn test_generate_curl_execute_trigger() { 63 | // Mock HTTP response 64 | let server = MockServer::start().await; 65 | let host = server.uri(); 66 | let body = json!({"username": "username1"}); 67 | Mock::given(matchers::method("GET")) 68 | .and(matchers::path("/users/username1")) 69 | .respond_with(ResponseTemplate::new(200).set_body_json(&body)) 70 | .mount(&server) 71 | .await; 72 | 73 | let (mut command, data_dir) = common::slumber(); 74 | command.args([ 75 | "generate", 76 | "curl", 77 | "chained", 78 | "--execute-triggers", 79 | "-o", 80 | &format!("host={host}"), 81 | ]); 82 | command 83 | .assert() 84 | .success() 85 | .stdout(format!("curl -XGET --url '{host}/chained/username1'\n")); 86 | 87 | // Executed request should not have been persisted 88 | let database = Database::from_directory(&data_dir).unwrap(); 89 | assert_eq!(&database.get_all_requests().unwrap(), &[]); 90 | } 91 | 92 | // More detailed test cases for curl are defined in unit tests 93 | -------------------------------------------------------------------------------- /crates/cli/tests/test_history.rs: -------------------------------------------------------------------------------- 1 | //! Test the `slumber history` subcommand 2 | 3 | mod common; 4 | 5 | use crate::common::collection_file; 6 | use itertools::Itertools; 7 | use rstest::rstest; 8 | use slumber_core::{ 9 | collection::{CollectionFile, ProfileId, RecipeId}, 10 | database::Database, 11 | http::{Exchange, RequestId}, 12 | }; 13 | use slumber_util::{Factory, paths::get_repo_root}; 14 | use std::path::Path; 15 | use uuid::Uuid; 16 | 17 | // Use static IDs for the recipes so we can refer to them in expectations 18 | const RECIPE1_NO_PROFILE_ID: RequestId = 19 | id("00000000-0000-0000-0000-000000000000"); 20 | const RECIPE1_PROFILE1_ID: RequestId = 21 | id("00000000-0000-0000-0000-000000000001"); 22 | const RECIPE2_ID: RequestId = id("00000000-0000-0000-0000-000000000002"); 23 | const OTHER_COLLECTION_ID: RequestId = 24 | id("00000000-0000-0000-0000-000000000003"); 25 | 26 | /// Test `slumber history list` 27 | #[rstest] 28 | #[case::recipe( 29 | &["history", "list", "recipe1"], 30 | &[RECIPE1_NO_PROFILE_ID, RECIPE1_PROFILE1_ID], 31 | )] 32 | #[case::no_profile( 33 | &["history", "list", "recipe1", "-p"], &[RECIPE1_NO_PROFILE_ID], 34 | )] 35 | #[case::profile( 36 | &["history", "list", "recipe1", "-p", "profile1"], &[RECIPE1_PROFILE1_ID], 37 | )] 38 | #[case::collection( 39 | &["history", "list"], 40 | &[RECIPE1_NO_PROFILE_ID, RECIPE1_PROFILE1_ID, RECIPE2_ID], 41 | )] 42 | #[case::different_collection( 43 | &["-f", "../../../slumber.yml", "history", "list"], 44 | &[OTHER_COLLECTION_ID], 45 | )] 46 | #[case::all( 47 | &["history", "list", "--all"], 48 | &[RECIPE1_NO_PROFILE_ID, RECIPE1_PROFILE1_ID, RECIPE2_ID, OTHER_COLLECTION_ID], 49 | )] 50 | fn test_history_list( 51 | #[case] arguments: &[&str], 52 | #[case] expected_requests: &[RequestId], 53 | ) { 54 | let (mut command, data_dir) = common::slumber(); 55 | init_db(&data_dir); 56 | 57 | command.args(arguments).assert().success().stdout( 58 | predicates::function::function(|stdout: &str| { 59 | expected_requests 60 | .iter() 61 | .all(|expected_id| stdout.contains(&expected_id.to_string())) 62 | }), 63 | ); 64 | } 65 | 66 | /// Test `slumber history delete` 67 | #[rstest] 68 | fn test_history_delete() { 69 | let (mut command, data_dir) = common::slumber(); 70 | let database = init_db(&data_dir); 71 | 72 | command 73 | .args([ 74 | "history", 75 | "delete", 76 | &RECIPE1_PROFILE1_ID.to_string(), 77 | &RECIPE1_NO_PROFILE_ID.to_string(), 78 | ]) 79 | .assert() 80 | .success(); 81 | let remaining = database 82 | .get_all_requests() 83 | .unwrap() 84 | .into_iter() 85 | .map(|exchange| exchange.id) 86 | .sorted() 87 | .collect_vec(); 88 | assert_eq!(&remaining, &[RECIPE2_ID, OTHER_COLLECTION_ID]); 89 | } 90 | 91 | const fn id(s: &str) -> RequestId { 92 | let Ok(uuid) = Uuid::try_parse(s) else { 93 | panic!("Bad value") // unwrap() isn't const 94 | }; 95 | RequestId(uuid) 96 | } 97 | 98 | fn init_db(data_dir: &Path) -> Database { 99 | let database = Database::from_directory(data_dir).unwrap(); 100 | let db = database 101 | .clone() 102 | .into_collection(&collection_file()) 103 | .unwrap(); 104 | let profile_id: ProfileId = "profile1".into(); 105 | let recipe_id: RecipeId = "recipe1".into(); 106 | db.insert_exchange(&Exchange::factory(( 107 | RECIPE1_NO_PROFILE_ID, 108 | None, 109 | recipe_id.clone(), 110 | ))) 111 | .unwrap(); 112 | db.insert_exchange(&Exchange::factory(( 113 | RECIPE1_PROFILE1_ID, 114 | Some(profile_id), 115 | recipe_id, 116 | ))) 117 | .unwrap(); 118 | db.insert_exchange(&Exchange::factory(( 119 | RECIPE2_ID, 120 | None, 121 | "recipe2".into(), 122 | ))) 123 | .unwrap(); 124 | 125 | // Add one under a different collection 126 | let db = database 127 | .clone() 128 | .into_collection( 129 | &CollectionFile::new(Some(get_repo_root().join("slumber.yml"))) 130 | .unwrap(), 131 | ) 132 | .unwrap(); 133 | db.insert_exchange(&Exchange::factory(OTHER_COLLECTION_ID)) 134 | .unwrap(); 135 | 136 | database 137 | } 138 | -------------------------------------------------------------------------------- /crates/config/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = {workspace = true} 3 | description = "Configuration for Slumber. Not intended for external use." 4 | edition = {workspace = true} 5 | homepage = {workspace = true} 6 | keywords = {workspace = true} 7 | license = {workspace = true} 8 | name = "slumber_config" 9 | repository = {workspace = true} 10 | rust-version = {workspace = true} 11 | version = {workspace = true} 12 | 13 | [dependencies] 14 | anyhow = {workspace = true} 15 | crossterm = {workspace = true} 16 | derive_more = {workspace = true, features = ["deref", "display"]} 17 | glob = "0.3.2" 18 | indexmap = {workspace = true, features = ["serde"]} 19 | itertools = {workspace = true} 20 | mime = {workspace = true} 21 | ratatui = {workspace = true, features = ["serde"]} 22 | serde = {workspace = true} 23 | slumber_util = {workspace = true} 24 | tracing = {workspace = true} 25 | 26 | [dev-dependencies] 27 | dirs = {workspace = true} 28 | env-lock = {workspace = true} 29 | rstest = {workspace = true} 30 | serde_test = {workspace = true} 31 | slumber_util = {workspace = true, features = ["test"]} 32 | 33 | [lints] 34 | workspace = true 35 | 36 | [package.metadata.release] 37 | tag = false 38 | -------------------------------------------------------------------------------- /crates/config/src/theme.rs: -------------------------------------------------------------------------------- 1 | use ratatui::style::Color; 2 | use serde::{Deserialize, Serialize}; 3 | 4 | /// User-configurable visual settings. These are used to generate the full style 5 | /// set. 6 | #[derive(Debug, Serialize, Deserialize)] 7 | #[cfg_attr(test, derive(PartialEq))] 8 | #[serde(default, deny_unknown_fields)] 9 | pub struct Theme { 10 | pub primary_color: Color, 11 | /// Theoretically we could calculate this bsed on primary color, but for 12 | /// named or indexed colors, we don't know the exact RGB code since it 13 | /// depends on the user's terminal theme. It's much easier and less 14 | /// fallible to just have the user specify it. 15 | pub primary_text_color: Color, 16 | pub secondary_color: Color, 17 | pub success_color: Color, 18 | pub error_color: Color, 19 | } 20 | 21 | impl Default for Theme { 22 | fn default() -> Self { 23 | Self { 24 | primary_color: Color::Blue, 25 | primary_text_color: Color::White, 26 | secondary_color: Color::Yellow, 27 | success_color: Color::Green, 28 | error_color: Color::Red, 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /crates/core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = {workspace = true} 3 | description = "Core library for Slumber. Not intended for external use." 4 | edition = {workspace = true} 5 | homepage = {workspace = true} 6 | keywords = {workspace = true} 7 | license = {workspace = true} 8 | name = "slumber_core" 9 | repository = {workspace = true} 10 | rust-version = {workspace = true} 11 | version = {workspace = true} 12 | 13 | [dependencies] 14 | anyhow = {workspace = true} 15 | async-trait = {workspace = true} 16 | bytes = {workspace = true, features = ["serde"]} 17 | chrono = {workspace = true, features = ["clock", "serde", "std"]} 18 | derive_more = {workspace = true, features = ["debug", "deref", "deref_mut", "display", "from", "from_str", "into"]} 19 | dialoguer = {workspace = true} 20 | futures = {workspace = true} 21 | indexmap = {workspace = true, features = ["serde"]} 22 | itertools = {workspace = true} 23 | mime = {workspace = true} 24 | regex = {version = "1.10.5", default-features = false} 25 | reqwest = {workspace = true, features = ["multipart", "rustls-tls", "rustls-tls-native-roots"]} 26 | rstest = {workspace = true, optional = true} 27 | rusqlite = {version = "0.35.0", default-features = false, features = ["bundled", "chrono", "uuid"]} 28 | rusqlite_migration = "2.1.0" 29 | serde = {workspace = true, features = ["derive"]} 30 | serde_json = {workspace = true} 31 | serde_json_path = "0.7.1" 32 | serde_yaml = {workspace = true} 33 | slumber_config = {workspace = true} 34 | slumber_util = {workspace = true} 35 | strum = {workspace = true, features = ["derive"]} 36 | thiserror = {workspace = true} 37 | tokio = {workspace = true, features = ["fs", "process"]} 38 | tracing = {workspace = true} 39 | url = {version = "2.0.0", features = ["serde"]}# Inherited from reqwest 40 | uuid = {workspace = true, features = ["serde", "v4"]} 41 | winnow = {workspace = true} 42 | 43 | [dev-dependencies] 44 | env-lock = {workspace = true} 45 | pretty_assertions = {workspace = true} 46 | proptest = "1.5.0" 47 | proptest-derive = "0.5.0" 48 | rstest = {workspace = true} 49 | serde_test = {workspace = true} 50 | slumber_util = {workspace = true, features = ["test"]} 51 | wiremock = {workspace = true} 52 | 53 | [features] 54 | test = ["dep:rstest", "slumber_util/test"] 55 | 56 | [lints] 57 | workspace = true 58 | 59 | [package.metadata.release] 60 | tag = false 61 | -------------------------------------------------------------------------------- /crates/core/proptest-regressions/template/parse.txt: -------------------------------------------------------------------------------- 1 | # Seeds for failure cases proptest has generated in the past. It is 2 | # automatically read and these particular cases re-run before any 3 | # novel cases are generated. 4 | # 5 | # It is recommended to check this file in to source control so that 6 | # everyone who runs the test benefits from these saved cases. 7 | cc 5da7cbd1d4c1191c7ecb571e3d1ac8b73e4bfda8b8f8024a5b0fe74636c049aa # shrinks to template = Template { chunks: [Raw("{"), Key(Field(Identifier("a")))] } 8 | cc 54c67ac2c41a41eae30d6b5d2527db9dcff8067404394b666a7c0ad0ae619cb4 # shrinks to template = Template { chunks: [Raw("\\{"), Key(Field(Identifier("_")))] } 9 | cc 118b4b0567516b0ca07967894b8be053da5ed0cbe03ceca967ae411e12e305bc # shrinks to template = Template { chunks: [Raw("{{{")] } 10 | -------------------------------------------------------------------------------- /crates/core/src/http/curl.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | collection::Authentication, 3 | http::{HttpMethod, RenderedBody}, 4 | }; 5 | use anyhow::Context; 6 | use reqwest::header::{self, HeaderMap, HeaderName, HeaderValue}; 7 | use std::fmt::Write; 8 | 9 | /// Builder pattern for constructing cURL commands from a recipe 10 | pub struct CurlBuilder { 11 | command: String, 12 | } 13 | 14 | impl CurlBuilder { 15 | /// Start building a new cURL command for an HTTP method 16 | pub fn new(method: HttpMethod) -> Self { 17 | Self { 18 | command: format!("curl -X{method}"), 19 | } 20 | } 21 | 22 | /// Add the URL, with query parameters, to the command 23 | pub fn url( 24 | mut self, 25 | mut url: reqwest::Url, 26 | query: &[(String, String)], 27 | ) -> Self { 28 | // Add a query string. The empty check prevents a dangling ? if there 29 | // are no query params 30 | if !query.is_empty() { 31 | url.query_pairs_mut().extend_pairs(query); 32 | } 33 | write!(&mut self.command, " --url '{url}'").unwrap(); 34 | self 35 | } 36 | 37 | /// Add an entire map of headers to the command 38 | pub fn headers(mut self, headers: &HeaderMap) -> anyhow::Result { 39 | for (name, value) in headers { 40 | self = self.header(name, value)?; 41 | } 42 | Ok(self) 43 | } 44 | 45 | /// Add a header to the command 46 | pub fn header( 47 | mut self, 48 | name: &HeaderName, 49 | value: &HeaderValue, 50 | ) -> anyhow::Result { 51 | let value = as_text(value.as_bytes())?; 52 | write!(&mut self.command, " --header '{name}: {value}'").unwrap(); 53 | Ok(self) 54 | } 55 | 56 | /// Add an authentication scheme to the command 57 | pub fn authentication( 58 | mut self, 59 | authentication: &Authentication, 60 | ) -> Self { 61 | match authentication { 62 | Authentication::Basic { username, password } => { 63 | write!( 64 | &mut self.command, 65 | " --user '{username}:{password}'", 66 | password = password.as_deref().unwrap_or_default() 67 | ) 68 | .unwrap(); 69 | self 70 | } 71 | Authentication::Bearer(token) => self 72 | .header( 73 | &header::AUTHORIZATION, 74 | // The token is base64-encoded so we know it's valid 75 | &HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), 76 | ) 77 | // Failure isn't possible because we know the value is UTF-8 78 | .unwrap(), 79 | } 80 | } 81 | 82 | /// Add a body to the command 83 | pub fn body(mut self, body: &RenderedBody) -> anyhow::Result { 84 | match body { 85 | RenderedBody::Raw(body) => { 86 | let body = as_text(body)?; 87 | write!(&mut self.command, " --data '{body}'").unwrap(); 88 | } 89 | // Use the first-class form support where possible 90 | RenderedBody::FormUrlencoded(form) => { 91 | for (field, value) in form { 92 | write!( 93 | &mut self.command, 94 | " --data-urlencode '{field}={value}'" 95 | ) 96 | .unwrap(); 97 | } 98 | } 99 | RenderedBody::FormMultipart(form) => { 100 | for (field, value) in form { 101 | let value = as_text(value)?; 102 | write!(&mut self.command, " -F '{field}={value}'").unwrap(); 103 | } 104 | } 105 | } 106 | Ok(self) 107 | } 108 | 109 | /// Finalize and return the command 110 | pub fn build(self) -> String { 111 | self.command 112 | } 113 | } 114 | 115 | /// Convert bytes to text, or return an error if it's not UTF-8 116 | fn as_text(bytes: &[u8]) -> anyhow::Result<&str> { 117 | std::str::from_utf8(bytes) 118 | .context("curl command generation only supports text values") 119 | } 120 | -------------------------------------------------------------------------------- /crates/core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Core frontend-agnostic functionality for Slumber, agnostic of the front end. 2 | //! 3 | //! **This crate is not semver compliant**. The version is locked to the root 4 | //! `slumber` crate version. If you choose to depend directly on this crate, you 5 | //! do so at your own risk of breakage. 6 | 7 | pub mod collection; 8 | pub mod database; 9 | pub mod http; 10 | pub mod template; 11 | #[cfg(any(test, feature = "test"))] 12 | pub mod test_util; 13 | pub mod util; 14 | -------------------------------------------------------------------------------- /crates/core/src/template/cereal.rs: -------------------------------------------------------------------------------- 1 | use crate::template::Template; 2 | use serde::{ 3 | Deserialize, Deserializer, Serialize, 4 | de::{Error, Visitor}, 5 | }; 6 | 7 | impl Serialize for Template { 8 | fn serialize(&self, serializer: S) -> Result 9 | where 10 | S: serde::Serializer, 11 | { 12 | self.display().serialize(serializer) 13 | } 14 | } 15 | 16 | // Custom deserializer for `Template`. This is useful for deserializing values 17 | // that are not strings, but should be treated as strings such as numbers, 18 | // booleans, and nulls. 19 | impl<'de> Deserialize<'de> for Template { 20 | fn deserialize(deserializer: D) -> Result 21 | where 22 | D: Deserializer<'de>, 23 | { 24 | struct TemplateVisitor; 25 | 26 | macro_rules! visit_primitive { 27 | ($func:ident, $type:ty) => { 28 | fn $func(self, v: $type) -> Result 29 | where 30 | E: Error, 31 | { 32 | self.visit_string(v.to_string()) 33 | } 34 | }; 35 | } 36 | 37 | impl Visitor<'_> for TemplateVisitor { 38 | type Value = Template; 39 | 40 | fn expecting( 41 | &self, 42 | formatter: &mut std::fmt::Formatter, 43 | ) -> std::fmt::Result { 44 | formatter.write_str("string, number, or boolean") 45 | } 46 | 47 | visit_primitive!(visit_bool, bool); 48 | visit_primitive!(visit_u64, u64); 49 | visit_primitive!(visit_i64, i64); 50 | visit_primitive!(visit_f64, f64); 51 | 52 | fn visit_str(self, v: &str) -> Result 53 | where 54 | E: Error, 55 | { 56 | v.parse().map_err(E::custom) 57 | } 58 | } 59 | 60 | deserializer.deserialize_any(TemplateVisitor) 61 | } 62 | } 63 | 64 | #[cfg(test)] 65 | mod tests { 66 | use super::*; 67 | use rstest::rstest; 68 | use serde_test::{Token, assert_de_tokens}; 69 | 70 | /// Test deserialization, which has some additional logic on top of parsing 71 | #[rstest] 72 | // boolean 73 | #[case::bool_true(Token::Bool(true), "true")] 74 | #[case::bool_false(Token::Bool(false), "false")] 75 | // numeric 76 | #[case::u64(Token::U64(1000), "1000")] 77 | #[case::i64_negative(Token::I64(-1000), "-1000")] 78 | #[case::float_positive(Token::F64(10.1), "10.1")] 79 | #[case::float_negative(Token::F64(-10.1), "-10.1")] 80 | // string 81 | #[case::str(Token::Str("hello"), "hello")] 82 | #[case::str_null(Token::Str("null"), "null")] 83 | #[case::str_true(Token::Str("true"), "true")] 84 | #[case::str_false(Token::Str("false"), "false")] 85 | #[case::str_with_keys(Token::Str("{{user_id}}"), "{{user_id}}")] 86 | fn test_deserialize(#[case] token: Token, #[case] expected: &str) { 87 | assert_de_tokens(&Template::from(expected), &[token]); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /crates/core/src/template/prompt.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use derive_more::From; 3 | use slumber_util::ResultTraced; 4 | use std::fmt::Debug; 5 | use tokio::sync::oneshot; 6 | 7 | /// A prompter is a bridge between the user and the template engine. It enables 8 | /// the template engine to request values from the user *during* the template 9 | /// process. The implementor is responsible for deciding *how* to ask the user. 10 | /// 11 | /// **Note:** The prompter has to be able to handle simultaneous prompt 12 | /// requests, if a template has multiple prompt values, or if multiple templates 13 | /// with prompts are being rendered simultaneously. The implementor is 14 | /// responsible for queueing prompts to show to the user one at a time. 15 | pub trait Prompter: Debug + Send + Sync { 16 | /// Ask the user a question, and use the given channel to return a response. 17 | /// To indicate "no response", simply drop the returner. 18 | /// 19 | /// If an error occurs while prompting the user, just drop the returner. 20 | /// The implementor is responsible for logging the error as appropriate. 21 | fn prompt(&self, prompt: Prompt); 22 | 23 | /// Ask the user to pick an item for a list of choices 24 | fn select(&self, select: Select); 25 | } 26 | 27 | /// Data defining a prompt which should be presented to the user 28 | #[derive(Debug)] 29 | pub struct Prompt { 30 | /// Tell the user what we're asking for 31 | pub message: String, 32 | /// Value used to pre-populate the text box 33 | pub default: Option, 34 | /// Should the value the user is typing be masked? E.g. password input 35 | pub sensitive: bool, 36 | /// How the prompter will pass the answer back 37 | pub channel: ResponseChannel, 38 | } 39 | 40 | /// A list of options to present to the user 41 | #[derive(Debug)] 42 | pub struct Select { 43 | /// Tell the user what we're asking for 44 | pub message: String, 45 | /// List of choices the user can pick from 46 | pub options: Vec, 47 | /// How the prompter will pass the answer back 48 | pub channel: ResponseChannel, 49 | } 50 | 51 | /// Channel used to return a response to a one-time request. This is its own 52 | /// type so we can provide wrapping functionality 53 | #[derive(Debug, From)] 54 | pub struct ResponseChannel(oneshot::Sender); 55 | 56 | impl ResponseChannel { 57 | /// Return the value that the user gave 58 | pub fn respond(self, response: T) { 59 | // This error *shouldn't* ever happen, because the templating task 60 | // stays open until it gets a response 61 | let _ = self 62 | .0 63 | .send(response) 64 | .map_err(|_| anyhow!("Response listener dropped")) 65 | .traced(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /crates/import/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = {workspace = true} 3 | description = "Import from other formats to Slumber collections. Not intended for external use." 4 | edition = {workspace = true} 5 | homepage = {workspace = true} 6 | keywords = {workspace = true} 7 | license = {workspace = true} 8 | name = "slumber_import" 9 | repository = {workspace = true} 10 | rust-version = {workspace = true} 11 | version = {workspace = true} 12 | 13 | [dependencies] 14 | anyhow = {workspace = true} 15 | indexmap = {workspace = true, features = ["serde"]} 16 | itertools = {workspace = true} 17 | mime = {workspace = true} 18 | openapiv3 = "2.0.0" 19 | reqwest = {workspace = true} 20 | rest_parser = "0.1.6" 21 | serde = {workspace = true} 22 | serde_json = {workspace = true} 23 | serde_yaml = {workspace = true} 24 | slumber_core = {workspace = true} 25 | slumber_util = {workspace = true} 26 | strum = {workspace = true} 27 | thiserror = {workspace = true} 28 | tracing = {workspace = true} 29 | winnow = {workspace = true} 30 | 31 | [dev-dependencies] 32 | pretty_assertions = {workspace = true} 33 | rstest = {workspace = true} 34 | serde_test = {workspace = true} 35 | slumber_core = {workspace = true, features = ["test"]} 36 | 37 | [lints] 38 | workspace = true 39 | 40 | [package.metadata.release] 41 | tag = false 42 | -------------------------------------------------------------------------------- /crates/import/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Import from external formats into Slumber. 2 | //! 3 | //! **This crate is not semver compliant**. The version is locked to the root 4 | //! `slumber` crate version. If you choose to depend directly on this crate, you 5 | //! do so at your own risk of breakage. 6 | 7 | mod insomnia; 8 | mod openapi; 9 | mod rest; 10 | 11 | pub use insomnia::from_insomnia; 12 | pub use openapi::from_openapi; 13 | pub use rest::from_rest; 14 | -------------------------------------------------------------------------------- /crates/tui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = {workspace = true} 3 | description = "Terminal user interface for Slumber. Not intended for external use." 4 | edition = {workspace = true} 5 | homepage = {workspace = true} 6 | keywords = {workspace = true} 7 | license = {workspace = true} 8 | name = "slumber_tui" 9 | repository = {workspace = true} 10 | rust-version = {workspace = true} 11 | version = {workspace = true} 12 | 13 | [dependencies] 14 | anyhow = {workspace = true} 15 | async-trait = {workspace = true} 16 | bytes = {workspace = true} 17 | chrono = {workspace = true} 18 | cli-clipboard = "0.4.0" 19 | crossterm = {workspace = true, features = ["bracketed-paste", "windows", "events", "event-stream"]} 20 | derive_more = {workspace = true, features = ["debug", "deref", "deref_mut", "display", "from"]} 21 | editor-command = "1.0.0" 22 | futures = {workspace = true} 23 | indexmap = {workspace = true} 24 | itertools = {workspace = true} 25 | mime = {workspace = true} 26 | notify = {version = "8.0.0", default-features = false, features = ["macos_fsevent"]} 27 | notify-debouncer-full = {version = "0.5.0", default-features = false} 28 | persisted = "1.0.0" 29 | ratatui = {workspace = true, features = ["crossterm", "underline-color", "unstable-widget-ref"]} 30 | reqwest = {workspace = true} 31 | serde = {workspace = true} 32 | serde_yaml = {workspace = true} 33 | shell-words = "1.1.0" 34 | slumber_config = {workspace = true} 35 | slumber_core = {workspace = true} 36 | slumber_util = {workspace = true} 37 | strum = {workspace = true} 38 | tokio = {workspace = true, features = ["macros", "signal", "tracing"]} 39 | tokio-util = "0.7.13" 40 | tracing = {workspace = true} 41 | tree-sitter-highlight = "0.25.4" 42 | tree-sitter-json = "0.24.8" 43 | unicode-width = "0.1.13" 44 | uuid = {workspace = true} 45 | 46 | [dev-dependencies] 47 | pretty_assertions = {workspace = true} 48 | rstest = {workspace = true} 49 | serde_json = {workspace = true} 50 | slumber_core = {workspace = true, features = ["test"]} 51 | wiremock = {workspace = true} 52 | 53 | [lints] 54 | workspace = true 55 | 56 | [package.metadata.release] 57 | tag = false 58 | -------------------------------------------------------------------------------- /crates/tui/src/context.rs: -------------------------------------------------------------------------------- 1 | use crate::{input::InputEngine, view::Styles}; 2 | use slumber_config::Config; 3 | use slumber_core::http::HttpEngine; 4 | use std::sync::OnceLock; 5 | 6 | /// The singleton value for the context. Initialized once during startup, then 7 | /// freely available *read only* everywhere. 8 | static INSTANCE: OnceLock = OnceLock::new(); 9 | 10 | /// Globally available context for the TUI. This is initialized once during 11 | /// **TUI** creation (not view creation), meaning there is only one per session. 12 | /// Data that can change through the lifespan of the process, e.g. by user input 13 | /// or collection reload, should *not* go in here. 14 | /// 15 | /// The purpose of this is to make it easy for components in the view to access 16 | /// **read-only** global data without needing to drill it all down the tree. 17 | /// This is purely for convenience. 18 | #[derive(Debug)] 19 | pub struct TuiContext { 20 | /// App-level configuration 21 | pub config: Config, 22 | /// Visual styles, derived from the theme 23 | pub styles: Styles, 24 | /// Input:action bindings 25 | pub input_engine: InputEngine, 26 | /// For sending HTTP requests 27 | pub http_engine: HttpEngine, 28 | } 29 | 30 | impl TuiContext { 31 | /// Initialize global context. Should be called only once, during startup. 32 | pub fn init(config: Config) { 33 | INSTANCE 34 | .set(Self::new(config)) 35 | .expect("Global context is already initialized"); 36 | } 37 | 38 | /// Initialize the global context for tests. This will use a default config, 39 | /// and if the context is already initialized, do nothing. 40 | #[cfg(test)] 41 | pub fn init_test() { 42 | INSTANCE.get_or_init(|| Self::new(Config::default())); 43 | } 44 | 45 | fn new(config: Config) -> Self { 46 | let styles = Styles::new(&config.theme); 47 | let input_engine = InputEngine::new(config.input_bindings.clone()); 48 | let http_engine = HttpEngine::new(&config.http); 49 | Self { 50 | config, 51 | styles, 52 | input_engine, 53 | http_engine, 54 | } 55 | } 56 | 57 | /// Get a reference to the global context 58 | pub fn get() -> &'static Self { 59 | // Right now the theme isn't configurable so this is fine. To make it 60 | // configurable we'll need to populate the static value during startup 61 | INSTANCE.get().expect("Global context is not initialized") 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /crates/tui/src/view/common/button.rs: -------------------------------------------------------------------------------- 1 | //! Buttons and button accessories 2 | 3 | use crate::{ 4 | context::TuiContext, 5 | view::{ 6 | context::UpdateContext, 7 | draw::{Draw, DrawMetadata, Generate}, 8 | event::{Emitter, Event, EventHandler, OptionEvent, ToEmitter}, 9 | state::fixed_select::{FixedSelect, FixedSelectState}, 10 | }, 11 | }; 12 | use ratatui::{ 13 | Frame, 14 | layout::{Constraint, Flex, Layout}, 15 | text::Span, 16 | }; 17 | use slumber_config::Action; 18 | 19 | /// An piece of text that the user can "press" with the submit action. It should 20 | /// only be interactable if it is focused, but that's up to the caller to 21 | /// enforce. 22 | pub struct Button<'a> { 23 | text: &'a str, 24 | has_focus: bool, 25 | } 26 | 27 | impl Generate for Button<'_> { 28 | type Output<'this> 29 | = Span<'this> 30 | where 31 | Self: 'this; 32 | 33 | fn generate<'this>(self) -> Self::Output<'this> 34 | where 35 | Self: 'this, 36 | { 37 | let styles = &TuiContext::get().styles; 38 | Span { 39 | content: self.text.into(), 40 | style: if self.has_focus { 41 | styles.text.highlight 42 | } else { 43 | Default::default() 44 | }, 45 | } 46 | } 47 | } 48 | 49 | /// A collection of buttons. User can cycle between buttons and hit enter to 50 | /// activate one. When a button is activated, it will emit a dynamic event with 51 | /// type `T`. 52 | #[derive(Debug, Default)] 53 | pub struct ButtonGroup { 54 | /// The only type of event we can emit is a button being selected, so just 55 | /// emit the button type 56 | emitter: Emitter, 57 | select: FixedSelectState, 58 | } 59 | 60 | impl EventHandler for ButtonGroup { 61 | fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { 62 | event.opt().action(|action, propagate| match action { 63 | Action::Left => self.select.previous(), 64 | Action::Right => self.select.next(), 65 | Action::Submit => { 66 | // Propagate the selected item as a dynamic event 67 | self.emitter.emit(self.select.selected()); 68 | } 69 | _ => propagate.set(), 70 | }) 71 | } 72 | 73 | // Do *not* treat the select state as a child, because the default select 74 | // action bindings aren't intuitive for this component 75 | } 76 | 77 | impl Draw for ButtonGroup { 78 | fn draw(&self, frame: &mut Frame, (): (), metadata: DrawMetadata) { 79 | let (areas, _) = 80 | Layout::horizontal(self.select.items().map(|button| { 81 | Constraint::Length(button.to_string().len() as u16) 82 | })) 83 | .flex(Flex::SpaceBetween) 84 | .split_with_spacers(metadata.area()); 85 | 86 | for (button, area) in self.select.items().zip(areas.iter()) { 87 | frame.render_widget( 88 | Button { 89 | text: &button.to_string(), 90 | has_focus: self.select.is_selected(button), 91 | } 92 | .generate(), 93 | *area, 94 | ); 95 | } 96 | } 97 | } 98 | 99 | impl ToEmitter for ButtonGroup { 100 | fn to_emitter(&self) -> Emitter { 101 | self.emitter 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /crates/tui/src/view/common/header_table.rs: -------------------------------------------------------------------------------- 1 | use crate::view::{common::table::Table, draw::Generate}; 2 | use itertools::Itertools; 3 | use ratatui::text::Text; 4 | use reqwest::header::HeaderMap; 5 | 6 | /// Render HTTP request/response headers in a table 7 | pub struct HeaderTable<'a> { 8 | pub headers: &'a HeaderMap, 9 | } 10 | 11 | impl Generate for HeaderTable<'_> { 12 | type Output<'this> 13 | = ratatui::widgets::Table<'this> 14 | where 15 | Self: 'this; 16 | 17 | fn generate<'this>(self) -> Self::Output<'this> 18 | where 19 | Self: 'this, 20 | { 21 | Table { 22 | rows: self 23 | .headers 24 | .iter() 25 | .map(|(k, v)| [Text::from(k.as_str()), v.generate().into()]) 26 | .collect_vec(), 27 | header: Some(["Header", "Value"]), 28 | alternate_row_style: true, 29 | ..Default::default() 30 | } 31 | .generate() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/tui/src/view/common/list.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | context::TuiContext, 3 | view::{ 4 | common::scrollbar::Scrollbar, 5 | draw::Generate, 6 | state::{ 7 | fixed_select::{FixedSelect, FixedSelectState}, 8 | select::{SelectItem, SelectState}, 9 | }, 10 | }, 11 | }; 12 | use ratatui::{ 13 | buffer::Buffer, 14 | layout::Rect, 15 | style::Styled, 16 | text::Text, 17 | widgets::{ 18 | List as TuiList, ListItem as TuiListItem, ListState, StatefulWidget, 19 | Widget, 20 | }, 21 | }; 22 | use std::marker::PhantomData; 23 | 24 | /// A sequence of items, with a scrollbar and optional surrounding pane 25 | pub struct List<'a, Item> { 26 | items: Vec>, 27 | /// This *shouldn't* be required, but without it we hit this ICE: 28 | /// https://github.com/rust-lang/rust/issues/124189 29 | phantom: PhantomData<&'a ()>, 30 | } 31 | 32 | impl<'a, Item> From<&'a SelectState> for List<'a, &'a Item> { 33 | fn from(select: &'a SelectState) -> Self { 34 | Self { 35 | items: select.items_with_metadata().map(ListItem::from).collect(), 36 | phantom: PhantomData, 37 | } 38 | } 39 | } 40 | 41 | impl<'a, Item> From<&'a FixedSelectState> for List<'a, &'a Item> 42 | where 43 | Item: FixedSelect, 44 | { 45 | fn from(select: &'a FixedSelectState) -> Self { 46 | Self { 47 | items: select.items_with_metadata().map(ListItem::from).collect(), 48 | phantom: PhantomData, 49 | } 50 | } 51 | } 52 | 53 | impl<'a, T, Item> StatefulWidget for List<'a, Item> 54 | where 55 | T: Into>, 56 | Item: 'a + Generate = T>, 57 | { 58 | type State = ListState; 59 | 60 | fn render(self, area: Rect, buf: &mut Buffer, state: &mut ListState) { 61 | let styles = &TuiContext::get().styles; 62 | 63 | // Draw list 64 | let items: Vec> = self 65 | .items 66 | .into_iter() 67 | .map(|item| { 68 | let mut list_item = TuiListItem::new(item.value.generate()); 69 | if item.disabled { 70 | list_item = list_item.set_style(styles.list.disabled); 71 | } 72 | list_item 73 | }) 74 | .collect(); 75 | let num_items = items.len(); 76 | let list = TuiList::new(items).highlight_style(styles.list.highlight); 77 | StatefulWidget::render(list, area, buf, state); 78 | 79 | // Draw scrollbar 80 | Scrollbar { 81 | content_length: num_items, 82 | offset: state.offset(), 83 | ..Default::default() 84 | } 85 | .render(area, buf); 86 | } 87 | } 88 | 89 | struct ListItem { 90 | value: T, 91 | disabled: bool, 92 | } 93 | 94 | impl<'a, T> From<&'a SelectItem> for ListItem<&'a T> { 95 | fn from(item: &'a SelectItem) -> Self { 96 | Self { 97 | value: &item.value, 98 | disabled: item.disabled(), 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /crates/tui/src/view/common/table.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | context::TuiContext, 3 | view::{common::Checkbox, draw::Generate}, 4 | }; 5 | use itertools::Itertools; 6 | use ratatui::{ 7 | prelude::Constraint, 8 | style::Styled, 9 | text::Text, 10 | widgets::{Block, Cell, Row}, 11 | }; 12 | use std::{iter, marker::PhantomData}; 13 | 14 | /// Tabular data display with a static number of columns. 15 | /// 16 | /// The `R` generic defines the row type, which should be either an array of 17 | /// cell types (e.g. `[Text; 3]`) or [ratatui::widgets::Row]. If using an array, 18 | /// the length should match `COLS`. Allowing `Row` makes it possible to override 19 | /// styling on a row-by-row basis. 20 | #[derive(Debug)] 21 | pub struct Table<'a, const COLS: usize, R> { 22 | pub title: Option<&'a str>, 23 | pub rows: Vec, 24 | /// Optional header row. Length should match column length 25 | pub header: Option<[&'a str; COLS]>, 26 | /// Use a different styling for alternating rows 27 | pub alternate_row_style: bool, 28 | /// Take an array ref (NOT a slice) so we can enforce the length, but the 29 | /// lifetime can outlive this struct 30 | pub column_widths: &'a [Constraint; COLS], 31 | } 32 | 33 | impl Default for Table<'_, COLS, Rows> { 34 | fn default() -> Self { 35 | Self { 36 | title: None, 37 | rows: Default::default(), 38 | header: None, 39 | alternate_row_style: false, 40 | // Evenly spaced by default 41 | column_widths: &[Constraint::Ratio(1, COLS as u32); COLS], 42 | } 43 | } 44 | } 45 | 46 | impl<'a, const COLS: usize, Cll> Generate for Table<'a, COLS, [Cll; COLS]> 47 | where 48 | Cll: Into>, 49 | { 50 | type Output<'this> 51 | = ratatui::widgets::Table<'this> 52 | where 53 | Self: 'this; 54 | 55 | fn generate<'this>(self) -> Self::Output<'this> 56 | where 57 | Self: 'this, 58 | { 59 | let table = Table { 60 | title: self.title, 61 | alternate_row_style: self.alternate_row_style, 62 | header: self.header, 63 | column_widths: self.column_widths, 64 | rows: self.rows.into_iter().map(Row::new).collect_vec(), 65 | }; 66 | table.generate() 67 | } 68 | } 69 | 70 | impl<'a, const COLS: usize> Generate for Table<'a, COLS, Row<'a>> { 71 | type Output<'this> 72 | = ratatui::widgets::Table<'this> 73 | where 74 | Self: 'this; 75 | 76 | fn generate<'this>(self) -> Self::Output<'this> 77 | where 78 | Self: 'this, 79 | { 80 | let styles = &TuiContext::get().styles; 81 | let rows = self.rows.into_iter().enumerate().map(|(i, row)| { 82 | // Apply theme styles, but let the row's individual styles override 83 | let base_style = if self.alternate_row_style && i % 2 == 1 { 84 | styles.table.alt 85 | } else { 86 | styles.table.text 87 | }; 88 | let row_style = Styled::style(&row); 89 | row.set_style(base_style.patch(row_style)) 90 | }); 91 | let mut table = ratatui::widgets::Table::new(rows, self.column_widths) 92 | .highlight_style(styles.table.highlight); 93 | 94 | // Add title 95 | if let Some(title) = self.title { 96 | table = table.block( 97 | Block::default() 98 | .title(title) 99 | .title_style(styles.table.title), 100 | ); 101 | } 102 | 103 | // Add optional header if given 104 | if let Some(header) = self.header { 105 | table = table.header(Row::new(header).style(styles.table.header)); 106 | } 107 | 108 | table 109 | } 110 | } 111 | 112 | /// A row in a table that can be toggled on/off. This will generate the checkbox 113 | /// column, and apply the appropriate row styling. 114 | #[derive(Debug)] 115 | pub struct ToggleRow<'a, Cells> { 116 | /// Needed to attach the lifetime of this value to the lifetime of the 117 | /// generated row 118 | phantom: PhantomData<&'a ()>, 119 | cells: Cells, 120 | enabled: bool, 121 | } 122 | 123 | impl ToggleRow<'_, Cells> { 124 | pub fn new(cells: Cells, enabled: bool) -> Self { 125 | Self { 126 | phantom: PhantomData, 127 | cells, 128 | enabled, 129 | } 130 | } 131 | } 132 | 133 | impl<'a, Cells> Generate for ToggleRow<'a, Cells> 134 | where 135 | Cells: IntoIterator, 136 | Cells::Item: Into>, 137 | { 138 | type Output<'this> 139 | = Row<'this> 140 | where 141 | Self: 'this; 142 | 143 | fn generate<'this>(self) -> Self::Output<'this> 144 | where 145 | Self: 'this, 146 | { 147 | let styles = &TuiContext::get().styles; 148 | // Include the given cells, then tack on the checkbox for enabled state 149 | Row::new( 150 | iter::once( 151 | Checkbox { 152 | checked: self.enabled, 153 | } 154 | .generate() 155 | .into(), 156 | ) 157 | .chain(self.cells.into_iter().map(Cell::from)), 158 | ) 159 | .style(if self.enabled { 160 | styles.table.text 161 | } else { 162 | styles.table.disabled 163 | }) 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /crates/tui/src/view/common/tabs.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | context::TuiContext, 3 | view::{ 4 | context::UpdateContext, 5 | draw::{Draw, DrawMetadata}, 6 | event::{Event, EventHandler, OptionEvent}, 7 | state::fixed_select::{FixedSelect, FixedSelectState}, 8 | }, 9 | }; 10 | use persisted::PersistedContainer; 11 | use ratatui::Frame; 12 | use slumber_config::Action; 13 | use std::fmt::Debug; 14 | 15 | /// Multi-tab display. Generic parameter defines the available tabs. 16 | #[derive(Debug, Default)] 17 | pub struct Tabs { 18 | tabs: FixedSelectState, 19 | } 20 | 21 | impl Tabs { 22 | pub fn selected(&self) -> T { 23 | self.tabs.selected() 24 | } 25 | } 26 | 27 | impl EventHandler for Tabs { 28 | fn update(&mut self, _: &mut UpdateContext, event: Event) -> Option { 29 | event.opt().action(|action, propagate| match action { 30 | Action::Left => self.tabs.previous(), 31 | Action::Right => self.tabs.next(), 32 | _ => propagate.set(), 33 | }) 34 | } 35 | } 36 | 37 | impl Draw for Tabs { 38 | fn draw(&self, frame: &mut Frame, (): (), metadata: DrawMetadata) { 39 | frame.render_widget( 40 | ratatui::widgets::Tabs::new(T::iter().map(|e| e.to_string())) 41 | .select(self.tabs.selected_index()) 42 | .highlight_style(TuiContext::get().styles.tab.highlight), 43 | metadata.area(), 44 | ); 45 | } 46 | } 47 | 48 | /// Persist selected tab 49 | impl PersistedContainer for Tabs 50 | where 51 | T: FixedSelect, 52 | { 53 | type Value = T; 54 | 55 | fn get_to_persist(&self) -> Self::Value { 56 | self.tabs.get_to_persist() 57 | } 58 | 59 | fn restore_persisted(&mut self, value: Self::Value) { 60 | self.tabs.restore_persisted(value); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /crates/tui/src/view/component.rs: -------------------------------------------------------------------------------- 1 | mod exchange_pane; 2 | mod help; 3 | mod history; 4 | mod internal; 5 | mod misc; 6 | mod primary; 7 | mod profile_select; 8 | mod queryable_body; 9 | mod recipe_list; 10 | mod recipe_pane; 11 | mod request_view; 12 | mod response_view; 13 | mod root; 14 | 15 | pub use internal::Component; 16 | pub use root::{Root, RootProps}; 17 | // Exported for the view context 18 | pub use recipe_pane::RecipeOverrideStore; 19 | -------------------------------------------------------------------------------- /crates/tui/src/view/component/help.rs: -------------------------------------------------------------------------------- 1 | use crate::{ 2 | context::TuiContext, 3 | view::{ 4 | common::{modal::Modal, table::Table}, 5 | context::ViewContext, 6 | draw::{Draw, DrawMetadata, Generate}, 7 | event::EventHandler, 8 | }, 9 | }; 10 | use itertools::Itertools; 11 | use ratatui::{ 12 | Frame, 13 | layout::{Alignment, Constraint, Layout}, 14 | text::{Line, Text}, 15 | }; 16 | use slumber_config::{Action, Config, InputBinding}; 17 | use slumber_core::util::doc_link; 18 | use slumber_util::paths; 19 | 20 | const CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); 21 | 22 | /// A mini helper in the footer for showing a few important key bindings 23 | #[derive(Debug)] 24 | pub struct HelpFooter; 25 | 26 | impl Generate for HelpFooter { 27 | type Output<'this> 28 | = Text<'this> 29 | where 30 | Self: 'this; 31 | 32 | fn generate<'this>(self) -> Self::Output<'this> 33 | where 34 | Self: 'this, 35 | { 36 | let actions = [Action::OpenActions, Action::OpenHelp, Action::Quit]; 37 | 38 | let tui_context = TuiContext::get(); 39 | 40 | let text = actions 41 | .into_iter() 42 | .map(|action| { 43 | let binding = tui_context.input_engine.binding_display(action); 44 | format!("{binding} {action}") 45 | }) 46 | .join(" / "); 47 | 48 | Text::styled(text, tui_context.styles.text.highlight) 49 | } 50 | } 51 | 52 | /// A whole ass modal for showing key binding help 53 | #[derive(Debug, Default)] 54 | pub struct HelpModal; 55 | 56 | impl HelpModal { 57 | /// Number of lines in the general section (not including header) 58 | const GENERAL_LENGTH: u16 = 5; 59 | 60 | /// Get the list of bindings that will be shown in the modal 61 | fn bindings() -> impl Iterator { 62 | TuiContext::get() 63 | .input_engine 64 | .bindings() 65 | .iter() 66 | .filter(|(action, _)| action.visible()) 67 | .map(|(action, binding)| (*action, binding)) 68 | } 69 | } 70 | 71 | impl Modal for HelpModal { 72 | fn title(&self) -> Line<'_> { 73 | "Help".into() 74 | } 75 | 76 | fn dimensions(&self) -> (Constraint, Constraint) { 77 | let num_bindings = Self::bindings().count() as u16; 78 | ( 79 | Constraint::Percentage(60), 80 | Constraint::Length(Self::GENERAL_LENGTH + 3 + num_bindings), 81 | ) 82 | } 83 | } 84 | 85 | impl EventHandler for HelpModal {} 86 | 87 | impl Draw for HelpModal { 88 | fn draw(&self, frame: &mut Frame, (): (), metadata: DrawMetadata) { 89 | // Create layout 90 | let [collection_area, _, keybindings_area] = Layout::vertical([ 91 | Constraint::Length(Self::GENERAL_LENGTH + 1), 92 | Constraint::Length(1), 93 | Constraint::Min(0), 94 | ]) 95 | .areas(metadata.area()); 96 | 97 | // Collection metadata 98 | let collection_metadata = Table { 99 | title: Some("General"), 100 | rows: [ 101 | ("Version", Line::from(CRATE_VERSION)), 102 | ("Docs", doc_link("").into()), 103 | ("Configuration", Config::path().display().to_string().into()), 104 | ("Log", paths::log_file().display().to_string().into()), 105 | ( 106 | "Collection", 107 | ViewContext::with_database(|database| { 108 | database.collection_path() 109 | }) 110 | .map(|path| path.display().to_string()) 111 | .unwrap_or_default() 112 | .into(), 113 | ), 114 | ] 115 | .into_iter() 116 | .map(|(label, value)| { 117 | [Line::from(label), value.alignment(Alignment::Right)] 118 | }) 119 | .collect(), 120 | column_widths: &[Constraint::Length(13), Constraint::Max(1000)], 121 | ..Default::default() 122 | }; 123 | frame.render_widget(collection_metadata.generate(), collection_area); 124 | 125 | // Keybindings 126 | let keybindings = Table { 127 | title: Some("Keybindings"), 128 | rows: Self::bindings() 129 | .map(|(action, binding)| { 130 | let action: Line = action.to_string().into(); 131 | let input: Line = binding.to_string().into(); 132 | [action, input.alignment(Alignment::Right)] 133 | }) 134 | .collect_vec(), 135 | ..Default::default() 136 | }; 137 | frame.render_widget(keybindings.generate(), keybindings_area); 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /crates/tui/src/view/debug.rs: -------------------------------------------------------------------------------- 1 | use ratatui::{ 2 | Frame, 3 | layout::{Alignment, Constraint, Layout}, 4 | style::{Color, Style}, 5 | text::Text, 6 | widgets::Paragraph, 7 | }; 8 | use std::{cell::Cell, time::Instant}; 9 | 10 | /// Globally track debug/performance information. This implements 11 | /// [tracing::Subscriber] to collect data. 12 | #[derive(Debug)] 13 | pub struct DebugMonitor { 14 | /// Track the start of the previous draw, so we can calculate frame rate 15 | last_draw_start: Cell, 16 | } 17 | 18 | impl DebugMonitor { 19 | /// Draw the view using the given closure, then render computed metrics on 20 | /// top at the end. 21 | pub fn draw(&self, frame: &mut Frame, draw_fn: impl FnOnce(&mut Frame)) { 22 | // Track elapsed time for the draw function 23 | let start = Instant::now(); 24 | draw_fn(frame); 25 | let duration = start.elapsed(); 26 | let fps = 1.0 / (start - self.last_draw_start.get()).as_secs_f32(); 27 | self.last_draw_start.set(start); 28 | 29 | // Draw in the bottom-right, on top of the help text 30 | let [_, area] = 31 | Layout::vertical([Constraint::Min(0), Constraint::Length(1)]) 32 | .areas(frame.area()); 33 | let text = Text::from(format!( 34 | "FPS: {fps:.1} / Render: {duration}ms", 35 | duration = duration.as_millis() 36 | )) 37 | .style(Style::default().fg(Color::Black).bg(Color::Green)); 38 | frame.render_widget( 39 | Paragraph::new(text).alignment(Alignment::Right), 40 | area, 41 | ); 42 | } 43 | } 44 | 45 | impl Default for DebugMonitor { 46 | fn default() -> Self { 47 | Self { 48 | last_draw_start: Instant::now().into(), 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /crates/tui/src/view/draw.rs: -------------------------------------------------------------------------------- 1 | //! Traits for rendering stuff 2 | 3 | use ratatui::{Frame, layout::Rect, text::Span}; 4 | use std::{fmt::Display, ops::Deref}; 5 | 6 | /// Something that can be drawn onto screen as one or more TUI widgets. 7 | /// 8 | /// Conceptually this is basically part of `Component`, but having it separate 9 | /// allows the `Props` associated type. Otherwise, there's no way to make a 10 | /// trait object from `Component` across components with different props. 11 | /// 12 | /// Props are additional temporary values that a struct may need in order 13 | /// to render. Useful for passing down state values that are managed by 14 | /// the parent, to avoid duplicating that state in the child. In most 15 | /// cases, `Props` would make more sense as an associated type, but there are 16 | /// some component types (e.g. `SelectState`) that have multiple `Draw` impls. 17 | /// Using an associated type also makes prop types with lifetimes much less 18 | /// ergonomic. 19 | pub trait Draw { 20 | /// Draw the component into the frame. This generally should not be called 21 | /// directly. Instead, use 22 | /// [Component::draw](crate::view::component::Component::draw), which 23 | /// will handle additional metadata management before deferring to this 24 | /// method for the actual draw. 25 | fn draw(&self, frame: &mut Frame, props: Props, metadata: DrawMetadata); 26 | } 27 | 28 | /// Allow transparenting drawing through Deref impls 29 | impl Draw for T 30 | where 31 | T: Deref, 32 | T::Target: Draw, 33 | { 34 | fn draw(&self, frame: &mut Frame, props: Props, metadata: DrawMetadata) { 35 | self.deref().draw(frame, props, metadata); 36 | } 37 | } 38 | 39 | /// Metadata associated with each draw action, which may instruct how the draw 40 | /// should occur. 41 | #[derive(Copy, Clone, Debug, Default)] 42 | pub struct DrawMetadata { 43 | /// Which area on the screen should we draw to? 44 | area: Rect, 45 | /// Does the drawn component have focus? Focus indicates the component 46 | /// receives keyboard events. Most of the time, the focused element should 47 | /// get some visual indicator that it's in focus. 48 | has_focus: bool, 49 | } 50 | 51 | impl DrawMetadata { 52 | /// Construct a new metadata. The naming is chosen to discourage calling 53 | /// this directly, which in turn discourages calling [Draw::draw] correctly. 54 | /// Instead, use 55 | /// [Component::draw](crate::view::component::Component::draw). 56 | /// 57 | /// It should probably be better to restrict this via visibility, but that 58 | /// requires refactoring the module layout and I'm not sure the benefit is 59 | /// worth it. 60 | pub fn new_dangerous(area: Rect, has_focus: bool) -> Self { 61 | Self { area, has_focus } 62 | } 63 | 64 | /// Which area on the screen should we draw to? 65 | pub fn area(self) -> Rect { 66 | self.area 67 | } 68 | 69 | /// Does the component have focus, i.e. is it the component that should 70 | /// receive keyboard events? 71 | pub fn has_focus(self) -> bool { 72 | self.has_focus 73 | } 74 | } 75 | 76 | /// A helper for building a UI. It can be converted into some UI element to be 77 | /// drawn. 78 | pub trait Generate { 79 | type Output<'this> 80 | where 81 | Self: 'this; 82 | 83 | /// Build a UI element 84 | fn generate<'this>(self) -> Self::Output<'this> 85 | where 86 | Self: 'this; 87 | } 88 | 89 | /// Marker trait to pull in a blanket impl of [Generate], which simply calls 90 | /// [ToString::to_string] on the value to create a [ratatui::text::Span]. 91 | pub trait ToStringGenerate: Display {} 92 | 93 | impl Generate for &T 94 | where 95 | T: ToStringGenerate, 96 | { 97 | type Output<'this> 98 | = Span<'this> 99 | where 100 | Self: 'this; 101 | 102 | fn generate<'this>(self) -> Self::Output<'this> 103 | where 104 | Self: 'this, 105 | { 106 | self.to_string().into() 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /crates/tui/src/view/state.rs: -------------------------------------------------------------------------------- 1 | //! State types for the view. 2 | 3 | pub mod fixed_select; 4 | pub mod select; 5 | 6 | use chrono::{DateTime, Utc}; 7 | use derive_more::Deref; 8 | use std::cell::{Ref, RefCell}; 9 | use uuid::Uuid; 10 | 11 | /// An internally mutable cell for UI state. Certain state needs to be updated 12 | /// during the draw phase, typically because it's derived from parent data 13 | /// passed via props. This is safe to use in the render phase, because rendering 14 | /// is entirely synchronous. 15 | /// 16 | /// In addition to storing the state value, this stores a state key as well. The 17 | /// key is used to determine when to update the state. The key should be 18 | /// something cheaply comparable. If the value itself is cheaply comparable, 19 | /// you can just use that as the key. 20 | /// 21 | /// The only way to initialize a new state cell is via its [Default] 22 | /// implementation. This requires that both `K` and `V` implement [Default] as 23 | /// well, so we can provide an initial value. In the past we stored an `Option` 24 | /// internally so we could avoid this requirement, but this polluted the type 25 | /// signatures of various functions for little gain. 26 | #[derive(Debug, Default)] 27 | pub struct StateCell { 28 | state: RefCell<(K, V)>, 29 | } 30 | 31 | impl StateCell { 32 | /// Get the current state value, or a new value if the state is stale. State 33 | /// will be stale if it is uninitialized OR the key has changed. In either 34 | /// case, `init` will be called to create a new value. The given key will be 35 | /// cloned iff the state is updated, so that the key can be stored. 36 | pub fn get_or_update(&self, key: &K, init: impl FnOnce() -> V) -> Ref<'_, V> 37 | where 38 | K: Clone + PartialEq, 39 | { 40 | let mut state = self.state.borrow_mut(); 41 | if &state.0 != key { 42 | // Recreate the state 43 | *state = (key.clone(), init()); 44 | } 45 | drop(state); 46 | 47 | // It'd be nice to return an `impl Deref` here instead to prevent 48 | // leaking implementation details, but I was struggling with the 49 | // lifetimes on that 50 | Ref::map(self.state.borrow(), |state| &state.1) 51 | } 52 | 53 | /// Get a reference to the state key. This can panic, if the state is 54 | /// already borrowed elsewhere. Returns `None` iff the state cell is 55 | /// uninitialized. 56 | pub fn borrow_key(&self) -> Ref<'_, K> { 57 | Ref::map(self.state.borrow(), |state| &state.0) 58 | } 59 | 60 | /// Get a reference to the state value. This can panic, if the state is 61 | /// already borrowed elsewhere. Returns `None` iff the state cell is 62 | /// uninitialized. 63 | pub fn borrow(&self) -> Ref<'_, V> { 64 | Ref::map(self.state.borrow(), |state| &state.1) 65 | } 66 | 67 | /// Get a mutable reference to the state value. This will never panic 68 | /// because `&mut self` guarantees exclusive access. Returns `None` iff 69 | /// the state cell is uninitialized. 70 | pub fn get_mut(&mut self) -> &mut V { 71 | &mut self.state.get_mut().1 72 | } 73 | } 74 | 75 | /// A uniquely identified immutable value. Useful for detecting changes in 76 | /// values that are expensive to do full comparisons on. 77 | #[derive(Debug, Deref)] 78 | pub struct Identified { 79 | id: Uuid, 80 | #[deref] 81 | value: T, 82 | } 83 | 84 | impl Identified { 85 | pub fn new(value: T) -> Self { 86 | Self { 87 | id: Uuid::new_v4(), 88 | value, 89 | } 90 | } 91 | 92 | pub fn id(&self) -> Uuid { 93 | self.id 94 | } 95 | } 96 | 97 | impl From for Identified { 98 | fn from(value: T) -> Self { 99 | Self::new(value) 100 | } 101 | } 102 | 103 | /// A notification is an ephemeral informational message generated by some async 104 | /// action. It doesn't grab focus, but will be useful to the user nonetheless. 105 | /// It should be shown for a short period of time, then disappear on its own. 106 | #[derive(Debug)] 107 | pub struct Notification { 108 | pub message: String, 109 | pub timestamp: DateTime, 110 | } 111 | 112 | impl Notification { 113 | pub fn new(message: String) -> Self { 114 | Self { 115 | message, 116 | timestamp: Utc::now(), 117 | } 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /crates/tui/src/view/util/persistence.rs: -------------------------------------------------------------------------------- 1 | //! Implementation of the [persisted] crate for UI data 2 | 3 | use crate::view::ViewContext; 4 | use persisted::PersistedStore; 5 | use serde::{Serialize, de::DeserializeOwned}; 6 | use std::fmt::Debug; 7 | 8 | /// This struct exists solely to hold an impl of [PersistedStore], which 9 | /// persists UI state into the database. 10 | pub struct DatabasePersistedStore; 11 | 12 | /// Wrapper for [persisted::PersistedKey] that applies additional bounds 13 | /// necessary for our store 14 | pub trait PersistedKey: Debug + Serialize + persisted::PersistedKey {} 15 | impl PersistedKey for T {} 16 | 17 | /// Wrapper for [persisted::Persisted] bound to our store 18 | pub type Persisted = persisted::Persisted; 19 | 20 | /// Wrapper for [persisted::PersistedLazy] bound to our store 21 | pub type PersistedLazy = 22 | persisted::PersistedLazy; 23 | 24 | /// Persist UI state via the database. We have to be able to serialize keys to 25 | /// insert and lookup. We have to serialize values to insert, and deserialize 26 | /// them to retrieve. 27 | impl PersistedStore for DatabasePersistedStore 28 | where 29 | K: PersistedKey, 30 | K::Value: Debug + Serialize + DeserializeOwned, 31 | { 32 | fn load_persisted(key: &K) -> Option { 33 | ViewContext::with_database(|database| { 34 | database.get_ui(K::type_name(), key) 35 | }) 36 | // Error is already traced in the DB, nothing to do with it here 37 | .ok() 38 | .flatten() 39 | } 40 | 41 | fn store_persisted(key: &K, value: &K::Value) { 42 | ViewContext::with_database(|database| { 43 | database.set_ui(K::type_name(), key, value) 44 | }) 45 | // Error is already traced in the DB, nothing to do with it here 46 | .ok(); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /crates/util/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | authors = {workspace = true} 3 | description = "Common utilities used across several subcrates of Slumber. Not for external use." 4 | edition = {workspace = true} 5 | homepage = {workspace = true} 6 | keywords = {workspace = true} 7 | license = {workspace = true} 8 | name = "slumber_util" 9 | repository = {workspace = true} 10 | rust-version = {workspace = true} 11 | version = {workspace = true} 12 | 13 | [package.metadata.release] 14 | tag = false 15 | 16 | [dependencies] 17 | anyhow = {workspace = true} 18 | dirs = {workspace = true} 19 | rstest = {workspace = true, optional = true} 20 | serde = {workspace = true} 21 | serde_path_to_error = "0.1.16" 22 | serde_yaml = {workspace = true} 23 | tracing = {workspace = true} 24 | uuid = {workspace = true, features = ["v4"], optional = true} 25 | 26 | [dev-dependencies] 27 | rstest = {workspace = true} 28 | uuid = {workspace = true, features = ["v4"]} 29 | 30 | [features] 31 | test = ["dep:rstest", "dep:uuid"] 32 | 33 | [lints] 34 | workspace = true 35 | -------------------------------------------------------------------------------- /crates/util/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Common utilities that aren't specific to one other subcrate and are unlikely 2 | //! to change frequently. The main purpose of this is to pull logic out of the 3 | //! core crate, because that one changes a lot and requires constant 4 | //! recompilation. 5 | //! 6 | //! **This crate is not semver compliant**. The version is locked to the root 7 | //! `slumber` crate version. If you choose to depend directly on this crate, you 8 | //! do so at your own risk of breakage. 9 | 10 | pub mod paths; 11 | #[cfg(feature = "test")] 12 | mod test_util; 13 | 14 | #[cfg(feature = "test")] 15 | pub use test_util::*; 16 | 17 | use serde::de::DeserializeOwned; 18 | use std::{fmt::Debug, io::Read, ops::Deref}; 19 | use tracing::error; 20 | 21 | /// A static mapping between values (of type `T`) and labels (strings). Used to 22 | /// both stringify from and parse to `T`. 23 | pub struct Mapping<'a, T: Copy>(&'a [(T, &'a [&'a str])]); 24 | 25 | impl<'a, T: Copy> Mapping<'a, T> { 26 | /// Construct a new mapping 27 | pub const fn new(mapping: &'a [(T, &'a [&'a str])]) -> Self { 28 | Self(mapping) 29 | } 30 | 31 | /// Get a value by one of its labels 32 | pub fn get(&self, s: &str) -> Option { 33 | for (value, strs) in self.0 { 34 | for other_string in *strs { 35 | if *other_string == s { 36 | return Some(*value); 37 | } 38 | } 39 | } 40 | None 41 | } 42 | 43 | /// Get the label mapped to a value. If it has multiple labels, use the 44 | /// first. Panic if the value has no mapped labels 45 | pub fn get_label(&self, value: T) -> &str 46 | where 47 | T: Debug + PartialEq, 48 | { 49 | let (_, strings) = self 50 | .0 51 | .iter() 52 | .find(|(v, _)| v == &value) 53 | .unwrap_or_else(|| panic!("Unknown value {value:?}")); 54 | strings 55 | .first() 56 | .unwrap_or_else(|| panic!("No mapped strings for value {value:?}")) 57 | } 58 | 59 | /// Get all available mapped strings 60 | pub fn all_strings(&self) -> impl Iterator { 61 | self.0 62 | .iter() 63 | .flat_map(|(_, strings)| strings.iter().copied()) 64 | } 65 | } 66 | /// Extension trait for [Result] 67 | pub trait ResultTraced: Sized { 68 | /// If this is an error, trace it. Return the same result. 69 | #[must_use] 70 | fn traced(self) -> Self; 71 | } 72 | 73 | impl ResultTraced for anyhow::Result { 74 | fn traced(self) -> Self { 75 | self.inspect_err(|err| error!(error = err.deref())) 76 | } 77 | } 78 | 79 | /// Parse bytes from a reader into YAML. This will merge any anchors/aliases. 80 | pub fn parse_yaml(reader: impl Read) -> anyhow::Result { 81 | // Two-step parsing is required for anchor/alias merging 82 | let deserializer = serde_yaml::Deserializer::from_reader(reader); 83 | let mut yaml_value: serde_yaml::Value = 84 | serde_path_to_error::deserialize(deserializer)?; 85 | yaml_value.apply_merge()?; 86 | let output = serde_path_to_error::deserialize(yaml_value)?; 87 | Ok(output) 88 | } 89 | -------------------------------------------------------------------------------- /crates/util/src/test_util.rs: -------------------------------------------------------------------------------- 1 | use crate::{ResultTraced, paths::get_repo_root}; 2 | use anyhow::Context; 3 | use rstest::fixture; 4 | use std::{ 5 | env, fs, 6 | ops::Deref, 7 | path::{Path, PathBuf}, 8 | }; 9 | use uuid::Uuid; 10 | 11 | /// Test-only trait to build a placeholder instance of a struct. This is similar 12 | /// to `Default`, but allows for useful placeholders that may not make sense in 13 | /// the context of the broader app. It also makes it possible to implement a 14 | /// factory for a type that already has `Default`. 15 | /// 16 | /// Factories can also be parameterized, meaning the implementor can define 17 | /// convenient knobs to let the caller customize the generated type. Each type 18 | /// can have any number of `Factory` implementations, so you can support 19 | /// multiple param types. 20 | pub trait Factory { 21 | fn factory(param: Param) -> Self; 22 | } 23 | 24 | /// Directory containing static test data 25 | #[fixture] 26 | pub fn test_data_dir() -> PathBuf { 27 | get_repo_root().join("test_data") 28 | } 29 | 30 | /// Create a new temporary folder. This will include a random subfolder to 31 | /// guarantee uniqueness for this test. 32 | #[fixture] 33 | pub fn temp_dir() -> TempDir { 34 | TempDir::new() 35 | } 36 | 37 | /// Guard for a temporary directory. Create the directory on creation, delete 38 | /// it on drop. 39 | #[derive(Debug)] 40 | pub struct TempDir(PathBuf); 41 | 42 | impl TempDir { 43 | fn new() -> Self { 44 | let path = env::temp_dir().join(Uuid::new_v4().to_string()); 45 | fs::create_dir(&path).unwrap(); 46 | Self(path) 47 | } 48 | } 49 | 50 | impl Deref for TempDir { 51 | type Target = Path; 52 | 53 | fn deref(&self) -> &Self::Target { 54 | &self.0 55 | } 56 | } 57 | 58 | impl Drop for TempDir { 59 | fn drop(&mut self) { 60 | // Clean up 61 | let _ = fs::remove_dir_all(&self.0) 62 | .with_context(|| { 63 | format!("Error deleting temporary directory {:?}", self.0) 64 | }) 65 | .traced(); 66 | } 67 | } 68 | 69 | /// Assert a result is the `Err` variant, and the stringified error contains 70 | /// the given message 71 | #[macro_export] 72 | macro_rules! assert_err { 73 | ($e:expr, $msg:expr) => {{ 74 | use itertools::Itertools as _; 75 | 76 | let msg = $msg; 77 | // Include all source errors so wrappers don't hide the important stuff 78 | let error: anyhow::Error = $e.unwrap_err().into(); 79 | let actual = error.chain().map(ToString::to_string).join(": "); 80 | assert!( 81 | actual.contains(msg), 82 | "Expected error message to contain {msg:?}, but was: {actual:?}" 83 | ) 84 | }}; 85 | } 86 | 87 | /// Assert the given expression matches a pattern and optional condition. 88 | /// Additionally, evaluate an expression using the bound pattern. This can be 89 | /// used to apply additional assertions inline, or extract bound values to use 90 | /// in subsequent statements. 91 | #[macro_export] 92 | macro_rules! assert_matches { 93 | ($expr:expr, $pattern:pat $(if $condition:expr)? $(,)?) => { 94 | $crate::assert_matches!($expr, $pattern $(if $condition)? => ()); 95 | }; 96 | ($expr:expr, $pattern:pat $(if $condition:expr)? => $output:expr $(,)?) => { 97 | match $expr { 98 | // If a conditional was given, check it. This has to be a separate 99 | // arm to prevent borrow fighting over the matched value 100 | $(value @ $pattern if !$condition => { 101 | panic!( 102 | "Value {value:?} does not match condition {condition}", 103 | condition = stringify!($condition), 104 | ); 105 | })? 106 | #[expect(unused_variables)] 107 | $pattern => $output, 108 | value => panic!( 109 | "Unexpected value {value:?} does not match pattern {expected}", 110 | expected = stringify!($pattern), 111 | ), 112 | } 113 | }; 114 | } 115 | -------------------------------------------------------------------------------- /docs/.gitignore: -------------------------------------------------------------------------------- 1 | book 2 | -------------------------------------------------------------------------------- /docs/book.toml: -------------------------------------------------------------------------------- 1 | [book] 2 | authors = ["Lucas Pickering"] 3 | description = "Terminal-based HTTP client" 4 | language = "en" 5 | multilingual = false 6 | src = "src" 7 | title = "Slumber" 8 | 9 | [preprocessor.toc] 10 | command = "mdbook-toc" 11 | renderer = ["html"] 12 | 13 | [output.html] 14 | edit-url-template = "https://github.com/LucasPickering/slumber/edit/master/docs/{path}" 15 | git-repository-url = "https://github.com/LucasPickering/slumber" 16 | -------------------------------------------------------------------------------- /docs/src/SUMMARY.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | - [Introduction](./introduction.md) 4 | - [Install](./install.md) 5 | - [Getting Started](./getting_started.md) 6 | 7 | # User Guide 8 | 9 | - [Key Concepts](./user_guide/key_concepts.md) 10 | - [Terminal User Interface (TUI)](./user_guide/tui/index.md) 11 | - [In-App Editing & File Viewing](./user_guide/tui/editor.md) 12 | - [Data Filtering & Querying](./user_guide/tui/filter_query.md) 13 | - [Command Line Interface (CLI)](./user_guide/cli.md) 14 | - [Templates](./user_guide/templates/index.md) 15 | - [Chains - Complex Template Values](./user_guide/templates/chains.md) 16 | - [Data Extraction via JSONPath](./user_guide/templates/selector.md) 17 | - [Collection Reuse & Inheritance](./user_guide/inheritance.md) 18 | - [Importing External Collections](./user_guide/import.md) 19 | - [Database & Persistence](./user_guide/database.md) 20 | 21 | # CLI Commands 22 | 23 | - [Subcommands](./cli/subcommands.md) 24 | - [Examples](./cli/examples.md) 25 | 26 | # API Reference 27 | 28 | - [Request Collection](./api/request_collection/index.md) 29 | - [Profile](./api/request_collection/profile.md) 30 | - [Template](./api/request_collection/template.md) 31 | - [Request Recipe](./api/request_collection/request_recipe.md) 32 | - [Query Parameters](./api/request_collection/query_parameters.md) 33 | - [Authentication](./api/request_collection/authentication.md) 34 | - [Recipe Body](./api/request_collection/recipe_body.md) 35 | - [Chain](./api/request_collection/chain.md) 36 | - [Chain Source](./api/request_collection/chain_source.md) 37 | - [Content Type](./api/request_collection/content_type.md) 38 | - [Configuration](./api/configuration/index.md) 39 | - [Input Bindings](./api/configuration/input_bindings.md) 40 | - [MIME Maps](./api/configuration/mime.md) 41 | - [Theme](./api/configuration/theme.md) 42 | 43 | # Integration 44 | 45 | - [Neovim Integration](./integration/neovim-integration.md) 46 | 47 | # Troubleshooting 48 | 49 | - [Logs](./troubleshooting/logs.md) 50 | - [Lost Request History](./troubleshooting/lost_history.md) 51 | - [Shell Completions](./troubleshooting/shell_completions.md) 52 | - [TLS Certificate Errors](./troubleshooting/tls.md) 53 | -------------------------------------------------------------------------------- /docs/src/api/configuration/index.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | 3 | Configuration provides _application_-level settings, as opposed to collection-level settings. 4 | 5 | ## Location & Creation 6 | 7 | By default, configuration is stored in a platform-specific configuration directory, according to [dirs::config_dir](https://docs.rs/dirs/latest/dirs/fn.config_dir.html). 8 | 9 | | Platform | Path | 10 | | -------- | ------------------------------------------------------ | 11 | | Linux | `$HOME/.config/slumber/config.yml` | 12 | | MacOS | `$HOME/Library/Application Support/slumber/config.yml` | 13 | | Windows | `C:\Users\\AppData\Roaming\slumber\config.yml` | 14 | 15 | You can also find the config path by running: 16 | 17 | ```sh 18 | slumber show paths 19 | ``` 20 | 21 | If the config directory doesn't exist yet, Slumber will create it automatically when starting the TUI for the first time. 22 | 23 | > Note: Prior to version 2.1.0, Slumber stored configuration in a different location on Linux (`~/.local/share/slumber/config.yml`). If that file exists on your system, **it will be used in place of the newer location.** For more context, see [issue #371](https://github.com/LucasPickering/slumber/issues/371). 24 | 25 | You can change the location of the config file by setting the environment variable `SLUMBER_CONFIG_PATH`. For example: 26 | 27 | ```sh 28 | SLUMBER_CONFIG_PATH=~/dotfiles/slumber.yml slumber 29 | ``` 30 | 31 | ## Fields 32 | 33 | The following fields are available in `config.yml`: 34 | 35 | 36 | 37 | ### `commands.shell` 38 | 39 | **Type:** `string[]` 40 | 41 | **Default:** `[sh, -c]` (Unix), `[cmd, /S, /C]` (Windows) 42 | 43 | Shell used to execute commands within the TUI. Use `[]` for no shell (commands will be parsed and executed directly). [More info](../../user_guide/tui/filter_query.md) 44 | 45 | ### `commands.default_query` 46 | 47 | **Type:** `string` or `mapping[Mime, string]` (see [MIME Maps](./mime.md)) 48 | 49 | **Default:** `""` 50 | 51 | Default query command for all responses. [More info](../../user_guide/tui/filter_query.md) 52 | 53 | ### `debug` 54 | 55 | **Type:** `boolean` 56 | 57 | **Default:** `false` 58 | 59 | Enable developer information in the TUI 60 | 61 | ### `editor` 62 | 63 | **Type:** `string` 64 | 65 | **Default:** `VISUAL`/`EDITOR` env vars, or `vim` 66 | 67 | Command to use when opening files for in-app editing. [More info](../../user_guide/tui/editor.md#editing) 68 | 69 | ### `ignore_certificate_hosts` 70 | 71 | **Type:** `string` 72 | 73 | **Default:** `[]` 74 | 75 | Hostnames whose TLS certificate errors will be ignored. [More info](../../troubleshooting/tls.md) 76 | 77 | ### `input_bindings` 78 | 79 | **Type:** `mapping[Action, KeyCombination[]]` 80 | 81 | **Default:** `{}` 82 | 83 | Override default input bindings. [More info](./input_bindings.md) 84 | 85 | ### `large_body_size` 86 | 87 | **Type:** `number` 88 | 89 | **Default:** `1000000` (1 MB) 90 | 91 | Size over which request/response bodies are not formatted/highlighted, for performance (bytes) 92 | 93 | ### `persist` 94 | 95 | **Type:** `boolean` 96 | 97 | **Default:** `true` 98 | 99 | Enable/disable the storage of requests and responses in Slumber's local database. This is only used in the TUI. CLI requests are _not_ persisted unless the `--persist` flag is passed, in which case they will always be persisted. [See here for more](../../user_guide/database.md). 100 | 101 | ### `preview_templates` 102 | 103 | **Type:** `boolean` 104 | 105 | **Default:** `true` 106 | 107 | Render template values in the TUI? If false, the raw template will be shown. 108 | 109 | ### `theme` 110 | 111 | **Type:** `Theme` 112 | 113 | **Default:** `{}` 114 | 115 | Visual customizations for the TUI. [More info](./theme.md) 116 | 117 | ### `pager` 118 | 119 | **Alias:** `viewer` (for historical compatibility) 120 | 121 | **Type:** `string` or `mapping[Mime, string]` (see [MIME Maps](./mime.md)) 122 | 123 | **Default:** `less` (Unix), `more` (Windows) 124 | 125 | Command to use when opening files for viewing. [More info](../../user_guide/tui/editor.md#paging) 126 | -------------------------------------------------------------------------------- /docs/src/api/configuration/mime.md: -------------------------------------------------------------------------------- 1 | # MIME Maps 2 | 3 | Some configuration fields support a mapping of [MIME types](https://en.wikipedia.org/wiki/Media_type) (AKA media types or content types). This allow you to set multiple values for the configuration field, and the correct value will be selected based on the MIME type of the relevant recipe/request/response. 4 | 5 | The keys of this map are glob-formatted (i.e. wildcard) MIME types. For example, if you're configuring your pager and you want to use `hexdump` for all images, `fx` for JSON, and `less` for everything else: 6 | 7 | ```yaml 8 | pager: 9 | image/*: hexdump 10 | application/json: fx 11 | "*/*": less 12 | ``` 13 | 14 | > **Note:** Paths are matched top to bottom, so `*/*` **should always go last**. Any pattern starting with `*` must be wrapped in quotes in order to be parsed as a string. 15 | 16 | - `image/png`: matches `image/*` 17 | - `image/jpeg`: matches `image/*` 18 | - `application/json`: matches `application/json` 19 | - `text/csv`: matches `*/*` 20 | 21 | ## Aliases 22 | 23 | In addition to accepting MIME patterns, there are also predefined aliases to make common matches more convenient: 24 | 25 | | Alias | Maps To | 26 | | --------- | ------------------- | 27 | | `default` | `*/*` | 28 | | `json` | `application/*json` | 29 | | `image` | `image/*` | 30 | 31 | ## Notes on Matching 32 | 33 | - Matching is done top to bottom, and **the first matching pattern will be used**. For this reason, your `*/*` pattern **should always be last**. 34 | - Matching is performed just against the [essence string](https://docs.rs/mime/latest/mime/struct.Mime.html#method.essence_str) of the recipe/request/response's `Content-Type` header, i.e. the `type/subtype` only. In the example `multipart/form-data; boundary=ABCDEFG`, the semicolon and everything after it **is not included in the match**. 35 | - Matching is performed by the [Rust glob crate](https://docs.rs/glob/latest/glob/struct.Pattern.html). Despite being intended for matching file paths, it works well for MIME types too because they are also `/`-delimited 36 | -------------------------------------------------------------------------------- /docs/src/api/configuration/theme.md: -------------------------------------------------------------------------------- 1 | # Theme 2 | 3 | Theming allows you to customize the appearance of the Slumber TUI. To start, [open up your configuration file](../configuration/index.md#location--creation) and add some theme settings: 4 | 5 | ```yaml 6 | theme: 7 | primary_color: green 8 | secondary_color: blue 9 | ``` 10 | 11 | ## Fields 12 | 13 | | Field | Type | Description | 14 | | -------------------- | ------- | -------------------------------------------------------------------- | 15 | | `primary_color` | `Color` | Color of most emphasized content | 16 | | `primary_text_color` | `Color` | Color of text on top of the primary color (generally white or black) | 17 | | `secondary_color` | `Color` | Color of secondary notable content | 18 | | `success_color` | `Color` | Color representing successful events | 19 | | `error_color` | `Color` | Color representing error messages | 20 | 21 | ## Color Format 22 | 23 | Colors can be specified as names (e.g. "yellow"), RGB codes (e.g. `#ffff00`) or ANSI color indexes. See the [Ratatui docs](https://docs.rs/ratatui/latest/ratatui/style/enum.Color.html#impl-FromStr-for-Color) for more details on color deserialization. 24 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/authentication.md: -------------------------------------------------------------------------------- 1 | # Authentication 2 | 3 | Authentication provides shortcuts for common HTTP authentication schemes. It populates the `authentication` field of a recipe. There are multiple source types, and the type is specified using [YAML's tag syntax](https://yaml.org/spec/1.2.2/#24-tags). 4 | 5 | ## Variants 6 | 7 | | Variant | Type | Value | 8 | | --------- | ----------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | 9 | | `!basic` | [`Basic Authentication`](#basic-authentication) | [Basic authentication](https://swagger.io/docs/specification/authentication/basic-authentication/) credentials | 10 | | `!bearer` | `string` | [Bearer token](https://swagger.io/docs/specification/authentication/bearer-authentication/) | 11 | 12 | ### Basic Authentication 13 | 14 | Basic authentication contains a username and optional password. 15 | 16 | | Field | Type | Description | Default | 17 | | ---------- | -------- | ----------- | -------- | 18 | | `username` | `string` | Username | Required | 19 | | `password` | `string` | Password | `""` | 20 | 21 | ## Examples 22 | 23 | ```yaml 24 | # Basic auth 25 | requests: 26 | create_fish: !request 27 | method: POST 28 | url: "{{host}}/fishes" 29 | body: !json { "kind": "barracuda", "name": "Jimmy" } 30 | authentication: !basic 31 | username: user 32 | password: pass 33 | --- 34 | # Bearer token auth 35 | chains: 36 | token: 37 | source: !file 38 | path: ./token.txt 39 | requests: 40 | create_fish: !request 41 | method: POST 42 | url: "{{host}}/fishes" 43 | body: !json { "kind": "barracuda", "name": "Jimmy" } 44 | authentication: !bearer "{{chains.token}}" 45 | ``` 46 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/content_type.md: -------------------------------------------------------------------------------- 1 | # Content Type 2 | 3 | Content type defines the various data formats that Slumber recognizes and can manipulate. Slumber is capable of displaying any text-based data format, but only specific formats support additional features such as [querying](../../user_guide/tui/filter_query.md) and formatting. 4 | 5 | ## Auto-detection 6 | 7 | For chained requests, Slumber uses the [HTTP `Content-Type` header](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Type) to detect the content type. For chained files, it uses the file extension. For other [chain sources](./chain_source.md), or Slumber is unable to detect the content type, you'll have to manually provide the content type via the [chain](./chain.md) `content_type` field. 8 | 9 | ## Supported Content Types 10 | 11 | | Content Type | HTTP Header | File Extension(s) | 12 | | ------------ | ------------------ | ----------------- | 13 | | JSON | `application/json` | `json` | 14 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/index.md: -------------------------------------------------------------------------------- 1 | # Request Collection 2 | 3 | The request collection is the primary configuration for Slumber. It defines which requests can be made, and how to make them. When running a `slumber` instance, a single collection file is loaded. If you want to work with multiple collections at once, you'll have to run multiple instances of Slumber. 4 | 5 | Collection files are designed to be sharable, meaning you can commit them to your Git repo. The most common pattern is to create one collection per API repo, and check it into the repo so other developers of the API can use the same collection. This makes it easy for any new developer or user to learn how to use an API. 6 | 7 | ## Format & Loading 8 | 9 | A collection is defined as a [YAML](https://yaml.org/) file. When you run `slumber`, it will search the current directory _and its parents_ for the following default collection files, in order: 10 | 11 | - `slumber.yml` 12 | - `slumber.yaml` 13 | - `.slumber.yml` 14 | - `.slumber.yaml` 15 | 16 | Whichever of those files is found _first_ will be used. For any given directory, if no collection file is found there, it will recursively go up the directory tree until we find a collection file or hit the root directory. If you want to use a different file for your collection (e.g. if you want to store multiple collections in the same directory), you can override the auto-search with the `--file` (or `-f`) command line argument. You can also pass a directory to `--file` to have it search that directory instead of the current one. E.g.: 17 | 18 | ```sh 19 | slumber --file my-collection.yml 20 | slumber --file ../another-project/ 21 | ``` 22 | 23 | ## Fields 24 | 25 | A request collection supports the following top-level fields: 26 | 27 | | Field | Type | Description | Default | 28 | | ---------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ | ------- | 29 | | `profiles` | [`mapping[string, Profile]`](./profile.md) | Static template values | `{}` | 30 | | `requests` | [`mapping[string, RequestRecipe]`](./request_recipe.md) | Requests Slumber can send | `{}` | 31 | | `chains` | [`mapping[string, Chain]`](./chain.md) | Complex template values | `{}` | 32 | | `.ignore` | Any | Extra data to be ignored by Slumber (useful with [YAML anchors](https://yaml.org/spec/1.2.2/#anchors-and-aliases)) | | 33 | 34 | ## Examples 35 | 36 | ```yaml 37 | profiles: 38 | local: 39 | name: Local 40 | data: 41 | host: http://localhost:5000 42 | user_guid: abc123 43 | prd: 44 | name: Production 45 | data: 46 | host: https://httpbin.org 47 | user_guid: abc123 48 | 49 | chains: 50 | username: 51 | source: !file 52 | path: ./username.txt 53 | password: 54 | source: !prompt 55 | message: Password 56 | sensitive: true 57 | auth_token: 58 | source: !request 59 | recipe: login 60 | selector: $.token 61 | 62 | # Use YAML anchors for de-duplication (Anything under .ignore will not trigger an error for unknown fields) 63 | .ignore: 64 | base: &base 65 | headers: 66 | Accept: application/json 67 | 68 | requests: 69 | login: !request 70 | <<: *base 71 | method: POST 72 | url: "{{host}}/anything/login" 73 | body: 74 | !json { 75 | "username": "{{chains.username}}", 76 | "password": "{{chains.password}}", 77 | } 78 | 79 | # Folders can be used to keep your recipes organized 80 | users: !folder 81 | requests: 82 | get_user: !request 83 | <<: *base 84 | name: Get User 85 | method: GET 86 | url: "{{host}}/anything/current-user" 87 | authentication: !bearer "{{chains.auth_token}}" 88 | 89 | update_user: !request 90 | <<: *base 91 | name: Update User 92 | method: PUT 93 | url: "{{host}}/anything/current-user" 94 | authentication: !bearer "{{chains.auth_token}}" 95 | body: !json { "username": "Kenny" } 96 | ``` 97 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/profile.md: -------------------------------------------------------------------------------- 1 | # Profile 2 | 3 | A profile is a collection of static template values. It's useful for configuring and switching between multiple different environments/settings/etc. Profile values are all templates themselves, so nested values can be used. 4 | 5 | ## Fields 6 | 7 | | Field | Type | Description | Default | 8 | | --------- | -------------------------------------------- | ----------------------------------------------------------- | ---------------------- | 9 | | `name` | `string` | Descriptive name to use in the UI | Value of key in parent | 10 | | `default` | `boolean` | Use this profile in the CLI when `--profile` isn't provided | `null` | 11 | | `data` | [`mapping[string, Template]`](./template.md) | Fields, mapped to their values | `{}` | 12 | 13 | ## Examples 14 | 15 | ```yaml 16 | profiles: 17 | local: 18 | name: Local 19 | data: 20 | host: localhost:5000 21 | url: "https://{{host}}" 22 | user_guid: abc123 23 | ``` 24 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/query_parameters.md: -------------------------------------------------------------------------------- 1 | # Query Parameters 2 | 3 | Query parameters are a component of a request URL. They provide additional information to the server about a request. In a request recipe, query parameters can be defined in one of two formats: 4 | 5 | - Mapping of `key: value` 6 | - List of strings, in the format `=` 7 | 8 | The mapping format is typically more readable, but the list format allows you to define the same query parameter multiple times. In either format, **the key is treated as a plain string but the value is treated as a template**. 9 | 10 | > Note: If you need to include a `=` in your parameter _name_, you'll need to use the mapping format. That means there is currently no support for multiple instances of a parameter with `=` in the name. This is very unlikely to be a restriction in the real world, but if you need support for this please [open an issue](https://github.com/LucasPickering/slumber/issues/new/choose). 11 | 12 | ## Examples 13 | 14 | ```yaml 15 | recipes: 16 | get_fishes_mapping: !request 17 | method: GET 18 | url: "{{host}}/get" 19 | query: 20 | big: true 21 | color: red 22 | name: "{{name}}" 23 | 24 | get_fishes_list: !request 25 | method: GET 26 | url: "{{host}}/get" 27 | query: 28 | - big=true 29 | - color=red 30 | - color=blue 31 | - name={{name}} 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/recipe_body.md: -------------------------------------------------------------------------------- 1 | # Recipe Body 2 | 3 | There are a variety of ways to define the body of your request. Slumber supports structured bodies for a fixed set of known content types (see table below). 4 | 5 | In addition, you can pass any [`Template`](./template.md) to render any text or binary data. In this case, you'll probably want to explicitly set the `Content-Type` header to tell the server what kind of data you're sending. This may not be necessary though, depending on the server implementation. 6 | 7 | ## Body Types 8 | 9 | The following content types have first-class support. Slumber will automatically set the `Content-Type` header to the specified value, but you can override this simply by providing your own value for the header. 10 | 11 | | Variant | Type | `Content-Type` | Description | 12 | | ------------------ | -------------------------------------------- | ----------------------------------- | ---------------------------------------------------------------------------------------------------------- | 13 | | `!json` | Any | `application/json` | Structured JSON body; all strings are treated as templates | 14 | | `!form_urlencoded` | [`mapping[string, Template]`](./template.md) | `application/x-www-form-urlencoded` | URL-encoded form data; [see here for more](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) | 15 | | `!form_multipart` | [`mapping[string, Template]`](./template.md) | `multipart/form-data` | Binary form data; [see here for more](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods/POST) | 16 | 17 | ## Examples 18 | 19 | ```yaml 20 | chains: 21 | image: 22 | source: !file 23 | path: ./fish.png 24 | 25 | requests: 26 | text_body: !request 27 | method: POST 28 | url: "{{host}}/fishes/{{fish_id}}/name" 29 | headers: 30 | Content-Type: text/plain 31 | body: Alfonso 32 | 33 | binary_body: !request 34 | method: POST 35 | url: "{{host}}/fishes/{{fish_id}}/image" 36 | headers: 37 | Content-Type: image/jpg 38 | body: "{{chains.fish_image}}" 39 | 40 | json_body: !request 41 | method: POST 42 | url: "{{host}}/fishes/{{fish_id}}" 43 | # Content-Type header will be set automatically based on the body type 44 | body: !json { "name": "Alfonso" } 45 | 46 | urlencoded_body: !request 47 | method: POST 48 | url: "{{host}}/fishes/{{fish_id}}" 49 | # Content-Type header will be set automatically based on the body type 50 | body: !form_urlencoded 51 | name: Alfonso 52 | 53 | multipart_body: !request 54 | method: POST 55 | url: "{{host}}/fishes/{{fish_id}}" 56 | # Content-Type header will be set automatically based on the body type 57 | body: !form_multipart 58 | name: Alfonso 59 | image: "{{chains.fish_image}}" 60 | ``` 61 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/request_recipe.md: -------------------------------------------------------------------------------- 1 | # Request Recipe 2 | 3 | A request recipe defines how to make a particular request. For a REST API, you'll typically create one request recipe per endpoint. Other HTTP tools often call this just a "request", but that name can be confusing because "request" can also refer to a single instance of an HTTP request. Slumber uses the term "recipe" because it's used to render many requests. The word "template" would work as a synonym here, although we avoid that term here because it also refers to [string templates](./template.md). 4 | 5 | Recipes can be organized into folders. This means your set of recipes can form a tree structure. Folders are purely organizational, and don't impact the behavior of their child recipes at all. 6 | 7 | **The IDs of your folders/recipes must be globally unique.** This means you can't have two recipes (or two folders, or one recipe and one folder) with the same associated key, even if they are in different folders. This restriction makes it easy to refer to recipes unambiguously using a single ID, which is helpful for CLI usage and data storage. 8 | 9 | ## Recipe Fields 10 | 11 | The tag for a recipe is `!request` (see examples). 12 | 13 | | Field | Type | Description | Default | 14 | | ---------------- | -------------------------------------------- | ----------------------------------------------------------------------------- | ---------------------- | 15 | | `name` | `string` | Descriptive name to use in the UI | Value of key in parent | 16 | | `method` | `string` | HTTP request method | Required | 17 | | `url` | [`Template`](./template.md) | HTTP request URL | Required | 18 | | `query` | [`QueryParameters`](./query_parameters.md) | URL query parameters | `{}` | 19 | | `headers` | [`mapping[string, Template]`](./template.md) | HTTP request headers | `{}` | 20 | | `authentication` | [`Authentication`](./authentication.md) | Authentication scheme | `null` | 21 | | `body` | [`RecipeBody`](./recipe_body.md) | HTTP request body | `null` | 22 | | `persist` | `boolean` | Enable/disable request persistence. [Read more](../../user_guide/database.md) | `true` | 23 | 24 | ## Folder Fields 25 | 26 | The tag for a folder is `!folder` (see examples). 27 | 28 | | Field | Type | Description | Default | 29 | | ---------- | ------------------------------------------------------- | ----------------------------------- | ---------------------- | 30 | | `name` | `string` | Descriptive name to use in the UI | Value of key in parent | 31 | | `children` | [`mapping[string, RequestRecipe]`](./request_recipe.md) | Recipes organized under this folder | `{}` | 32 | 33 | ## Examples 34 | 35 | ```yaml 36 | recipes: 37 | login: !request 38 | name: Login 39 | method: POST 40 | url: "{{host}}/anything/login" 41 | headers: 42 | accept: application/json 43 | query: 44 | - root_access=yes_please 45 | body: 46 | !json { 47 | "username": "{{chains.username}}", 48 | "password": "{{chains.password}}", 49 | } 50 | fish: !folder 51 | name: Users 52 | requests: 53 | create_fish: !request 54 | method: POST 55 | url: "{{host}}/fishes" 56 | body: !json { "kind": "barracuda", "name": "Jimmy" } 57 | 58 | list_fish: !request 59 | method: GET 60 | url: "{{host}}/fishes" 61 | query: 62 | - big=true 63 | ``` 64 | -------------------------------------------------------------------------------- /docs/src/api/request_collection/template.md: -------------------------------------------------------------------------------- 1 | # Template 2 | 3 | A template is represented in YAML as a normal string, and thus supports [all of YAML's string syntaxes](https://www.educative.io/answers/how-to-represent-strings-in-yaml). Templates receive post-processing that injects dynamic values into the string. A templated value is represented with `{{...}}`. 4 | 5 | Templates can generally be used in any _value_ in a request recipe (_not_ in keys), as well as in profile values and chains. This makes them very powerful, because you can compose templates with complex transformations. 6 | 7 | For more detail on usage and examples, see the [user guide page on templates](../../user_guide/templates/index.md). 8 | 9 | ## Template Sources 10 | 11 | There are several ways of sourcing templating values: 12 | 13 | | Source | Syntax | Description | Default | 14 | | ----------------------------- | --------------------- | ---------------------------------------------- | ---------------- | 15 | | [Profile](./profile.md) Field | `{{field_name}}` | Static value from a profile | Error if unknown | 16 | | Environment Variable | `{{env.VARIABLE}}` | Environment variable from parent shell/process | `""` | 17 | | [Chain](./chain.md) | `{{chains.chain_id}}` | Complex chained value | Error if unknown | 18 | 19 | ## Escape Sequences 20 | 21 | In some scenarios you may want to use the `{{` sequence to represent those literal characters, rather than the start of a template key. To achieve this, you can escape the sequence with an underscore inside it, e.g. `{_{`. If you want the literal string `{_{`, then add an extra underscore: `{__{`. 22 | 23 | | Template | Parses as | 24 | | ----------------------- | -------------------------- | 25 | | `{_{this is raw text}}` | `["{{this is raw text}}"]` | 26 | | `{_{{field1}}` | `["{", field("field1")]` | 27 | | `{__{{field1}}` | `["{__", field("field1")]` | 28 | | `{_` | `["{_"]` (no escaping) | 29 | 30 | ## Examples 31 | 32 | ```yaml 33 | # Profile value 34 | "hello, {{location}}" 35 | --- 36 | # Multiple dynamic values 37 | "{{greeting}}, {{location}}" 38 | --- 39 | # Environment variable 40 | "hello, {{env.LOCATION}}" 41 | --- 42 | # Chained value 43 | "hello, {{chains.where_am_i}}" 44 | --- 45 | # No dynamic values 46 | "hello, world!" 47 | --- 48 | # Escaped template key 49 | "{_{this is raw text}}" 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/src/cli/examples.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | The Slumber CLI can be composed with other CLI tools, making it a powerful tool for scripting and bulk tasks. Here are some examples of how to use it with common tools. 4 | 5 | > Note: These examples are written for a POSIX shell (bash, zsh, etc.). It assumes some basic familiarity with shell features such as pipes. Unfortunately I have no shell experience with Windows so I can't help you there :( 6 | 7 | ## Filtering responses with `jq` 8 | 9 | Let's say you want to fetch the name of each fish from your fish-tracking service. Here's your collection file: 10 | 11 | ```yaml 12 | requests: 13 | list_fish: !request 14 | method: GET 15 | url: "https://myfishes.fish/fishes" 16 | ``` 17 | 18 | This endpoint returns a response like: 19 | 20 | ```json 21 | [ 22 | { 23 | "kind": "barracuda", 24 | "name": "Jimmy" 25 | }, 26 | { 27 | "kind": "striped bass", 28 | "name": "Balthazar" 29 | }, 30 | { 31 | "kind": "rockfish", 32 | "name": "Maureen" 33 | } 34 | ] 35 | ``` 36 | 37 | You can fetch this response and filter it down to just the names: 38 | 39 | ```sh 40 | slumber rq list_fish | jq -r '.[].name' 41 | ``` 42 | 43 | And the output: 44 | 45 | ``` 46 | Jimmy 47 | Balthazar 48 | Maureen 49 | ``` 50 | 51 | ## Running requests in parallel with `xargs` 52 | 53 | Building on [the previous example](#filtering-responses-with-jq), let's say you want to fetch details on each fish returned from the list response. We'll add a `get_fish` recipe to the collection. By default, the fish name will come from a prompt: 54 | 55 | ```yaml 56 | chains: 57 | fish_name: 58 | source: !prompt 59 | message: "Which fish?" 60 | 61 | requests: 62 | list_fish: !request 63 | method: GET 64 | url: "https://myfishes.fish/fishes" 65 | 66 | get_fish: !request 67 | method: GET 68 | url: "https://myfishes.fish/fishes/{{chains.fish_name}}" 69 | ``` 70 | 71 | We can use `xargs` and the `-o` flag of `slumber request` to fetch details for each fish in parallel: 72 | 73 | ```sh 74 | slumber rq list_fish | jq -r '.[].name' > fish.txt 75 | cat fish.txt | xargs -L1 -I'{}' -P3 slumber rq get_fish -o chains.fish_name={} --output {}.json 76 | ``` 77 | 78 | Let's break this down: 79 | 80 | - `-L1` means to consume one argument (in this case, one fish name) per invocation of `slumber` 81 | - `-I{}` sets the substitution string, i.e. the string that will be replaced with each argument 82 | - `-P3` tells `xargs` the maximum number of processes to run concurrently, which in this case means the maximum number of concurrent requests 83 | - Everything else is the `slumber` command 84 | - `-o chains.fish_name={} `chains.fish_name` with the argument from the file, so it doesn't prompt for a name 85 | - `--output {}.json` writes to a JSON file with the fish's name (e.g. `Jimmy.json`) 86 | -------------------------------------------------------------------------------- /docs/src/getting_started.md: -------------------------------------------------------------------------------- 1 | # Getting Started 2 | 3 | ## Quick Start 4 | 5 | Once you've [installed Slumber](/artifacts), setup is easy. 6 | 7 | ### 1. Create a Slumber collection file 8 | 9 | Slumber's core feature is that it's **source-based**. That means you write down your configuration in a file first, then run Slumber and it reads the file. This differs from other popular clients such as Postman and Insomnia, where you define your configuration in the app, and it saves it to a file for you. The goal of being source-based is to make it easy to save and share your configurations. 10 | 11 | The easiest way to get started is to generate a new collection with the `new` subcommand: 12 | 13 | ```sh 14 | slumber new 15 | ``` 16 | 17 | ### 2. Run Slumber 18 | 19 | ```sh 20 | slumber 21 | ``` 22 | 23 | This will start the TUI, and you'll see the example requests available. Use tab/shift+tab (or the shortcut keys shown in the pane headers) to navigate around. Select a recipe in the left pane, then hit Enter to send a request. 24 | 25 | ## Going Further 26 | 27 | Now that you have a collection, you'll want to customize it. Here's another example of a simple collection, showcasing multiple profiles: 28 | 29 | ```yaml 30 | # slumber.yml 31 | profiles: 32 | local: 33 | data: 34 | host: http://localhost:5000 35 | production: 36 | data: 37 | host: https://myfishes.fish 38 | 39 | requests: 40 | create_fish: !request 41 | method: POST 42 | url: "{{host}}/fishes" 43 | body: !json { "kind": "barracuda", "name": "Jimmy" } 44 | 45 | list_fish: !request 46 | method: GET 47 | url: "{{host}}/fishes" 48 | query: 49 | - big=true 50 | ``` 51 | 52 | > Note: the `!request` tag, which tells Slumber that this is a request recipe, not a folder. This is [YAML's tag syntax](https://yaml.org/spec/1.2.2/#24-tags), which is used commonly throughout Slumber to provide explicit configuration. 53 | 54 | This request collection uses [templates](./user_guide/templates/index.md) and [profiles](./api/request_collection/profile.md), allowing you to dynamically change the target host. 55 | 56 | To learn more about the powerful features of Slumber you can use in your collections, keep reading with [Key Concepts](./user_guide/key_concepts.md). 57 | -------------------------------------------------------------------------------- /docs/src/images/editor.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/docs/src/images/editor.gif -------------------------------------------------------------------------------- /docs/src/images/export.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/docs/src/images/export.gif -------------------------------------------------------------------------------- /docs/src/images/query_jq.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/docs/src/images/query_jq.gif -------------------------------------------------------------------------------- /docs/src/images/query_pipe.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/docs/src/images/query_pipe.gif -------------------------------------------------------------------------------- /docs/src/install.md: -------------------------------------------------------------------------------- 1 | # Install 2 | 3 | See [installation instructions](/artifacts). Optionally, after installation you can [enable shell completions](./troubleshooting/shell_completions.md). 4 | -------------------------------------------------------------------------------- /docs/src/integration/neovim-integration.md: -------------------------------------------------------------------------------- 1 | # Neovim Integration 2 | 3 | Slumber can be integrated into Neovim to allow you quickly switch between your codebase and Slumber. 4 | 5 | Ensure you have `which-key` and `toggleterm` installed. Most premade distros (LunarVim, etc) will have these installed by default. 6 | 7 | Add this snippet to your Neovim config: 8 | ```lua 9 | local Slumber = {} 10 | Slumber.toggle = function() 11 | local Terminal = require("toggleterm.terminal").Terminal 12 | local slumber = Terminal:new { 13 | cmd = "slumber", 14 | hidden = true, 15 | direction = "float", 16 | float_opts = { 17 | border = "none", 18 | width = 100000, 19 | height = 100000, 20 | }, 21 | on_open = function(_) 22 | vim.cmd "startinsert!" 23 | end, 24 | on_close = function(_) end, 25 | count = 99, 26 | } 27 | slumber:toggle() 28 | end 29 | 30 | local wk = require("which-key") 31 | wk.add({ 32 | -- Map space V S to open slumber 33 | { "vs", Slumber.toggle, desc = "Open in Slumber"} 34 | }) 35 | ``` 36 | -------------------------------------------------------------------------------- /docs/src/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | Slumber is a terminal-based HTTP client, built for interacting with REST and other HTTP clients. It has two usage modes: Terminal User Interface (TUI) and Command Line Interface (CLI). The TUI is the most useful, and allows for interactively sending requests and viewing responses. The CLI is useful for sending quick requests and scripting. 4 | 5 | The goal of Slumber is to be **easy to use, configurable, and sharable**. To that end, configuration is defined in a YAML file called the **request collection**. Both usage modes (TUI and CLI) share the same basic configuration, which is called the [request collection](./api/request_collection/index.md). 6 | 7 | Check out the [Getting Started guide](./getting_started.md) to try it out, or move onto [Key Concepts](./user_guide/key_concepts.md) to start learning in depth about Slumber. 8 | -------------------------------------------------------------------------------- /docs/src/troubleshooting/logs.md: -------------------------------------------------------------------------------- 1 | # Logs 2 | 3 | Each Slumber session logs information and events to a log file. This can often be helpful in debugging bugs and other issues with the app. All sessions of Slumber log to the same file. Currently there is no easy to way disambiguate between logs from parallel sessions :( 4 | 5 | ## Finding the Log File 6 | 7 | To find the path to the log file, hit the `?` to open the help dialog. It will be listed under the General section. Alternatively, run the command `slumber show paths`. 8 | 9 | Once you have the path to a log file, you can watch the logs with `tail -f `, or get the entire log contents with `cat `. 10 | 11 | ## Increasing Verbosity 12 | 13 | In some scenarios, the default logging level is not verbose enough to debug issues. To increase the verbosity, set the `RUST_LOG` environment variable when starting Slumber: 14 | 15 | ```sh 16 | RUST_LOG=slumber= slumber ... 17 | ``` 18 | 19 | The `slumber=` filter applies this level only to Slumber's internal logging, instead of all libraries, to cut down on verbosity that will likely not be helpful. The available log levels are, in increasing verbosity: 20 | 21 | - `error` 22 | - `warn` 23 | - `info` 24 | - `debug` 25 | - `trace` 26 | -------------------------------------------------------------------------------- /docs/src/troubleshooting/lost_history.md: -------------------------------------------------------------------------------- 1 | # Lost Request History 2 | 3 | If you've lost your request history, there are a few possible causes. In most cases request history is non-essential, but if you really want it back there may be a fix available. 4 | 5 | If none of these fixes worked for you, and you still want your request history back, please [open an issue](https://github.com/LucasPickering/slumber/issues/new) and provide as much detail as possible. 6 | 7 | ## Moved Collection File 8 | 9 | If history is lost for an entire collection, the most likely cause is that you moved your collection file. To fix this, you can [migrate your request history](../cli/subcommands.md#history--migration). 10 | 11 | ## Changed Recipe ID 12 | 13 | If you've lost request history for just a single recipe, you likely changed the recipe ID, which is the key associated with the recipe in your collection file (the parent folder(s) do **not** affect this). Unfortunately currently the only way to fix this is to revert to the old recipe ID. 14 | 15 | ## Wrong Profile or Changed Profile ID 16 | 17 | Each request+response in history is associated with a specific profile. If you're not seeing your expected request history, you may have a different profile selected than the one used to send the request(s). 18 | 19 | Alternatively, you may have changed the ID of the associated profile. If so, unfortunately the only way to fix this is to revert to the old profile ID. 20 | -------------------------------------------------------------------------------- /docs/src/troubleshooting/shell_completions.md: -------------------------------------------------------------------------------- 1 | # Shell Completions 2 | 3 | Slumber provides tab completions for most shells. For the full list of supported shells, [see the clap docs](https://docs.rs/clap_complete/latest/clap_complete/aot/enum.Shell.html). 4 | 5 | > Note: Slumber uses clap's native shell completions, which are still experimental. [This issue](https://github.com/clap-rs/clap/issues/3166) outlines the remaining work to be done. 6 | 7 | To source your completions: 8 | 9 | ## Bash 10 | 11 | ```bash 12 | echo "source <(COMPLETE=bash slumber)" >> ~/.bashrc 13 | ``` 14 | 15 | ## Elvish 16 | 17 | ```elvish 18 | echo "eval (E:COMPLETE=elvish slumber | slurp)" >> ~/.elvish/rc.elv 19 | ``` 20 | 21 | ## Fish 22 | 23 | ```fish 24 | echo "source (COMPLETE=fish slumber | psub)" >> ~/.config/fish/config.fish 25 | ``` 26 | 27 | ## Powershell 28 | 29 | ```powershell 30 | echo "COMPLETE=powershell slumber | Invoke-Expression" >> $PROFILE 31 | ``` 32 | 33 | ## Zsh 34 | 35 | ```zsh 36 | echo "source <(COMPLETE=zsh slumber)" >> ~/.zshrc 37 | ``` 38 | -------------------------------------------------------------------------------- /docs/src/troubleshooting/tls.md: -------------------------------------------------------------------------------- 1 | # TLS Certificate Errors 2 | 3 | If you're receiving certificate errors such as this one: 4 | 5 | ``` 6 | invalid peer certificate: UnknownIssuer 7 | ``` 8 | 9 | This is probably because the TLS certificate of the server you're hitting is expired, invalid, or self-signed. The best solution is to fix the error on the server, either by renewing the certificate or creating a signed one. In most cases this is the best solution. If not possible, you should just disable TLS on your server because it's not doing anything for you anyway. 10 | 11 | If you can't or don't want to fix the certificate, and you need to keep TLS enabled for some reason, it's possible to configure Slumber to ignore TLS certificate errors on certain hosts. 12 | 13 | > **WARNING:** This is dangerous. You will be susceptible to MITM attacks on these hosts. Only do this if you control the server you're hitting, and are confident your network is not compromised. 14 | 15 | - Open your [Slumber configuration](../api/configuration/index.md) 16 | - Add the field `ignore_certificate_hosts: [""]` 17 | - `` is the domain or IP of the server you're requesting from 18 | -------------------------------------------------------------------------------- /docs/src/user_guide/cli.md: -------------------------------------------------------------------------------- 1 | # Command Line Interface 2 | 3 | While Slumber is primary intended as a TUI, it also provides a Command Line Interface (CLI). The CLI can be used to send requests, just like the TUI. It also provides some utility commands for functionality not available in the TUI. For a full list of available commands see the side bar or run: 4 | 5 | ```sh 6 | slumber help 7 | ``` 8 | 9 | Some common CLI use cases: 10 | 11 | - [Send requests](../cli/subcommands.md#slumber-request) 12 | - [Import from an external format](../cli/subcommands.md#slumber-import) 13 | - [Generate request in an external format (e.g. curl)](../cli/subcommands.md#slumber-generate) 14 | - [View Slumber configuration/metadata](../cli/subcommands.md#slumber-show) 15 | -------------------------------------------------------------------------------- /docs/src/user_guide/database.md: -------------------------------------------------------------------------------- 1 | # Database & Persistence 2 | 3 | > Note: This is an advanced feature. The vast majority of users can use Slumber all they want without even knowing the database exists. 4 | 5 | Slumber uses a [SQLite](https://www.sqlite.org/) database to persist requests and responses. The database also stores UI state that needs to be persisted between sessions. This database exists exclusively on your device. **The Slumber database is never uploaded to the cloud or shared in any way.** Slumber does not make any network connections beyond the ones you define and execute yourself. You own your data; I don't want it. 6 | 7 | Data for all your Slumber collections are stored in a single file. To find this file, run `slumber show paths`, and look for the `Database` entry. I encourage you to browse this file if you're curious; it's pretty simple and there's nothing secret in it. Keep in mind though that **the database format is NOT considered part of Slumber's API contract.** It may change at any time, including the database path moving or tables be changed or removed, even in a minor or patch release. 8 | 9 | ### Controlling Persistence 10 | 11 | By default, all requests made in the TUI are stored in the database. This enables the history browser, allowing you to browse past requests. While generally useful, this may not be desired in all cases. However, there are some cases where you may not want requests persisted: 12 | 13 | - The request or response may contain sensitive data 14 | - The response is very large and impacts app performance 15 | 16 | You can disable persistence for a single recipe by setting `persist: false` [for that recipe](../api/request_collection/request_recipe.md#recipe-fields). You can disable history globally by setting `persist: false` in the [global config file](../api/configuration/index.md). Note that this only disables _request_ persistence. UI state, such as selection state for panes and checkboxes, is still written to the database. 17 | 18 | > **NOTE:** Disabling persistence does _not_ delete existing request history. [See here](#deleting-request-history) for how to do that. 19 | 20 | Slumber will generally continue to work just fine with request persistence disabled. Requests and responses are still cached in memory, they just aren't written to the database anymore and therefore can't be recovered after the current session is closed. If you disable persistence, you will notice a few impacts on functionality: 21 | 22 | - The history modal will only show requests made during the current session 23 | - Chained requests can only access responses from the current session. [Consider adding `trigger: !no_history` to the request](../api/request_collection/chain_source.md#chain-request-trigger) to automatically refetch it on new sessions. 24 | 25 | Unlike the TUI, requests made from the CLI are _not_ persisted by default. This is because the CLI is often used for scripting and bulk requests. Persisting these requests could have major performance impacts for little to no practical gain. Pass the `--persist` flag to `slumber request` to persist a CLI request. 26 | 27 | ### Deleting Request History 28 | 29 | There are a few ways to delete requests from history: 30 | 31 | - In the TUI. Open the actions menu while a request/response is selected to delete that request. From the recipe list/recipe pane, you can delete all requests for that recipe. 32 | - The `slumber history delete` can delete one or more commands at a time. Combine with `slumber history list` for bulk deletes: `slumber history list login --id-only | xargs slumber history delete` 33 | - Manually modifying the database. You can access the DB with `slumber db`. While this is not an officially supported technique (as the DB schema may change without warning), it's simple enough to navigate if you want to performance bulk deletes with custom criteria. 34 | 35 | ### Migrating Collections 36 | 37 | As all Slumber collections' histories are stored in the same SQLite database, each collection gets a unique UUID generated when it is first accessed. This UUID is used to persist request history and other data related to the collection. This UUID is bound to the collection's path. If you move a collection file, a new UUID will be generated and it will be unlinked from its previous history. If you want to retain that history, you can migrate data from the old ID to the new one like so: 38 | 39 | ```sh 40 | slumber collections migrate slumber-old.yml slumber-new.yml 41 | ``` 42 | 43 | If you don't remember the path of the old file, you can list all known collections with: 44 | 45 | ```sh 46 | slumber collections list 47 | ``` 48 | -------------------------------------------------------------------------------- /docs/src/user_guide/import.md: -------------------------------------------------------------------------------- 1 | # Importing External Collections 2 | 3 | See the [`slumber import`](../cli/subcommands.md#slumber-import) subcommand. 4 | -------------------------------------------------------------------------------- /docs/src/user_guide/inheritance.md: -------------------------------------------------------------------------------- 1 | # Collection Reuse & Inheritance 2 | 3 | ## The Problem 4 | 5 | Let's start with an example of something that sucks. Let's say you're making requests to a fish-themed JSON API, and it requires authentication. Gotta protect your fish! Your request collection might look like so: 6 | 7 | ```yaml 8 | profiles: 9 | production: 10 | data: 11 | host: https://myfishes.fish 12 | fish_id: 6 13 | 14 | chains: 15 | token: 16 | source: !file 17 | path: ./api_token.txt 18 | fish_id: 19 | source: !prompt 20 | message: Fish ID 21 | 22 | requests: 23 | list_fish: !request 24 | method: GET 25 | url: "{{host}}/fishes" 26 | query: 27 | - big=true 28 | headers: 29 | Accept: application/json 30 | authentication: !bearer "{{chains.token}}" 31 | 32 | get_fish: !request 33 | method: GET 34 | url: "{{host}}/fishes/{{fish_id}}" 35 | headers: 36 | Accept: application/json 37 | authentication: !bearer "{{chains.token}}" 38 | ``` 39 | 40 | ## The Solution 41 | 42 | You've heard of [DRY](https://en.wikipedia.org/wiki/Don%27t_repeat_yourself), so you know this is bad. Every new request recipe requires re-specifying the headers, and if anything about the authorization changes, you have to change it in multiple places. 43 | 44 | You can easily re-use components of your collection using [YAML's merge feature](https://yaml.org/type/merge.html). 45 | 46 | ```yaml 47 | profiles: 48 | production: 49 | data: 50 | host: https://myfishes.fish 51 | 52 | chains: 53 | token: 54 | source: !file 55 | path: ./api_token.txt 56 | fish_id: 57 | source: !prompt 58 | message: Fish ID 59 | 60 | # This is needed to tell Slumber not to complain about an unknown key 61 | .ignore: 62 | # The name here is arbitrary, pick any name you like 63 | request_base: &request_base 64 | headers: 65 | Accept: application/json 66 | authentication: !bearer "{{chains.token}}" 67 | 68 | requests: 69 | list_fish: !request 70 | <<: *request_base 71 | method: GET 72 | url: "{{host}}/fishes" 73 | query: 74 | - big=true 75 | 76 | get_fish: !request 77 | <<: *request_base 78 | method: GET 79 | url: "{{host}}/fishes/{{chains.fish_id}}" 80 | ``` 81 | 82 | Great! That's so much cleaner. Now each recipe can inherit whatever base properties you want just by including `<<: *request_base`. This is still a bit repetitive, but it has the advantage of being explicit. You may have some requests that _don't_ want to include those values. 83 | 84 | ## Recursive Inheritance 85 | 86 | But wait! What if you have a new request that needs an additional header? Unfortunately, YAML's merge feature does not support recursive merging. If you need to extend the `headers` map from the base request, you'll need to pull that map in manually: 87 | 88 | ```yaml 89 | profiles: 90 | production: 91 | data: 92 | host: https://myfishes.fish 93 | 94 | chains: 95 | token: 96 | source: !file 97 | path: ./api_token.txt 98 | fish_id: 99 | source: !prompt 100 | message: Fish ID 101 | 102 | .ignore: 103 | request_base: &request_base 104 | headers: &headers_base # This will let us pull in the header map 105 | Accept: application/json 106 | authentication: !bearer "{{chains.token}}" 107 | 108 | requests: 109 | list_fish: !request 110 | <<: *request_base 111 | method: GET 112 | url: "{{host}}/fishes" 113 | query: 114 | - big=true 115 | 116 | get_fish: !request 117 | <<: *request_base 118 | method: GET 119 | url: "{{host}}/fishes/{{chains.fish_id}}" 120 | 121 | create_fish: !request 122 | <<: *request_base 123 | method: POST 124 | url: "{{host}}/fishes" 125 | headers: 126 | <<: *headers_base 127 | Host: myfishes.fish 128 | body: !json { "kind": "barracuda", "name": "Jimmy" } 129 | ``` 130 | -------------------------------------------------------------------------------- /docs/src/user_guide/key_concepts.md: -------------------------------------------------------------------------------- 1 | # Key Concepts 2 | 3 | There are a handful of key concepts you need to understand how to effectively configure and use Slumber. You can read about each one in detail on its linked API reference page. 4 | 5 | ## [Request Collection](../api/request_collection/index.md) 6 | 7 | The collection is the main form of configuration. It defines a set of request recipes, which enable Slumber to make requests to your API. 8 | 9 | ## [Request Recipe](../api/request_collection/request_recipe.md) 10 | 11 | A recipe defines which HTTP requests Slumber can make. A recipe generally corresponds to one endpoint on an API, although you can create as many recipes per endpoint as you'd like. 12 | 13 | ## [Template](./templates/index.md) 14 | 15 | Templates are Slumber's most powerful feature. They allow you to dynamically build URLs, query parameters, request bodies, etc. using predefined _or_ dynamic values. 16 | 17 | ## [Profile](../api/request_collection/profile.md) 18 | 19 | A profile is a set of static template values. A collection can contain a list of profiles, allowing you to quickly switch between different sets of values. This is useful for using different deployment environments, different sets of IDs, etc. 20 | -------------------------------------------------------------------------------- /docs/src/user_guide/templates/chains.md: -------------------------------------------------------------------------------- 1 | # Chains 2 | 3 | Chains are Slumber's most powerful feature. They allow you to dynamically build requests based on other responses, shell commands, and more. 4 | 5 | ## Chains in Practice 6 | 7 | The most common example of a chain is with a login request. You can define a recipe to log in to a service using username+password, then get the returned API token to authenticate subsequent requests. Of course, we don't want to store our credentials in Slumber file, so we can also use chains to fetch those. Let's see this in action: 8 | 9 | ```yaml 10 | chains: 11 | username: 12 | source: !file 13 | path: ./username.txt 14 | password: 15 | source: !file 16 | path: ./password.txt 17 | auth_token: 18 | source: !request 19 | recipe: login 20 | selector: $.token 21 | 22 | requests: 23 | # This returns a response like {"token": "abc123"} 24 | login: !request 25 | method: POST 26 | url: "https://myfishes.fish/login" 27 | body: 28 | !json { 29 | "username": "{{chains.username}}", 30 | "password": "{{chains.password}}", 31 | } 32 | 33 | get_user: !request 34 | method: GET 35 | url: "https://myfishes.fish/current-user" 36 | authentication: !bearer "{{chains.auth_token}}" 37 | ``` 38 | 39 | > For more info on the `selector` field, see [Data Extraction via JSONPath](./selector.md) 40 | 41 | ### Automatically Executing the Upstream Request 42 | 43 | By default, the chained request (i.e. the "upstream" request) has to be executed manually to get the login token. You can have the upstream request automatically execute using the `trigger` field: 44 | 45 | ```yaml 46 | chains: 47 | auth_token: 48 | source: !request 49 | recipe: login 50 | # Execute only if we've never logged in before 51 | trigger: !no_history 52 | selector: $.token 53 | --- 54 | chains: 55 | auth_token: 56 | source: !request 57 | recipe: login 58 | # Execute only if the latest response is older than a day. Useful if your 59 | # token expires after a fixed amount of time 60 | trigger: !expire 1d 61 | selector: $.token 62 | --- 63 | chains: 64 | auth_token: 65 | source: !request 66 | recipe: login 67 | # Always execute 68 | trigger: !always 69 | selector: $.token 70 | ``` 71 | 72 | For more detail about the various trigger variants, including the syntax of the `expire` variant, see [the API docs](../../api/request_collection/chain_source.md#chain-request-trigger). 73 | 74 | ## Chaining Chains 75 | 76 | Chains on their own are powerful enough, but what makes them _really_ cool is that the arguments to a chain are templates in themselves, meaning you can use [nested templates](./index.md#nested-templates) to chain chains to other chains! Wait, what? 77 | 78 | Let's say the login response doesn't return JSON, but instead the response looks like this: 79 | 80 | ``` 81 | token:abc123 82 | ``` 83 | 84 | Clearly this isn't a well-designed API, but sometimes that's all you get. You can use a nested chain with [cut](https://man7.org/linux/man-pages/man1/cut.1.html) to parse this: 85 | 86 | ```yaml 87 | chains: 88 | username: 89 | source: !file 90 | path: ./username.txt 91 | password_encrypted: 92 | source: !file 93 | path: ./password.txt 94 | password: 95 | source: !command 96 | command: 97 | auth_token_raw: 98 | source: !request 99 | recipe: login 100 | auth_token: 101 | source: !command 102 | command: ["cut", "-d':'", "-f2"] 103 | stdin: "{{chains.auth_token_raw}}" 104 | 105 | requests: 106 | login: !request 107 | method: POST 108 | url: "https://myfishes.fish/login" 109 | body: 110 | !json { 111 | "username": "{{chains.username}}", 112 | "password": "{{chains.password}}", 113 | } 114 | 115 | get_user: !request 116 | method: GET 117 | url: "https://myfishes.fish/current-user" 118 | authentication: !bearer "{{chains.auth_token}}" 119 | ``` 120 | 121 | This means you can use external commands to perform any manipulation on data that you want. 122 | -------------------------------------------------------------------------------- /docs/src/user_guide/templates/index.md: -------------------------------------------------------------------------------- 1 | # Templates 2 | 3 | Templates enable dynamic string/binary construction. Slumber's template language is relatively simple, compared to complex HTML templating languages like Handlebars or Jinja. The goal is to be intuitive and unsurprising. It doesn't support complex features like loops, conditionals, etc. 4 | 5 | Most string values in a request collection (e.g. URL, request body, etc.) are templates. Map keys (e.g. recipe ID, profile ID) are _not_ templates; they must be static strings. 6 | 7 | The syntax for injecting a dynamic value into a template is double curly braces `{{...}}`. The contents inside the braces tell Slumber how to retrieve the dynamic value. 8 | 9 | This guide serves as a functional example of how to use templates. For detailed information on options available, see [the API reference](../../api/request_collection/template.md). 10 | 11 | > **A note on YAML string syntax** 12 | > 13 | > One of the advantages (and disadvantages) of YAML is that it has a number of different string syntaxes. This enables you to customize your templates according to your specific needs around the behavior of whitespace and newlines. See [YAML's string syntaxes](https://www.educative.io/answers/how-to-represent-strings-in-yaml) and [yaml-multiline.info](https://yaml-multiline.info/) for more info on YAML strings. 14 | 15 | ## A Basic Example 16 | 17 | Let's start with a simple example. Let's say you're working on a fish-themed website, and you want to make requests both to your local stack and the deployed site. Templates, combined with profiles, allow you to easily switch between hosts: 18 | 19 | > Note: for the purposes of these examples, I've made up some theoretical endpoints and responses, following standard REST practice. This isn't a real API but it should get the point across. 20 | > 21 | > Additionally, these examples will use the CLI because it's easy to demonstrate in text. All these concepts apply equally to the TUI. 22 | 23 | ```yaml 24 | profiles: 25 | local: 26 | data: 27 | host: http://localhost:5000 28 | production: 29 | data: 30 | host: https://myfishes.fish 31 | 32 | requests: 33 | list_fish: !request 34 | method: GET 35 | url: "{{host}}/fishes" 36 | query: 37 | - big=true 38 | ``` 39 | 40 | Now you can easily select which host to hit. In the TUI, this is done via the Profile list. In the CLI, use the `--profile` option: 41 | 42 | ```sh 43 | > slumber request --profile local list_fish 44 | # http://localhost:5000/fishes 45 | # Only one fish :( 46 | [{"id": 1, "kind": "tuna", "name": "Bart"}] 47 | > slumber request --profile production list_fish 48 | # https://myfishes.fish/fishes 49 | # More fish! 50 | [ 51 | {"id": 1, "kind": "marlin", "name": "Kim"}, 52 | {"id": 2, "kind": "salmon", "name": "Francis"} 53 | ] 54 | ``` 55 | 56 | ## Nested Templates 57 | 58 | What if you need a more complex chained value? Let's say the endpoint to get a fish requires the fish ID to be in the format `fish_{id}`. Why? Don't worry about it. Fish are particular. Templates support nesting implicitly. You can use this to compose template values into more complex strings. Just be careful not to trigger infinite recursion! 59 | 60 | ```yaml 61 | profiles: 62 | local: 63 | data: 64 | host: http://localhost:5000 65 | fish_id: "fish_{{chains.fish_id}}" 66 | 67 | chains: 68 | fish_id: 69 | source: !request 70 | recipe: create_fish 71 | selector: $.id 72 | 73 | requests: 74 | create_fish: !request 75 | method: POST 76 | url: "{{host}}/fishes" 77 | body: !json { "kind": "barracuda", "name": "Jimmy" } 78 | 79 | get_fish: !request 80 | method: GET 81 | url: "{{host}}/fishes/{{fish_id}}" 82 | ``` 83 | 84 | And let's see it in action: 85 | 86 | ```sh 87 | > slumber request -p local create_fish 88 | # http://localhost:5000/fishes 89 | {"id": 2, "kind": "barracuda", "name": "Jimmy"} 90 | > slumber request -p local get_fish 91 | # http://localhost:5000/fishes/fish_2 92 | {"id": "fish_2", "kind": "barracuda", "name": "Jimmy"} 93 | ``` 94 | 95 | ## Binary Templates 96 | 97 | While templates are mostly useful for generating strings, they can also generate binary data. This is most useful for sending binary request bodies. Some fields (e.g. URL) do _not_ support binary templates because they need valid text; in those cases, if the template renders to non-UTF-8 data, an error will be returned. In general, if binary data _can_ be supported, it is. 98 | 99 | > Note: Support for binary form data is currently incomplete. You can render binary data from templates, but forms must be constructed manually. See [#235](https://github.com/LucasPickering/slumber/discussions/235) for more info. 100 | 101 | ```yaml 102 | profiles: 103 | local: 104 | data: 105 | host: http://localhost:5000 106 | fish_id: "cod_father" 107 | 108 | chains: 109 | fish_image: 110 | source: !file 111 | path: ./cod_father.jpg 112 | 113 | requests: 114 | set_fish_image: !request 115 | method: POST 116 | url: "{{host}}/fishes/{{fish_id}}/image" 117 | headers: 118 | Content-Type: image/jpg 119 | body: "{{chains.fish_image}}" 120 | ``` 121 | -------------------------------------------------------------------------------- /docs/src/user_guide/templates/selector.md: -------------------------------------------------------------------------------- 1 | # Data Extraction via JSONPath 2 | 3 | [Chains](./chains.md) support querying data structures to transform or reduce response data. THis is done via the `selector` field of a chain. 4 | 5 | **Regardless of data format, querying is done via [JSONPath](https://www.ietf.org/archive/id/draft-goessner-dispatch-jsonpath-00.html).** For non-JSON formats, the data will be converted to JSON, queried, and converted back. This keeps querying simple and uniform across data types. 6 | 7 | ## Querying Chained Values 8 | 9 | Here's some examples of using queries to extract data from a chained value. Let's say you have two chained value sources. The first is a JSON file, called `creds.json`. It has the following contents: 10 | 11 | ```json 12 | { "user": "fishman", "pw": "hunter2" } 13 | ``` 14 | 15 | We'll use these credentials to log in and get an API token, so the second data source is the login response, which looks like so: 16 | 17 | ```json 18 | { "token": "abcdef123" } 19 | ``` 20 | 21 | ```yaml 22 | chains: 23 | username: 24 | # Slumber knows how to query this file based on its extension 25 | source: !file 26 | path: ./creds.json 27 | selector: $.user 28 | password: 29 | source: !file 30 | path: ./creds.json 31 | selector: $.pw 32 | auth_token: 33 | source: !request 34 | recipe: login 35 | selector: $.token 36 | 37 | requests: 38 | login: !request 39 | method: POST 40 | url: "https://myfishes.fish/anything/login" 41 | body: 42 | !json { 43 | "username": "{{chains.username}}", 44 | "password": "{{chains.password}}", 45 | } 46 | 47 | get_user: !request 48 | method: GET 49 | url: "https://myfishes.fish/anything/current-user" 50 | query: 51 | - auth={{chains.auth_token}} 52 | ``` 53 | 54 | While this example simple extracts inner fields, JSONPath can be used for much more powerful transformations. See the [JSONPath docs](https://www.ietf.org/archive/id/draft-goessner-dispatch-jsonpath-00.html) or [this JSONPath editor](https://jsonpath.com/) for more examples. 55 | 56 | ### More Powerful Querying with Nested Chains 57 | 58 | If JSONPath isn't enough for the data extraction you need, you can use nested chains to filter with whatever external programs you want. For example, if you want to use `jq` instead: 59 | 60 | ```yaml 61 | chains: 62 | username: 63 | source: !file 64 | path: ./creds.json 65 | selector: $.user 66 | password: 67 | source: !file 68 | path: ./creds.json 69 | selector: $.pw 70 | auth_token_raw: 71 | source: !request 72 | recipe: login 73 | auth_token: 74 | source: !command 75 | command: [ "jq", ".token" ] 76 | stdin: "{{chains.auth_token_raw}} 77 | 78 | requests: 79 | login: !request 80 | method: POST 81 | url: "https://myfishes.fish/anything/login" 82 | body: !json 83 | { 84 | "username": "{{chains.username}}", 85 | "password": "{{chains.password}}" 86 | } 87 | 88 | get_user: !request 89 | method: GET 90 | url: "https://myfishes.fish/anything/current-user" 91 | query: 92 | - auth={{chains.auth_token}} 93 | ``` 94 | 95 | You can use this capability to manipulate responses via `grep`, `awk`, or any other program you like. 96 | -------------------------------------------------------------------------------- /docs/src/user_guide/tui/editor.md: -------------------------------------------------------------------------------- 1 | # In-App Editing & File Viewing 2 | 3 | ## Editing 4 | 5 | ![Open collection file in vim](../../images/editor.gif) 6 | 7 | Slumber supports editing your collection file without leaving the app. To do so, open the actions menu (`x` by default), then select `Edit Collection`. Slumber will open an external editor to modify the file. To determine which editor to use, Slumber checks these places in the following order: 8 | 9 | - `editor` field of the [configuration file](../../api/configuration/index.md) 10 | - `VISUAL` environment variable 11 | - `EDITOR` environment variable 12 | - Default to `vim` 13 | 14 | The `VISUAL` and `EDITOR` environment variables are a common standard to define a user's preferred text editor. For example, it's what [git uses by default](https://git-scm.com/book/en/v2/Customizing-Git-Git-Configuration) to determine how to edit commit messages. If you want to use the same editor for all programs, you should set these. If you want to use a command specific to Slumber, set the `editor` config field. 15 | 16 | Slumber supports passing additional arguments to the editor. For example, if you want to open `VSCode` and have wait for the file to be saved, you can configure your editor like so: 17 | 18 | ```yaml 19 | editor: code --wait 20 | ``` 21 | 22 | The command will be parsed like a shell command (although a shell is never actually invoked). For exact details on parsing behavior, see [shell-words](https://docs.rs/shell-words/1.1.0/shell_words/fn.split.html). 23 | 24 | ## Paging 25 | 26 | You can open request and response bodies in a separate file browser if you want additional features beyond what Slumber provides. To configure the command to use, set the `PAGER` environment variable or the `pager` configuration field: 27 | 28 | ```yaml 29 | pager: bat 30 | ``` 31 | 32 | Slumber will check these places in the following order for a command: 33 | 34 | - `pager` field of the [configuration file](../../api/configuration/index.md) 35 | - `PAGER` environment variable 36 | - Default to `less` (Unix) or `more` (Windows) 37 | 38 | > The pager command uses the same format as the `editor` field. The command is parsed with [shell-words](https://docs.rs/shell-words/1.1.0/shell_words/fn.split.html), then a temporary file path is passed as the final argument. 39 | 40 | To open a body in the pager, use the actions menu keybinding (`x` by default, see [input bindings](../../api/configuration/input_bindings.md)), and select `View Body`. 41 | 42 | Some popular pagers: 43 | 44 | - [bat](https://github.com/sharkdp/bat) 45 | - [fx](https://fx.wtf/) 46 | - [jless](https://github.com/PaulJuliusMartinez/jless) 47 | 48 | ### Setting a content-specific pager 49 | 50 | If you want to use a different pager for certain content types, such as using `jless` for JSON, you can pass a map of MIME type patterns to commands. For example: 51 | 52 | ```yaml 53 | pager: 54 | json: jless 55 | default: less 56 | ``` 57 | 58 | For more details on matching, see [MIME Maps](../../api/configuration/mime.md). 59 | -------------------------------------------------------------------------------- /docs/src/user_guide/tui/filter_query.md: -------------------------------------------------------------------------------- 1 | # Data Filtering & Querying 2 | 3 | When browsing an HTTP response in Slumber, you may want to filter, query, or otherwise transform the response to make it easier to view. Slumber supports this via embedded shell commands. The query box at the bottom of the response pane allows you to execute any shell command, which will be passed the response body via stdin and its output will be shown in the response pane. You can use `grep`, `jq`, `sed`, or any other text processing tool. 4 | 5 | ![Querying response via jq](../../images/query_jq.gif) 6 | 7 | _Example of querying with jq_ 8 | 9 | ![Querying response with pipes](../../images/query_pipe.gif) 10 | 11 | _Example of using pipes in a query command_ 12 | 13 | ## Exporting data 14 | 15 | Keep in mind that your queries are being executed as shell commands on your system. You should avoid running any commands that interact with the file system, such as using `>` or `<` to pipe to/from files. However, if you want to export response data from Slumber, you can do so with the export command palette. To open the export palette, select the Response pane and press the `export` key binding (`:` by default). Then enter any shell command, which will receive the response body as stdin. 16 | 17 | > **Note:** For text bodies, whatever text is visible in the response pane is what will be passed to stdin. So if you have a query applied, the queried body will be exported. For binary bodies, the original bytes will be exported. 18 | 19 | Some useful commands for exporting data: 20 | 21 | - `tee > response.json` - Save the response to `response.json` 22 | - `tee` takes data from stdin and sends it to zero or more files as well as stdout. Another way to write this would be `tee response.json` 23 | - `pbcopy` - Copy the body to the clipboard (MacOS only - search online to find the correct command for your platform) 24 | 25 | Remember: This is a real shell, so you can pipe through whatever transformation commands you want here! 26 | 27 | ## Default command 28 | 29 | You can set the default command to query with via the [`commands.default_query`](../../api/configuration/index.md#commandsdefault_query) config field. This accepts either a single string to set it for all content types, or a [MIME map](../../api/configuration/mime.md) to set different defaults based on the response content type. For example, to default to `jq` for all JSON responses: 30 | 31 | ```yaml 32 | commands: 33 | default_query: 34 | json: jq 35 | ``` 36 | 37 | ## Which shell does Slumber use? 38 | 39 | By default, Slumber executes your command via `sh -c` on Unix and `cmd /S /C` on Windows. You can customize this via the [`commands.shell` configuration field](../../api/configuration/index.md#commandsshell). For example, to use `fish` instead of `sh`: 40 | 41 | ```yaml 42 | commands: 43 | shell: [fish, -c] 44 | ``` 45 | 46 | If you don't want to execute via _any_ shell, you can set it to `[]`. In this case, query commands will be parsed via [shell-words](https://docs.rs/shell-words/latest/shell_words/) and executed directly. For example, `jq .args` will be parsed into `["jq", ".args"]`, then `jq` will be executed with a single argument: `.args`. 47 | -------------------------------------------------------------------------------- /docs/src/user_guide/tui/index.md: -------------------------------------------------------------------------------- 1 | # Terminal User Interface 2 | 3 | The Terminal User Interface (TUI) is the primary use case for Slumber. It provides a long-lived, interactive interface for sending HTTP requests, akin to Insomnia or Postman. The difference of course is Slumber runs entirely in the terminal. 4 | 5 | To start the TUI, simply run: 6 | 7 | ```sh 8 | slumber 9 | ``` 10 | 11 | This will detect your request collection file [according to the protocol](../../api/request_collection/index.md#format--loading). If you want to load a different file, you can use the `--file` parameter: 12 | 13 | ```sh 14 | slumber --file my-slumber.yml 15 | ``` 16 | 17 | ## Auto-Reload 18 | 19 | Once you start your Slumber, that session is tied to a single collection file. Whenever that file is modified, Slumber will automatically reload it and changes will immediately be reflected in the TUI. If auto-reload isn't working for some reason, you can manually reload the file with the `r` key. 20 | 21 | ## Multiple Sessions 22 | 23 | Slumber supports running multiple sessions at once, even on the same collection. Request history is stored in a thread-safe [SQLite](https://www.sqlite.org/index.html), so multiple sessions can safely interact simultaneously. 24 | 25 | If you frequently run multiple sessions together and want to quickly switch between them, consider a configurable terminal manager like [tmux](https://github.com/tmux/tmux/wiki) or [Zellij](https://zellij.dev/). 26 | -------------------------------------------------------------------------------- /gifs.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | """ 4 | Generate GIFs from VHS tapes 5 | """ 6 | 7 | import argparse 8 | import glob 9 | import os 10 | import re 11 | import shutil 12 | import subprocess 13 | 14 | TAPE_DIR = "tapes/" 15 | OUTPUT_REGEX = re.compile(r"^Output \"(?P.*)\"$") 16 | 17 | 18 | def main() -> None: 19 | parser = argparse.ArgumentParser(description="Generate GIFs for docs") 20 | parser.add_argument( 21 | "--check", action="store_true", help="Check if all GIFs are up to date" 22 | ) 23 | parser.add_argument("tapes", nargs="*", help="Generate or check specific tapes") 24 | args = parser.parse_args() 25 | 26 | tapes = [get_tape_path(tape) for tape in args.tapes] 27 | if args.check: 28 | check_all(tapes) 29 | else: 30 | generate_all(tapes) 31 | 32 | 33 | def generate_all(tapes: list[str]) -> None: 34 | if not tapes: 35 | tapes = get_tapes() 36 | print(f"Generating GIFs for: {tapes}") 37 | 38 | run(["cargo", "build"]) 39 | for tape in tapes: 40 | generate(tape) 41 | print("Don't forget to check all GIFs before pushing!") 42 | 43 | 44 | def generate(tape: str) -> None: 45 | print("Deleting data/") 46 | shutil.rmtree("data/") 47 | run(["vhs", tape]) 48 | 49 | 50 | def check_all(tapes: list[str]) -> None: 51 | if not tapes: 52 | tapes = get_tapes() 53 | latest_commit = run(["git", "rev-parse", "HEAD"]) 54 | failed = [] 55 | for tape in tapes: 56 | gif = get_gif_path(tape) 57 | good = check(gif_path=gif, latest_commit=latest_commit) 58 | if not good: 59 | failed.append(gif) 60 | print(f" {tape} -> {gif}: {'PASS' if good else 'FAIL'}") 61 | if failed: 62 | raise Exception(f"Some GIFs are outdated: {failed}") 63 | else: 64 | print("All GIFs are up to date") 65 | 66 | 67 | def check(gif_path: str, latest_commit: str) -> bool: 68 | """Check if the GIF is outdated""" 69 | latest_gif_commit = run( 70 | ["git", "log", "-n", "1", "--pretty=format:%H", "--", gif_path] 71 | ) 72 | return latest_commit == latest_gif_commit 73 | 74 | 75 | def get_tapes() -> list[str]: 76 | return glob.glob(os.path.join(TAPE_DIR, "*")) 77 | 78 | 79 | def get_tape_path(tape_name: str) -> str: 80 | return os.path.join(TAPE_DIR, f"{tape_name}.tape") 81 | 82 | 83 | def get_gif_path(tape_path: str) -> str: 84 | with open(tape_path) as f: 85 | for line in f: 86 | m = OUTPUT_REGEX.match(line) 87 | if m: 88 | return m.group("path") 89 | raise ValueError(f"Tape file {tape_path} missing Output declaration") 90 | 91 | 92 | def run(command: list[str]) -> str: 93 | output = subprocess.check_output(command) 94 | return output.decode().strip() 95 | 96 | 97 | if __name__ == "__main__": 98 | main() 99 | -------------------------------------------------------------------------------- /oranda.json: -------------------------------------------------------------------------------- 1 | { 2 | "styles": { 3 | "logo": "./static/slumber.png", 4 | "favicon": "./static/favicon.ico", 5 | "theme": "dark" 6 | }, 7 | "components": { 8 | "artifacts": { 9 | "package_managers": { 10 | "preferred": { 11 | "cargo": "cargo install slumber --locked" 12 | }, 13 | "additional": { 14 | "binstall": "cargo binstall slumber" 15 | } 16 | } 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /rust-toolchain.toml: -------------------------------------------------------------------------------- 1 | [toolchain] 2 | # Keep in sync w/ Cargo.toml 3 | channel = "1.86.0" 4 | components = ["cargo", "clippy", "rustfmt"] 5 | -------------------------------------------------------------------------------- /slumber.yml: -------------------------------------------------------------------------------- 1 | # Note: This file is meant primarily for development and testing of Slumber. It 2 | # is not intended as documentation. While it may be a helpful example, canonical 3 | # documentation is at: https://slumber.lucaspickering.me/book/ 4 | 5 | profiles: 6 | works: 7 | name: This Works 8 | data: 9 | host: https://httpbin.org 10 | username: xX{{chains.username}}Xx 11 | user_guid: abc123 12 | local: 13 | name: Local 14 | default: true 15 | data: 16 | host: http://localhost:80 17 | username: xX{{chains.username}}Xx 18 | user_guid: abc123 19 | init-fails: 20 | name: Request Init Fails 21 | data: 22 | request-fails: 23 | name: Request Fails 24 | data: 25 | host: http://localhost:5000 26 | username: xX{{chains.username}}Xx 27 | user_guid: abc123 28 | 29 | chains: 30 | username: 31 | source: !command 32 | command: [whoami] 33 | trim: both 34 | password: 35 | source: !prompt 36 | message: Password 37 | sensitive: true 38 | select_value: 39 | source: !select 40 | message: Select a value 41 | options: 42 | - foo 43 | - bar 44 | - baz 45 | - a really really really really long option 46 | - "{{chains.username}}" 47 | select_dynamic: 48 | source: !select 49 | message: Select a value 50 | options: "{{chains.login_form_values}}" 51 | login_form_values: 52 | source: !request 53 | recipe: login 54 | trigger: !expire 12h 55 | selector: $.form[*] 56 | selector_mode: array 57 | auth_token: 58 | source: !request 59 | recipe: login 60 | trigger: !expire 12h 61 | selector: $.form 62 | image: 63 | source: !file 64 | path: ./static/slumber.png 65 | big_file: 66 | source: !file 67 | path: Cargo.lock 68 | response_type: 69 | source: !select 70 | options: 71 | - json 72 | - html 73 | - xml 74 | 75 | .ignore: 76 | base: &base 77 | authentication: !bearer "{{chains.auth_token}}" 78 | headers: 79 | Accept: application/json 80 | 81 | requests: 82 | login: !request 83 | method: POST 84 | url: "{{host}}/anything/login" 85 | persist: false 86 | authentication: !basic 87 | username: "{{username}}" 88 | password: "{{chains.password}}" 89 | query: 90 | - sudo=yes_please 91 | - fast=no_thanks 92 | - fast=actually_maybe 93 | headers: 94 | Accept: application/json 95 | body: !form_urlencoded 96 | username: "{{username}}" 97 | password: "{{chains.password}}" 98 | 99 | users: !folder 100 | name: Users 101 | requests: 102 | get_users: !request 103 | <<: *base 104 | name: Get Users 105 | method: GET 106 | url: "{{host}}/get" 107 | query: 108 | - foo=bar 109 | - select={{chains.select_dynamic}} 110 | 111 | get_user: !request 112 | <<: *base 113 | name: Get User 114 | method: GET 115 | url: "{{host}}/anything/{{user_guid}}" 116 | 117 | modify_user: !request 118 | <<: *base 119 | name: Modify User 120 | method: PUT 121 | url: "{{host}}/anything/{{user_guid}}" 122 | body: 123 | !json { 124 | "new_username": "user formerly known as {{chains.username}}", 125 | "number": 3, 126 | "bool": true, 127 | "null": null, 128 | "array": [1, 2, false, 3.3, "www.www"], 129 | } 130 | 131 | get_image: !request 132 | headers: 133 | Accept: image/png 134 | name: Get Image 135 | method: GET 136 | url: "{{host}}/image" 137 | 138 | upload_image: !request 139 | name: Upload Image 140 | method: POST 141 | url: "{{host}}/anything/image" 142 | body: !form_multipart 143 | filename: "logo.png" 144 | image: "{{chains.image}}" 145 | 146 | big_file: !request 147 | name: Big File 148 | method: POST 149 | url: "{{host}}/anything" 150 | body: "{{chains.big_file}}" 151 | 152 | raw_json: !request 153 | name: Raw JSON 154 | method: POST 155 | url: "{{host}}/anything" 156 | headers: 157 | Content-Type: application/json 158 | body: > 159 | { 160 | "location": "boston", 161 | "size": "HUGE" 162 | } 163 | 164 | delay: !request 165 | <<: *base 166 | name: Delay 167 | method: GET 168 | url: "{{host}}/delay/5" 169 | 170 | dynamic_repsonse_type: !request 171 | name: Dynamic Response Type 172 | method: GET 173 | url: "{{host}}/{{chains.response_type}}" 174 | -------------------------------------------------------------------------------- /static/demo.gif: -------------------------------------------------------------------------------- 1 | version https://git-lfs.github.com/spec/v1 2 | oid sha256:bb3db5c58fdf4e6679f4b34a84629a5133c77428fb73b99b52ee61b67443ddb8 3 | size 374154 4 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/static/favicon.ico -------------------------------------------------------------------------------- /static/favicon.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Generate favicon.ico using Imagemagick 4 | 5 | convert -background transparent slumber.png -define icon:auto-resize=16,24,32,48,64 favicon.ico 6 | -------------------------------------------------------------------------------- /static/slumber.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/static/slumber.png -------------------------------------------------------------------------------- /tapes/demo.tape: -------------------------------------------------------------------------------- 1 | Output "static/demo.gif" 2 | Set Shell bash 3 | Set Width 1200 4 | Set Height 800 5 | Set FontSize 18 6 | Set Framerate 24 7 | Set Margin 0 8 | Set Padding 10 9 | Set Theme "MaterialDark" 10 | 11 | Hide 12 | Type "cargo run" 13 | Enter 14 | Sleep 2s 15 | Type "l" 16 | Show 17 | 18 | # Send login request 19 | Sleep 1s 20 | Enter 21 | Sleep 0.5s 22 | Type "password" 23 | Sleep 0.5s 24 | Enter 25 | Sleep 2s 26 | Down 2 27 | 28 | # Edit param for get_users 29 | Sleep 1s 30 | Type "c" 31 | Sleep 0.5s 32 | Right 33 | Sleep 0.5s 34 | Type "e" 35 | Sleep 0.5s 36 | Backspace 3 37 | Sleep 0.5s 38 | Type "baz" 39 | Sleep 0.5s 40 | Enter 41 | Sleep 0.5s 42 | 43 | # Send get_users request 44 | Enter 45 | Sleep 0.5s 46 | Down 1 47 | Sleep 0.5s 48 | Enter 49 | Type "r" 50 | Sleep 2s 51 | 52 | # Search response 53 | Type "/jq .headers" 54 | Enter 55 | Sleep 2s 56 | -------------------------------------------------------------------------------- /tapes/editor.tape: -------------------------------------------------------------------------------- 1 | Output "docs/src/images/editor.gif" 2 | Set Shell bash 3 | Set Width 1200 4 | Set Height 800 5 | Set FontSize 18 6 | Set Framerate 24 7 | Set Margin 0 8 | Set Padding 10 9 | Set Theme "MaterialDark" 10 | 11 | Hide 12 | Type "cargo run" 13 | Enter 14 | Sleep 2s 15 | Show 16 | 17 | # Send login request 18 | Sleep 1s 19 | Type "x" 20 | Sleep 1s 21 | Enter 22 | Sleep 2s 23 | Type ":wq" 24 | Enter 25 | Sleep 1s 26 | -------------------------------------------------------------------------------- /tapes/export.tape: -------------------------------------------------------------------------------- 1 | Output "docs/src/images/export.gif" 2 | Set Shell bash 3 | Set Width 1200 4 | Set Height 800 5 | Set FontSize 18 6 | Set Framerate 24 7 | Set Margin 0 8 | Set Padding 10 9 | Set Theme "MaterialDark" 10 | 11 | Hide 12 | Type "cargo run" 13 | Enter 14 | Sleep 2s 15 | 16 | Enter # Send login request 17 | Type "hunter2" 18 | Enter 19 | Sleep 2s 20 | Type "rf" # Fullscreen response pane 21 | Show 22 | 23 | Sleep 1s 24 | Type ":" 25 | Sleep 1s 26 | Type "jq .args > response.json" 27 | Sleep 0.5s 28 | Enter 29 | Sleep 2s 30 | -------------------------------------------------------------------------------- /tapes/query_jq.tape: -------------------------------------------------------------------------------- 1 | Output "docs/src/images/query_jq.gif" 2 | Set Shell bash 3 | Set Width 1200 4 | Set Height 800 5 | Set FontSize 18 6 | Set Framerate 24 7 | Set Margin 0 8 | Set Padding 10 9 | Set Theme "MaterialDark" 10 | 11 | Hide 12 | Type "cargo run" 13 | Enter 14 | Sleep 2s 15 | 16 | Enter # Send login request 17 | Type "hunter2" 18 | Enter 19 | Sleep 2s 20 | Type "rf" # Fullscreen response pane 21 | Show 22 | 23 | Sleep 1s 24 | Type "/" 25 | Sleep 1s 26 | Type "jq .args" 27 | Enter 28 | Sleep 2s 29 | Type "/.fast" 30 | Enter 31 | Sleep 2s 32 | -------------------------------------------------------------------------------- /tapes/query_pipe.tape: -------------------------------------------------------------------------------- 1 | Output "docs/src/images/query_pipe.gif" 2 | Set Shell bash 3 | Set Width 1200 4 | Set Height 800 5 | Set FontSize 18 6 | Set Framerate 24 7 | Set Margin 0 8 | Set Padding 10 9 | Set Theme "MaterialDark" 10 | 11 | Hide 12 | Type "cargo run" 13 | Enter 14 | Sleep 2s 15 | 16 | Enter # Send login request 17 | Type "hunter2" 18 | Enter 19 | Sleep 2s 20 | Type "rf" # Fullscreen response pane 21 | Show 22 | 23 | Type "/" 24 | Sleep 1s 25 | Type "jq .args | head -n 3" 26 | Enter 27 | Sleep 2s 28 | -------------------------------------------------------------------------------- /test_data/insomnia_imported.yml: -------------------------------------------------------------------------------- 1 | # What we expect the Insomnia example collection to import as 2 | profiles: 3 | env_3b607180e18c41228387930058c9ca43: 4 | name: Local 5 | data: 6 | base_field: base 7 | host: http://localhost:3000 8 | greeting: hello! 9 | env_4fb19173966e42898a0a77f45af591c9: 10 | name: Remote 11 | data: 12 | base_field: base 13 | host: https://httpbin.org 14 | greeting: howdy 15 | 16 | chains: 17 | pair_b9dfab38415a4c98a08d99a1d4a35682: 18 | source: !file 19 | path: ./public/slumber.png 20 | 21 | requests: 22 | fld_9a7332db608943b093c929a82c81df50: !folder 23 | name: My Folder 24 | requests: 25 | fld_8077c48f5a89436bbe4b3a53c06471f5: !folder 26 | name: Inner Folder 27 | requests: 28 | req_2ec3dc9ff6774ac78248777e75984831: !request 29 | name: Bearer Auth 30 | method: GET 31 | url: https://httpbin.org/get 32 | body: null 33 | authentication: !bearer " {% response 'body', 'req_3bc2de939f1a4d1ebc00835cbefd6b5d', 'b64::JC5oZWFkZXJzLkhvc3Q=::46b', 'when-expired', 60 %}" 34 | query: {} 35 | headers: {} 36 | 37 | req_b08ee35904784b5f9af598f9b7fd7ca0: !request 38 | name: Digest Auth (Unsupported) 39 | method: GET 40 | url: https://httpbin.org/get 41 | body: null 42 | authentication: null 43 | query: {} 44 | headers: {} 45 | 46 | req_284e0d90f0d647b483f863af5ee79c23: !request 47 | name: Basic Auth 48 | method: GET 49 | url: https://httpbin.org/get 50 | body: null 51 | authentication: !basic 52 | username: user 53 | password: pass 54 | query: {} 55 | headers: {} 56 | 57 | req_814a5e9b63a7482da1d8261311bc6c84: !request 58 | name: No Auth 59 | method: GET 60 | url: https://httpbin.org/get 61 | body: null 62 | authentication: null 63 | query: {} 64 | headers: {} 65 | 66 | req_583c296a600247d6b0c28a0afcefdb89: !request 67 | name: With Text Body 68 | method: POST 69 | url: https://httpbin.org/post 70 | authentication: null 71 | query: {} 72 | headers: 73 | content-type: text/plain 74 | body: "hello!" 75 | 76 | req_1419670d20eb4964956df954e1eb7c4b: !request 77 | name: With JSON Body 78 | method: POST 79 | url: https://httpbin.org/post 80 | authentication: null 81 | query: {} 82 | headers: 83 | # This is redundant with the body type, but it's more effort than 84 | # it's worth to remove it 85 | content-type: application/json 86 | body: !json { "message": "hello!" } 87 | 88 | req_a01b6de924274654bda0835e2a073bd0: !request 89 | name: With Multipart Body 90 | method: POST 91 | url: https://httpbin.org/post 92 | authentication: null 93 | query: {} 94 | headers: 95 | # This is redundant with the body type, but it's more effort than 96 | # it's worth to remove it 97 | content-type: multipart/form-data 98 | body: !form_multipart 99 | username: user 100 | image: "{{chains.pair_b9dfab38415a4c98a08d99a1d4a35682}}" 101 | 102 | req_a345faa530a7453e83ee967d18555712: !request 103 | name: Login 104 | method: POST 105 | url: https://httpbin.org/anything/login 106 | body: !form_urlencoded 107 | username: user 108 | password: pass 109 | authentication: null 110 | query: {} 111 | headers: 112 | content-type: "application/x-www-form-urlencoded" 113 | -------------------------------------------------------------------------------- /test_data/invalid_utf8.bin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LucasPickering/slumber/83ef1b13cc8f94a8662b0edfbecd4d97578ad9b5/test_data/invalid_utf8.bin -------------------------------------------------------------------------------- /test_data/openapiv3_petstore_imported.yml: -------------------------------------------------------------------------------- 1 | profiles: 2 | /v3: 3 | name: /v3 4 | data: 5 | host: /v3 6 | chains: {} 7 | requests: 8 | tag/pet: !folder 9 | name: pet 10 | requests: 11 | addPet: !request 12 | name: Add a new pet to the store 13 | method: POST 14 | url: "{{host}}/pet" 15 | body: null 16 | authentication: null 17 | query: {} 18 | headers: {} 19 | updatePet: !request 20 | name: Update an existing pet 21 | method: PUT 22 | url: "{{host}}/pet" 23 | body: null 24 | authentication: null 25 | query: {} 26 | headers: {} 27 | findPetsByStatus: !request 28 | name: Finds Pets by status 29 | method: GET 30 | url: "{{host}}/pet/findByStatus" 31 | body: null 32 | authentication: null 33 | query: 34 | status: "" 35 | headers: {} 36 | findPetsByTags: !request 37 | name: Finds Pets by tags 38 | method: GET 39 | url: "{{host}}/pet/findByTags" 40 | body: null 41 | authentication: null 42 | query: 43 | tags: "" 44 | headers: {} 45 | deletePet: !request 46 | name: Deletes a pet 47 | method: DELETE 48 | url: "{{host}}/pet/{{petId}}" 49 | body: null 50 | authentication: null 51 | query: {} 52 | headers: 53 | api_key: "" 54 | getPetById: !request 55 | name: Find pet by ID 56 | method: GET 57 | url: "{{host}}/pet/{{petId}}" 58 | body: null 59 | authentication: null 60 | query: {} 61 | headers: 62 | api_key: "{{api_key}}" 63 | updatePetWithForm: !request 64 | name: Updates a pet in the store with form data 65 | method: POST 66 | url: "{{host}}/pet/{{petId}}" 67 | body: null 68 | authentication: null 69 | query: 70 | name: "" 71 | status: "" 72 | headers: {} 73 | uploadFile: !request 74 | name: uploads an image 75 | method: POST 76 | url: "{{host}}/pet/{{petId}}/uploadImage" 77 | body: null 78 | authentication: null 79 | query: 80 | additionalMetadata: "" 81 | headers: {} 82 | tag/store: !folder 83 | name: store 84 | requests: 85 | getInventory: !request 86 | name: Returns pet inventories by status 87 | method: GET 88 | url: "{{host}}/store/inventory" 89 | body: null 90 | authentication: null 91 | query: {} 92 | headers: 93 | api_key: "{{api_key}}" 94 | placeOrder: !request 95 | name: Place an order for a pet 96 | method: POST 97 | url: "{{host}}/store/order" 98 | body: null 99 | authentication: null 100 | query: {} 101 | headers: {} 102 | deleteOrder: !request 103 | name: Delete purchase order by ID 104 | method: DELETE 105 | url: "{{host}}/store/order/{{orderId}}" 106 | body: null 107 | authentication: null 108 | query: {} 109 | headers: {} 110 | getOrderById: !request 111 | name: Find purchase order by ID 112 | method: GET 113 | url: "{{host}}/store/order/{{orderId}}" 114 | body: null 115 | authentication: null 116 | query: {} 117 | headers: {} 118 | tag/user: !folder 119 | name: user 120 | requests: 121 | createUser: !request 122 | name: Create user 123 | method: POST 124 | url: "{{host}}/user" 125 | body: null 126 | authentication: null 127 | query: {} 128 | headers: {} 129 | createUsersWithListInput: !request 130 | name: Creates list of users with given input array 131 | method: POST 132 | url: "{{host}}/user/createWithList" 133 | body: null 134 | authentication: null 135 | query: {} 136 | headers: {} 137 | loginUser: !request 138 | name: Logs user into the system 139 | method: GET 140 | url: "{{host}}/user/login" 141 | body: null 142 | authentication: null 143 | query: 144 | username: "" 145 | password: "" 146 | headers: {} 147 | logoutUser: !request 148 | name: Logs out current logged in user session 149 | method: GET 150 | url: "{{host}}/user/logout" 151 | body: null 152 | authentication: null 153 | query: {} 154 | headers: {} 155 | deleteUser: !request 156 | name: Delete user 157 | method: DELETE 158 | url: "{{host}}/user/{{username}}" 159 | body: null 160 | authentication: null 161 | query: {} 162 | headers: {} 163 | getUserByName: !request 164 | name: Get user by user name 165 | method: GET 166 | url: "{{host}}/user/{{username}}" 167 | body: null 168 | authentication: null 169 | query: {} 170 | headers: {} 171 | updateUser: !request 172 | name: Update user 173 | method: PUT 174 | url: "{{host}}/user/{{username}}" 175 | body: null 176 | authentication: null 177 | query: {} 178 | headers: {} 179 | -------------------------------------------------------------------------------- /test_data/regression.yml: -------------------------------------------------------------------------------- 1 | # This collection contains one of everything. This is a regression test for 2 | # deserialization, to make sure we don't accidentally introduce breaking changes 3 | # to the format 4 | 5 | .ignore: 6 | base_profile_data: &base_profile_data 7 | host: https://httpbin.org 8 | base_recipe: &base_recipe 9 | headers: 10 | Accept: application/json 11 | 12 | profiles: 13 | profile1: 14 | name: Profile 1 15 | data: 16 | <<: *base_profile_data 17 | username: xX{{chains.username}}Xx 18 | user_guid: abc123 19 | profile2: 20 | name: Profile 2 21 | default: true 22 | data: 23 | <<: *base_profile_data 24 | 25 | chains: 26 | command: 27 | source: !command 28 | command: [whoami] 29 | command_stdin: 30 | source: !command 31 | command: [head -c 1] 32 | stdin: abcdef 33 | command_trim_none: 34 | source: !command 35 | command: [whoami] 36 | trim: none 37 | command_trim_start: 38 | source: !command 39 | command: [whoami] 40 | trim: start 41 | command_trim_end: 42 | source: !command 43 | command: [whoami] 44 | trim: end 45 | command_trim_both: 46 | source: !command 47 | command: [whoami] 48 | trim: both 49 | 50 | prompt_sensitive: 51 | source: !prompt 52 | message: Password 53 | sensitive: true 54 | prompt_default: 55 | source: !prompt 56 | message: User GUID 57 | default: "{{user_guid}}" 58 | 59 | file: 60 | source: !file 61 | path: ./README.md 62 | file_content_type: 63 | source: !file 64 | path: ./data.json 65 | content_type: json 66 | 67 | request_selector: 68 | source: !request 69 | recipe: login 70 | selector: $.data 71 | request_trigger_never: 72 | source: !request 73 | recipe: login 74 | request_trigger_no_history: 75 | source: !request 76 | recipe: login 77 | request_trigger_expire: 78 | source: !request 79 | recipe: login 80 | trigger: !expire 12h 81 | request_trigger_always: 82 | source: !request 83 | recipe: login 84 | request_section_body: 85 | source: !request 86 | recipe: login 87 | section: !body 88 | request_section_header: 89 | source: !request 90 | recipe: login 91 | section: !header content-type 92 | 93 | requests: 94 | text_body: !request 95 | method: POST 96 | # Missing name 97 | url: "{{host}}/anything/login" 98 | query: 99 | sudo: yes_please 100 | fast: no_thanks 101 | headers: 102 | Accept: application/json 103 | # Text body 104 | body: '{"username": "{{username}}", "password": "{{chains.password}}"}' 105 | 106 | users: !folder 107 | name: Users 108 | requests: 109 | simple: !request 110 | name: Get User 111 | method: GET 112 | # No headers or authentication 113 | url: "{{host}}/anything/{{user_guid}}" 114 | query: 115 | - value={{field1}} 116 | - value={{field2}} 117 | headers: # Should parse as an empty map 118 | 119 | json_body: !request 120 | <<: *base_recipe 121 | name: Modify User 122 | method: PUT 123 | url: "{{host}}/anything/{{user_guid}}" 124 | authentication: !bearer "{{chains.auth_token}}" 125 | body: !json { "username": "new username" } 126 | query: # Should parse as an empty map 127 | 128 | json_body_but_not: !request 129 | <<: *base_recipe 130 | name: Modify User 131 | method: PUT 132 | url: "{{host}}/anything/{{user_guid}}" 133 | authentication: !basic 134 | username: "{{username}}" 135 | password: "{{password}}" 136 | # Nested JSON is *not* parsed 137 | body: !json '{"warning": "NOT an object"}' 138 | 139 | form_urlencoded_body: !request 140 | <<: *base_recipe 141 | name: Modify User 142 | method: PUT 143 | url: "{{host}}/anything/{{user_guid}}" 144 | body: !form_urlencoded 145 | username: "new username" 146 | -------------------------------------------------------------------------------- /test_data/rest_http_bin.http: -------------------------------------------------------------------------------- 1 | @HOST = http://httpbin.org 2 | ### SimpleGet 3 | 4 | GET {{ HOST}}/get HTTP/1.1 5 | 6 | /// Another comment 7 | 8 | ### 9 | # @name JsonPost 10 | @FIRST=Joe 11 | @LAST=Smith 12 | @FULL={{ FIRST }} {{LAST}} 13 | 14 | POST {{HOST}}/post?hello=123 HTTP/1.1 15 | authorization: Basic Zm9vOmJhcg== 16 | content-type: application/json 17 | X-Http-Method-Override: PUT 18 | 19 | { 20 | "data": "my data", 21 | "name": "{{FULL}}" 22 | } 23 | 24 | 25 | ####### 26 | @ENDPOINT = post 27 | 28 | POST https://httpbin.org/{{ENDPOINT}} HTTP/1.1 29 | authorization: Bearer efaxijasdfjasdfa 30 | content-type: application/x-www-form-urlencoded 31 | my-header: hello 32 | other-header: goodbye 33 | 34 | first={{ FIRST}}&last={{LAST}}&full={{FULL}} 35 | 36 | ### Pet.json 37 | 38 | POST {{HOST}}/post HTTP/1.1 39 | content-type: application/json 40 | 41 | < ./test_data/rest_pets.json 42 | -------------------------------------------------------------------------------- /test_data/rest_imported.yml: -------------------------------------------------------------------------------- 1 | profiles: 2 | http_file: 3 | name: Jetbrains HTTP File 4 | default: true 5 | data: 6 | HOST: http://httpbin.org 7 | FIRST: Joe 8 | LAST: Smith 9 | FULL: "{{FIRST}} {{LAST}}" 10 | ENDPOINT: post 11 | chains: 12 | Pet_json_3_body: 13 | source: !file 14 | path: ./test_data/rest_pets.json 15 | sensitive: false 16 | selector: null 17 | selector_mode: single 18 | content_type: json 19 | trim: none 20 | requests: 21 | SimpleGet_0: !request 22 | name: SimpleGet 23 | method: GET 24 | url: "{{HOST}}/get" 25 | body: null 26 | authentication: null 27 | query: [] 28 | headers: {} 29 | JsonPost_1: !request 30 | name: JsonPost 31 | method: POST 32 | url: "{{HOST}}/post" 33 | body: !json 34 | data: my data 35 | name: "{{FULL}}" 36 | authentication: !basic 37 | username: foo 38 | password: bar 39 | query: 40 | - hello=123 41 | headers: 42 | Content-Type: application/json 43 | X-Http-Method-Override: PUT 44 | Request_2: !request 45 | name: Request 46 | method: POST 47 | url: https://httpbin.org/{{ENDPOINT}} 48 | body: first={{FIRST}}&last={{LAST}}&full={{FULL}} 49 | authentication: !bearer efaxijasdfjasdfa 50 | query: [] 51 | headers: 52 | Content-Type: application/x-www-form-urlencoded 53 | My-Header: hello 54 | Other-Header: goodbye 55 | Pet_json_3: !request 56 | name: Pet.json 57 | method: POST 58 | url: "{{HOST}}/post" 59 | body: "{{chains.Pet_json_3_body}}" 60 | authentication: null 61 | query: [] 62 | headers: 63 | Content-Type: application/json 64 | -------------------------------------------------------------------------------- /test_data/rest_pets.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "my pets", 3 | "animals": ["cat", "dog", "frog"] 4 | } 5 | -------------------------------------------------------------------------------- /tui.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # Run the TUI with watchexec, for development. Normally we could use 4 | # cargo-watch, but it kills the TUI with SIGKILL so it isn't able to clean up 5 | # after itself, which fucks the terminal. Once cargo-watch is updated to the 6 | # latest watchexec we can get rid of this. 7 | # https://github.com/watchexec/cargo-watch/issues/269 8 | 9 | RUST_LOG=${RUST_LOG:-slumber=${LOG:-DEBUG}} RUST_BACKTRACE=1 watchexec --restart --no-process-group \ 10 | --watch Cargo.toml --watch Cargo.lock --watch src/ --watch crates/ \ 11 | -- cargo run --no-default-features --features tui \ 12 | -- $@ 13 | --------------------------------------------------------------------------------