├── .github └── workflows │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── examples └── auto-vary.rs ├── rustfmt.toml └── src ├── auto_vary.rs ├── error.rs ├── extractors.rs ├── guard.rs ├── headers.rs ├── lib.rs ├── responders.rs └── responders ├── location.rs ├── trigger.rs └── vary.rs /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | env: 4 | CARGO_TERM_COLOR: always 5 | 6 | on: 7 | push: 8 | branches: 9 | - main 10 | pull_request: {} 11 | 12 | jobs: 13 | check: 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | toolchain: 18 | # - stable 19 | # - beta 20 | - nightly 21 | steps: 22 | - uses: actions/checkout@v4 23 | - uses: dtolnay/rust-toolchain@master 24 | with: 25 | toolchain: ${{ matrix.toolchain }} 26 | components: clippy, rustfmt 27 | - uses: Swatinem/rust-cache@v2 28 | - name: run clippy 29 | run: cargo clippy --all-features -- -D warnings 30 | - name: run formatter checks 31 | run: cargo fmt --all --check 32 | 33 | test: 34 | needs: check 35 | runs-on: ubuntu-latest 36 | strategy: 37 | matrix: 38 | toolchain: 39 | # - stable 40 | # - beta 41 | - nightly 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: ${{ matrix.toolchain }} 47 | - uses: Swatinem/rust-cache@v2 48 | - name: run tests 49 | run: cargo test --all-features 50 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Directories 2 | .cargo/ 3 | .turbo/ 4 | assets/ 5 | build/ 6 | data/ 7 | dist/ 8 | node_modules/ 9 | public/ 10 | target/ 11 | 12 | # Files 13 | .env 14 | .env.development 15 | .env.production 16 | .log 17 | Cargo.lock 18 | pnpm-lock.yaml 19 | 20 | # User Settings 21 | .idea 22 | .vscode -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## v0.7.0 4 | 5 | - Support axum v0.8. _([@kakalos12](https://github.com/kakalos12))_ 6 | 7 | ## v0.6.0 8 | 9 | - Added support for Vary headers in responses via the `VaryHxRequest`, `VaryHxTarget`, `VaryHxTrigger`, and `VaryHxTriggerName` responders. _([@imbolc](https://github.com/imbolc))_ 10 | - Header names/values are now typed as `HeaderName` and `HeaderValue` instead of `&str`. _([@imbolc](https://github.com/imbolc))_ 11 | - `HxError` now implements source on `error::Error`. _([@imbolc](https://github.com/imbolc))_ 12 | - Added `AutoVaryLayer` middleware to automatically manage `Vary` headers when using corresponding extractors. The middleware is behind the `auto-vary` feature. [See this section of the README for more details.](https://github.com/robertwayne/axum-htmx?tab=readme-ov-file#vary-responders). _([@imbolc](https://github.com/imbolc))_ 13 | 14 | ## v0.5.0 15 | 16 | There are some several breaking changes in this release. Big thanks to [@ItsEthra](https://github.com/ItsEthra) for their work in several PRs! 17 | 18 | - All responders now take an `HxEvent` instead of a `String | HxEvent`. When the `serde` flag is enabled, it will expose additional data fields. 19 | - `HxResponseTrigger` is now a simple struct containing an `TriggerMode` and a `Vec`. There are several methods to make constructing these easier: `HxResponseTrigger::normal`, `HxResponseTrigger::after_settle`, and `HxResponseTrigger::after_swap`. 20 | - The `HxCurrentUrl` extractor now returns an `Option` instead of a `String`. If the Uri cannot be parsed, it will return `None`. 21 | - All Uri-related responders now impl `TryFrom<&str>`. 22 | - `HxError::Serialization` has been renamed to `HxError::Json`. 23 | - The `HxResponseTrigger*` header will not be added to the response if the event list is empty. 24 | - Added feature flag badges and made additional updates to the docs.rs pages. 25 | - Reduced dependency count / compile time by swapping `axum` out for the `axum-core`, `async-trait`, and `http` crates. 26 | 27 | ## v0.4.0 28 | 29 | - Added support for all [htmx response headers](https://htmx.org/reference/#response_headers) via a type implementing `IntoResponseParts`. These "responders" allow you to simply and safely apply the HX-* headers to any of your responses. Thanks to [@pfz4](https://github.com/pfz4) for the implementation work! ([#5](https://github.com/robertwayne/axum-htmx/pull/5)) 30 | 31 | ## v0.3.1 32 | 33 | - Rebuild docs with features enabled so `HxRequestGuardLayer` is visible on docs.rs. 34 | 35 | ## v0.3.0 36 | 37 | - `HxRequestGuardLayer` now redirects on failures instead of returning a 403\. By default, it will redirect to "/", but you can specify a different route to redirect to with `HxRequestGuardLayer::new("/your-route-here")`. 38 | 39 | ## v0.2.0 40 | 41 | - Added `HxRequestGuardLayer`, allowing you to protect an entire router from non-htmx requests. 42 | 43 | ## v0.1.0 44 | 45 | - Initial release. 46 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "axum-htmx" 3 | authors = ["Rob Wagner "] 4 | license = "MIT OR Apache-2.0" 5 | description = "A set of htmx extractors, responders, and request guards for axum." 6 | repository = "https://github.com/robertwayne/axum-htmx" 7 | categories = ["web-programming"] 8 | keywords = ["axum", "htmx"] 9 | readme = "README.md" 10 | version = "0.7.0" 11 | edition = "2021" 12 | 13 | [features] 14 | default = [] 15 | unstable = [] 16 | guards = ["tower", "futures-core", "pin-project-lite"] 17 | serde = ["dep:serde", "dep:serde_json"] 18 | auto-vary = ["futures", "tokio", "tower"] 19 | 20 | [dependencies] 21 | axum-core = "0.5" 22 | http = { version = "1", default-features = false } 23 | 24 | # Optional dependencies required for the `guards` feature. 25 | tower = { version = "0.5", default-features = false, optional = true } 26 | futures-core = { version = "0.3", optional = true } 27 | pin-project-lite = { version = "0.2", optional = true } 28 | 29 | # Optional dependencies required for the `serde` feature. 30 | serde = { version = "1", features = ["derive"], optional = true } 31 | serde_json = { version = "1", optional = true } 32 | 33 | # Optional dependencies required for the `auto-vary` feature. 34 | tokio = { version = "1", features = ["sync"], optional = true } 35 | futures = { version = "0.3", default-features = false, features = [ 36 | "alloc", 37 | ], optional = true } 38 | 39 | [dev-dependencies] 40 | axum = { version = "0.8", default-features = false } 41 | axum-test = "17" 42 | tokio = { version = "1", features = ["full"] } 43 | tokio-test = "0.4" 44 | 45 | [package.metadata.docs.rs] 46 | all-features = true 47 | -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. 10 | 11 | "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. 12 | 13 | "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. 14 | 15 | "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. 16 | 17 | "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. 18 | 19 | "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. 20 | 21 | "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). 22 | 23 | "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. 24 | 25 | "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." 26 | 27 | "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 28 | 29 | 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 30 | 31 | 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 32 | 33 | 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: 34 | 35 | You must give any other recipients of the Work or Derivative Works a copy of this License; and 36 | You must cause any modified files to carry prominent notices stating that You changed the files; and 37 | You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and 38 | If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. 39 | 40 | You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 41 | 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 42 | 43 | 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 44 | 45 | 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 46 | 47 | 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 48 | 49 | 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. 50 | 51 | END OF TERMS AND CONDITIONS -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | Copyright 2023 Rob Wagner 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 | # axum-htmx 2 | 3 | 4 |
5 | 6 | crates.io badge 7 | 8 | 9 | docs.rs badge 10 | 11 |
12 |
13 | 14 | 15 | `axum-htmx` is a small extension library providing extractors, responders, and 16 | request guards for [htmx](https://htmx.org/) headers within 17 | [axum](https://github.com/tokio-rs/axum). 18 | 19 | ## Table of Contents 20 | 21 | - [Getting Started](#getting-started) 22 | - [Extractors](#extractors) 23 | - [Responders](#responders) 24 | - [Vary Responders](#vary-responders) 25 | - [Auto Caching Management](#auto-caching-management) 26 | - [Request Guards](#request-guards) 27 | - [Examples](#examples) 28 | - [Example: Extractors](#example-extractors) 29 | - [Example: Responders](#example-responders) 30 | - [Example: Router Guard](#example-router-guard) 31 | - [Feature Flags](#feature-flags) 32 | - [Contributing](#contributing) 33 | - [Testing](#testing) 34 | - [License](#license) 35 | 36 | ## Getting Started 37 | 38 | Run `cargo add axum-htmx` to add the library to your project. 39 | 40 | ## Extractors 41 | 42 | All of the [htmx request headers](https://htmx.org/reference/#request_headers) 43 | have a supported extractor. Extractors are infallible, meaning they will always 44 | succeed and never return an error. In the case where a header is not present, 45 | the extractor will return `None` or `false` dependant on the expected return 46 | type. 47 | 48 | | Header | Extractor | Value | 49 | |------------------------------|---------------------------|---------------------------| 50 | | `HX-Boosted` | `HxBoosted` | `bool` | 51 | | `HX-Current-URL` | `HxCurrentUrl` | `Option` | 52 | | `HX-History-Restore-Request` | `HxHistoryRestoreRequest` | `bool` | 53 | | `HX-Prompt` | `HxPrompt` | `Option` | 54 | | `HX-Request` | `HxRequest` | `bool` | 55 | | `HX-Target` | `HxTarget` | `Option` | 56 | | `HX-Trigger-Name` | `HxTriggerName` | `Option` | 57 | | `HX-Trigger` | `HxTrigger` | `Option` | 58 | 59 | ## Responders 60 | 61 | All of the [htmx response headers](https://htmx.org/reference/#response_headers) 62 | have a supported responder. A responder is a basic type that implements 63 | `IntoResponseParts`, allowing you to simply and safely apply the HX-* headers to 64 | any of your responses. 65 | 66 | | Header | Responder | Value | 67 | |---------------------------|---------------------|-------------------------------------| 68 | | `HX-Location` | `HxLocation` | `axum::http::Uri` | 69 | | `HX-Push-Url` | `HxPushUrl` | `axum::http::Uri` | 70 | | `HX-Redirect` | `HxRedirect` | `axum::http::Uri` | 71 | | `HX-Refresh` | `HxRefresh` | `bool` | 72 | | `HX-Replace-Url` | `HxReplaceUrl` | `axum::http::Uri` | 73 | | `HX-Reswap` | `HxReswap` | `axum_htmx::responders::SwapOption` | 74 | | `HX-Retarget` | `HxRetarget` | `String` | 75 | | `HX-Reselect` | `HxReselect` | `String` | 76 | | `HX-Trigger` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | 77 | | `HX-Trigger-After-Settle` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | 78 | | `HX-Trigger-After-Swap` | `HxResponseTrigger` | `axum_htmx::serde::HxEvent` | 79 | 80 | ### Vary Responders 81 | 82 | Also, there are corresponding cache-related headers, which you may want to add to 83 | `GET` responses, depending on the htmx headers. 84 | 85 | _For example, if your server renders the full HTML when the `HX-Request` header is 86 | missing or `false`, and it renders a fragment of that HTML when `HX-Request: true`, 87 | you need to add `Vary: HX-Request`. That causes the cache to be keyed based on a 88 | composite of the response URL and the `HX-Request` request header - rather than 89 | being based just on the response URL._ 90 | 91 | Refer to [caching htmx docs section][htmx-caching] for details. 92 | 93 | | Header | Responder | 94 | |-------------------------|---------------------| 95 | | `Vary: HX-Request` | `VaryHxRequest` | 96 | | `Vary: HX-Target` | `VaryHxTarget` | 97 | | `Vary: HX-Trigger` | `VaryHxTrigger` | 98 | | `Vary: HX-Trigger-Name` | `VaryHxTriggerName` | 99 | 100 | Look at the [Auto Caching Management](#auto-caching-management) section for 101 | automatic `Vary` headers management. 102 | 103 | ## Auto Caching Management 104 | 105 | __Requires feature `auto-vary`.__ 106 | 107 | Manual use of [Vary Reponders](#vary-responders) adds fragility to the code, 108 | because of the need to manually control correspondence between used extractors 109 | and the responders. 110 | 111 | We provide a [middleware](crate::AutoVaryLayer) to address this issue by 112 | automatically adding `Vary` headers when corresponding extractors are used. 113 | For example, on extracting [`HxRequest`], the middleware automatically adds 114 | `Vary: hx-request` header to the response. 115 | 116 | Look at the usage [example][auto-vary-example]. 117 | 118 | ## Request Guards 119 | 120 | __Requires feature `guards`.__ 121 | 122 | In addition to the extractors, there is also a route-wide layer request guard 123 | for the `HX-Request` header. This will redirect any requests without the header 124 | to "/" by default. 125 | 126 | _It should be noted that this is NOT a replacement for an auth guard. A user can 127 | trivially set the `HX-Request` header themselves. This is merely a convenience 128 | for preventing users from receiving partial responses without context. If you 129 | need to secure an endpoint you should be using a proper auth system._ 130 | 131 | ## Examples 132 | 133 | ### Example: Extractors 134 | 135 | In this example, we'll look for the `HX-Boosted` header, which is set when 136 | applying the [hx-boost](https://htmx.org/attributes/hx-boost/) attribute to an 137 | element. In our case, we'll use it to determine what kind of response we send. 138 | 139 | When is this useful? When using a templating engine, like 140 | [minijinja](https://github.com/mitsuhiko/minijinja), it is common to extend 141 | different templates from a `_base.html` template. However, htmx works by sending 142 | partial responses, so extending our `_base.html` would result in lots of extra 143 | data being sent over the wire. 144 | 145 | If we wanted to swap between pages, we would need to support both full template 146 | responses and partial responses _(as the page can be accessed directly or 147 | through a boosted anchor)_, so we look for the `HX-Boosted` header and extend 148 | from a `_partial.html` template instead. 149 | 150 | ```rust 151 | use axum::response::IntoResponse; 152 | use axum_htmx::HxBoosted; 153 | 154 | async fn get_index(HxBoosted(boosted): HxBoosted) -> impl IntoResponse { 155 | if boosted { 156 | // Send a template extending from _partial.html 157 | } else { 158 | // Send a template extending from _base.html 159 | } 160 | } 161 | ``` 162 | 163 | ### Example: Responders 164 | 165 | We can trigger any event being listened to by the DOM using an [htmx 166 | trigger](https://htmx.org/attributes/hx-trigger/) header. 167 | 168 | ```rust 169 | use axum_htmx::HxResponseTrigger; 170 | 171 | // When we load our page, we will trigger any event listeners for "my-event. 172 | async fn index() -> (HxResponseTrigger, &'static str) { 173 | // Note: As HxResponseTrigger only implements `IntoResponseParts`, we must 174 | // return our trigger first here. 175 | ( 176 | HxResponseTrigger::normal(["my-event", "second-event"]), 177 | "Hello, world!", 178 | ) 179 | } 180 | ``` 181 | 182 | `htmx` also allows arbitrary data to be sent along with the event, which we can 183 | use via the `serde` feature flag and the `HxEvent` type. 184 | 185 | ```rust 186 | use serde_json::json; 187 | 188 | // Note that we are using `HxResponseTrigger` from the `axum_htmx::serde` module 189 | // instead of the root module. 190 | use axum_htmx::{HxEvent, HxResponseTrigger}; 191 | 192 | async fn index() -> (HxResponseTrigger, &'static str) { 193 | let event = HxEvent::new_with_data( 194 | "my-event", 195 | // May be any object that implements `serde::Serialize` 196 | json!({"level": "info", "message": { 197 | "title": "Hello, world!", 198 | "body": "This is a test message.", 199 | }}), 200 | ) 201 | .unwrap(); 202 | 203 | // Note: As HxResponseTrigger only implements `IntoResponseParts`, we must 204 | // return our trigger first here. 205 | (HxResponseTrigger::normal([event]), "Hello, world!") 206 | } 207 | ``` 208 | 209 | ### Example: Router Guard 210 | 211 | ```rust 212 | use axum::Router; 213 | use axum_htmx::HxRequestGuardLayer; 214 | 215 | fn router_one() -> Router { 216 | Router::new() 217 | // Redirects to "/" if the HX-Request header is not present 218 | .layer(HxRequestGuardLayer::default()) 219 | } 220 | 221 | fn router_two() -> Router { 222 | Router::new() 223 | .layer(HxRequestGuardLayer::new("/redirect-to-this-route")) 224 | } 225 | ``` 226 | 227 | ## Feature Flags 228 | 229 | 230 | | Flag | Default | Description | Dependencies | 231 | |-------------|----------|------------------------------------------------------------|---------------------------------------------| 232 | | `auto-vary` | Disabled | A middleware to address [htmx caching issue][htmx-caching] | `futures`, `tokio`, `tower` | 233 | | `guards` | Disabled | Adds request guard layers. | `tower`, `futures-core`, `pin-project-lite` | 234 | | `serde` | Disabled | Adds serde support for the `HxEvent` and `LocationOptions` | `serde`, `serde_json` | 235 | 236 | 237 | ## Contributing 238 | 239 | Contributions are always welcome! If you have an idea for a feature or find a 240 | bug, let me know. PR's are appreciated, but if it's not a small change, please 241 | open an issue first so we're all on the same page! 242 | 243 | ### Testing 244 | 245 | ```sh 246 | cargo +nightly test --all-features 247 | ``` 248 | 249 | ## License 250 | 251 | `axum-htmx` is dual-licensed under either 252 | 253 | - **[MIT License](/LICENSE-MIT)** 254 | - **[Apache License, Version 2.0](/LICENSE-APACHE)** 255 | 256 | at your option. 257 | 258 | [htmx-caching]: https://htmx.org/docs/#caching 259 | [auto-vary-example]: https://github.com/robertwayne/axum-htmx/blob/main/examples/auto-vary.rs 260 | -------------------------------------------------------------------------------- /examples/auto-vary.rs: -------------------------------------------------------------------------------- 1 | //! Using `auto-vary` middleware 2 | //! 3 | //! Don't forget about the feature while running it: 4 | //! `cargo run --features auto-vary --example auto-vary` 5 | use std::time::Duration; 6 | 7 | use axum::{response::Html, routing::get, serve, Router}; 8 | use axum_htmx::{AutoVaryLayer, HxRequest}; 9 | use tokio::{net::TcpListener, time::sleep}; 10 | 11 | #[tokio::main] 12 | async fn main() { 13 | let app = Router::new() 14 | .route("/", get(handler)) 15 | // Add the middleware 16 | .layer(AutoVaryLayer); 17 | 18 | let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap(); 19 | serve(listener, app).await.unwrap(); 20 | } 21 | 22 | // Our handler differentiates full-page GET requests from htmx-based ones by looking at the `hx-request` 23 | // requestheader. 24 | // 25 | // The middleware sees the usage of the `HxRequest` extractor and automatically adds the 26 | // `Vary: hx-request` response header. 27 | async fn handler(HxRequest(hx_request): HxRequest) -> Html<&'static str> { 28 | if hx_request { 29 | // For htmx-based GET request, it returns a partial page update 30 | sleep(Duration::from_secs(3)).await; 31 | return Html("htmx response"); 32 | } 33 | // While for a normal GET request, it returns the whole page 34 | Html( 35 | r#" 36 | 37 |

Loading ...

38 | "#, 39 | ) 40 | } 41 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | group_imports = "StdExternalCrate" 2 | imports_granularity = "Crate" 3 | reorder_imports = true 4 | -------------------------------------------------------------------------------- /src/auto_vary.rs: -------------------------------------------------------------------------------- 1 | //! A middleware to automatically add a `Vary` header when needed to address 2 | //! [htmx caching issue](https://htmx.org/docs/#caching) 3 | 4 | use std::{ 5 | sync::Arc, 6 | task::{Context, Poll}, 7 | }; 8 | 9 | use axum_core::{ 10 | extract::Request, 11 | response::{IntoResponse, Response}, 12 | }; 13 | use futures::future::{join_all, BoxFuture}; 14 | use http::{ 15 | header::{HeaderValue, VARY}, 16 | Extensions, 17 | }; 18 | use tokio::sync::oneshot::{self, Receiver, Sender}; 19 | use tower::{Layer, Service}; 20 | 21 | use crate::{ 22 | headers::{HX_REQUEST_STR, HX_TARGET_STR, HX_TRIGGER_NAME_STR, HX_TRIGGER_STR}, 23 | HxError, 24 | }; 25 | #[cfg(doc)] 26 | use crate::{HxRequest, HxTarget, HxTrigger, HxTriggerName}; 27 | 28 | const MIDDLEWARE_DOUBLE_USE: &str = 29 | "Configuration error: `axum_httpx::vary_middleware` is used twice"; 30 | 31 | /// Addresses [htmx caching issues](https://htmx.org/docs/#caching) 32 | /// by automatically adding a corresponding `Vary` header when 33 | /// [`HxRequest`], [`HxTarget`], [`HxTrigger`], [`HxTriggerName`] 34 | /// or their combination is used. 35 | #[derive(Clone)] 36 | pub struct AutoVaryLayer; 37 | 38 | /// Tower service for [`AutoVaryLayer`] 39 | #[derive(Clone)] 40 | pub struct AutoVaryMiddleware { 41 | inner: S, 42 | } 43 | 44 | pub(crate) trait Notifier { 45 | fn sender(&mut self) -> Option>; 46 | 47 | fn notify(&mut self) { 48 | if let Some(sender) = self.sender() { 49 | sender.send(()).ok(); 50 | } 51 | } 52 | 53 | fn insert(extensions: &mut Extensions) -> Receiver<()>; 54 | } 55 | 56 | macro_rules! define_notifiers { 57 | ($($name:ident),*) => { 58 | $( 59 | #[derive(Clone)] 60 | pub(crate) struct $name(Option>>); 61 | 62 | impl Notifier for $name { 63 | fn sender(&mut self) -> Option> { 64 | self.0.take().and_then(Arc::into_inner) 65 | } 66 | 67 | fn insert(extensions: &mut Extensions) -> Receiver<()> { 68 | let (tx, rx) = oneshot::channel(); 69 | if extensions.insert(Self(Some(Arc::new(tx)))).is_some() { 70 | panic!("{}", MIDDLEWARE_DOUBLE_USE); 71 | } 72 | rx 73 | } 74 | } 75 | )* 76 | } 77 | } 78 | 79 | define_notifiers!( 80 | HxRequestExtracted, 81 | HxTargetExtracted, 82 | HxTriggerExtracted, 83 | HxTriggerNameExtracted 84 | ); 85 | 86 | impl Layer for AutoVaryLayer { 87 | type Service = AutoVaryMiddleware; 88 | 89 | fn layer(&self, inner: S) -> Self::Service { 90 | AutoVaryMiddleware { inner } 91 | } 92 | } 93 | 94 | impl Service for AutoVaryMiddleware 95 | where 96 | S: Service + Send + 'static, 97 | S::Future: Send + 'static, 98 | { 99 | type Response = S::Response; 100 | type Error = S::Error; 101 | type Future = BoxFuture<'static, Result>; 102 | 103 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 104 | self.inner.poll_ready(cx) 105 | } 106 | 107 | fn call(&mut self, mut request: Request) -> Self::Future { 108 | let exts = request.extensions_mut(); 109 | let rx_header = [ 110 | (HxRequestExtracted::insert(exts), HX_REQUEST_STR), 111 | (HxTargetExtracted::insert(exts), HX_TARGET_STR), 112 | (HxTriggerExtracted::insert(exts), HX_TRIGGER_STR), 113 | (HxTriggerNameExtracted::insert(exts), HX_TRIGGER_NAME_STR), 114 | ]; 115 | let future = self.inner.call(request); 116 | Box::pin(async move { 117 | let mut response: Response = future.await?; 118 | let used_headers: Vec<_> = join_all( 119 | rx_header 120 | .into_iter() 121 | .map(|(rx, header)| async move { rx.await.ok().map(|_| header) }), 122 | ) 123 | .await 124 | .into_iter() 125 | .flatten() 126 | .collect(); 127 | 128 | if used_headers.is_empty() { 129 | return Ok(response); 130 | } 131 | 132 | let value = match HeaderValue::from_str(&used_headers.join(", ")) { 133 | Ok(x) => x, 134 | Err(e) => return Ok(HxError::from(e).into_response()), 135 | }; 136 | 137 | if let Err(e) = response.headers_mut().try_append(VARY, value) { 138 | return Ok(HxError::from(e).into_response()); 139 | } 140 | 141 | Ok(response) 142 | }) 143 | } 144 | } 145 | 146 | #[cfg(test)] 147 | mod tests { 148 | use axum::{routing::get, Router}; 149 | 150 | use super::*; 151 | use crate::{HxRequest, HxTarget, HxTrigger, HxTriggerName}; 152 | 153 | fn vary_headers(resp: &axum_test::TestResponse) -> Vec { 154 | resp.iter_headers_by_name("vary").cloned().collect() 155 | } 156 | 157 | fn server() -> axum_test::TestServer { 158 | let app = Router::new() 159 | .route("/no-extractors", get(|| async { () })) 160 | .route("/hx-request", get(|_: HxRequest| async { () })) 161 | .route("/hx-target", get(|_: HxTarget| async { () })) 162 | .route("/hx-trigger", get(|_: HxTrigger| async { () })) 163 | .route("/hx-trigger-name", get(|_: HxTriggerName| async { () })) 164 | .route( 165 | "/repeated-extractor", 166 | get(|_: HxRequest, _: HxRequest| async { () }), 167 | ) 168 | .route( 169 | "/multiple-extractors", 170 | get(|_: HxRequest, _: HxTarget, _: HxTrigger, _: HxTriggerName| async { () }), 171 | ) 172 | .layer(AutoVaryLayer); 173 | axum_test::TestServer::new(app).unwrap() 174 | } 175 | 176 | #[tokio::test] 177 | async fn no_extractors() { 178 | assert!(vary_headers(&server().get("/no-extractors").await).is_empty()); 179 | } 180 | 181 | #[tokio::test] 182 | async fn single_hx_request() { 183 | assert_eq!( 184 | vary_headers(&server().get("/hx-request").await), 185 | ["hx-request"] 186 | ); 187 | } 188 | 189 | #[tokio::test] 190 | async fn single_hx_target() { 191 | assert_eq!( 192 | vary_headers(&server().get("/hx-target").await), 193 | ["hx-target"] 194 | ); 195 | } 196 | 197 | #[tokio::test] 198 | async fn single_hx_trigger() { 199 | assert_eq!( 200 | vary_headers(&server().get("/hx-trigger").await), 201 | ["hx-trigger"] 202 | ); 203 | } 204 | 205 | #[tokio::test] 206 | async fn single_hx_trigger_name() { 207 | assert_eq!( 208 | vary_headers(&server().get("/hx-trigger-name").await), 209 | ["hx-trigger-name"] 210 | ); 211 | } 212 | 213 | #[tokio::test] 214 | async fn repeated_extractor() { 215 | assert_eq!( 216 | vary_headers(&server().get("/repeated-extractor").await), 217 | ["hx-request"] 218 | ); 219 | } 220 | 221 | // Extractors can be used multiple times e.g. in middlewares 222 | #[tokio::test] 223 | async fn multiple_extractors() { 224 | assert_eq!( 225 | vary_headers(&server().get("/multiple-extractors").await), 226 | ["hx-request, hx-target, hx-trigger, hx-trigger-name"], 227 | ); 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | use std::{error, fmt}; 2 | 3 | use axum_core::response::IntoResponse; 4 | use http::{ 5 | header::{InvalidHeaderValue, MaxSizeReached}, 6 | StatusCode, 7 | }; 8 | 9 | #[derive(Debug)] 10 | pub enum HxError { 11 | InvalidHeaderValue(InvalidHeaderValue), 12 | TooManyResponseHeaders(MaxSizeReached), 13 | 14 | #[cfg(feature = "serde")] 15 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 16 | Json(serde_json::Error), 17 | } 18 | 19 | impl From for HxError { 20 | fn from(value: InvalidHeaderValue) -> Self { 21 | Self::InvalidHeaderValue(value) 22 | } 23 | } 24 | 25 | impl From for HxError { 26 | fn from(value: MaxSizeReached) -> Self { 27 | Self::TooManyResponseHeaders(value) 28 | } 29 | } 30 | 31 | #[cfg(feature = "serde")] 32 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 33 | impl From for HxError { 34 | fn from(value: serde_json::Error) -> Self { 35 | Self::Json(value) 36 | } 37 | } 38 | 39 | impl fmt::Display for HxError { 40 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 41 | match self { 42 | HxError::InvalidHeaderValue(_) => write!(f, "Invalid header value"), 43 | HxError::TooManyResponseHeaders(_) => write!(f, "Too many response headers"), 44 | #[cfg(feature = "serde")] 45 | HxError::Json(_) => write!(f, "Json"), 46 | } 47 | } 48 | } 49 | 50 | impl error::Error for HxError { 51 | fn source(&self) -> Option<&(dyn error::Error + 'static)> { 52 | match self { 53 | HxError::InvalidHeaderValue(ref e) => Some(e), 54 | HxError::TooManyResponseHeaders(ref e) => Some(e), 55 | #[cfg(feature = "serde")] 56 | HxError::Json(ref e) => Some(e), 57 | } 58 | } 59 | } 60 | 61 | impl IntoResponse for HxError { 62 | fn into_response(self) -> axum_core::response::Response { 63 | (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()).into_response() 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/extractors.rs: -------------------------------------------------------------------------------- 1 | //! Axum extractors for htmx request headers. 2 | 3 | use axum_core::extract::FromRequestParts; 4 | use http::request::Parts; 5 | 6 | use crate::{ 7 | HX_BOOSTED, HX_CURRENT_URL, HX_HISTORY_RESTORE_REQUEST, HX_PROMPT, HX_REQUEST, HX_TARGET, 8 | HX_TRIGGER, HX_TRIGGER_NAME, 9 | }; 10 | 11 | /// The `HX-Boosted` header. 12 | /// 13 | /// This is set when a request is made from an element where its parent has the 14 | /// `hx-boost` attribute set to `true`. 15 | /// 16 | /// This extractor will always return a value. If the header is not present, it 17 | /// will return `false`. 18 | /// 19 | /// See for more information. 20 | #[derive(Debug, Clone, Copy)] 21 | pub struct HxBoosted(pub bool); 22 | 23 | impl FromRequestParts for HxBoosted 24 | where 25 | S: Send + Sync, 26 | { 27 | type Rejection = std::convert::Infallible; 28 | 29 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 30 | if parts.headers.contains_key(HX_BOOSTED) { 31 | Ok(HxBoosted(true)) 32 | } else { 33 | Ok(HxBoosted(false)) 34 | } 35 | } 36 | } 37 | 38 | /// The `HX-Current-Url` header. 39 | /// 40 | /// This is set on every request made by htmx itself. As its name implies, it 41 | /// just contains the current url. 42 | /// 43 | /// This extractor will always return a value. If the header is not present, or 44 | /// extractor fails to parse the url it will return `None`. 45 | #[derive(Debug, Clone)] 46 | pub struct HxCurrentUrl(pub Option); 47 | 48 | impl FromRequestParts for HxCurrentUrl 49 | where 50 | S: Send + Sync, 51 | { 52 | type Rejection = std::convert::Infallible; 53 | 54 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 55 | if let Some(url) = parts.headers.get(HX_CURRENT_URL) { 56 | let url = url 57 | .to_str() 58 | .ok() 59 | .and_then(|url| url.parse::().ok()); 60 | 61 | return Ok(HxCurrentUrl(url)); 62 | } 63 | 64 | Ok(HxCurrentUrl(None)) 65 | } 66 | } 67 | 68 | /// The `HX-History-Restore-Request` header. 69 | /// 70 | /// This extractor will always return a value. If the header is not present, it 71 | /// will return `false`. 72 | #[derive(Debug, Clone, Copy)] 73 | pub struct HxHistoryRestoreRequest(pub bool); 74 | 75 | impl FromRequestParts for HxHistoryRestoreRequest 76 | where 77 | S: Send + Sync, 78 | { 79 | type Rejection = std::convert::Infallible; 80 | 81 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 82 | if parts.headers.contains_key(HX_HISTORY_RESTORE_REQUEST) { 83 | Ok(HxHistoryRestoreRequest(true)) 84 | } else { 85 | Ok(HxHistoryRestoreRequest(false)) 86 | } 87 | } 88 | } 89 | 90 | /// The `HX-Prompt` header. 91 | /// 92 | /// This is set when a request is made from an element that has the `hx-prompt` 93 | /// attribute set. The value will contain the string input by the user. 94 | /// 95 | /// This extractor will always return a value. If the header is not present, it 96 | /// will return `None`. 97 | #[derive(Debug, Clone)] 98 | pub struct HxPrompt(pub Option); 99 | 100 | impl FromRequestParts for HxPrompt 101 | where 102 | S: Send + Sync, 103 | { 104 | type Rejection = std::convert::Infallible; 105 | 106 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 107 | if let Some(prompt) = parts.headers.get(HX_PROMPT) { 108 | if let Ok(prompt) = prompt.to_str() { 109 | return Ok(HxPrompt(Some(prompt.to_string()))); 110 | } 111 | } 112 | 113 | Ok(HxPrompt(None)) 114 | } 115 | } 116 | 117 | /// The `HX-Request` header. 118 | /// 119 | /// This is set on every request made by htmx itself. It won't be present on 120 | /// requests made manually, or by other libraries. 121 | /// 122 | /// This extractor will always return a value. If the header is not present, it 123 | /// will return `false`. 124 | #[derive(Debug, Clone, Copy)] 125 | pub struct HxRequest(pub bool); 126 | 127 | impl FromRequestParts for HxRequest 128 | where 129 | S: Send + Sync, 130 | { 131 | type Rejection = std::convert::Infallible; 132 | 133 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 134 | #[cfg(feature = "auto-vary")] 135 | parts 136 | .extensions 137 | .get_mut::() 138 | .map(crate::auto_vary::Notifier::notify); 139 | 140 | if parts.headers.contains_key(HX_REQUEST) { 141 | Ok(HxRequest(true)) 142 | } else { 143 | Ok(HxRequest(false)) 144 | } 145 | } 146 | } 147 | 148 | /// The `HX-Target` header. 149 | /// 150 | /// This is set when a request is made from an element that has the `hx-target` 151 | /// attribute set. The value will contain the target element's id. If the id 152 | /// does not exist on the page, the value will be None. 153 | /// 154 | /// This extractor will always return a value. If the header is not present, it 155 | /// will return `None`. 156 | #[derive(Debug, Clone)] 157 | pub struct HxTarget(pub Option); 158 | 159 | impl FromRequestParts for HxTarget 160 | where 161 | S: Send + Sync, 162 | { 163 | type Rejection = std::convert::Infallible; 164 | 165 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 166 | #[cfg(feature = "auto-vary")] 167 | parts 168 | .extensions 169 | .get_mut::() 170 | .map(crate::auto_vary::Notifier::notify); 171 | 172 | if let Some(target) = parts.headers.get(HX_TARGET) { 173 | if let Ok(target) = target.to_str() { 174 | return Ok(HxTarget(Some(target.to_string()))); 175 | } 176 | } 177 | 178 | Ok(HxTarget(None)) 179 | } 180 | } 181 | 182 | /// The `HX-Trigger-Name` header. 183 | /// 184 | /// This is set when a request is made from an element that has the `hx-trigger` 185 | /// attribute set. The value will contain the trigger element's name. If the 186 | /// name does not exist on the page, the value will be None. 187 | /// 188 | /// This extractor will always return a value. If the header is not present, it 189 | /// will return `None`. 190 | #[derive(Debug, Clone)] 191 | pub struct HxTriggerName(pub Option); 192 | 193 | impl FromRequestParts for HxTriggerName 194 | where 195 | S: Send + Sync, 196 | { 197 | type Rejection = std::convert::Infallible; 198 | 199 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 200 | #[cfg(feature = "auto-vary")] 201 | parts 202 | .extensions 203 | .get_mut::() 204 | .map(crate::auto_vary::Notifier::notify); 205 | 206 | if let Some(trigger_name) = parts.headers.get(HX_TRIGGER_NAME) { 207 | if let Ok(trigger_name) = trigger_name.to_str() { 208 | return Ok(HxTriggerName(Some(trigger_name.to_string()))); 209 | } 210 | } 211 | 212 | Ok(HxTriggerName(None)) 213 | } 214 | } 215 | 216 | /// The `HX-Trigger` header. 217 | /// 218 | /// This is set when a request is made from an element that has the `hx-trigger` 219 | /// attribute set. The value will contain the trigger element's id. If the id 220 | /// does not exist on the page, the value will be None. 221 | /// 222 | /// This extractor will always return a value. If the header is not present, it 223 | /// will return `None`. 224 | #[derive(Debug, Clone)] 225 | pub struct HxTrigger(pub Option); 226 | 227 | impl FromRequestParts for HxTrigger 228 | where 229 | S: Send + Sync, 230 | { 231 | type Rejection = std::convert::Infallible; 232 | 233 | async fn from_request_parts(parts: &mut Parts, _: &S) -> Result { 234 | #[cfg(feature = "auto-vary")] 235 | parts 236 | .extensions 237 | .get_mut::() 238 | .map(crate::auto_vary::Notifier::notify); 239 | 240 | if let Some(trigger) = parts.headers.get(HX_TRIGGER) { 241 | if let Ok(trigger) = trigger.to_str() { 242 | return Ok(HxTrigger(Some(trigger.to_string()))); 243 | } 244 | } 245 | 246 | Ok(HxTrigger(None)) 247 | } 248 | } 249 | -------------------------------------------------------------------------------- /src/guard.rs: -------------------------------------------------------------------------------- 1 | //! Request guard for protecting a router against non-htmx requests. 2 | 3 | use std::{ 4 | future::Future, 5 | pin::Pin, 6 | task::{Context, Poll}, 7 | }; 8 | 9 | use futures_core::ready; 10 | use http::{header::LOCATION, response::Response, Request, StatusCode}; 11 | use pin_project_lite::pin_project; 12 | use tower::{Layer, Service}; 13 | 14 | use crate::HX_REQUEST; 15 | 16 | /// Checks if the request contains the `HX-Request` header, redirecting to the 17 | /// given location if not. 18 | /// 19 | /// This can be useful for preventing users from accidently ending up on a route 20 | /// which would otherwise return only partial HTML data. 21 | #[derive(Debug, Clone)] 22 | pub struct HxRequestGuardLayer<'a> { 23 | redirect_to: &'a str, 24 | } 25 | 26 | impl<'a> HxRequestGuardLayer<'a> { 27 | pub fn new(redirect_to: &'a str) -> Self { 28 | Self { redirect_to } 29 | } 30 | } 31 | 32 | impl Default for HxRequestGuardLayer<'_> { 33 | fn default() -> Self { 34 | Self { redirect_to: "/" } 35 | } 36 | } 37 | 38 | impl<'a, S> Layer for HxRequestGuardLayer<'a> { 39 | type Service = HxRequestGuard<'a, S>; 40 | 41 | fn layer(&self, inner: S) -> Self::Service { 42 | HxRequestGuard { 43 | inner, 44 | hx_request: false, 45 | layer: self.clone(), 46 | } 47 | } 48 | } 49 | 50 | /// Tower service that implements redirecting to non-partial routes. 51 | #[derive(Debug, Clone)] 52 | pub struct HxRequestGuard<'a, S> { 53 | inner: S, 54 | hx_request: bool, 55 | layer: HxRequestGuardLayer<'a>, 56 | } 57 | 58 | impl<'a, S, T, U> Service> for HxRequestGuard<'a, S> 59 | where 60 | S: Service, Response = Response>, 61 | U: Default, 62 | { 63 | type Response = S::Response; 64 | type Error = S::Error; 65 | type Future = private::ResponseFuture<'a, S::Future>; 66 | 67 | fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { 68 | self.inner.poll_ready(cx) 69 | } 70 | 71 | fn call(&mut self, req: Request) -> Self::Future { 72 | // This will always contain a "true" value. 73 | if req.headers().contains_key(HX_REQUEST) { 74 | self.hx_request = true; 75 | } 76 | 77 | let response_future = self.inner.call(req); 78 | 79 | private::ResponseFuture { 80 | response_future, 81 | hx_request: self.hx_request, 82 | layer: self.layer.clone(), 83 | } 84 | } 85 | } 86 | 87 | mod private { 88 | use super::*; 89 | 90 | pin_project! { 91 | pub struct ResponseFuture<'a, F> { 92 | #[pin] 93 | pub(super) response_future: F, 94 | pub(super) hx_request: bool, 95 | pub(super) layer: HxRequestGuardLayer<'a>, 96 | } 97 | } 98 | 99 | impl Future for ResponseFuture<'_, F> 100 | where 101 | F: Future, E>>, 102 | B: Default, 103 | { 104 | type Output = Result, E>; 105 | 106 | fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { 107 | let this = self.project(); 108 | let response: Response = ready!(this.response_future.poll(cx))?; 109 | 110 | match *this.hx_request { 111 | true => Poll::Ready(Ok(response)), 112 | false => { 113 | let res = Response::builder() 114 | .status(StatusCode::SEE_OTHER) 115 | .header(LOCATION, this.layer.redirect_to) 116 | .body(B::default()) 117 | .expect("failed to build response"); 118 | 119 | Poll::Ready(Ok(res)) 120 | } 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /src/headers.rs: -------------------------------------------------------------------------------- 1 | //! HTTP headers used by htmx. 2 | 3 | use http::HeaderName; 4 | 5 | /// Indicates that the request is via an element using `hx-boost` attribute. 6 | /// 7 | /// See for more information. 8 | pub const HX_BOOSTED: HeaderName = HeaderName::from_static("hx-boosted"); 9 | 10 | /// The current URL of the browser. 11 | pub const HX_CURRENT_URL: HeaderName = HeaderName::from_static("hx-current-url"); 12 | 13 | /// `true` if the request is for history restoration after a miss in the local 14 | /// history cache. 15 | pub const HX_HISTORY_RESTORE_REQUEST: HeaderName = 16 | HeaderName::from_static("hx-history-restore-request"); 17 | 18 | /// The user response to an `hx-prompt` 19 | /// 20 | /// See for more information. 21 | pub const HX_PROMPT: HeaderName = HeaderName::from_static("hx-prompt"); 22 | 23 | pub(crate) const HX_REQUEST_STR: &str = "hx-request"; 24 | 25 | /// Always `true`. 26 | pub const HX_REQUEST: HeaderName = HeaderName::from_static(HX_REQUEST_STR); 27 | 28 | pub(crate) const HX_TARGET_STR: &str = "hx-target"; 29 | 30 | /// The `id` of the target element, if it exists. 31 | pub const HX_TARGET: HeaderName = HeaderName::from_static(HX_TARGET_STR); 32 | 33 | pub(crate) const HX_TRIGGER_NAME_STR: &str = "hx-trigger-name"; 34 | 35 | /// The `name` of the triggered element, if it exists. 36 | pub const HX_TRIGGER_NAME: HeaderName = HeaderName::from_static(HX_TRIGGER_NAME_STR); 37 | 38 | /// Allows you to do a client-side redirect that does not do a full page reload. 39 | pub const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location"); 40 | 41 | /// Pushes a new URL onto the history stack. 42 | pub const HX_PUSH_URL: HeaderName = HeaderName::from_static("hx-push-url"); 43 | 44 | /// Can be used to do a client-side redirect to a new location. 45 | pub const HX_REDIRECT: HeaderName = HeaderName::from_static("hx-redirect"); 46 | 47 | /// If set to `true`, the client will do a full refresh on the page. 48 | pub const HX_REFRESH: HeaderName = HeaderName::from_static("hx-refresh"); 49 | 50 | /// Replaces the currelt URL in the location bar. 51 | pub const HX_REPLACE_URL: HeaderName = HeaderName::from_static("hx-replace-url"); 52 | 53 | /// Allows you to specify how the response value will be swapped. 54 | /// 55 | /// See for more information. 56 | pub const HX_RESWAP: HeaderName = HeaderName::from_static("hx-reswap"); 57 | 58 | /// A CSS selector that update the target of the content update to a different 59 | /// element on the page. 60 | pub const HX_RETARGET: HeaderName = HeaderName::from_static("hx-retarget"); 61 | 62 | /// A CSS selector that allows you to choose which part of the response is used 63 | /// to be swapped in. Overrides an existing `hx-select` on the triggering 64 | /// element 65 | pub const HX_RESELECT: HeaderName = HeaderName::from_static("hx-reselect"); 66 | 67 | pub(crate) const HX_TRIGGER_STR: &str = "hx-trigger"; 68 | 69 | /// Can be set as a request or response header. 70 | /// 71 | /// In a request, it contains the `id` of the element that triggered the 72 | /// request. 73 | /// 74 | /// In a response, it can be used to trigger client-side events. 75 | /// 76 | /// See for more information. 77 | pub const HX_TRIGGER: HeaderName = HeaderName::from_static(HX_TRIGGER_STR); 78 | 79 | /// Allows you to trigger client-side events. 80 | /// 81 | /// See for more information. 82 | pub const HX_TRIGGER_AFTER_SETTLE: HeaderName = HeaderName::from_static("hx-trigger-after-settle"); 83 | 84 | /// Allows you to trigger client-side events. 85 | /// 86 | /// See for more information. 87 | pub const HX_TRIGGER_AFTER_SWAP: HeaderName = HeaderName::from_static("hx-trigger-after-swap"); 88 | -------------------------------------------------------------------------------- /src/lib.rs: -------------------------------------------------------------------------------- 1 | #![cfg_attr(feature = "unstable", feature(doc_cfg))] 2 | #![doc = include_str!("../README.md")] 3 | #![forbid(unsafe_code)] 4 | #![allow(clippy::too_long_first_doc_paragraph)] 5 | 6 | mod error; 7 | pub use error::*; 8 | 9 | #[cfg(feature = "auto-vary")] 10 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "auto-vary")))] 11 | pub mod auto_vary; 12 | pub mod extractors; 13 | #[cfg(feature = "guards")] 14 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))] 15 | pub mod guard; 16 | pub mod headers; 17 | pub mod responders; 18 | 19 | #[cfg(feature = "auto-vary")] 20 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "auto-vary")))] 21 | #[doc(inline)] 22 | pub use auto_vary::*; 23 | #[doc(inline)] 24 | pub use extractors::*; 25 | #[cfg(feature = "guards")] 26 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "guards")))] 27 | #[doc(inline)] 28 | pub use guard::*; 29 | #[doc(inline)] 30 | pub use headers::*; 31 | #[doc(inline)] 32 | pub use responders::*; 33 | -------------------------------------------------------------------------------- /src/responders.rs: -------------------------------------------------------------------------------- 1 | //! Axum responses for htmx response headers. 2 | 3 | use std::{convert::Infallible, str::FromStr}; 4 | 5 | use axum_core::response::{IntoResponseParts, ResponseParts}; 6 | use http::{HeaderValue, Uri}; 7 | 8 | use crate::{headers, HxError}; 9 | 10 | mod location; 11 | pub use location::*; 12 | mod trigger; 13 | pub use trigger::*; 14 | mod vary; 15 | pub use vary::*; 16 | 17 | const HX_SWAP_INNER_HTML: &str = "innerHTML"; 18 | const HX_SWAP_OUTER_HTML: &str = "outerHTML"; 19 | const HX_SWAP_BEFORE_BEGIN: &str = "beforebegin"; 20 | const HX_SWAP_AFTER_BEGIN: &str = "afterbegin"; 21 | const HX_SWAP_BEFORE_END: &str = "beforeend"; 22 | const HX_SWAP_AFTER_END: &str = "afterend"; 23 | const HX_SWAP_DELETE: &str = "delete"; 24 | const HX_SWAP_NONE: &str = "none"; 25 | 26 | /// The `HX-Push-Url` header. 27 | /// 28 | /// Pushes a new url into the history stack. 29 | /// 30 | /// Will fail if the supplied Uri contains characters that are not visible ASCII 31 | /// (32-127). 32 | /// 33 | /// See for more information. 34 | #[derive(Debug, Clone)] 35 | pub struct HxPushUrl(pub Uri); 36 | 37 | impl IntoResponseParts for HxPushUrl { 38 | type Error = HxError; 39 | 40 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 41 | res.headers_mut().insert( 42 | headers::HX_PUSH_URL, 43 | HeaderValue::from_maybe_shared(self.0.to_string())?, 44 | ); 45 | 46 | Ok(res) 47 | } 48 | } 49 | 50 | impl From for HxPushUrl { 51 | fn from(uri: Uri) -> Self { 52 | Self(uri) 53 | } 54 | } 55 | 56 | impl<'a> TryFrom<&'a str> for HxPushUrl { 57 | type Error = ::Err; 58 | 59 | fn try_from(value: &'a str) -> Result { 60 | Ok(Self(value.parse()?)) 61 | } 62 | } 63 | 64 | /// The `HX-Redirect` header. 65 | /// 66 | /// Can be used to do a client-side redirect to a new location. 67 | /// 68 | /// Will fail if the supplied Uri contains characters that are not visible ASCII 69 | /// (32-127). 70 | #[derive(Debug, Clone)] 71 | pub struct HxRedirect(pub Uri); 72 | 73 | impl IntoResponseParts for HxRedirect { 74 | type Error = HxError; 75 | 76 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 77 | res.headers_mut().insert( 78 | headers::HX_REDIRECT, 79 | HeaderValue::from_maybe_shared(self.0.to_string())?, 80 | ); 81 | 82 | Ok(res) 83 | } 84 | } 85 | 86 | impl From for HxRedirect { 87 | fn from(uri: Uri) -> Self { 88 | Self(uri) 89 | } 90 | } 91 | 92 | impl<'a> TryFrom<&'a str> for HxRedirect { 93 | type Error = ::Err; 94 | 95 | fn try_from(value: &'a str) -> Result { 96 | Ok(Self(value.parse()?)) 97 | } 98 | } 99 | 100 | /// The `HX-Refresh`header. 101 | /// 102 | /// If set to `true` the client-side will do a full refresh of the page. 103 | /// 104 | /// This responder will never fail. 105 | #[derive(Debug, Copy, Clone)] 106 | pub struct HxRefresh(pub bool); 107 | 108 | impl From for HxRefresh { 109 | fn from(value: bool) -> Self { 110 | Self(value) 111 | } 112 | } 113 | 114 | impl IntoResponseParts for HxRefresh { 115 | type Error = Infallible; 116 | 117 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 118 | res.headers_mut().insert( 119 | headers::HX_REFRESH, 120 | if self.0 { 121 | HeaderValue::from_static("true") 122 | } else { 123 | HeaderValue::from_static("false") 124 | }, 125 | ); 126 | 127 | Ok(res) 128 | } 129 | } 130 | 131 | /// The `HX-Replace-Url` header. 132 | /// 133 | /// Replaces the currelt URL in the location bar. 134 | /// 135 | /// Will fail if the supplied Uri contains characters that are not visible ASCII 136 | /// (32-127). 137 | /// 138 | /// See for more information. 139 | #[derive(Debug, Clone)] 140 | pub struct HxReplaceUrl(pub Uri); 141 | 142 | impl IntoResponseParts for HxReplaceUrl { 143 | type Error = HxError; 144 | 145 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 146 | res.headers_mut().insert( 147 | headers::HX_REPLACE_URL, 148 | HeaderValue::from_maybe_shared(self.0.to_string())?, 149 | ); 150 | 151 | Ok(res) 152 | } 153 | } 154 | 155 | impl From for HxReplaceUrl { 156 | fn from(uri: Uri) -> Self { 157 | Self(uri) 158 | } 159 | } 160 | 161 | impl<'a> TryFrom<&'a str> for HxReplaceUrl { 162 | type Error = ::Err; 163 | 164 | fn try_from(value: &'a str) -> Result { 165 | Ok(Self(value.parse()?)) 166 | } 167 | } 168 | 169 | /// The `HX-Reswap` header. 170 | /// 171 | /// Allows you to specidy how the response will be swapped. 172 | /// 173 | /// This responder will never fail. 174 | #[derive(Debug, Copy, Clone)] 175 | pub struct HxReswap(pub SwapOption); 176 | 177 | impl IntoResponseParts for HxReswap { 178 | type Error = Infallible; 179 | 180 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 181 | res.headers_mut().insert(headers::HX_RESWAP, self.0.into()); 182 | 183 | Ok(res) 184 | } 185 | } 186 | 187 | impl From for HxReswap { 188 | fn from(value: SwapOption) -> Self { 189 | Self(value) 190 | } 191 | } 192 | 193 | /// The `HX-Retarget` header. 194 | /// 195 | /// A CSS selector that updates the target of the content update to a different 196 | /// element on the page. 197 | /// 198 | /// Will fail if the supplied String contains characters that are not visible 199 | /// ASCII (32-127). 200 | #[derive(Debug, Clone)] 201 | pub struct HxRetarget(pub String); 202 | 203 | impl IntoResponseParts for HxRetarget { 204 | type Error = HxError; 205 | 206 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 207 | res.headers_mut().insert( 208 | headers::HX_RETARGET, 209 | HeaderValue::from_maybe_shared(self.0)?, 210 | ); 211 | 212 | Ok(res) 213 | } 214 | } 215 | 216 | impl> From for HxRetarget { 217 | fn from(value: T) -> Self { 218 | Self(value.into()) 219 | } 220 | } 221 | 222 | /// The `HX-Reselect` header. 223 | /// 224 | /// A CSS selector that allows you to choose which part of the response is used 225 | /// to be swapped in. Overrides an existing hx-select on the triggering element. 226 | /// 227 | /// Will fail if the supplied String contains characters that are not visible 228 | /// ASCII (32-127). 229 | #[derive(Debug, Clone)] 230 | pub struct HxReselect(pub String); 231 | 232 | impl IntoResponseParts for HxReselect { 233 | type Error = HxError; 234 | 235 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 236 | res.headers_mut().insert( 237 | headers::HX_RESELECT, 238 | HeaderValue::from_maybe_shared(self.0)?, 239 | ); 240 | 241 | Ok(res) 242 | } 243 | } 244 | 245 | impl> From for HxReselect { 246 | fn from(value: T) -> Self { 247 | Self(value.into()) 248 | } 249 | } 250 | 251 | /// Values of the `hx-swap` attribute. 252 | // serde::Serialize is implemented in responders/serde.rs 253 | #[derive(Debug, Copy, Clone)] 254 | pub enum SwapOption { 255 | /// Replace the inner html of the target element. 256 | InnerHtml, 257 | /// Replace the entire target element with the response. 258 | OuterHtml, 259 | /// Insert the response before the target element. 260 | BeforeBegin, 261 | /// Insert the response before the first child of the target element. 262 | AfterBegin, 263 | /// Insert the response after the last child of the target element 264 | BeforeEnd, 265 | /// Insert the response after the target element 266 | AfterEnd, 267 | /// Deletes the target element regardless of the response 268 | Delete, 269 | /// Does not append content from response (out of band items will still be 270 | /// processed). 271 | None, 272 | } 273 | 274 | // can be removed and automatically derived when 275 | // https://github.com/serde-rs/serde/issues/2485 is implemented 276 | #[cfg(feature = "serde")] 277 | impl ::serde::Serialize for SwapOption { 278 | fn serialize(&self, serializer: S) -> Result 279 | where 280 | S: ::serde::Serializer, 281 | { 282 | const UNIT_NAME: &str = "SwapOption"; 283 | match self { 284 | Self::InnerHtml => serializer.serialize_unit_variant(UNIT_NAME, 0, HX_SWAP_INNER_HTML), 285 | Self::OuterHtml => serializer.serialize_unit_variant(UNIT_NAME, 1, HX_SWAP_OUTER_HTML), 286 | Self::BeforeBegin => { 287 | serializer.serialize_unit_variant(UNIT_NAME, 2, HX_SWAP_BEFORE_BEGIN) 288 | } 289 | Self::AfterBegin => { 290 | serializer.serialize_unit_variant(UNIT_NAME, 3, HX_SWAP_AFTER_BEGIN) 291 | } 292 | Self::BeforeEnd => serializer.serialize_unit_variant(UNIT_NAME, 4, HX_SWAP_BEFORE_END), 293 | Self::AfterEnd => serializer.serialize_unit_variant(UNIT_NAME, 5, HX_SWAP_AFTER_END), 294 | Self::Delete => serializer.serialize_unit_variant(UNIT_NAME, 6, HX_SWAP_DELETE), 295 | Self::None => serializer.serialize_unit_variant(UNIT_NAME, 7, HX_SWAP_NONE), 296 | } 297 | } 298 | } 299 | 300 | impl From for HeaderValue { 301 | fn from(value: SwapOption) -> Self { 302 | match value { 303 | SwapOption::InnerHtml => HeaderValue::from_static(HX_SWAP_INNER_HTML), 304 | SwapOption::OuterHtml => HeaderValue::from_static(HX_SWAP_OUTER_HTML), 305 | SwapOption::BeforeBegin => HeaderValue::from_static(HX_SWAP_BEFORE_BEGIN), 306 | SwapOption::AfterBegin => HeaderValue::from_static(HX_SWAP_AFTER_BEGIN), 307 | SwapOption::BeforeEnd => HeaderValue::from_static(HX_SWAP_BEFORE_END), 308 | SwapOption::AfterEnd => HeaderValue::from_static(HX_SWAP_AFTER_END), 309 | SwapOption::Delete => HeaderValue::from_static(HX_SWAP_DELETE), 310 | SwapOption::None => HeaderValue::from_static(HX_SWAP_NONE), 311 | } 312 | } 313 | } 314 | -------------------------------------------------------------------------------- /src/responders/location.rs: -------------------------------------------------------------------------------- 1 | use std::str::FromStr; 2 | 3 | use axum_core::response::{IntoResponseParts, ResponseParts}; 4 | use http::{HeaderValue, Uri}; 5 | 6 | use crate::{headers, HxError}; 7 | 8 | /// The `HX-Location` header. 9 | /// 10 | /// This response header can be used to trigger a client side redirection 11 | /// without reloading the whole page. If you intend to redirect to a specific 12 | /// target on the page, you must enable the `serde` feature flag and specify 13 | /// [`LocationOptions`]. 14 | /// 15 | /// Will fail if the supplied Uri contains characters that are not visible ASCII 16 | /// (32-127). 17 | /// 18 | /// See for more information. 19 | #[derive(Debug, Clone)] 20 | pub struct HxLocation { 21 | /// Uri of the new location. 22 | pub uri: Uri, 23 | /// Extra options. 24 | #[cfg(feature = "serde")] 25 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 26 | pub options: LocationOptions, 27 | } 28 | 29 | impl HxLocation { 30 | /// Creates location from [`Uri`] without any options. 31 | pub fn from_uri(uri: Uri) -> Self { 32 | Self { 33 | #[cfg(feature = "serde")] 34 | options: LocationOptions::default(), 35 | uri, 36 | } 37 | } 38 | 39 | /// Creates location from [`Uri`] and options. 40 | #[cfg(feature = "serde")] 41 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 42 | pub fn from_uri_with_options(uri: Uri, options: LocationOptions) -> Self { 43 | Self { uri, options } 44 | } 45 | 46 | /// Parses `uri` and sets it as location. 47 | #[allow(clippy::should_implement_trait)] 48 | pub fn from_str(uri: impl AsRef) -> Result { 49 | Ok(Self { 50 | #[cfg(feature = "serde")] 51 | options: LocationOptions::default(), 52 | uri: uri.as_ref().parse::()?, 53 | }) 54 | } 55 | 56 | /// Parses `uri` and sets it as location with additional options. 57 | #[cfg(feature = "serde")] 58 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 59 | pub fn from_str_with_options( 60 | uri: impl AsRef, 61 | options: LocationOptions, 62 | ) -> Result { 63 | Ok(Self { 64 | options, 65 | uri: uri.as_ref().parse::()?, 66 | }) 67 | } 68 | 69 | #[cfg(feature = "serde")] 70 | fn into_header_with_options(self) -> Result { 71 | if self.options.is_default() { 72 | return Ok(self.uri.to_string()); 73 | } 74 | 75 | #[derive(::serde::Serialize)] 76 | struct LocWithOpts { 77 | path: String, 78 | #[serde(flatten)] 79 | opts: LocationOptions, 80 | } 81 | 82 | let loc_with_opts = LocWithOpts { 83 | path: self.uri.to_string(), 84 | opts: self.options, 85 | }; 86 | 87 | Ok(serde_json::to_string(&loc_with_opts)?) 88 | } 89 | } 90 | 91 | impl From for HxLocation { 92 | fn from(uri: Uri) -> Self { 93 | Self::from_uri(uri) 94 | } 95 | } 96 | 97 | #[cfg(feature = "serde")] 98 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 99 | impl From<(Uri, LocationOptions)> for HxLocation { 100 | fn from((uri, options): (Uri, LocationOptions)) -> Self { 101 | Self::from_uri_with_options(uri, options) 102 | } 103 | } 104 | 105 | impl<'a> TryFrom<&'a str> for HxLocation { 106 | type Error = ::Err; 107 | 108 | fn try_from(uri: &'a str) -> Result { 109 | Self::from_str(uri) 110 | } 111 | } 112 | 113 | #[cfg(feature = "serde")] 114 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 115 | impl<'a> TryFrom<(&'a str, LocationOptions)> for HxLocation { 116 | type Error = ::Err; 117 | 118 | fn try_from((uri, options): (&'a str, LocationOptions)) -> Result { 119 | Self::from_str_with_options(uri, options) 120 | } 121 | } 122 | 123 | impl IntoResponseParts for HxLocation { 124 | type Error = HxError; 125 | 126 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 127 | #[cfg(feature = "serde")] 128 | let header = self.into_header_with_options()?; 129 | #[cfg(not(feature = "serde"))] 130 | let header = self.uri.to_string(); 131 | 132 | res.headers_mut().insert( 133 | headers::HX_LOCATION, 134 | HeaderValue::from_maybe_shared(header)?, 135 | ); 136 | 137 | Ok(res) 138 | } 139 | } 140 | 141 | /// More options for `HX-Location` header. 142 | /// 143 | /// - `source` - the source element of the request 144 | /// - `event` - an event that “triggered” the request 145 | /// - `handler` - a callback that will handle the response HTML 146 | /// - `target` - the target to swap the response into 147 | /// - `swap` - how the response will be swapped in relative to the target 148 | /// - `values` - values to submit with the request 149 | /// - `headers` - headers to submit with the request 150 | /// - `select` - allows you to select the content you want swapped from a 151 | /// response 152 | #[cfg(feature = "serde")] 153 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 154 | #[derive(Debug, Clone, serde::Serialize, Default)] 155 | #[non_exhaustive] 156 | pub struct LocationOptions { 157 | /// The source element of the request. 158 | #[serde(skip_serializing_if = "Option::is_none")] 159 | pub source: Option, 160 | /// An event that "triggered" the request. 161 | #[serde(skip_serializing_if = "Option::is_none")] 162 | pub event: Option, 163 | /// A callback that will handle the response HTML. 164 | #[serde(skip_serializing_if = "Option::is_none")] 165 | pub handler: Option, 166 | /// The target to swap the response into. 167 | #[serde(skip_serializing_if = "Option::is_none")] 168 | pub target: Option, 169 | /// How the response will be swapped in relative to the target. 170 | #[serde(skip_serializing_if = "Option::is_none")] 171 | pub swap: Option, 172 | /// Values to submit with the request. 173 | #[serde(skip_serializing_if = "Option::is_none")] 174 | pub values: Option, 175 | /// Headers to submit with the request. 176 | #[serde(skip_serializing_if = "Option::is_none")] 177 | pub headers: Option, 178 | } 179 | 180 | #[cfg(feature = "serde")] 181 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 182 | impl LocationOptions { 183 | pub(super) fn is_default(&self) -> bool { 184 | let Self { 185 | source: None, 186 | event: None, 187 | handler: None, 188 | target: None, 189 | swap: None, 190 | values: None, 191 | headers: None, 192 | } = self 193 | else { 194 | return false; 195 | }; 196 | 197 | true 198 | } 199 | } 200 | 201 | #[cfg(test)] 202 | mod tests { 203 | use super::*; 204 | 205 | #[test] 206 | #[cfg(feature = "serde")] 207 | fn test_serialize_location() { 208 | use crate::SwapOption; 209 | 210 | let loc = HxLocation::try_from("/foo").unwrap(); 211 | assert_eq!(loc.into_header_with_options().unwrap(), "/foo"); 212 | 213 | let loc = HxLocation::from_uri_with_options( 214 | "/foo".parse().unwrap(), 215 | LocationOptions { 216 | event: Some("click".into()), 217 | swap: Some(SwapOption::InnerHtml), 218 | ..Default::default() 219 | }, 220 | ); 221 | assert_eq!( 222 | loc.into_header_with_options().unwrap(), 223 | r#"{"path":"/foo","event":"click","swap":"innerHTML"}"# 224 | ); 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /src/responders/trigger.rs: -------------------------------------------------------------------------------- 1 | use axum_core::response::{IntoResponseParts, ResponseParts}; 2 | 3 | use crate::{headers, HxError}; 4 | 5 | /// Represents a client-side event carrying optional data. 6 | #[derive(Debug, Clone)] 7 | #[cfg_attr(feature = "serde", derive(serde::Serialize))] 8 | pub struct HxEvent { 9 | pub name: String, 10 | #[serde(skip_serializing_if = "Option::is_none")] 11 | #[cfg(feature = "serde")] 12 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 13 | pub data: Option, 14 | } 15 | 16 | impl HxEvent { 17 | /// Creates new event with no associated data. 18 | pub fn new(name: impl AsRef) -> Self { 19 | Self { 20 | name: name.as_ref().to_owned(), 21 | #[cfg(feature = "serde")] 22 | data: None, 23 | } 24 | } 25 | 26 | /// Creates new event with data. 27 | #[cfg(feature = "serde")] 28 | #[cfg_attr(feature = "unstable", doc(cfg(feature = "serde")))] 29 | pub fn new_with_data( 30 | name: impl AsRef, 31 | data: T, 32 | ) -> Result { 33 | let data = serde_json::to_value(data)?; 34 | 35 | Ok(Self { 36 | name: name.as_ref().to_owned(), 37 | #[cfg(feature = "serde")] 38 | data: Some(data), 39 | }) 40 | } 41 | } 42 | 43 | impl> From for HxEvent { 44 | fn from(name: N) -> Self { 45 | Self { 46 | name: name.as_ref().to_owned(), 47 | #[cfg(feature = "serde")] 48 | data: None, 49 | } 50 | } 51 | } 52 | 53 | #[cfg(not(feature = "serde"))] 54 | fn events_to_header_value(events: Vec) -> Result { 55 | let header = events 56 | .into_iter() 57 | .map(|HxEvent { name }| name) 58 | .collect::>() 59 | .join(", "); 60 | 61 | http::HeaderValue::from_str(&header).map_err(Into::into) 62 | } 63 | 64 | #[cfg(feature = "serde")] 65 | fn events_to_header_value(events: Vec) -> Result { 66 | use std::collections::HashMap; 67 | 68 | use http::HeaderValue; 69 | use serde_json::Value; 70 | 71 | let with_data = events.iter().any(|e| e.data.is_some()); 72 | 73 | let header_value = if with_data { 74 | // at least one event contains data so the header_value needs to be json 75 | // encoded. 76 | let header_value = events 77 | .into_iter() 78 | .map(|e| (e.name, e.data.unwrap_or_default())) 79 | .collect::>(); 80 | 81 | serde_json::to_string(&header_value)? 82 | } else { 83 | // no event contains data, the event names can be put in the header 84 | // value separated by a comma. 85 | events 86 | .into_iter() 87 | .map(|e| e.name) 88 | .reduce(|acc, e| acc + ", " + &e) 89 | .unwrap_or_default() 90 | }; 91 | 92 | HeaderValue::from_maybe_shared(header_value).map_err(HxError::from) 93 | } 94 | 95 | /// Describes when should event be triggered. 96 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 97 | #[non_exhaustive] 98 | pub enum TriggerMode { 99 | Normal, 100 | AfterSettle, 101 | AfterSwap, 102 | } 103 | 104 | /// The `HX-Trigger*` header. 105 | /// 106 | /// Allows you to trigger client-side events. Corresponds to `HX-Trigger`, 107 | /// `HX-Trigger-After-Settle` and `HX-Trigger-After-Swap` headers. To change 108 | /// when events trigger use appropriate `mode`. 109 | /// 110 | /// Will fail if the supplied events contain or produce characters that are not 111 | /// visible ASCII (32-127) when serializing to JSON. 112 | /// 113 | /// See for more information. 114 | /// 115 | /// Note: An `HxResponseTrigger` implements `IntoResponseParts` and should be 116 | /// used before any other response object would consume the response parts. 117 | #[derive(Debug, Clone)] 118 | pub struct HxResponseTrigger { 119 | pub mode: TriggerMode, 120 | pub events: Vec, 121 | } 122 | 123 | impl HxResponseTrigger { 124 | /// Creates new [trigger](https://htmx.org/headers/hx-trigger/) with 125 | /// specified mode and events. 126 | pub fn new>(mode: TriggerMode, events: impl IntoIterator) -> Self { 127 | Self { 128 | mode, 129 | events: events.into_iter().map(Into::into).collect(), 130 | } 131 | } 132 | 133 | /// Creates new [normal](https://htmx.org/headers/hx-trigger/) trigger from 134 | /// events. 135 | /// 136 | /// See `HxResponseTrigger` for more information. 137 | pub fn normal>(events: impl IntoIterator) -> Self { 138 | Self::new(TriggerMode::Normal, events) 139 | } 140 | 141 | /// Creates new [after settle](https://htmx.org/headers/hx-trigger/) trigger 142 | /// from events. 143 | /// 144 | /// See `HxResponseTrigger` for more information. 145 | pub fn after_settle>(events: impl IntoIterator) -> Self { 146 | Self::new(TriggerMode::AfterSettle, events) 147 | } 148 | 149 | /// Creates new [after swap](https://htmx.org/headers/hx-trigger/) trigger 150 | /// from events. 151 | /// 152 | /// See `HxResponseTrigger` for more information. 153 | pub fn after_swap>(events: impl IntoIterator) -> Self { 154 | Self::new(TriggerMode::AfterSwap, events) 155 | } 156 | } 157 | 158 | impl From<(TriggerMode, T)> for HxResponseTrigger 159 | where 160 | T: IntoIterator, 161 | T::Item: Into, 162 | { 163 | fn from((mode, events): (TriggerMode, T)) -> Self { 164 | Self { 165 | mode, 166 | events: events.into_iter().map(Into::into).collect(), 167 | } 168 | } 169 | } 170 | 171 | impl IntoResponseParts for HxResponseTrigger { 172 | type Error = HxError; 173 | 174 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 175 | if !self.events.is_empty() { 176 | let header = match self.mode { 177 | TriggerMode::Normal => headers::HX_TRIGGER, 178 | TriggerMode::AfterSettle => headers::HX_TRIGGER_AFTER_SETTLE, 179 | TriggerMode::AfterSwap => headers::HX_TRIGGER_AFTER_SETTLE, 180 | }; 181 | 182 | res.headers_mut() 183 | .insert(header, events_to_header_value(self.events)?); 184 | } 185 | 186 | Ok(res) 187 | } 188 | } 189 | 190 | #[cfg(test)] 191 | mod tests { 192 | use http::HeaderValue; 193 | use serde_json::json; 194 | 195 | use super::*; 196 | 197 | #[test] 198 | fn valid_event_to_header_encoding() { 199 | let evt = HxEvent::new_with_data( 200 | "my-event", 201 | json!({"level": "info", "message": { 202 | "body": "This is a test message.", 203 | "title": "Hello, world!", 204 | }}), 205 | ) 206 | .unwrap(); 207 | 208 | let header_value = events_to_header_value(vec![evt]).unwrap(); 209 | 210 | let expected_value = r#"{"my-event":{"level":"info","message":{"body":"This is a test message.","title":"Hello, world!"}}}"#; 211 | 212 | assert_eq!(header_value, HeaderValue::from_static(expected_value)); 213 | 214 | let value = 215 | events_to_header_value(HxResponseTrigger::normal(["foo", "bar"]).events).unwrap(); 216 | assert_eq!(value, HeaderValue::from_static("foo, bar")); 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/responders/vary.rs: -------------------------------------------------------------------------------- 1 | use axum_core::response::{IntoResponseParts, ResponseParts}; 2 | use http::header::{HeaderValue, VARY}; 3 | 4 | use crate::{extractors, headers, HxError}; 5 | 6 | const HX_REQUEST: HeaderValue = HeaderValue::from_static(headers::HX_REQUEST_STR); 7 | const HX_TARGET: HeaderValue = HeaderValue::from_static(headers::HX_TARGET_STR); 8 | const HX_TRIGGER: HeaderValue = HeaderValue::from_static(headers::HX_TRIGGER_STR); 9 | const HX_TRIGGER_NAME: HeaderValue = HeaderValue::from_static(headers::HX_TRIGGER_NAME_STR); 10 | 11 | /// The `Vary: HX-Request` header. 12 | /// 13 | /// You may want to add this header to the response if your handler responds 14 | /// differently based on the `HX-Request` request header. 15 | /// 16 | /// For example, if your server renders the full HTML when the `HX-Request` 17 | /// header is missing or `false`, and it renders a fragment of that HTML when 18 | /// `HX-Request: true`. 19 | /// 20 | /// You probably need this only for `GET` requests, as other HTTP methods are 21 | /// not cached by default. 22 | /// 23 | /// See for more information. 24 | #[derive(Debug, Clone)] 25 | pub struct VaryHxRequest; 26 | 27 | impl IntoResponseParts for VaryHxRequest { 28 | type Error = HxError; 29 | 30 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 31 | res.headers_mut().try_append(VARY, HX_REQUEST)?; 32 | 33 | Ok(res) 34 | } 35 | } 36 | 37 | impl extractors::HxRequest { 38 | /// Convenience method to create the corresponding `Vary` response header 39 | pub fn vary_response() -> VaryHxRequest { 40 | VaryHxRequest 41 | } 42 | } 43 | 44 | /// The `Vary: HX-Target` header. 45 | /// 46 | /// You may want to add this header to the response if your handler responds 47 | /// differently based on the `HX-Target` request header. 48 | /// 49 | /// You probably need this only for `GET` requests, as other HTTP methods are 50 | /// not cached by default. 51 | /// 52 | /// See for more information. 53 | #[derive(Debug, Clone)] 54 | pub struct VaryHxTarget; 55 | 56 | impl IntoResponseParts for VaryHxTarget { 57 | type Error = HxError; 58 | 59 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 60 | res.headers_mut().try_append(VARY, HX_TARGET)?; 61 | 62 | Ok(res) 63 | } 64 | } 65 | 66 | impl extractors::HxTarget { 67 | /// Convenience method to create the corresponding `Vary` response header 68 | pub fn vary_response() -> VaryHxTarget { 69 | VaryHxTarget 70 | } 71 | } 72 | 73 | /// The `Vary: HX-Trigger` header. 74 | /// 75 | /// You may want to add this header to the response if your handler responds 76 | /// differently based on the `HX-Trigger` request header. 77 | /// 78 | /// You probably need this only for `GET` requests, as other HTTP methods are 79 | /// not cached by default. 80 | /// 81 | /// See for more information. 82 | #[derive(Debug, Clone)] 83 | pub struct VaryHxTrigger; 84 | 85 | impl IntoResponseParts for VaryHxTrigger { 86 | type Error = HxError; 87 | 88 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 89 | res.headers_mut().try_append(VARY, HX_TRIGGER)?; 90 | 91 | Ok(res) 92 | } 93 | } 94 | 95 | impl extractors::HxTrigger { 96 | /// Convenience method to create the corresponding `Vary` response header 97 | pub fn vary_response() -> VaryHxTrigger { 98 | VaryHxTrigger 99 | } 100 | } 101 | 102 | /// The `Vary: HX-Trigger-Name` header. 103 | /// 104 | /// You may want to add this header to the response if your handler responds 105 | /// differently based on the `HX-Trigger-Name` request header. 106 | /// 107 | /// You probably need this only for `GET` requests, as other HTTP methods are 108 | /// not cached by default. 109 | /// 110 | /// See for more information. 111 | #[derive(Debug, Clone)] 112 | pub struct VaryHxTriggerName; 113 | 114 | impl IntoResponseParts for VaryHxTriggerName { 115 | type Error = HxError; 116 | 117 | fn into_response_parts(self, mut res: ResponseParts) -> Result { 118 | res.headers_mut().try_append(VARY, HX_TRIGGER_NAME)?; 119 | 120 | Ok(res) 121 | } 122 | } 123 | 124 | impl extractors::HxTriggerName { 125 | /// Convenience method to create the corresponding `Vary` response header 126 | pub fn vary_response() -> VaryHxTriggerName { 127 | VaryHxTriggerName 128 | } 129 | } 130 | 131 | #[cfg(test)] 132 | mod tests { 133 | use std::collections::hash_set::HashSet; 134 | 135 | use axum::{routing::get, Router}; 136 | 137 | use super::*; 138 | 139 | #[tokio::test] 140 | async fn multiple_headers() { 141 | let app = Router::new().route("/", get(|| async { (VaryHxRequest, VaryHxTarget, "foo") })); 142 | let server = axum_test::TestServer::new(app).unwrap(); 143 | 144 | let resp = server.get("/").await; 145 | let values: HashSet = resp.iter_headers_by_name("vary").cloned().collect(); 146 | assert_eq!(values, HashSet::from([HX_REQUEST, HX_TARGET])); 147 | } 148 | } 149 | --------------------------------------------------------------------------------