├── .cargo-ok ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── audit.yml │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE-APACHE ├── LICENSE-MIT ├── README.md ├── README.tpl ├── feattle-core ├── Cargo.toml ├── README.md └── src │ ├── __internal.rs │ ├── definition.rs │ ├── feattle_value.rs │ ├── json_reading.rs │ ├── last_reload.rs │ ├── lib.rs │ ├── macros.rs │ └── persist.rs ├── feattle-sync ├── Cargo.toml ├── README.md └── src │ ├── aws_sdk_s3.rs │ ├── background_sync.rs │ ├── disk.rs │ ├── lib.rs │ └── rusoto_s3.rs ├── feattle-ui ├── Cargo.toml ├── README.md ├── src │ ├── api.rs │ ├── axum_ui.rs │ ├── lib.rs │ ├── pages.rs │ └── warp_ui.rs └── web │ ├── favicon-32x32.png │ ├── feattle.hbs │ ├── feattles.hbs │ ├── layout.hbs │ ├── script.js │ └── style.css ├── feattle ├── Cargo.toml ├── README.md ├── examples │ └── full.rs └── src │ └── lib.rs ├── imgs ├── edit_enum.png ├── edit_json.png └── home.png ├── rust-toolchain └── update_readmes.sh /.cargo-ok: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitegui/feattle-rs/ccfdea3df8123a4062b23661c63617a880b6c58a/.cargo-ok -------------------------------------------------------------------------------- /.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 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 16 | 1. Go to '...' 17 | 2. Click on '....' 18 | 3. Scroll down to '....' 19 | 4. See error 20 | 21 | **Expected behavior** 22 | A clear and concise description of what you expected to happen. 23 | 24 | **Screenshots** 25 | If applicable, add screenshots to help explain your problem. 26 | 27 | **Desktop (please complete the following information):** 28 | 29 | - OS: [e.g. iOS] 30 | - Browser [e.g. chrome, safari] 31 | - Version [e.g. 22] 32 | 33 | **Additional context** 34 | Add any other context about the problem here. 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ## Description 2 | 3 | Please explain the changes you made here. 4 | 5 | ## Checklist 6 | 7 | - [ ] I have read 8 | [CONTRIBUTING](https://github.com/sitegui/feattle-rs/blob/master/docs/CONTRIBUTING.md) 9 | guidelines. 10 | - [ ] I have formatted the code using [rustfmt](https://github.com/rust-lang/rustfmt) 11 | - [ ] I have checked that all tests pass, by running `cargo test --all` 12 | 13 | #### [CHANGELOG](https://github.com/sitegui/feattle-rs/blob/master/CHANGELOG.md): 14 | 15 | - [ ] Updated 16 | - [ ] I will update it after this PR has been discussed 17 | - [ ] No need to update 18 | -------------------------------------------------------------------------------- /.github/workflows/audit.yml: -------------------------------------------------------------------------------- 1 | name: Security audit 2 | 3 | on: 4 | schedule: 5 | # Runs at 00:00 UTC everyday 6 | - cron: '0 0 * * *' 7 | push: 8 | paths: 9 | - '**/Cargo.toml' 10 | - '**/Cargo.lock' 11 | pull_request: 12 | 13 | jobs: 14 | audit: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Checkout repository 18 | uses: actions/checkout@v2 19 | - uses: actions-rs/audit-check@v1 20 | with: 21 | token: ${{ secrets.GITHUB_TOKEN }} 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [ push, pull_request ] 2 | name: Continuous Integration 3 | 4 | jobs: 5 | 6 | test: 7 | name: Test Suite 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout repository 11 | uses: actions/checkout@v2 12 | - name: Install Rust 13 | uses: actions-rs/toolchain@v1 14 | with: 15 | toolchain: stable 16 | profile: minimal 17 | override: true 18 | - uses: actions-rs/cargo@v1 19 | with: 20 | command: test 21 | args: --all-features 22 | env: 23 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 24 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 25 | AWS_REGION: ${{ secrets.AWS_REGION }} 26 | S3_BUCKET: ${{ secrets.S3_BUCKET }} 27 | S3_KEY_PREFIX: ${{ secrets.S3_KEY_PREFIX }} 28 | 29 | test_msrv: 30 | name: Test Suite MSRV 31 | runs-on: ubuntu-latest 32 | steps: 33 | - name: Checkout repository 34 | uses: actions/checkout@v2 35 | - name: Install Rust 36 | uses: actions-rs/toolchain@v1 37 | with: 38 | toolchain: 1.82.0 39 | profile: minimal 40 | override: true 41 | - uses: actions-rs/cargo@v1 42 | with: 43 | command: test 44 | args: --all-features 45 | env: 46 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 47 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 48 | AWS_REGION: ${{ secrets.AWS_REGION }} 49 | S3_BUCKET: ${{ secrets.S3_BUCKET }} 50 | S3_KEY_PREFIX: ${{ secrets.S3_KEY_PREFIX }} 51 | 52 | rustfmt: 53 | name: Rustfmt 54 | runs-on: ubuntu-latest 55 | steps: 56 | - name: Checkout repository 57 | uses: actions/checkout@v2 58 | - name: Install Rust 59 | uses: actions-rs/toolchain@v1 60 | with: 61 | toolchain: stable 62 | profile: minimal 63 | override: true 64 | components: rustfmt 65 | - name: Check formatting 66 | uses: actions-rs/cargo@v1 67 | with: 68 | command: fmt 69 | args: --all -- --check 70 | 71 | clippy: 72 | name: Clippy 73 | runs-on: ubuntu-latest 74 | steps: 75 | - name: Checkout repository 76 | uses: actions/checkout@v2 77 | - name: Install Rust 78 | uses: actions-rs/toolchain@v1 79 | with: 80 | toolchain: stable 81 | profile: minimal 82 | override: true 83 | components: clippy 84 | - name: Clippy Check 85 | uses: actions-rs/cargo@v1 86 | with: 87 | command: clippy 88 | args: --all-features -- -D warnings 89 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /target 2 | .idea 3 | data 4 | .env -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 6 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 7 | 8 | ## [feattle 4.0.0] - 2025-05-07 9 | 10 | ### Changed 11 | 12 | - `axum_router()` takes a second argument with the logic to extract the name of the user that modified a feattle 13 | 14 | ## [feattle-ui 4.0.0] - 2025-05-07 15 | 16 | ### Changed 17 | 18 | - `axum_router()` takes a second argument with the logic to extract the name of the user that modified a feattle 19 | 20 | ## [feattle-ui 3.0.1] - 2025-04-17 21 | 22 | ### Fixed 23 | 24 | - Fixed error on Axum routes, which cause runtime errors 25 | 26 | ## [feattle 3.0.0] - 2025-03-13 27 | 28 | ### Changed 29 | 30 | - MSRV 1.82.0 31 | - Update `axum` 0.8, `handlebars` 6.3, `thiserror` 2.0 32 | - Use edition 2021 33 | 34 | ## [feattle-ui 3.0.0] - 2025-03-13 35 | 36 | ### Changed 37 | 38 | - MSRV 1.82.0 39 | - Update `axum` 0.8, `handlebars` 6.3, `thiserror` 2.0 40 | - Use edition 2021 41 | 42 | ## [feattle-sync 3.0.0] - 2025-03-13 43 | 44 | ### Changed 45 | 46 | - MSRV 1.82.0 47 | - Update `this-error` 2.0 (no public impact in the API) 48 | - Use edition 2021 49 | 50 | ## [feattle-core 3.0.0] - 2025-03-13 51 | 52 | ### Changed 53 | 54 | - MSRV 1.82.0 55 | - Update `this-error` 2.0 (no public impact in the API) 56 | - Use edition 2021 57 | 58 | ## [feattle 2.0.0] - 2024-06-26 59 | 60 | ### Changed 61 | 62 | - BREAKING: Renamed feature "s3" to "rusoto_s3" in `feattle` and `feattle-sync` 63 | - BREAKING: Renamed `feattle_sync::S3` to `feattle_sync::RusotoS3` 64 | - BREAKING: Upgraded from `axum` 0.6 to 0.7 65 | - BREAKING: Upgraded from `handlebars` 4 to 5 66 | - BREAKING: Minimum Rust version is now 1.76 67 | 68 | ### Added 69 | 70 | - Added feature "aws_sdk_s3" to `feattle` and `feattle-sync` 71 | 72 | ## [feattle 1.0.0] - 2023-06-28 73 | 74 | ### Changed 75 | 76 | - Remove generic type from `Feattles` trait 77 | 78 | ## [feattle-core 1.0.0] - 2023-06-28 79 | 80 | ### Changed 81 | 82 | - Remove generic type from `Feattles` trait 83 | This generic represented the persistence layer, because each concrete implementation was free to declare their own 84 | associate error type. 85 | However, this feature caused "generics contamination" in the API, forcing users to carry the generic type parameter 86 | around. 87 | Instead, we can force persistent implementation to use a boxed error, removing this syntax complexity. 88 | This means that the constructor now takes `Arc` instead of a direct instance of `Persist`. 89 | 90 | ## [feattle-sync 1.0.0] - 2023-06-28 91 | 92 | ### Changed 93 | 94 | - Remove generic type from `Feattles` trait 95 | - Added `BackgroundSync::start()` that waits for the first update to complete 96 | - Deprecate `BackgroundSync::spawn()` since it will be replaced in favor of `start()`, that is more flexible. 97 | - Added a new parameter to `S3::new()`: the `timeout`. Any operation will return an error after this time has elapsed. 98 | 99 | ## [feattle-ui 1.0.0] - 2023-06-28 100 | 101 | ### Changed 102 | 103 | - Remove generic type from `Feattles` trait 104 | 105 | ## [feattle 0.10.0] - 2023-04-21 106 | 107 | ### Changed 108 | 109 | - Update `feattle-ui` to 0.10.0 110 | - Minimum supported Rust version is now 1.60 111 | 112 | ## [feattle-ui 0.10.0] - 2023-04-21 113 | 114 | ### Changed 115 | 116 | - Add optional support for `axum` 117 | - Minimum supported Rust version is now 1.60 118 | 119 | ## [feattle 0.9.0] - 2022-07-11 120 | 121 | ### Changed 122 | 123 | - Update `feattle-core` to 0.9.0 124 | - Update `feattle-sync` to 0.9.0 125 | - Update `feattle-ui` to 0.9.0 126 | - Update rusoto to `0.48.0` 127 | - Update uuid to `1.1.2` 128 | - Minimum supported Rust version is now 1.57 129 | 130 | ## [feattle-core 0.9.0] - 2022-07-11 131 | 132 | ### Changed 133 | 134 | - Update uuid to `1.1.2` 135 | - Minimum supported Rust version is now 1.57 136 | 137 | ## [feattle-sync 0.9.0] - 2022-07-11 138 | 139 | ### Changed 140 | 141 | - Update rusoto to `0.48.0` 142 | - Update `feattle-core` to 0.9.0 143 | - Minimum supported Rust version is now 1.57 144 | 145 | ## [feattle-ui 0.9.0] - 2022-07-11 146 | 147 | ### Changed 148 | 149 | - Update `feattle-core` to 0.9.0 150 | - Change `pagecdn` with `cdnjs` 151 | - Minimum supported Rust version is now 1.57 152 | 153 | ## [feattle 0.8.0] - 2022-03-16 154 | 155 | ### Changed 156 | 157 | - Update `feattle-core` to 0.8.0 158 | - Update `feattle-sync` to 0.8.0 159 | - Update `feattle-ui` to 0.8.0 160 | - Update parking_lot to `0.12.0` 161 | 162 | ## [feattle-core 0.8.0] - 2022-03-16 163 | 164 | ### Changed 165 | 166 | - Update parking_lot to `0.12.0` 167 | 168 | ## [feattle-sync 0.8.0] - 2022-03-16 169 | 170 | ### Changed 171 | 172 | - Update parking_lot to `0.12.0` 173 | 174 | ## [feattle-ui 0.8.0] - 2022-03-16 175 | 176 | ### Changed 177 | 178 | - Update parking_lot to `0.12.0` 179 | 180 | ## [feattle 0.7.0] - 2021-09-09 181 | 182 | ### Changed 183 | 184 | - Update `feattle-core` to 0.7.0 185 | - Update `feattle-sync` to 0.7.0 186 | - Update `feattle-ui` to 0.7.0 187 | - Minimum supported Rust version is now 1.51 188 | 189 | ## [feattle-core 0.7.0] - 2021-09-09 190 | 191 | ### Changed 192 | 193 | - Minimum supported Rust version is now 1.51 194 | 195 | ## [feattle-sync 0.7.0] - 2021-09-09 196 | 197 | ### Changed 198 | 199 | - Update `rusoto` to 0.47.0 200 | - Minimum supported Rust version is now 1.51 201 | 202 | ## [feattle-ui 0.7.0] - 2021-09-09 203 | 204 | ### Changed 205 | 206 | - Update `handlebars` to 4.1.2 207 | - Minimum supported Rust version is now 1.51 208 | 209 | ## [feattle 0.6.0] - 2021-05-08 210 | 211 | ### Changed 212 | 213 | - Update `feattle-core` to 0.6.0 214 | - Update `feattle-sync` to 0.6.0 215 | - Update `feattle-ui` to 0.6.0 216 | - Minimum supported Rust version is now 1.51 217 | 218 | ## [feattle-core 0.6.0] - 2021-05-08 219 | 220 | ### Added 221 | 222 | - Implement `serde::Serialize` for `LastReload` 223 | 224 | ### Changed 225 | 226 | - Minimum supported Rust version is now 1.51 227 | 228 | ## [feattle-sync 0.6.0] - 2021-03-23 229 | 230 | ### Changed 231 | 232 | - Minimum supported Rust version is now 1.51 233 | - Update `feattle-core` to 0.6.0 234 | 235 | ## [feattle-ui 0.6.0] - 2021-05-08 236 | 237 | ### Added 238 | 239 | - Add new methods in `AdminPanel` (`list_feattles_api_v1()`, `show_feattle_api_v1()` and `edit_feattle_api_v1()`) adding 240 | access to a lower-level API 241 | - Expose new methods in `warp` as a JSON API under `/api/v1/` 242 | 243 | ### Changed 244 | 245 | - Minimum supported Rust version is now 1.51 246 | - Update `feattle-core` to 0.6.0 247 | 248 | ## [feattle 0.5.0] - 2021-03-23 249 | 250 | ### Changed 251 | 252 | - Update `feattle-core` to 0.5.0 253 | - Update `feattle-sync` to 0.5.0 254 | - Update `feattle-ui` to 0.5.0 255 | 256 | ## [feattle-core 0.5.0] - 2021-03-23 257 | 258 | ### Added 259 | 260 | - Update doc on `Feattles::update()` to warn user about consistency guarantees. 261 | 262 | ### Changed 263 | 264 | - `Feattles::last_reload()` now returns `LastReload` that contains more information about the last 265 | reload operation. 266 | 267 | ## [feattle-sync 0.5.0] - 2021-03-23 268 | 269 | ### Changed 270 | 271 | - Update `feattle-core` to 0.5.0 272 | 273 | ## [feattle-ui 0.5.0] - 2021-03-23 274 | 275 | ### Added 276 | 277 | - Show a warning in the UI if the last reload failed 278 | 279 | ### Changed 280 | 281 | - `AdminPanel::list_feattles()` calls `Feattles:reload()` and is now asynchronous 282 | - `AdminPanel::show_feattle()` calls `Feattles:reload()` 283 | - `AdminPanel::edit_feattle()` calls `Feattles:reload()` and may return `RenderError::Reload` 284 | - `AdminPanel::edit_feattle()` takes a new parameter `modified_by` 285 | 286 | ## [feattle-core 0.4.0] - 2021-03-21 287 | 288 | ### Changed 289 | 290 | - Minimum supported Rust version is now `1.45` 291 | 292 | ## [feattle-sync 0.4.0] - 2021-03-21 293 | 294 | ### Changed 295 | 296 | - Update `feattle-core` to 0.4.0 297 | - Update `rusoto_core` to 0.46.0 298 | - Update `rusoto_s3` to 0.46.0 299 | - Update `tokio` to 1.4.0 300 | - Minimum supported Rust version is now `1.45` 301 | 302 | ## [feattle-ui 0.4.0] - 2021-03-21 303 | 304 | ### Changed 305 | 306 | - Update `feattle-core` to 0.4.0 307 | - Update `warp` to 0.3.0 308 | - Minimum supported Rust version is now `1.45` 309 | 310 | ## [feattle 0.4.0] - 2021-03-21 311 | 312 | ### Changed 313 | 314 | - Update `feattle-core` to 0.4.0 315 | - Update `feattle-sync` to 0.4.0 316 | - Update `feattle-ui` to 0.4.0 317 | - Minimum supported Rust version is now `1.45` 318 | 319 | ## [feattle-core 0.3.0] - 2021-01-13 320 | 321 | ### Changed 322 | 323 | Instead of adding the bound `Persist` to the trait `Feattles`, only add it to methods that actually 324 | need it. This gives more freedom to code that use methods other than update/reload/etc. 325 | 326 | Also remove `Send`, `Sync` and `'static` bounds from `Feattles` and `Persist` traits. 327 | 328 | The concrete types (created by `feattles!`) still implement those, but removing from the trait 329 | makes code require the minimum contracts required. However, it makes the code somewhat more 330 | verbose at times. 331 | 332 | ## [feattle-sync 0.3.0] - 2021-01-13 333 | 334 | ### Changed 335 | 336 | Update `feattle-core` to 0.3.0 337 | 338 | ## [feattle-ui 0.3.0] - 2021-01-13 339 | 340 | ### Changed 341 | 342 | Update `feattle-core` to 0.3.0 343 | 344 | ## [feattle 0.3.0] - 2021-01-13 345 | 346 | ### Changed 347 | 348 | Update `feattle-core` to 0.3.0 349 | 350 | ## [feattle 0.2.5] - 2020-10-23 351 | 352 | ### Fixed 353 | 354 | Fixed a bug in which when updating one feattle, all the others would be reset to their default value. 355 | 356 | ### Added 357 | 358 | When the clipboard API is not available, show a dialog with the content for the user to copy it 359 | manually. 360 | 361 | ## [feattle-core 0.2.5] - 2020-10-23 362 | 363 | ### Fixed 364 | 365 | Fixed a bug in which when updating one feattle, all the others would be reset to their default value. 366 | 367 | ## [feattle-ui 0.2.5] - 2020-10-23 368 | 369 | ### Added 370 | 371 | When the clipboard API is not available, show a dialog with the content for the user to copy it 372 | manually. 373 | 374 | ## [feattle-core 0.2.4] - 2020-10-12 375 | 376 | First fully documented and supported version 377 | 378 | ## [feattle-sync 0.2.4] - 2020-10-12 379 | 380 | First fully documented and supported version 381 | 382 | ## [feattle-ui 0.2.4] - 2020-10-12 383 | 384 | First fully documented and supported version 385 | 386 | ## [feattle 0.2.4] - 2020-10-12 387 | 388 | First fully documented and supported version 389 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | This project adheres to the Rust Code of Conduct, which [can be found online](https://www.rust-lang.org/conduct.html). 4 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contribution guidelines 2 | 3 | First off, thank you for considering contributing to feattle-rs. 4 | 5 | If your contribution is not straightforward, please first discuss the change you 6 | wish to make by creating a new issue before making the change. 7 | 8 | One of the project goals is to be easy to understand so, expacially for github 9 | actions, try to keep things simple and to add comments whenever this is not 10 | possible. 11 | 12 | ## Reporting issues 13 | 14 | Issues have to be reported on our [issue tracker][issue-tracker]. Please: 15 | 16 | - Check that the issue has not already been reported. 17 | - This can be achieved by searching keywords on the [issue tracker][issue-tracker]. 18 | - Try to use a clear title, and describe your problem with complete sentences. 19 | 20 | [issue-tracker]: https://github.com/sitegui/feattle-rs/issues 21 | 22 | ## Pull requests 23 | 24 | Try to do one pull request per change. 25 | 26 | ### Updating the changelog 27 | 28 | Update the changes you have made in 29 | [CHANGELOG](https://github.com/sitegui/feattle-rs/blob/master/CHANGELOG.md) 30 | file under the **Unreleased** section. 31 | 32 | Add the changes of your pull request to one of the following subsections, 33 | depending on the types of changes defined by 34 | [Keep a changelog](https://keepachangelog.com/en/1.0.0/): 35 | 36 | - `Added` for new features. 37 | - `Changed` for changes in existing functionality. 38 | - `Deprecated` for soon-to-be removed features. 39 | - `Removed` for now removed features. 40 | - `Fixed` for any bug fixes. 41 | - `Security` in case of vulnerabilities. 42 | 43 | If the required subsection does not exist yet under **Unreleased**, create it! 44 | 45 | ## Developing 46 | 47 | ### Set up 48 | 49 | This is no different than other Rust projects. 50 | 51 | ```shell 52 | git clone https://github.com/sitegui/feattle-rs 53 | cd feattle-rs 54 | cargo build 55 | ``` 56 | 57 | ### Useful Commands 58 | 59 | - Build and run release version: 60 | 61 | ```shell 62 | cargo build --release && cargo run --release 63 | ``` 64 | 65 | - Run Clippy: 66 | 67 | ```shell 68 | cargo clippy --all 69 | ``` 70 | 71 | - Run all tests: 72 | 73 | ```shell 74 | cargo test --all 75 | ``` 76 | 77 | - Check to see if there are code formatting issues 78 | 79 | ```shell 80 | cargo fmt --all -- --check 81 | ``` 82 | 83 | - Format the code in the project 84 | 85 | ```shell 86 | cargo fmt --all 87 | ``` 88 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["feattle", "feattle-core", "feattle-sync", "feattle-ui"] 3 | resolver = "2" -------------------------------------------------------------------------------- /LICENSE-APACHE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | -------------------------------------------------------------------------------- /LICENSE-MIT: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Guilherme 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # feattle 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/feattle.svg)](https://crates.io/crates/feattle) 4 | [![Docs.rs](https://docs.rs/feattle/badge.svg)](https://docs.rs/feattle) 5 | [![CI](https://github.com/sitegui/feattle-rs/workflows/Continuous%20Integration/badge.svg)](https://github.com/sitegui/feattle-rs/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/sitegui/feattle-rs/badge.svg?branch=master)](https://coveralls.io/github/sitegui/feattle-rs?branch=master) 7 | 8 | Featture toggles for Rust (called "feattles", for short), extensible and with background 9 | synchronization and administration UI. 10 | 11 | ### Features 12 | - Feature toggles that synchronize automatically with a backing storage 13 | - Feature toggles can be as simple `bool`, but can also be lists, maps and arbitrary tpes ( 14 | (through the [`FeattleValue`] trait). 15 | - Web UI with documentation, change history, validation 16 | - JSON API to read and set the toggles 17 | - Modular and extensible: use as much or as little of the bundled features as you want. Want to 18 | use a different Web UI? A different storage layer? No problem. 19 | 20 | ### Example 21 | 22 | ```rust 23 | use feattle::*; 24 | use std::sync::Arc; 25 | 26 | /// A struct with your feature toggles: you can use primitive types (like `bool`, `i32`, etc), 27 | /// standard collections (like `Vec`, `BTreeSet`, etc) or any arbitrary type that implements 28 | /// the required trait. 29 | feattles! { 30 | struct MyFeattles { 31 | /// Is this usage considered cool? 32 | is_cool: bool = true, 33 | /// Limit the number of "blings" available. 34 | /// This will not change the number of "blengs", though! 35 | max_blings: i32, 36 | /// List the actions that should not be available 37 | blocked_actions: Vec, 38 | } 39 | } 40 | 41 | #[tokio::main] 42 | async fn main() { 43 | // Store their values and history in AWS' S3 44 | use std::future::IntoFuture; 45 | use std::time::Duration; 46 | use tokio::net::TcpListener; 47 | let config = aws_config::load_from_env().await; 48 | let persistence = Arc::new(S3::new( 49 | &config, 50 | "my-bucket".to_owned(), 51 | "some/s3/prefix/".to_owned(), 52 | )); 53 | 54 | // Create a new instance 55 | let my_feattles = Arc::new(MyFeattles::new(persistence)); 56 | 57 | // Poll the storage in the background 58 | BackgroundSync::new(&my_feattles).start().await; 59 | 60 | // Start the admin UI with `warp` 61 | let admin_panel = Arc::new(AdminPanel::new(my_feattles.clone(), "Project Panda - DEV".to_owned())); 62 | tokio::spawn(run_warp_server(admin_panel.clone(), ([127, 0, 0, 1], 3030))); 63 | 64 | // Or serve the admin panel with `axum` 65 | let router = axum_router(admin_panel); 66 | let listener = TcpListener::bind(("127.0.0.1", 3031)).await.unwrap(); 67 | tokio::spawn(axum::serve(listener, router.into_make_service()).into_future()); 68 | 69 | // Read values (note the use of `*`) 70 | assert_eq!(*my_feattles.is_cool(), true); 71 | assert_eq!(*my_feattles.max_blings(), 0); 72 | assert_eq!(*my_feattles.blocked_actions(), Vec::::new()); 73 | } 74 | ``` 75 | 76 | You can run a full example locally with: `cargo run --example full --features='s3 uuid warp axum'`. 77 | 78 | With this code, you'll get an Web Admin UI like: 79 | 80 | ![Home Web Admin UI](https://raw.githubusercontent.com/sitegui/feattle-rs/master/imgs/home.png) 81 | 82 | You can use the UI to edit the current values and see their change history. For example, this 83 | is what you can expect when editing an `enum`: 84 | 85 | ![Edit enum](https://raw.githubusercontent.com/sitegui/feattle-rs/master/imgs/edit_enum.png) 86 | 87 | It also supports complex types with a JSON editor and helpful error diagnostics: 88 | 89 | ![Edit JSON](https://raw.githubusercontent.com/sitegui/feattle-rs/master/imgs/edit_json.png) 90 | 91 | ## How it works 92 | 93 | The macro will generate a struct with the given name and visibility modifier (assuming private 94 | by default). The generated struct implements [`Feattles`] and also exposes one method for each 95 | feattle. 96 | 97 | The methods created for each feattle allow reading their current value. For example, for a 98 | feattle `is_cool: bool`, there will be a method like 99 | `pub fn is_cool(&self) -> MappedRwLockReadGuard`. Note the use of 100 | [`parking_lot::MappedRwLockReadGuard`] because the interior of the struct is stored behind a `RwLock` to 101 | control concurrent access. 102 | 103 | A feattle is created with the syntax `$key: $type [= $default]`. You can use doc coments ( 104 | starting with `///`) to describe nicely what they do in your system. You can use any type that 105 | implements [`FeattleValue`] and optionally provide a default. If not provided, the default 106 | will be created with `Default::default()`. 107 | 108 | ## Minimum supported Rust version 109 | 110 | As of this release, the MSRV is 1.82.0, as tested in the CI. A patch release will never require 111 | a newer MSRV. 112 | 113 | ## Optional features 114 | 115 | You can easily declare feattles with your custom types, use another persistance storage logic 116 | or Web Framework (or any at all). For some out-of-the-box functionality, you can activate these 117 | cargo features: 118 | 119 | - **uuid**: will add support for [`uuid::Uuid`]. 120 | - **rusoto_s3**: provides [`RusotoS3`] to integrate with AWS' S3 121 | - **aws_sdk_s3**: provides [`S3`] to integrate with AWS' S3 122 | - **warp**: provides [`run_warp_server`] for a read-to-use integration with [`warp`] 123 | - **axum**: provides [`axum_router`] for a read-to-use integration with [`axum`] 124 | 125 | ### Crate's organization 126 | 127 | This crate is a simple re-export of these three components: 128 | 129 | * `feattle-core`: [![Crates.io](https://img.shields.io/crates/v/feattle-core.svg)](https://crates.io/crates/feattle-core) 130 | * `feattle-sync`: [![Crates.io](https://img.shields.io/crates/v/feattle-sync.svg)](https://crates.io/crates/feattle-sync) 131 | * `feattle-ui`: [![Crates.io](https://img.shields.io/crates/v/feattle-ui.svg)](https://crates.io/crates/feattle-ui) 132 | 133 | Having them separate allows for leaner lower-level integration. If you're creating a crate to 134 | provide a different storage or admin, you just need `feattle-core`. 135 | 136 | ## License 137 | 138 | Licensed under either of 139 | 140 | * Apache License, Version 2.0 141 | ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 142 | * MIT license 143 | ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 144 | 145 | at your option. 146 | 147 | ## Contribution 148 | 149 | Unless you explicitly state otherwise, any contribution intentionally submitted 150 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 151 | dual licensed as above, without any additional terms or conditions. 152 | 153 | See [CONTRIBUTING.md](CONTRIBUTING.md). 154 | -------------------------------------------------------------------------------- /README.tpl: -------------------------------------------------------------------------------- 1 | # {{crate}} 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/{{crate}}.svg)](https://crates.io/crates/{{crate}}) 4 | [![Docs.rs](https://docs.rs/{{crate}}/badge.svg)](https://docs.rs/{{crate}}) 5 | [![CI](https://github.com/sitegui/feattle-rs/workflows/Continuous%20Integration/badge.svg)](https://github.com/sitegui/feattle-rs/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/sitegui/feattle-rs/badge.svg?branch=master)](https://coveralls.io/github/sitegui/feattle-rs?branch=master) 7 | 8 | {{readme}} 9 | 10 | ## License 11 | 12 | Licensed under either of 13 | 14 | * Apache License, Version 2.0 15 | ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 16 | * MIT license 17 | ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 18 | 19 | at your option. 20 | 21 | ## Contribution 22 | 23 | Unless you explicitly state otherwise, any contribution intentionally submitted 24 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 25 | dual licensed as above, without any additional terms or conditions. 26 | 27 | See [CONTRIBUTING.md](CONTRIBUTING.md). 28 | -------------------------------------------------------------------------------- /feattle-core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "feattle-core" 3 | version = "3.0.0" 4 | authors = ["Guilherme Souza "] 5 | edition = "2021" 6 | rust-version = "1.82.0" 7 | description = "Featture toggles for Rust, extensible and with background synchronization and administration UI" 8 | repository = "https://github.com/sitegui/feattle-rs" 9 | license = "MIT OR Apache-2.0" 10 | readme = "README.md" 11 | keywords = ["toggle", "feature", "flag", "flipper"] 12 | categories = ["config", "data-structures", "development-tools", "web-programming"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | async-trait = "0.1.40" 18 | chrono = { version = "0.4.15", features = ["serde"] } 19 | log = "0.4.11" 20 | parking_lot = "0.12.0" 21 | serde = { version = "1.0.115", features = ["derive"] } 22 | serde_json = "1.0.57" 23 | thiserror = "2.0.12" 24 | uuid = { version = "1.1.2", optional = true } 25 | 26 | [dev-dependencies] 27 | tokio = { version = "1.4.0", features = ["macros", "rt"] } 28 | 29 | [package.metadata.docs.rs] 30 | all-features = true 31 | -------------------------------------------------------------------------------- /feattle-core/README.md: -------------------------------------------------------------------------------- 1 | # feattle-core 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/feattle-core.svg)](https://crates.io/crates/feattle-core) 4 | [![Docs.rs](https://docs.rs/feattle-core/badge.svg)](https://docs.rs/feattle-core) 5 | [![CI](https://github.com/sitegui/feattle-rs/workflows/Continuous%20Integration/badge.svg)](https://github.com/sitegui/feattle-rs/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/sitegui/feattle-rs/badge.svg?branch=master)](https://coveralls.io/github/sitegui/feattle-rs?branch=master) 7 | 8 | This crate is the core implementation of the feature flags (called "feattles", for short). 9 | 10 | Its main parts are the macro [`feattles!`] together with the trait [`Feattles`]. Please refer to 11 | the [main package - `feattle`](https://crates.io/crates/feattle) for more information. 12 | 13 | ## Usage example 14 | ```rust 15 | use std::sync::Arc; 16 | use feattle_core::{feattles, Feattles}; 17 | use feattle_core::persist::NoPersistence; 18 | 19 | // Declare the struct 20 | feattles! { 21 | struct MyFeattles { 22 | /// Is this usage considered cool? 23 | is_cool: bool = true, 24 | /// Limit the number of "blings" available. 25 | /// This will not change the number of "blengs", though! 26 | max_blings: i32, 27 | /// List the actions that should not be available 28 | blocked_actions: Vec, 29 | } 30 | } 31 | 32 | // Create a new instance (`NoPersistence` is just a mock for the persistence layer) 33 | let my_feattles = MyFeattles::new(Arc::new(NoPersistence)); 34 | 35 | // Read values (note the use of `*`) 36 | assert_eq!(*my_feattles.is_cool(), true); 37 | assert_eq!(*my_feattles.max_blings(), 0); 38 | assert_eq!(*my_feattles.blocked_actions(), Vec::::new()); 39 | ``` 40 | 41 | ## How it works 42 | 43 | The macro will generate a struct with the given name and visibility modifier (assuming private 44 | by default). The generated struct implements [`Feattles`] and also exposes one method for each 45 | feattle. 46 | 47 | The methods created for each feattle allow reading their current value. For example, for a 48 | feattle `is_cool: bool`, there will be a method like 49 | `pub fn is_cool(&self) -> MappedRwLockReadGuard`. Note the use of 50 | [`parking_lot::MappedRwLockReadGuard`] because the interior of the struct is stored behind a `RwLock` to 51 | control concurrent access. 52 | 53 | A feattle is created with the syntax `$key: $type [= $default]`. You can use doc coments ( 54 | starting with `///`) to describe nicely what they do in your system. You can use any type that 55 | implements [`FeattleValue`] and optionally provide a default. If not provided, the default 56 | will be created with `Default::default()`. 57 | 58 | ## Updating values 59 | This crate only disposes of low-level methods to load current feattles with [`Feattles::reload()`] 60 | and update their values with [`Feattles::update()`]. Please look for the crates 61 | [feattle-sync](https://crates.io/crates/feattle-sync) and 62 | [feattle-ui](https://crates.io/crates/feattle-ui) for higher-level functionalities. 63 | 64 | ## Limitations 65 | Due to some restrictions on how the macro is written, you can only use [`feattles!`] once per 66 | module. For example, the following does not compile: 67 | 68 | ```compile_fail 69 | use feattle_core::feattles; 70 | 71 | feattles! { struct A { } } 72 | feattles! { struct B { } } 73 | ``` 74 | 75 | You can work around this limitation by creating a sub-module and then re-exporting the generated 76 | struct. Note the use of `pub struct` in the second case. 77 | ```rust 78 | use feattle_core::feattles; 79 | 80 | feattles! { struct A { } } 81 | 82 | mod b { 83 | use feattle_core::feattles; 84 | feattles! { pub struct B { } } 85 | } 86 | 87 | use b::B; 88 | ``` 89 | 90 | ## Optional features 91 | 92 | - **uuid**: will add support for [`uuid::Uuid`]. 93 | 94 | ## License 95 | 96 | Licensed under either of 97 | 98 | * Apache License, Version 2.0 99 | ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 100 | * MIT license 101 | ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 102 | 103 | at your option. 104 | 105 | ## Contribution 106 | 107 | Unless you explicitly state otherwise, any contribution intentionally submitted 108 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 109 | dual licensed as above, without any additional terms or conditions. 110 | 111 | See [CONTRIBUTING.md](CONTRIBUTING.md). 112 | -------------------------------------------------------------------------------- /feattle-core/src/__internal.rs: -------------------------------------------------------------------------------- 1 | //! Internal types and re-exports used by the macros 2 | 3 | pub use crate::json_reading::FromJsonError; 4 | pub use crate::persist::{CurrentValue, Persist}; 5 | pub use crate::{FeattleDefinition, Feattles, FeattlesPrivate}; 6 | pub use parking_lot::{MappedRwLockReadGuard, RwLockReadGuard, RwLockWriteGuard}; 7 | 8 | use crate::last_reload::LastReload; 9 | use crate::persist::CurrentValues; 10 | use crate::FeattleValue; 11 | use parking_lot::RwLock; 12 | use std::error::Error; 13 | use std::fmt::{Debug, Formatter}; 14 | pub use std::sync::Arc; 15 | use std::{fmt, mem}; 16 | 17 | /// The main implementation of this crate. The struct generated by the macro [`feattles!`] is just 18 | /// a new-type over this struct. 19 | pub struct FeattlesImpl { 20 | pub persistence: Arc, 21 | pub inner_feattles: RwLock>, 22 | } 23 | 24 | /// The main content of a `Feattles` instance, protected behind a lock 25 | #[derive(Debug, Clone)] 26 | pub struct InnerFeattles { 27 | pub last_reload: LastReload, 28 | pub current_values: Option, 29 | pub feattles_struct: FS, 30 | } 31 | 32 | /// The generic representation of each feattle inside the feattles struct 33 | #[derive(Debug, Clone)] 34 | pub struct Feattle { 35 | key: &'static str, 36 | description: &'static str, 37 | value: T, 38 | default: T, 39 | current_value: Option, 40 | } 41 | 42 | #[derive(Copy, Clone, Debug)] 43 | pub struct ParseError; 44 | 45 | impl fmt::Display for ParseError { 46 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 47 | write!(f, "Matching variant not found") 48 | } 49 | } 50 | 51 | impl Error for ParseError {} 52 | 53 | /// The auto-generated internal struct will implement this trait 54 | pub trait FeattlesStruct: 'static { 55 | /// Try to update the given key, returning the previous value, if any. 56 | fn try_update( 57 | &mut self, 58 | key: &str, 59 | value: Option, 60 | ) -> Result, FromJsonError>; 61 | } 62 | 63 | impl FeattlesImpl { 64 | pub fn new(persistence: Arc, feattles_struct: FS) -> Self { 65 | FeattlesImpl { 66 | persistence, 67 | inner_feattles: RwLock::new(InnerFeattles { 68 | last_reload: LastReload::Never, 69 | current_values: None, 70 | feattles_struct, 71 | }), 72 | } 73 | } 74 | } 75 | 76 | impl Feattle { 77 | pub fn new(key: &'static str, description: &'static str, default: T) -> Self { 78 | Feattle { 79 | key, 80 | description, 81 | value: default.clone(), 82 | default, 83 | current_value: None, 84 | } 85 | } 86 | 87 | pub fn definition(&self) -> FeattleDefinition { 88 | FeattleDefinition { 89 | key: self.key, 90 | description: self.description.to_owned(), 91 | format: T::serialized_format(), 92 | value: self.value.as_json(), 93 | value_overview: self.value.overview(), 94 | default: self.default.as_json(), 95 | modified_at: self.current_value.as_ref().map(|v| v.modified_at), 96 | modified_by: self.current_value.as_ref().map(|v| v.modified_by.clone()), 97 | } 98 | } 99 | 100 | /// Try to update this value, returning the previous value, if any. 101 | pub fn try_update( 102 | &mut self, 103 | value: Option, 104 | ) -> Result, FromJsonError> { 105 | // Note: we must call `try_from_json` to fail **before** updating anything 106 | self.value = match &value { 107 | None => self.default.clone(), 108 | Some(value) => FeattleValue::try_from_json(&value.value)?, 109 | }; 110 | Ok(mem::replace(&mut self.current_value, value)) 111 | } 112 | 113 | pub fn value(&self) -> &T { 114 | &self.value 115 | } 116 | } 117 | 118 | impl Debug for FeattlesImpl { 119 | fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { 120 | f.debug_struct("FeattlesImpl") 121 | .field("persistence", &"Arc") 122 | .field("inner_feattles", &self.inner_feattles) 123 | .finish() 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /feattle-core/src/definition.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::Serialize; 3 | use serde_json::Value; 4 | use std::fmt; 5 | 6 | /// A precise description of a feattle type 7 | #[derive(Debug, Clone, Eq, PartialEq, Serialize)] 8 | pub struct SerializedFormat { 9 | /// An exact and machine-readable description of the format 10 | pub kind: SerializedFormatKind, 11 | /// A human-readable description of the format, shown by `Display` 12 | pub tag: String, 13 | } 14 | 15 | /// An exact and machine-readable description of a feattle type. 16 | /// 17 | /// This type can be used to create a nice human interface, like a HTML form, to edit the value 18 | /// of a feattle, for example. It can also be used to validate user input. 19 | #[derive(Debug, Clone, Eq, PartialEq, Serialize)] 20 | #[serde(tag = "tag", content = "content")] 21 | pub enum SerializedFormatKind { 22 | Bool, 23 | Integer, 24 | Float, 25 | String(StringFormatKind), 26 | /// An ordered list of homogenous types 27 | List(Box), 28 | /// An unordered bag of homogenous types 29 | Set(Box), 30 | /// An unordered bag of homogenous keys and values 31 | Map(StringFormatKind, Box), 32 | Optional(Box), 33 | } 34 | 35 | /// A precise description of a feattle string-type 36 | #[derive(Debug, Clone, Eq, PartialEq, Serialize)] 37 | pub struct StringFormat { 38 | /// An exact and machine-readable description of the format 39 | pub kind: StringFormatKind, 40 | /// A human-readable description of the format, shown by `Display` 41 | pub tag: String, 42 | } 43 | 44 | /// An exact and machine-readable description of a feattle string-type 45 | #[derive(Debug, Clone, Eq, PartialEq, Serialize)] 46 | #[serde(tag = "tag", content = "content")] 47 | pub enum StringFormatKind { 48 | /// Accepts any possible string. 49 | Any, 50 | /// The string must conform to the pattern, described using 51 | /// [JavaScript's RegExp syntax](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions/Cheatsheet) 52 | /// in `'u'` (Unicode) mode. 53 | /// The matching is done against the entire value, not just any subset, as if a `^(?:` was 54 | /// implied at the start of the pattern and a `)$` at the end. 55 | Pattern(&'static str), 56 | /// Only one of the listed values is accepted. 57 | Choices(&'static [&'static str]), 58 | } 59 | 60 | /// A data struct, describing a single feattle. 61 | #[derive(Debug, Clone, Serialize)] 62 | pub struct FeattleDefinition { 63 | /// The feattle's name 64 | pub key: &'static str, 65 | /// Its documentation 66 | pub description: String, 67 | /// The precise description of its format 68 | pub format: SerializedFormat, 69 | /// Its current in-memory value, as JSON 70 | pub value: Value, 71 | /// A short human description of its current in-memory value 72 | pub value_overview: String, 73 | /// Its default value, as JSON 74 | pub default: Value, 75 | /// The last time it was modified by an user 76 | pub modified_at: Option>, 77 | /// The user that last modified it 78 | pub modified_by: Option, 79 | } 80 | 81 | impl fmt::Display for SerializedFormat { 82 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 83 | write!(f, "{}", self.tag) 84 | } 85 | } 86 | 87 | impl fmt::Display for StringFormat { 88 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 89 | write!(f, "{}", self.tag) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /feattle-core/src/feattle_value.rs: -------------------------------------------------------------------------------- 1 | use crate::definition::{SerializedFormat, StringFormat}; 2 | use crate::json_reading::{ 3 | extract_array, extract_bool, extract_f64, extract_i64, extract_object, extract_str, 4 | FromJsonError, 5 | }; 6 | use crate::{SerializedFormatKind, StringFormatKind}; 7 | use serde_json::{Number, Value}; 8 | use std::collections::{BTreeMap, BTreeSet}; 9 | use std::convert::TryInto; 10 | use std::error::Error; 11 | use std::fmt::Debug; 12 | use std::fmt::Write; 13 | use std::str::FromStr; 14 | #[cfg(feature = "uuid")] 15 | use uuid::Uuid; 16 | 17 | /// The base trait for types that can be used for feattles. 18 | /// 19 | /// This lib already implements this trait for many base types from the std lib, but the user can 20 | /// make their own types compatible by providing their own logic. 21 | /// 22 | /// For types that are string based, it suffices to implement the somewhat simpler 23 | /// [`FeattleStringValue`] trait. 24 | pub trait FeattleValue: Debug + Sized { 25 | /// Convert the value to its JSON representation. 26 | fn as_json(&self) -> Value; 27 | 28 | /// Return a short overview of the current value. This is meant to give an overall idea of the 29 | /// actual value. For example, it can choose to display only the first 3 items of a large list. 30 | fn overview(&self) -> String; 31 | 32 | /// Parse from a JSON representation of the value, if possible. 33 | fn try_from_json(value: &Value) -> Result; 34 | 35 | /// Return a precise description of a feattle type. This will be consumed, for example, by the 36 | /// UI code to show an appropriate HTML form in the admin panel. 37 | fn serialized_format() -> SerializedFormat; 38 | } 39 | 40 | /// The base trait for string-types that can be used for feattles. 41 | /// 42 | /// This trait should be used for types that behave like string. A blanked implementation of 43 | /// [`FeattleValue`] for types that implement this trait will provide the necessary compatibility 44 | /// to use them as feattles. 45 | /// 46 | /// Note that this trait also requires that the type implements: 47 | /// * [`Debug`] 48 | /// * [`ToString`] 49 | /// * [`FromStr`], with a compatible error 50 | pub trait FeattleStringValue: FromStr + ToString + Debug 51 | where 52 | ::Err: Error + Send + Sync + 'static, 53 | { 54 | /// Return a precise description of a feattle type. This will be consumed, for example, by the 55 | /// UI code to show an appropriate HTML form in the admin panel. 56 | fn serialized_string_format() -> StringFormat; 57 | } 58 | 59 | impl FeattleValue for T 60 | where 61 | T: FeattleStringValue, 62 | ::Err: Error + Send + Sync + 'static, 63 | { 64 | fn as_json(&self) -> Value { 65 | Value::String(self.to_string()) 66 | } 67 | fn overview(&self) -> String { 68 | self.to_string() 69 | } 70 | fn try_from_json(value: &Value) -> Result { 71 | extract_str(value)?.parse().map_err(FromJsonError::parsing) 72 | } 73 | fn serialized_format() -> SerializedFormat { 74 | let f = Self::serialized_string_format(); 75 | SerializedFormat { 76 | kind: SerializedFormatKind::String(f.kind), 77 | tag: f.tag, 78 | } 79 | } 80 | } 81 | 82 | impl FeattleValue for bool { 83 | fn as_json(&self) -> Value { 84 | Value::Bool(*self) 85 | } 86 | fn overview(&self) -> String { 87 | self.to_string() 88 | } 89 | fn try_from_json(value: &Value) -> Result { 90 | extract_bool(value) 91 | } 92 | fn serialized_format() -> SerializedFormat { 93 | SerializedFormat { 94 | kind: SerializedFormatKind::Bool, 95 | tag: "bool".to_owned(), 96 | } 97 | } 98 | } 99 | 100 | macro_rules! impl_try_from_value_i64 { 101 | ($kind:ty) => { 102 | impl FeattleValue for $kind { 103 | fn as_json(&self) -> Value { 104 | serde_json::to_value(*self).unwrap() 105 | } 106 | fn overview(&self) -> String { 107 | self.to_string() 108 | } 109 | fn try_from_json(value: &Value) -> Result { 110 | extract_i64(value)? 111 | .try_into() 112 | .map_err(FromJsonError::parsing) 113 | } 114 | fn serialized_format() -> SerializedFormat { 115 | SerializedFormat { 116 | kind: SerializedFormatKind::Integer, 117 | tag: stringify!($kind).to_owned(), 118 | } 119 | } 120 | } 121 | }; 122 | } 123 | 124 | impl_try_from_value_i64! {u8} 125 | impl_try_from_value_i64! {i8} 126 | impl_try_from_value_i64! {u16} 127 | impl_try_from_value_i64! {i16} 128 | impl_try_from_value_i64! {u32} 129 | impl_try_from_value_i64! {i32} 130 | impl_try_from_value_i64! {u64} 131 | impl_try_from_value_i64! {i64} 132 | impl_try_from_value_i64! {usize} 133 | impl_try_from_value_i64! {isize} 134 | 135 | impl FeattleValue for f32 { 136 | fn as_json(&self) -> Value { 137 | Value::Number(Number::from_f64(*self as f64).unwrap()) 138 | } 139 | fn overview(&self) -> String { 140 | self.to_string() 141 | } 142 | fn try_from_json(value: &Value) -> Result { 143 | let n_64 = extract_f64(value)?; 144 | let n_32 = n_64 as f32; 145 | if (n_64 - n_32 as f64).abs() > 1e-6 { 146 | Err(FromJsonError::WrongKind { 147 | actual: "Number::f64", 148 | expected: "Number::f32", 149 | }) 150 | } else { 151 | Ok(n_32) 152 | } 153 | } 154 | fn serialized_format() -> SerializedFormat { 155 | SerializedFormat { 156 | kind: SerializedFormatKind::Float, 157 | tag: "f32".to_owned(), 158 | } 159 | } 160 | } 161 | 162 | impl FeattleValue for f64 { 163 | fn as_json(&self) -> Value { 164 | Value::Number(Number::from_f64(*self).unwrap()) 165 | } 166 | fn overview(&self) -> String { 167 | self.to_string() 168 | } 169 | fn try_from_json(value: &Value) -> Result { 170 | extract_f64(value) 171 | } 172 | fn serialized_format() -> SerializedFormat { 173 | SerializedFormat { 174 | kind: SerializedFormatKind::Float, 175 | tag: "f64".to_owned(), 176 | } 177 | } 178 | } 179 | 180 | #[cfg(feature = "uuid")] 181 | impl FeattleStringValue for Uuid { 182 | fn serialized_string_format() -> StringFormat { 183 | StringFormat { 184 | kind: StringFormatKind::Pattern( 185 | "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}", 186 | ), 187 | tag: "Uuid".to_owned(), 188 | } 189 | } 190 | } 191 | 192 | impl FeattleStringValue for String { 193 | fn serialized_string_format() -> StringFormat { 194 | StringFormat { 195 | kind: StringFormatKind::Any, 196 | tag: "String".to_owned(), 197 | } 198 | } 199 | } 200 | 201 | impl FeattleValue for Vec { 202 | fn as_json(&self) -> Value { 203 | Value::Array(self.iter().map(|item| item.as_json()).collect()) 204 | } 205 | fn overview(&self) -> String { 206 | format!("[{}]", iter_overview(self.iter())) 207 | } 208 | fn try_from_json(value: &Value) -> Result { 209 | let mut list = Vec::new(); 210 | for item in extract_array(value)? { 211 | list.push(T::try_from_json(item)?); 212 | } 213 | Ok(list) 214 | } 215 | fn serialized_format() -> SerializedFormat { 216 | let f = T::serialized_format(); 217 | SerializedFormat { 218 | kind: SerializedFormatKind::List(Box::new(f.kind)), 219 | tag: format!("Vec<{}>", f.tag), 220 | } 221 | } 222 | } 223 | 224 | impl FeattleValue for BTreeSet { 225 | fn as_json(&self) -> Value { 226 | Value::Array(self.iter().map(|item| item.as_json()).collect()) 227 | } 228 | fn overview(&self) -> String { 229 | format!("[{}]", iter_overview(self.iter())) 230 | } 231 | fn try_from_json(value: &Value) -> Result { 232 | let mut set = BTreeSet::new(); 233 | for item in extract_array(value)? { 234 | set.insert(T::try_from_json(item)?); 235 | } 236 | Ok(set) 237 | } 238 | fn serialized_format() -> SerializedFormat { 239 | let f = T::serialized_format(); 240 | SerializedFormat { 241 | kind: SerializedFormatKind::Set(Box::new(f.kind)), 242 | tag: format!("Set<{}>", f.tag), 243 | } 244 | } 245 | } 246 | 247 | impl FeattleValue for BTreeMap 248 | where 249 | ::Err: Error + Send + Sync + 'static, 250 | { 251 | fn as_json(&self) -> Value { 252 | Value::Object( 253 | self.iter() 254 | .map(|(item_key, item_value)| (item_key.to_string(), item_value.as_json())) 255 | .collect(), 256 | ) 257 | } 258 | fn overview(&self) -> String { 259 | // Group by value 260 | let mut keys_by_value: BTreeMap<_, Vec<_>> = BTreeMap::new(); 261 | for (key, value) in self { 262 | keys_by_value.entry(value.overview()).or_default().push(key); 263 | } 264 | 265 | let overview_by_value: Vec<_> = keys_by_value 266 | .into_iter() 267 | .map(|(value, keys)| format!("{}: {}", iter_overview(keys.into_iter()), value)) 268 | .collect(); 269 | 270 | format!("{{{}}}", iter_overview(overview_by_value.iter())) 271 | } 272 | fn try_from_json(value: &Value) -> Result { 273 | let mut map = BTreeMap::new(); 274 | for (item_key, item_value) in extract_object(value)? { 275 | map.insert( 276 | item_key.parse().map_err(FromJsonError::parsing)?, 277 | V::try_from_json(item_value)?, 278 | ); 279 | } 280 | Ok(map) 281 | } 282 | fn serialized_format() -> SerializedFormat { 283 | let fk = K::serialized_string_format(); 284 | let fv = V::serialized_format(); 285 | SerializedFormat { 286 | kind: SerializedFormatKind::Map(fk.kind, Box::new(fv.kind)), 287 | tag: format!("Map<{}, {}>", fk.tag, fv.tag), 288 | } 289 | } 290 | } 291 | 292 | impl FeattleValue for Option { 293 | fn as_json(&self) -> Value { 294 | match self { 295 | None => Value::Null, 296 | Some(inner) => inner.as_json(), 297 | } 298 | } 299 | fn overview(&self) -> String { 300 | match self { 301 | None => "None".to_owned(), 302 | Some(s) => format!("Some({})", s.overview()), 303 | } 304 | } 305 | fn try_from_json(value: &Value) -> Result { 306 | match value { 307 | Value::Null => Ok(None), 308 | other => T::try_from_json(other).map(Some), 309 | } 310 | } 311 | fn serialized_format() -> SerializedFormat { 312 | let f = T::serialized_format(); 313 | SerializedFormat { 314 | kind: SerializedFormatKind::Optional(Box::new(f.kind)), 315 | tag: format!("Option<{}>", f.tag), 316 | } 317 | } 318 | } 319 | 320 | fn iter_overview<'a, T: FeattleValue + 'a>(iter: impl Iterator) -> String { 321 | const MAX_ITEMS: usize = 3; 322 | let mut overview = String::new(); 323 | let mut iter = iter.enumerate(); 324 | 325 | while let Some((i, value)) = iter.next() { 326 | if i == MAX_ITEMS { 327 | write!(overview, ", ... {} more", iter.count() + 1).unwrap(); 328 | break; 329 | } else if i > 0 { 330 | overview += ", "; 331 | } 332 | overview += &value.overview(); 333 | } 334 | 335 | overview 336 | } 337 | 338 | #[cfg(test)] 339 | mod tests { 340 | use super::*; 341 | use serde_json::json; 342 | 343 | fn converts(value: Value, parsed: T, overview: &str) { 344 | converts2(value.clone(), parsed, overview, value); 345 | } 346 | 347 | fn converts2( 348 | value: Value, 349 | parsed: T, 350 | overview: &str, 351 | converted: Value, 352 | ) { 353 | assert_eq!(parsed.as_json(), converted); 354 | assert_eq!(parsed.overview(), overview); 355 | assert_eq!(T::try_from_json(&value).ok(), Some(parsed)); 356 | } 357 | 358 | fn fails(value: Value) { 359 | assert_eq!(T::try_from_json(&value).ok(), None); 360 | } 361 | 362 | #[test] 363 | fn bool() { 364 | converts(json!(true), true, "true"); 365 | converts(json!(false), false, "false"); 366 | 367 | fails::(json!(0)); 368 | fails::(json!(null)); 369 | 370 | assert_eq!(bool::serialized_format().kind, SerializedFormatKind::Bool); 371 | } 372 | 373 | #[test] 374 | fn int() { 375 | fn basic(parsed: T) { 376 | converts(json!(17), parsed, "17"); 377 | fails::(json!(17.5)); 378 | fails::(json!(null)); 379 | assert_eq!(T::serialized_format().kind, SerializedFormatKind::Integer); 380 | } 381 | 382 | basic(17u8); 383 | basic(17i8); 384 | basic(17u16); 385 | basic(17i16); 386 | basic(17u32); 387 | basic(17i32); 388 | basic(17u64); 389 | basic(17i64); 390 | basic(17usize); 391 | basic(17isize); 392 | 393 | fails::(json!(-17)); 394 | converts(json!(-17), -17i8, "-17"); 395 | fails::(json!(-17)); 396 | converts(json!(-17), -17i16, "-17"); 397 | fails::(json!(-17)); 398 | converts(json!(-17), -17i32, "-17"); 399 | fails::(json!(-17)); 400 | converts(json!(-17), -17i64, "-17"); 401 | fails::(json!(-17)); 402 | converts(json!(-17), -17isize, "-17"); 403 | 404 | let overview = u32::MAX.to_string(); 405 | fails::(json!(u32::MAX)); 406 | fails::(json!(u32::MAX)); 407 | fails::(json!(u32::MAX)); 408 | fails::(json!(u32::MAX)); 409 | converts(json!(u32::MAX), u32::MAX, &overview); 410 | fails::(json!(u32::MAX)); 411 | converts(json!(u32::MAX), u32::MAX as u64, &overview); 412 | converts(json!(u32::MAX), u32::MAX as i64, &overview); 413 | converts(json!(u32::MAX), u32::MAX as usize, &overview); 414 | converts(json!(u32::MAX), u32::MAX as isize, &overview); 415 | } 416 | 417 | #[test] 418 | fn float() { 419 | converts2(json!(17), 17f32, "17", json!(17.0)); 420 | converts2(json!(17), 17f64, "17", json!(17.0)); 421 | converts(json!(17.5), 17.5f32, "17.5"); 422 | converts(json!(17.5), 17.5f64, "17.5"); 423 | 424 | fails::(json!(null)); 425 | 426 | assert_eq!(f32::serialized_format().kind, SerializedFormatKind::Float); 427 | assert_eq!(f64::serialized_format().kind, SerializedFormatKind::Float); 428 | } 429 | 430 | #[test] 431 | #[cfg(feature = "uuid")] 432 | fn uuid() { 433 | converts( 434 | json!("8886fc87-93e1-4d08-9722-9fc1411b6b96"), 435 | Uuid::parse_str("8886fc87-93e1-4d08-9722-9fc1411b6b96").unwrap(), 436 | "8886fc87-93e1-4d08-9722-9fc1411b6b96", 437 | ); 438 | 439 | fails::(json!("yadayada")); 440 | let kind = Uuid::serialized_format().kind; 441 | match kind { 442 | SerializedFormatKind::String(StringFormatKind::Pattern(_)) => {} 443 | _ => panic!("invalid serialized format kind: {:?}", kind), 444 | } 445 | } 446 | 447 | #[test] 448 | fn string() { 449 | converts(json!("17"), "17".to_owned(), "17"); 450 | converts(json!(""), "".to_owned(), ""); 451 | fails::(json!(17)); 452 | fails::(json!(null)); 453 | assert_eq!( 454 | String::serialized_format().kind, 455 | SerializedFormatKind::String(StringFormatKind::Any) 456 | ); 457 | } 458 | 459 | #[test] 460 | fn vec() { 461 | converts(json!([3, 14, 15]), vec![3i32, 14, 15], "[3, 14, 15]"); 462 | converts( 463 | json!([3, 14, 15, 92]), 464 | vec![3i32, 14, 15, 92], 465 | "[3, 14, 15, ... 1 more]", 466 | ); 467 | converts( 468 | json!([3, 14, 15, 92, 65, 35]), 469 | vec![3i32, 14, 15, 92, 65, 35], 470 | "[3, 14, 15, ... 3 more]", 471 | ); 472 | fails::>(json!([3, 14, "15", 92])); 473 | assert_eq!( 474 | Vec::::serialized_format().kind, 475 | SerializedFormatKind::List(Box::new(SerializedFormatKind::Integer)) 476 | ) 477 | } 478 | 479 | #[test] 480 | fn set() { 481 | converts( 482 | json!([3, 14, 15]), 483 | vec![3, 14, 15].into_iter().collect::>(), 484 | "[3, 14, 15]", 485 | ); 486 | converts2( 487 | json!([1, 2, 4, 4, 3]), 488 | vec![1, 2, 3, 4].into_iter().collect::>(), 489 | "[1, 2, 3, ... 1 more]", 490 | json!([1, 2, 3, 4]), 491 | ); 492 | fails::>(json!([3, 14, "15", 92])); 493 | assert_eq!( 494 | BTreeSet::::serialized_format().kind, 495 | SerializedFormatKind::Set(Box::new(SerializedFormatKind::Integer)) 496 | ) 497 | } 498 | 499 | #[test] 500 | fn map() { 501 | converts( 502 | json!({ 503 | "a": 1, 504 | "b": 2, 505 | "x": 1, 506 | }), 507 | vec![ 508 | ("a".to_owned(), 1), 509 | ("b".to_owned(), 2), 510 | ("x".to_owned(), 1), 511 | ] 512 | .into_iter() 513 | .collect::>(), 514 | "{a, x: 1, b: 2}", 515 | ); 516 | fails::>(json!({ 517 | "a": "1", 518 | "b": 2, 519 | "x": 1, 520 | })); 521 | assert_eq!( 522 | BTreeMap::::serialized_format().kind, 523 | SerializedFormatKind::Map( 524 | StringFormatKind::Any, 525 | Box::new(SerializedFormatKind::Integer) 526 | ) 527 | ) 528 | } 529 | 530 | #[test] 531 | fn option() { 532 | converts(json!(17), Some(17), "Some(17)"); 533 | converts(json!(null), None::, "None"); 534 | fails::>(json!(17.5)); 535 | assert_eq!( 536 | Option::::serialized_format().kind, 537 | SerializedFormatKind::Optional(Box::new(SerializedFormatKind::Integer)) 538 | ) 539 | } 540 | 541 | #[test] 542 | fn choices() { 543 | use crate::feattle_enum; 544 | feattle_enum! {enum Choices { Red, Green, Blue }}; 545 | 546 | converts(json!("Red"), Choices::Red, "Red"); 547 | fails::(json!("Black")); 548 | assert_eq!( 549 | Choices::serialized_format().kind, 550 | SerializedFormatKind::String(StringFormatKind::Choices(&["Red", "Green", "Blue"])) 551 | ) 552 | } 553 | } 554 | -------------------------------------------------------------------------------- /feattle-core/src/json_reading.rs: -------------------------------------------------------------------------------- 1 | //! Helper free functions to read Rust values from `serde_json::Value` 2 | 3 | use crate::Error; 4 | use serde_json::{Map, Value}; 5 | 6 | /// Indicate an error that occurred while trying to read a feattle value from JSON 7 | #[derive(thiserror::Error, Debug)] 8 | pub enum FromJsonError { 9 | #[error("wrong JSON kind, got {actual} and was expecting {expected}")] 10 | WrongKind { 11 | expected: &'static str, 12 | actual: &'static str, 13 | }, 14 | #[error("failed to parse")] 15 | ParseError { 16 | cause: Box, 17 | }, 18 | } 19 | 20 | impl FromJsonError { 21 | /// Create a new [`FromJsonError::ParseError`] variant 22 | pub fn parsing(error: E) -> FromJsonError { 23 | FromJsonError::ParseError { 24 | cause: Box::new(error), 25 | } 26 | } 27 | } 28 | 29 | fn json_kind(value: &Value) -> &'static str { 30 | match value { 31 | Value::Null => "Null", 32 | Value::Bool(_) => "Bool", 33 | Value::Number(_) => "Number", 34 | Value::String(_) => "String", 35 | Value::Array(_) => "Array", 36 | Value::Object(_) => "Object", 37 | } 38 | } 39 | 40 | macro_rules! impl_extract_json { 41 | ($fn_name:ident, $output:ty, $method:ident, $expected:expr) => { 42 | #[doc = "Try to read as"] 43 | #[doc = $expected] 44 | pub fn $fn_name(value: &Value) -> Result<$output, FromJsonError> { 45 | value.$method().ok_or_else(|| FromJsonError::WrongKind { 46 | expected: $expected, 47 | actual: json_kind(value), 48 | }) 49 | } 50 | }; 51 | } 52 | 53 | impl_extract_json! { extract_array, &Vec, as_array, "Array" } 54 | impl_extract_json! { extract_bool, bool, as_bool, "Bool" } 55 | impl_extract_json! { extract_f64, f64, as_f64, "Number::f64" } 56 | impl_extract_json! { extract_i64, i64, as_i64, "Number::i64" } 57 | impl_extract_json! { extract_null, (), as_null, "Null" } 58 | impl_extract_json! { extract_object, &Map, as_object, "Object" } 59 | impl_extract_json! { extract_str, &str, as_str, "String" } 60 | impl_extract_json! { extract_u64, u64, as_u64, "Number::u64" } 61 | -------------------------------------------------------------------------------- /feattle-core/src/last_reload.rs: -------------------------------------------------------------------------------- 1 | use chrono::{DateTime, Utc}; 2 | use serde::Serialize; 3 | 4 | /// Store details of the last time the data was synchronized by calling 5 | /// [`crate::Feattles::reload()`]. 6 | #[derive(Debug, Copy, Clone, Eq, PartialEq, Serialize)] 7 | pub enum LastReload { 8 | /// The data was never updated and all feattles carry their default values. 9 | Never, 10 | /// The reload finished with success, but no data was found. All feattle carry their default 11 | /// values. 12 | NoData { reload_date: DateTime }, 13 | /// The reload finished with success. 14 | Data { 15 | reload_date: DateTime, 16 | version: i32, 17 | version_date: DateTime, 18 | }, 19 | } 20 | 21 | impl LastReload { 22 | /// Indicate when, if ever, a reload finished with success. 23 | pub fn reload_date(self) -> Option> { 24 | match self { 25 | LastReload::Never => None, 26 | LastReload::NoData { reload_date, .. } | LastReload::Data { reload_date, .. } => { 27 | Some(reload_date) 28 | } 29 | } 30 | } 31 | 32 | /// Indicate which is, if any, the current data version. Note that the value `0` is used for 33 | /// [`LastReload::NoData`]. 34 | pub fn version(self) -> Option { 35 | match self { 36 | LastReload::Never => None, 37 | LastReload::NoData { .. } => Some(0), 38 | LastReload::Data { version, .. } => Some(version), 39 | } 40 | } 41 | 42 | /// Indicate when, if known, this data version was created. 43 | pub fn version_date(self) -> Option> { 44 | match self { 45 | LastReload::Never | LastReload::NoData { .. } => None, 46 | LastReload::Data { version_date, .. } => Some(version_date), 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /feattle-core/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate is the core implementation of the feature flags (called "feattles", for short). 2 | //! 3 | //! Its main parts are the macro [`feattles!`] together with the trait [`Feattles`]. Please refer to 4 | //! the [main package - `feattle`](https://crates.io/crates/feattle) for more information. 5 | //! 6 | //! # Usage example 7 | //! ``` 8 | //! use std::sync::Arc; 9 | //! use feattle_core::{feattles, Feattles}; 10 | //! use feattle_core::persist::NoPersistence; 11 | //! 12 | //! // Declare the struct 13 | //! feattles! { 14 | //! struct MyFeattles { 15 | //! /// Is this usage considered cool? 16 | //! is_cool: bool = true, 17 | //! /// Limit the number of "blings" available. 18 | //! /// This will not change the number of "blengs", though! 19 | //! max_blings: i32, 20 | //! /// List the actions that should not be available 21 | //! blocked_actions: Vec, 22 | //! } 23 | //! } 24 | //! 25 | //! // Create a new instance (`NoPersistence` is just a mock for the persistence layer) 26 | //! let my_feattles = MyFeattles::new(Arc::new(NoPersistence)); 27 | //! 28 | //! // Read values (note the use of `*`) 29 | //! assert_eq!(*my_feattles.is_cool(), true); 30 | //! assert_eq!(*my_feattles.max_blings(), 0); 31 | //! assert_eq!(*my_feattles.blocked_actions(), Vec::::new()); 32 | //! ``` 33 | //! 34 | //! # How it works 35 | //! 36 | //! The macro will generate a struct with the given name and visibility modifier (assuming private 37 | //! by default). The generated struct implements [`Feattles`] and also exposes one method for each 38 | //! feattle. 39 | //! 40 | //! The methods created for each feattle allow reading their current value. For example, for a 41 | //! feattle `is_cool: bool`, there will be a method like 42 | //! `pub fn is_cool(&self) -> MappedRwLockReadGuard`. Note the use of 43 | //! [`parking_lot::MappedRwLockReadGuard`] because the interior of the struct is stored behind a `RwLock` to 44 | //! control concurrent access. 45 | //! 46 | //! A feattle is created with the syntax `$key: $type [= $default]`. You can use doc coments ( 47 | //! starting with `///`) to describe nicely what they do in your system. You can use any type that 48 | //! implements [`FeattleValue`] and optionally provide a default. If not provided, the default 49 | //! will be created with `Default::default()`. 50 | //! 51 | //! # Updating values 52 | //! This crate only disposes of low-level methods to load current feattles with [`Feattles::reload()`] 53 | //! and update their values with [`Feattles::update()`]. Please look for the crates 54 | //! [feattle-sync](https://crates.io/crates/feattle-sync) and 55 | //! [feattle-ui](https://crates.io/crates/feattle-ui) for higher-level functionalities. 56 | //! 57 | //! # Limitations 58 | //! Due to some restrictions on how the macro is written, you can only use [`feattles!`] once per 59 | //! module. For example, the following does not compile: 60 | //! 61 | //! ```compile_fail 62 | //! use feattle_core::feattles; 63 | //! 64 | //! feattles! { struct A { } } 65 | //! feattles! { struct B { } } 66 | //! ``` 67 | //! 68 | //! You can work around this limitation by creating a sub-module and then re-exporting the generated 69 | //! struct. Note the use of `pub struct` in the second case. 70 | //! ``` 71 | //! use feattle_core::feattles; 72 | //! 73 | //! feattles! { struct A { } } 74 | //! 75 | //! mod b { 76 | //! use feattle_core::feattles; 77 | //! feattles! { pub struct B { } } 78 | //! } 79 | //! 80 | //! use b::B; 81 | //! ``` 82 | //! 83 | //! # Optional features 84 | //! 85 | //! - **uuid**: will add support for [`uuid::Uuid`]. 86 | 87 | #[doc(hidden)] 88 | pub mod __internal; 89 | mod definition; 90 | mod feattle_value; 91 | pub mod json_reading; 92 | pub mod last_reload; 93 | /// This module only contains exported macros, that are documented at the root level. 94 | #[doc(hidden)] 95 | pub mod macros; 96 | pub mod persist; 97 | 98 | use crate::__internal::{FeattlesStruct, InnerFeattles}; 99 | use crate::json_reading::FromJsonError; 100 | use crate::last_reload::LastReload; 101 | use async_trait::async_trait; 102 | use chrono::Utc; 103 | pub use definition::*; 104 | pub use feattle_value::*; 105 | use parking_lot::{MappedRwLockReadGuard, RwLockReadGuard, RwLockWriteGuard}; 106 | use persist::*; 107 | use serde_json::Value; 108 | use std::error::Error; 109 | use std::fmt::Debug; 110 | use std::sync::Arc; 111 | use thiserror::Error; 112 | 113 | /// Represents a type-erased error that comes from some external source 114 | pub type BoxError = Box; 115 | 116 | /// The error type returned by [`Feattles::update()`] 117 | #[derive(Error, Debug)] 118 | pub enum UpdateError { 119 | /// Cannot update because current values were never successfully loaded from the persist layer 120 | #[error("cannot update because current values were never successfully loaded from the persist layer")] 121 | NeverReloaded, 122 | /// The key is unknown 123 | #[error("the key {0} is unknown")] 124 | UnknownKey(String), 125 | /// Failed to parse the value from JSON 126 | #[error("failed to parse the value from JSON")] 127 | Parsing( 128 | #[source] 129 | #[from] 130 | FromJsonError, 131 | ), 132 | /// Failed to persist new state 133 | #[error("failed to persist new state")] 134 | Persistence(#[source] BoxError), 135 | } 136 | 137 | /// The error type returned by [`Feattles::history()`] 138 | #[derive(Error, Debug)] 139 | pub enum HistoryError { 140 | /// The key is unknown 141 | #[error("the key {0} is unknown")] 142 | UnknownKey(String), 143 | /// Failed to load persisted state 144 | #[error("failed to load persisted state")] 145 | Persistence(#[source] BoxError), 146 | } 147 | 148 | /// The main trait of this crate. 149 | /// 150 | /// The struct created with [`feattles!`] will implement this trait in addition to a method for each 151 | /// feattle. Read more at the [crate documentation](crate). 152 | #[async_trait] 153 | pub trait Feattles: FeattlesPrivate { 154 | /// Create a new feattles instance, using the given persistence layer logic. 155 | /// 156 | /// All feattles will start with their default values. You can force an initial synchronization 157 | /// with [`Feattles::update`]. 158 | fn new(persistence: Arc) -> Self; 159 | 160 | /// Return a shared reference to the persistence layer. 161 | fn persistence(&self) -> &Arc; 162 | 163 | /// The list of all available keys. 164 | fn keys(&self) -> &'static [&'static str]; 165 | 166 | /// Describe one specific feattle, returning `None` if the feattle with the given name does not 167 | /// exist. 168 | fn definition(&self, key: &str) -> Option; 169 | 170 | /// Return details of the last time the data was synchronized by calling [`Feattles::reload()`]. 171 | fn last_reload(&self) -> LastReload { 172 | self._read().last_reload 173 | } 174 | 175 | /// Return a reference to the last synchronized data. The reference is behind a 176 | /// read-write lock and will block any update until it is dropped. `None` is returned if a 177 | /// successful synchronization have never happened. 178 | fn current_values(&self) -> Option> { 179 | let inner = self._read(); 180 | if inner.current_values.is_none() { 181 | None 182 | } else { 183 | Some(RwLockReadGuard::map(inner, |x| { 184 | x.current_values.as_ref().unwrap() 185 | })) 186 | } 187 | } 188 | 189 | /// Reload the current feattles' data from the persistence layer, propagating any errors 190 | /// produced by it. 191 | /// 192 | /// If any of the feattle values fail to be parsed from previously persisted values, their 193 | /// updates will be skipped. Other feattles that parsed successfully will still be updated. 194 | /// In this case, a [`log::error!`] will be generated for each time it occurs. 195 | async fn reload(&self) -> Result<(), BoxError> { 196 | let current_values = self.persistence().load_current().await?; 197 | let mut inner = self._write(); 198 | let now = Utc::now(); 199 | match current_values { 200 | None => { 201 | inner.last_reload = LastReload::NoData { reload_date: now }; 202 | let empty = CurrentValues { 203 | version: 0, 204 | date: now, 205 | feattles: Default::default(), 206 | }; 207 | inner.current_values = Some(empty); 208 | } 209 | Some(current_values) => { 210 | inner.last_reload = LastReload::Data { 211 | reload_date: now, 212 | version: current_values.version, 213 | version_date: current_values.date, 214 | }; 215 | for &key in self.keys() { 216 | let value = current_values.feattles.get(key).cloned(); 217 | log::debug!("Will update {} with {:?}", key, value); 218 | if let Err(error) = inner.feattles_struct.try_update(key, value) { 219 | log::error!("Failed to update {}: {:?}", key, error); 220 | } 221 | } 222 | inner.current_values = Some(current_values); 223 | } 224 | } 225 | Ok(()) 226 | } 227 | 228 | /// Update a single feattle, passing the new value (in JSON representation) and the user that 229 | /// is associated with this change. The change will be persisted directly. 230 | /// 231 | /// While the update is happening, the new value will already be observable from other 232 | /// execution tasks or threads. However, if the update fails, the change will be rolled back. 233 | /// 234 | /// # Consistency 235 | /// 236 | /// To avoid operating on stale data, before doing an update the caller should usually call 237 | /// [`Feattles::reload()`] to ensure data is current. 238 | async fn update( 239 | &self, 240 | key: &str, 241 | value: Value, 242 | modified_by: String, 243 | ) -> Result<(), UpdateError> { 244 | use UpdateError::*; 245 | 246 | // The update operation is made of 4 steps, each of which may fail: 247 | // 1. parse and update the inner generic struct 248 | // 2. persist the new history entry 249 | // 3. persist the new current values 250 | // 4. update the copy of the current values 251 | // If any step fails, the others will be rolled back 252 | 253 | // Assert the key exists 254 | if !self.keys().contains(&key) { 255 | return Err(UnknownKey(key.to_owned())); 256 | } 257 | 258 | let new_value = CurrentValue { 259 | modified_at: Utc::now(), 260 | modified_by, 261 | value, 262 | }; 263 | 264 | let (new_values, old_value) = { 265 | let mut inner = self._write(); 266 | 267 | // Check error condition for step 4 and prepare the new instance 268 | let mut new_values = inner.current_values.clone().ok_or(NeverReloaded)?; 269 | new_values 270 | .feattles 271 | .insert(key.to_owned(), new_value.clone()); 272 | new_values.version += 1; 273 | 274 | // Step 1 275 | let old_value = inner 276 | .feattles_struct 277 | .try_update(key, Some(new_value.clone()))?; 278 | 279 | (new_values, old_value) 280 | }; 281 | 282 | log::debug!("new_values = {:?}", new_values); 283 | 284 | let rollback_step_1 = || { 285 | // Note that if the old value was failing to parse, then the update will be final. 286 | let _ = self 287 | ._write() 288 | .feattles_struct 289 | .try_update(key, old_value.clone()); 290 | }; 291 | 292 | // Step 2: load + modify + save history 293 | let persistence = self.persistence(); 294 | let old_history = persistence 295 | .load_history(key) 296 | .await 297 | .map_err(|err| { 298 | rollback_step_1(); 299 | Persistence(err) 300 | })? 301 | .unwrap_or_default(); 302 | 303 | // Prepare updated history 304 | let new_definition = self 305 | .definition(key) 306 | .expect("the key is guaranteed to exist"); 307 | let mut new_history = old_history.clone(); 308 | new_history.entries.push(HistoryEntry { 309 | value: new_value.value.clone(), 310 | value_overview: new_definition.value_overview, 311 | modified_at: new_value.modified_at, 312 | modified_by: new_value.modified_by.clone(), 313 | }); 314 | 315 | persistence 316 | .save_history(key, &new_history) 317 | .await 318 | .map_err(|err| { 319 | rollback_step_1(); 320 | Persistence(err) 321 | })?; 322 | 323 | // Step 3 324 | if let Err(err) = persistence.save_current(&new_values).await { 325 | rollback_step_1(); 326 | if let Err(err) = self.persistence().save_history(key, &old_history).await { 327 | log::warn!("Failed to rollback history for {}: {:?}", key, err); 328 | } 329 | return Err(Persistence(err)); 330 | } 331 | 332 | // Step 4 333 | self._write().current_values = Some(new_values); 334 | 335 | Ok(()) 336 | } 337 | 338 | /// Return the definition for all the feattles. 339 | fn definitions(&self) -> Vec { 340 | self.keys() 341 | .iter() 342 | .map(|&key| { 343 | self.definition(key) 344 | .expect("since we iterate over the list of known keys, this should always work") 345 | }) 346 | .collect() 347 | } 348 | 349 | /// Return the history for a single feattle. It can be potentially empty (not entries). 350 | async fn history(&self, key: &str) -> Result { 351 | // Assert the key exists 352 | if !self.keys().contains(&key) { 353 | return Err(HistoryError::UnknownKey(key.to_owned())); 354 | } 355 | 356 | let history = self 357 | .persistence() 358 | .load_history(key) 359 | .await 360 | .map_err(HistoryError::Persistence)?; 361 | 362 | Ok(history.unwrap_or_default()) 363 | } 364 | } 365 | 366 | /// This struct is `pub` because the macro must have access to it, but should be otherwise invisible 367 | /// to the users of this crate. 368 | #[doc(hidden)] 369 | pub trait FeattlesPrivate { 370 | type FeattleStruct: FeattlesStruct; 371 | fn _read(&self) -> RwLockReadGuard>; 372 | fn _write(&self) -> RwLockWriteGuard>; 373 | } 374 | 375 | #[cfg(test)] 376 | mod tests { 377 | use super::*; 378 | use parking_lot::Mutex; 379 | use serde_json::json; 380 | use std::collections::BTreeMap; 381 | use std::sync::Arc; 382 | 383 | #[derive(Debug, thiserror::Error)] 384 | #[error("Some error")] 385 | struct SomeError; 386 | 387 | #[derive(Default)] 388 | struct MockPersistence(Mutex); 389 | 390 | #[derive(Default)] 391 | struct MockPersistenceInner { 392 | current: Option, 393 | history: BTreeMap, 394 | next_error: Option, 395 | } 396 | 397 | impl MockPersistence { 398 | fn put_error(&self) { 399 | let previous = self.0.lock().next_error.replace(Box::new(SomeError)); 400 | assert!(previous.is_none()); 401 | } 402 | 403 | fn get_error(&self) -> Result<(), BoxError> { 404 | match self.0.lock().next_error.take() { 405 | None => Ok(()), 406 | Some(e) => Err(e), 407 | } 408 | } 409 | 410 | fn unwrap_current(&self) -> CurrentValues { 411 | self.0.lock().current.clone().unwrap() 412 | } 413 | 414 | fn unwrap_history(&self, key: &str) -> ValueHistory { 415 | self.0.lock().history.get(key).cloned().unwrap() 416 | } 417 | } 418 | 419 | #[async_trait] 420 | impl Persist for MockPersistence { 421 | async fn save_current(&self, value: &CurrentValues) -> Result<(), BoxError> { 422 | self.get_error().map(|_| { 423 | self.0.lock().current = Some(value.clone()); 424 | }) 425 | } 426 | 427 | async fn load_current(&self) -> Result, BoxError> { 428 | self.get_error().map(|_| self.0.lock().current.clone()) 429 | } 430 | 431 | async fn save_history(&self, key: &str, value: &ValueHistory) -> Result<(), BoxError> { 432 | self.get_error().map(|_| { 433 | self.0.lock().history.insert(key.to_owned(), value.clone()); 434 | }) 435 | } 436 | 437 | async fn load_history(&self, key: &str) -> Result, BoxError> { 438 | self.get_error() 439 | .map(|_| self.0.lock().history.get(key).cloned()) 440 | } 441 | } 442 | 443 | #[tokio::test] 444 | async fn test() { 445 | feattles! { 446 | struct Config { 447 | /// A 448 | a: i32, 449 | b: i32 = 17 450 | } 451 | } 452 | 453 | let persistence = Arc::new(MockPersistence::default()); 454 | let config = Config::new(persistence.clone()); 455 | 456 | // Initial state 457 | assert_eq!(*config.a(), 0); 458 | assert_eq!(*config.b(), 17); 459 | assert_eq!(config.keys(), &["a", "b"]); 460 | assert!(config.last_reload() == LastReload::Never); 461 | assert!(config.current_values().is_none()); 462 | 463 | // Load from empty storage 464 | config.reload().await.unwrap(); 465 | assert_eq!(*config.a(), 0); 466 | assert_eq!(*config.b(), 17); 467 | let last_reload = config.last_reload(); 468 | assert!(matches!(last_reload, LastReload::NoData { .. })); 469 | assert!(config.current_values().is_some()); 470 | 471 | // Load from failing storage 472 | persistence.put_error(); 473 | config.reload().await.unwrap_err(); 474 | assert_eq!(config.last_reload(), last_reload); 475 | 476 | // Update value 477 | config 478 | .update("a", json!(27i32), "somebody".to_owned()) 479 | .await 480 | .unwrap(); 481 | assert_eq!(*config.a(), 27); 482 | let values = persistence.unwrap_current(); 483 | assert_eq!(values.version, 1); 484 | let value = values.feattles.get("a").unwrap(); 485 | assert_eq!(value.modified_by, "somebody"); 486 | assert_eq!(value.value, json!(27i32)); 487 | let history = persistence.unwrap_history("a"); 488 | assert_eq!(history.entries.len(), 1); 489 | assert_eq!(&history.entries[0].value, &json!(27i32)); 490 | assert_eq!(&history.entries[0].value_overview, "27"); 491 | assert_eq!(&history.entries[0].modified_by, "somebody"); 492 | 493 | // Failed to update 494 | persistence.put_error(); 495 | config 496 | .update("a", json!(207i32), "somebody else".to_owned()) 497 | .await 498 | .unwrap_err(); 499 | assert_eq!(*config.a(), 27); 500 | let values = persistence.unwrap_current(); 501 | assert_eq!(values.version, 1); 502 | let value = values.feattles.get("a").unwrap(); 503 | assert_eq!(value.modified_by, "somebody"); 504 | assert_eq!(value.value, json!(27i32)); 505 | let history = persistence.unwrap_history("a"); 506 | assert_eq!(history.entries.len(), 1); 507 | assert_eq!(&history.entries[0].value, &json!(27i32)); 508 | assert_eq!(&history.entries[0].value_overview, "27"); 509 | assert_eq!(&history.entries[0].modified_by, "somebody"); 510 | } 511 | } 512 | -------------------------------------------------------------------------------- /feattle-core/src/macros.rs: -------------------------------------------------------------------------------- 1 | /// Define an `enum` that can be used as a type for a feattle 2 | /// 3 | /// The generated `enum` will have these standard traits: `Debug`, `Clone`, `Copy`, `Eq`, 4 | /// `PartialEq`, `PartialOrd`, `Ord`, `FromStr`, `Display`. And mainly, it will implement 5 | /// [`crate::FeattleStringValue`] so that it can be used a feattle type. 6 | /// 7 | /// Only `enum`s whose variants do not carry any extra information are supported. 8 | /// 9 | /// # Examples 10 | /// In the simplest form: 11 | /// ``` 12 | /// use feattle_core::feattle_enum; 13 | /// 14 | /// feattle_enum! { 15 | /// enum Colors { Red, Green, Blue } 16 | /// } 17 | /// ``` 18 | /// 19 | /// However, it also works with other visibility keywords and additional attributes on the enum 20 | /// itself or its variants. Those attributes will not be modified by this lib, allowing composition 21 | /// with other libs. For example, you can also make the enum `Serialize`: 22 | /// ``` 23 | /// use feattle_core::feattle_enum; 24 | /// use serde::Serialize; 25 | /// 26 | /// feattle_enum! { 27 | /// #[derive(Serialize)] 28 | /// pub(crate) enum Colors { 29 | /// #[serde(rename = "R")] 30 | /// Red, 31 | /// #[serde(rename = "G")] 32 | /// Green, 33 | /// #[serde(rename = "B")] 34 | /// Blue, 35 | /// } 36 | /// } 37 | /// ``` 38 | #[macro_export] 39 | macro_rules! feattle_enum { 40 | ( 41 | $(#[$enum_meta:meta])* 42 | $visibility:vis enum $name:ident { 43 | $( 44 | $(#[$variant_meta:meta])* 45 | $variant:ident 46 | ),+ $(,)? 47 | } 48 | ) => { 49 | #[derive(Debug, Clone, Copy, Eq, PartialEq, PartialOrd, Ord)] 50 | $(#[$enum_meta])* 51 | $visibility enum $name { 52 | $( 53 | $(#[$variant_meta])* 54 | $variant 55 | ),+ 56 | } 57 | 58 | impl ::std::str::FromStr for $name { 59 | type Err = $crate::__internal::ParseError; 60 | fn from_str(s: &str) -> ::std::result::Result { 61 | match s { 62 | $( 63 | stringify!($variant) => ::std::result::Result::Ok(Self::$variant) 64 | ),+, 65 | _ => ::std::result::Result::Err($crate::__internal::ParseError) 66 | } 67 | } 68 | } 69 | 70 | impl ::std::fmt::Display for $name { 71 | fn fmt(&self, f: &mut ::std::fmt::Formatter) -> ::std::fmt::Result { 72 | let as_str = match self { 73 | $( 74 | Self::$variant => stringify!($variant) 75 | ),+ 76 | }; 77 | ::std::write!(f, "{}", as_str) 78 | } 79 | } 80 | 81 | impl $name { 82 | const VARIANTS: &'static [&'static str] = &[ 83 | $( 84 | stringify!($variant) 85 | ),+ 86 | ]; 87 | } 88 | 89 | impl $crate::FeattleStringValue for $name { 90 | fn serialized_string_format() -> $crate::StringFormat { 91 | let variants = Self::VARIANTS.join(", "); 92 | $crate::StringFormat { 93 | kind: $crate::StringFormatKind::Choices(&Self::VARIANTS), 94 | tag: format!("enum {{{}}}", variants), 95 | } 96 | } 97 | } 98 | } 99 | } 100 | 101 | #[macro_export] 102 | #[doc(hidden)] 103 | macro_rules! __init_field { 104 | ($default:expr) => { 105 | $default 106 | }; 107 | () => { 108 | Default::default() 109 | }; 110 | } 111 | 112 | /// The main macro of this crate, used to generate a struct that will provide the Feattles 113 | /// functionalities. 114 | /// 115 | /// See more at the [crate level](crate). 116 | #[macro_export] 117 | macro_rules! feattles { 118 | ( 119 | $(#[$meta:meta])* 120 | $visibility:vis struct $name:ident { 121 | $( 122 | $(#[doc=$description:tt])* 123 | $key:ident: $type:ty $(= $default:expr)? 124 | ),* 125 | $(,)? 126 | } 127 | ) => { 128 | use $crate::__internal; 129 | 130 | $(#[$meta])* 131 | #[derive(Debug)] 132 | $visibility struct $name(__internal::FeattlesImpl<__Feattles>); 133 | 134 | impl __internal::FeattlesPrivate for $name { 135 | type FeattleStruct = __Feattles; 136 | 137 | fn _read( 138 | &self, 139 | ) -> __internal::RwLockReadGuard<'_, __internal::InnerFeattles> 140 | { 141 | self.0.inner_feattles.read() 142 | } 143 | 144 | fn _write( 145 | &self, 146 | ) -> __internal::RwLockWriteGuard<'_, __internal::InnerFeattles> 147 | { 148 | self.0.inner_feattles.write() 149 | } 150 | } 151 | 152 | impl __internal::Feattles for $name { 153 | fn new(persistence: __internal::Arc) -> Self { 154 | $name(__internal::FeattlesImpl::new( 155 | persistence, 156 | __Feattles { 157 | $( 158 | $key: __internal::Feattle::new( 159 | stringify!($key), 160 | concat!($($description),*).trim(), 161 | $crate::__init_field!($($default)?), 162 | ) 163 | ),* 164 | }, 165 | )) 166 | } 167 | 168 | fn persistence(&self) -> &__internal::Arc { 169 | &self.0.persistence 170 | } 171 | 172 | fn keys(&self) -> &'static [&'static str] { 173 | &[$(stringify!($key)),*] 174 | } 175 | 176 | fn definition(&self, key: &str) -> Option<__internal::FeattleDefinition> { 177 | use __internal::FeattlesPrivate; 178 | let inner = self._read(); 179 | match key { 180 | $(stringify!($key) => Some(inner.feattles_struct.$key.definition()),)* 181 | _ => None, 182 | } 183 | } 184 | } 185 | 186 | impl $name { 187 | $( 188 | pub fn $key(&self) -> __internal::MappedRwLockReadGuard<$type> { 189 | __internal::RwLockReadGuard::map(self.0.inner_feattles.read(), |inner| { 190 | inner.feattles_struct.$key.value() 191 | }) 192 | } 193 | )* 194 | } 195 | 196 | #[derive(Debug)] 197 | pub struct __Feattles { 198 | $($key: __internal::Feattle<$type>),* 199 | } 200 | 201 | impl __internal::FeattlesStruct for __Feattles { 202 | fn try_update( 203 | &mut self, 204 | key: &str, 205 | value: Option<__internal::CurrentValue>, 206 | ) -> Result, __internal::FromJsonError> { 207 | match key { 208 | $(stringify!($key) => self.$key.try_update(value),)* 209 | _ => unreachable!(), 210 | } 211 | } 212 | } 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /feattle-core/src/persist.rs: -------------------------------------------------------------------------------- 1 | //! Define the interface with some external persistence logic 2 | //! 3 | //! This core module does not provide any concrete implementation for persisting the current and 4 | //! historical values for the feattles. Instead, it defines this extension point that can be 5 | //! used to create your own custom logic, however some implementors are available in the package 6 | //! `feattle-sync`. 7 | 8 | use crate::BoxError; 9 | use async_trait::async_trait; 10 | use chrono::{DateTime, Utc}; 11 | use serde::{Deserialize, Serialize}; 12 | use serde_json::Value; 13 | use std::collections::BTreeMap; 14 | 15 | /// Responsible for storing and loading data from a permanent storage. 16 | /// 17 | /// # Async 18 | /// The methods on this trait are async and can be implemented with the help of the `async_trait` 19 | /// crate: 20 | /// 21 | /// ``` 22 | /// use async_trait::async_trait; 23 | /// use feattle_core::BoxError; 24 | /// use feattle_core::persist::*; 25 | /// 26 | /// struct MyPersistenceLogic; 27 | /// 28 | /// #[async_trait] 29 | /// impl Persist for MyPersistenceLogic { 30 | /// async fn save_current(&self, value: &CurrentValues) -> Result<(), BoxError> { 31 | /// unimplemented!() 32 | /// } 33 | /// 34 | /// async fn load_current(&self) -> Result, BoxError> { 35 | /// unimplemented!() 36 | /// } 37 | /// 38 | /// async fn save_history(&self, key: &str, value: &ValueHistory) -> Result<(), BoxError> { 39 | /// unimplemented!() 40 | /// } 41 | /// 42 | /// async fn load_history(&self, key: &str) -> Result, BoxError> { 43 | /// unimplemented!() 44 | /// } 45 | /// } 46 | /// ``` 47 | /// 48 | /// # Errors 49 | /// The persistence layer can return an error, that will be bubbled up by other error 50 | /// types, like [`super::UpdateError`] and [`super::HistoryError`]. 51 | #[async_trait] 52 | pub trait Persist: Send + Sync { 53 | /// Save current state of all feattles. 54 | async fn save_current(&self, value: &CurrentValues) -> Result<(), BoxError>; 55 | 56 | /// Load the current state of all feattles. With no previous state existed, `Ok(None)` should be 57 | /// returned. 58 | async fn load_current(&self) -> Result, BoxError>; 59 | 60 | /// Save the full history of a single feattle. 61 | async fn save_history(&self, key: &str, value: &ValueHistory) -> Result<(), BoxError>; 62 | 63 | /// Load the full history of a single feattle. With the feattle has no history, `Ok(None)` 64 | /// should be returned. 65 | async fn load_history(&self, key: &str) -> Result, BoxError>; 66 | } 67 | 68 | /// Store the current values of all feattles 69 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 70 | pub struct CurrentValues { 71 | /// A monotonically increasing version, that can be used to detect race conditions 72 | pub version: i32, 73 | /// When this version was created 74 | pub date: DateTime, 75 | /// Data for each feattle. Some feattles may not be present in this map, since they were never 76 | /// modified. Also, some extra feattles may be present in this map because they were used in a 77 | /// previous invocation of feattles. 78 | pub feattles: BTreeMap, 79 | } 80 | 81 | /// Store the current value of a single featttle 82 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 83 | pub struct CurrentValue { 84 | /// When this modification was made 85 | pub modified_at: DateTime, 86 | /// Who did that modification 87 | pub modified_by: String, 88 | /// The value, expressed in JSON 89 | pub value: Value, 90 | } 91 | 92 | /// Store the history of modification of a single feattle 93 | #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] 94 | pub struct ValueHistory { 95 | /// The entries are not necessarily stored in any specific order 96 | pub entries: Vec, 97 | } 98 | 99 | /// Store the value at a given point in time of a single feattle 100 | #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] 101 | pub struct HistoryEntry { 102 | /// The value, expressed in JSON 103 | pub value: Value, 104 | /// A human-readable description of the value 105 | pub value_overview: String, 106 | /// When this modification was made 107 | pub modified_at: DateTime, 108 | /// Who did that modification 109 | pub modified_by: String, 110 | } 111 | 112 | /// A mock implementation that does not store the information anywhere. 113 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 114 | pub struct NoPersistence; 115 | 116 | #[async_trait] 117 | impl Persist for NoPersistence { 118 | async fn save_current(&self, _value: &CurrentValues) -> Result<(), BoxError> { 119 | Ok(()) 120 | } 121 | 122 | async fn load_current(&self) -> Result, BoxError> { 123 | Ok(None) 124 | } 125 | 126 | async fn save_history(&self, _key: &str, _value: &ValueHistory) -> Result<(), BoxError> { 127 | Ok(()) 128 | } 129 | 130 | async fn load_history(&self, _key: &str) -> Result, BoxError> { 131 | Ok(None) 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /feattle-sync/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "feattle-sync" 3 | version = "3.0.0" 4 | authors = ["Guilherme Souza "] 5 | edition = "2021" 6 | rust-version = "1.82.0" 7 | description = "Featture toggles for Rust, extensible and with background synchronization and administration UI" 8 | repository = "https://github.com/sitegui/feattle-rs" 9 | license = "MIT OR Apache-2.0" 10 | readme = "README.md" 11 | keywords = ["toggle", "feature", "flag", "flipper"] 12 | categories = ["config", "data-structures", "development-tools", "web-programming"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [features] 17 | rusoto_s3 = ["dep:rusoto_core", "dep:rusoto_s3"] 18 | aws_sdk_s3 = ["dep:aws-types", "dep:aws-sdk-s3"] 19 | 20 | [dependencies] 21 | async-trait = "0.1.40" 22 | aws-sdk-s3 = { version = "1.38.0", optional = true } 23 | aws-types = { version = "1.3.2", optional = true } 24 | feattle-core = { path = "../feattle-core", version = "3.0.0" } 25 | log = "0.4.11" 26 | rusoto_core = { version = "0.48.0", optional = true } 27 | rusoto_s3 = { version = "0.48.0", optional = true } 28 | serde = { version = "1.0.115", features = ["derive"] } 29 | serde_json = "1.0.57" 30 | thiserror = "2.0.12" 31 | tokio = { version = "1.4.0", features = ["time", "fs", "io-util", "rt"] } 32 | 33 | [dev-dependencies] 34 | aws-config = { version = "1.5.3", features = ["behavior-version-latest"] } 35 | chrono = { version = "0.4.15", features = ["serde"] } 36 | dotenv = "0.15.0" 37 | parking_lot = "0.12.0" 38 | tempfile = "3.1.0" 39 | tokio = { version = "1.4.0", features = ["macros", "rt", "test-util"] } 40 | 41 | [package.metadata.docs.rs] 42 | all-features = true 43 | -------------------------------------------------------------------------------- /feattle-sync/README.md: -------------------------------------------------------------------------------- 1 | # feattle-sync 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/feattle-sync.svg)](https://crates.io/crates/feattle-sync) 4 | [![Docs.rs](https://docs.rs/feattle-sync/badge.svg)](https://docs.rs/feattle-sync) 5 | [![CI](https://github.com/sitegui/feattle-rs/workflows/Continuous%20Integration/badge.svg)](https://github.com/sitegui/feattle-rs/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/sitegui/feattle-rs/badge.svg?branch=master)](https://coveralls.io/github/sitegui/feattle-rs?branch=master) 7 | 8 | This crate is the implementation for some synchronization strategies for the feature flags 9 | (called "feattles", for short). 10 | 11 | The crate [`feattle_core`] provides the trait [`feattle_core::persist::Persist`] as the 12 | extension point to implementors of the persistence layer logic. This crates has some useful 13 | concrete implementations: [`Disk`] and [`S3`]. Please refer to the 14 | [main package - `feattle`](https://crates.io/crates/feattle) for more information. 15 | 16 | It also provides a simple way to poll the persistence layer for updates in [`BackgroundSync`]. 17 | 18 | ## Optional features 19 | 20 | - **aws_sdk_s3**: provides [`S3`] to integrate with AWS' S3 using the crate `aws-sdk-s3` crate 21 | - **rusoto_s3**: provides [`RusotoS3`] to integrate with AWS' S3 using the crate `rusoto` crate 22 | 23 | ## License 24 | 25 | Licensed under either of 26 | 27 | * Apache License, Version 2.0 28 | ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 29 | * MIT license 30 | ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 31 | 32 | at your option. 33 | 34 | ## Contribution 35 | 36 | Unless you explicitly state otherwise, any contribution intentionally submitted 37 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 38 | dual licensed as above, without any additional terms or conditions. 39 | 40 | See [CONTRIBUTING.md](CONTRIBUTING.md). 41 | -------------------------------------------------------------------------------- /feattle-sync/src/aws_sdk_s3.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use aws_sdk_s3::operation::get_object::GetObjectError; 3 | use aws_sdk_s3::primitives::ByteStream; 4 | use aws_sdk_s3::Client; 5 | use aws_types::SdkConfig; 6 | use feattle_core::persist::{CurrentValues, Persist, ValueHistory}; 7 | use feattle_core::BoxError; 8 | use serde::de::DeserializeOwned; 9 | use serde::Serialize; 10 | use std::fmt; 11 | 12 | /// Persist the data in an [AWS S3](https://aws.amazon.com/s3/) bucket. 13 | /// 14 | /// To use it, make sure to activate the cargo feature `"aws_sdk_s3"` in your `Cargo.toml`. 15 | /// 16 | /// # Example 17 | /// ``` 18 | /// use std::sync::Arc; 19 | /// use std::time::Duration; 20 | /// use feattle_core::{feattles, Feattles}; 21 | /// use feattle_sync::S3; 22 | /// 23 | /// feattles! { 24 | /// struct MyToggles { 25 | /// a: bool, 26 | /// } 27 | /// } 28 | /// 29 | /// #[tokio::main] 30 | /// async fn main() { 31 | /// // Create an AWS config, read more at the official documentation 32 | /// use feattle_sync::S3; 33 | /// let config = aws_config::load_from_env().await; 34 | /// 35 | /// let persistence = Arc::new(S3::new( 36 | /// &config, 37 | /// "my-bucket".to_owned(), 38 | /// "some/s3/prefix/".to_owned(), 39 | /// )); 40 | /// let my_toggles = MyToggles::new(persistence); 41 | /// } 42 | /// ``` 43 | #[derive(Clone)] 44 | pub struct S3 { 45 | client: Client, 46 | bucket: String, 47 | prefix: String, 48 | } 49 | 50 | impl fmt::Debug for S3 { 51 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 52 | f.debug_struct("S3") 53 | .field("client", &"S3Client") 54 | .field("bucket", &self.bucket) 55 | .field("prefix", &self.prefix) 56 | .finish() 57 | } 58 | } 59 | 60 | impl S3 { 61 | pub fn new(config: &SdkConfig, bucket: String, prefix: String) -> Self { 62 | S3 { 63 | client: Client::new(config), 64 | bucket, 65 | prefix, 66 | } 67 | } 68 | 69 | async fn save(&self, name: &str, value: T) -> Result<(), BoxError> { 70 | let key = format!("{}{}", self.prefix, name); 71 | let contents = serde_json::to_vec(&value)?; 72 | self.client 73 | .put_object() 74 | .bucket(self.bucket.clone()) 75 | .key(key) 76 | .body(ByteStream::from(contents)) 77 | .send() 78 | .await?; 79 | 80 | Ok(()) 81 | } 82 | 83 | async fn load(&self, name: &str) -> Result, BoxError> { 84 | let key = format!("{}{}", self.prefix, name); 85 | let get_object = self 86 | .client 87 | .get_object() 88 | .bucket(self.bucket.clone()) 89 | .key(key) 90 | .send() 91 | .await 92 | .map_err(|x| x.into_service_error()); 93 | match get_object { 94 | Err(GetObjectError::NoSuchKey(_)) => Ok(None), 95 | Ok(response) => { 96 | let contents = response.body.collect().await?.to_vec(); 97 | Ok(Some(serde_json::from_slice(&contents)?)) 98 | } 99 | Err(error) => Err(error.into()), 100 | } 101 | } 102 | } 103 | 104 | #[async_trait] 105 | impl Persist for S3 { 106 | async fn save_current(&self, value: &CurrentValues) -> Result<(), BoxError> { 107 | self.save("current.json", value).await 108 | } 109 | 110 | async fn load_current(&self) -> Result, BoxError> { 111 | self.load("current.json").await 112 | } 113 | 114 | async fn save_history(&self, key: &str, value: &ValueHistory) -> Result<(), BoxError> { 115 | self.save(&format!("history-{}.json", key), value).await 116 | } 117 | 118 | async fn load_history(&self, key: &str) -> Result, BoxError> { 119 | self.load(&format!("history-{}.json", key)).await 120 | } 121 | } 122 | 123 | #[cfg(test)] 124 | mod tests { 125 | use super::*; 126 | use crate::tests::test_persistence; 127 | use aws_sdk_s3::types::{Delete, ObjectIdentifier}; 128 | 129 | #[tokio::test] 130 | async fn s3() { 131 | use std::env; 132 | 133 | dotenv::dotenv().ok(); 134 | 135 | // Please set the environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, 136 | // AWS_REGION, S3_BUCKET and S3_KEY_PREFIX accordingly 137 | let config = aws_config::load_from_env().await; 138 | let bucket = env::var("S3_BUCKET").unwrap(); 139 | let prefix = format!("{}/aws-sdk-s3", env::var("S3_KEY_PREFIX").unwrap()); 140 | let client = Client::new(&config); 141 | 142 | // Clear all previous objects 143 | let objects_to_delete = client 144 | .list_objects_v2() 145 | .bucket(&bucket) 146 | .prefix(&prefix) 147 | .send() 148 | .await 149 | .unwrap() 150 | .contents 151 | .unwrap_or_default(); 152 | let keys_to_delete: Vec<_> = objects_to_delete 153 | .into_iter() 154 | .filter_map(|o| o.key) 155 | .collect(); 156 | 157 | if !keys_to_delete.is_empty() { 158 | println!( 159 | "Will first clear previous objects in S3: {:?}", 160 | keys_to_delete 161 | ); 162 | 163 | let mut delete_builder = Delete::builder(); 164 | for key in keys_to_delete { 165 | delete_builder = 166 | delete_builder.objects(ObjectIdentifier::builder().key(key).build().unwrap()); 167 | } 168 | 169 | client 170 | .delete_objects() 171 | .bucket(&bucket) 172 | .delete(delete_builder.build().unwrap()) 173 | .send() 174 | .await 175 | .unwrap(); 176 | } 177 | 178 | test_persistence(S3::new(&config, bucket, prefix)).await; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /feattle-sync/src/background_sync.rs: -------------------------------------------------------------------------------- 1 | use feattle_core::{BoxError, Feattles}; 2 | use std::sync::{Arc, Weak}; 3 | use std::time::Duration; 4 | use tokio::task::JoinHandle; 5 | use tokio::time::sleep; 6 | 7 | /// Spawn a tokio task to poll [`Feattles::reload()`] continuously 8 | /// 9 | /// A feattles instance will only ask the persistence layer for the current values when the 10 | /// [`Feattles::reload()`] method is called. This type would do so regularly for you, until the 11 | /// [`Feattles`] instance is dropped. 12 | /// 13 | /// # Example 14 | /// ``` 15 | /// # #[tokio::main] 16 | /// # async fn main() { 17 | /// use feattle_core::{feattles, Feattles}; 18 | /// use feattle_sync::BackgroundSync; 19 | /// use feattle_core::persist::NoPersistence; 20 | /// use std::sync::Arc; 21 | /// 22 | /// feattles! { 23 | /// struct MyToggles { 24 | /// a: bool, 25 | /// } 26 | /// } 27 | /// 28 | /// // `NoPersistence` here is just a mock for the sake of the example 29 | /// let toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence))); 30 | /// 31 | /// BackgroundSync::new(&toggles).start().await; 32 | /// # } 33 | /// ``` 34 | #[derive(Debug)] 35 | pub struct BackgroundSync { 36 | ok_interval: Duration, 37 | err_interval: Duration, 38 | feattles: Weak, 39 | } 40 | 41 | impl BackgroundSync { 42 | /// Create a new poller for the given feattles instance. It will call [`Arc::downgrade()`] to 43 | /// detect when the value is dropped. 44 | pub fn new(feattles: &Arc) -> Self { 45 | BackgroundSync { 46 | ok_interval: Duration::from_secs(30), 47 | err_interval: Duration::from_secs(60), 48 | feattles: Arc::downgrade(feattles), 49 | } 50 | } 51 | 52 | /// Set both [`Self::ok_interval`] and [`Self::err_interval`] 53 | pub fn interval(&mut self, value: Duration) -> &mut Self { 54 | self.ok_interval = value; 55 | self.err_interval = value; 56 | self 57 | } 58 | 59 | /// After a successful reload, will wait for this long before starting the next one. By default 60 | /// this is 30 seconds. 61 | pub fn ok_interval(&mut self, value: Duration) -> &mut Self { 62 | self.ok_interval = value; 63 | self 64 | } 65 | 66 | /// After a failed reload, will wait for this long before starting the next one. By default 67 | /// this is 60 seconds. 68 | pub fn err_interval(&mut self, value: Duration) -> &mut Self { 69 | self.err_interval = value; 70 | self 71 | } 72 | } 73 | 74 | impl BackgroundSync { 75 | /// Spawn a new tokio task, returning its handle. Usually you do not want to anything with the 76 | /// returned handle, since the task will run by itself until the feattles instance gets dropped. 77 | /// 78 | /// Operational logs are generated with the crate [`log`]. 79 | #[deprecated = "use `start_sync()` that will try a first update right away"] 80 | pub fn spawn(self) -> JoinHandle<()> { 81 | tokio::spawn(async move { 82 | while let Some(feattles) = self.feattles.upgrade() { 83 | match feattles.reload().await { 84 | Ok(()) => { 85 | log::debug!("Feattles updated"); 86 | sleep(self.ok_interval).await; 87 | } 88 | Err(err) => { 89 | log::warn!("Failed to sync Feattles: {:?}", err); 90 | sleep(self.err_interval).await; 91 | } 92 | } 93 | } 94 | 95 | log::info!("Stop background sync since Feattles got dropped") 96 | }) 97 | } 98 | 99 | /// Start the sync operation by executing an update right now and then spawning a new tokio 100 | /// task. 101 | /// 102 | /// This call will block until the first update returns. If it fails, the obtained error will be 103 | /// returned. 104 | /// 105 | /// Note that the return type is `Option<_>` and not `Result<_>`, to avoid confusion: even if 106 | /// the first update fails, the sync process will continue in the background. 107 | /// 108 | /// The tokio task will run by itself until the feattles instance gets dropped. 109 | /// 110 | /// Operational logs are generated with the crate [`log`]. 111 | pub async fn start(self) -> Option { 112 | let feattles = self.feattles.upgrade()?; 113 | 114 | let first_error = feattles.reload().await.err(); 115 | let first_sleep = match &first_error { 116 | Some(err) => { 117 | log::warn!("Failed to sync Feattles: {:?}", err); 118 | self.err_interval 119 | } 120 | None => { 121 | log::debug!("Feattles updated"); 122 | self.ok_interval 123 | } 124 | }; 125 | 126 | tokio::spawn(async move { 127 | sleep(first_sleep).await; 128 | 129 | while let Some(feattles) = self.feattles.upgrade() { 130 | match feattles.reload().await { 131 | Ok(()) => { 132 | log::debug!("Feattles updated"); 133 | sleep(self.ok_interval).await; 134 | } 135 | Err(err) => { 136 | log::warn!("Failed to sync Feattles: {:?}", err); 137 | sleep(self.err_interval).await; 138 | } 139 | } 140 | } 141 | 142 | log::info!("Stop background sync since Feattles got dropped") 143 | }); 144 | 145 | first_error 146 | } 147 | } 148 | 149 | #[cfg(test)] 150 | mod tests { 151 | use super::*; 152 | use async_trait::async_trait; 153 | use feattle_core::persist::{CurrentValues, Persist, ValueHistory}; 154 | use feattle_core::{feattles, BoxError, Feattles}; 155 | use parking_lot::Mutex; 156 | use tokio::time; 157 | use tokio::time::Instant; 158 | 159 | #[derive(Debug, thiserror::Error)] 160 | #[error("Some error")] 161 | struct SomeError; 162 | 163 | #[derive(Clone)] 164 | struct MockPersistence { 165 | call_instants: Arc>>, 166 | } 167 | 168 | impl MockPersistence { 169 | fn new() -> Self { 170 | MockPersistence { 171 | call_instants: Arc::new(Mutex::new(vec![Instant::now()])), 172 | } 173 | } 174 | 175 | fn call_intervals(&self) -> Vec { 176 | self.call_instants 177 | .lock() 178 | .windows(2) 179 | .map(|instants| instants[1] - instants[0]) 180 | .collect() 181 | } 182 | } 183 | 184 | #[async_trait] 185 | impl Persist for MockPersistence { 186 | async fn save_current(&self, _value: &CurrentValues) -> Result<(), BoxError> { 187 | unimplemented!() 188 | } 189 | async fn load_current(&self) -> Result, BoxError> { 190 | let mut call_instants = self.call_instants.lock(); 191 | call_instants.push(Instant::now()); 192 | if call_instants.len() == 3 { 193 | // Second call returns an error 194 | Err(Box::new(SomeError)) 195 | } else { 196 | Ok(None) 197 | } 198 | } 199 | async fn save_history(&self, _key: &str, _value: &ValueHistory) -> Result<(), BoxError> { 200 | unimplemented!() 201 | } 202 | async fn load_history(&self, _key: &str) -> Result, BoxError> { 203 | unimplemented!() 204 | } 205 | } 206 | 207 | #[tokio::test] 208 | async fn test() { 209 | feattles! { 210 | struct MyToggles { } 211 | } 212 | 213 | time::pause(); 214 | 215 | let persistence = Arc::new(MockPersistence::new()); 216 | let toggles = Arc::new(MyToggles::new(persistence.clone())); 217 | BackgroundSync::new(&toggles).start().await; 218 | 219 | // First update: success 220 | // Second update after 30s: fails 221 | // Third update after 60s: success 222 | // Forth update after 30s 223 | loop { 224 | let call_intervals = persistence.call_intervals(); 225 | if call_intervals.len() == 4 { 226 | assert_eq!(call_intervals[0].as_secs_f32().round() as i32, 0); 227 | assert_eq!(call_intervals[1].as_secs_f32().round() as i32, 30); 228 | assert_eq!(call_intervals[2].as_secs_f32().round() as i32, 60); 229 | assert_eq!(call_intervals[3].as_secs_f32().round() as i32, 30); 230 | break; 231 | } 232 | tokio::task::yield_now().await; 233 | time::sleep(Duration::from_millis(100)).await; 234 | } 235 | 236 | // No more updates 237 | drop(toggles); 238 | for _ in 0..5 { 239 | tokio::task::yield_now().await; 240 | } 241 | assert_eq!(persistence.call_intervals().len(), 4); 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /feattle-sync/src/disk.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use feattle_core::persist::*; 3 | use feattle_core::BoxError; 4 | use serde::de::DeserializeOwned; 5 | use serde::Serialize; 6 | use std::io::ErrorKind; 7 | use std::path::PathBuf; 8 | use tokio::fs::{create_dir_all, File}; 9 | use tokio::io::{AsyncReadExt, AsyncWriteExt}; 10 | 11 | /// Persist the data in the local filesystem, under a given directory. 12 | /// 13 | /// At every save action, if the directory does not exist, it will be created. 14 | /// 15 | /// # Example 16 | /// ``` 17 | /// use std::sync::Arc; 18 | /// use feattle_core::{feattles, Feattles}; 19 | /// use feattle_sync::Disk; 20 | /// 21 | /// feattles! { 22 | /// struct MyToggles { 23 | /// a: bool, 24 | /// } 25 | /// } 26 | /// 27 | /// let my_toggles = MyToggles::new(Arc::new(Disk::new("some/local/directory"))); 28 | /// ``` 29 | #[derive(Debug, Clone)] 30 | pub struct Disk { 31 | dir: PathBuf, 32 | } 33 | 34 | impl Disk { 35 | pub fn new>(dir: P) -> Self { 36 | let dir = dir.into(); 37 | Disk { dir } 38 | } 39 | 40 | async fn save(&self, name: &str, value: T) -> Result<(), BoxError> { 41 | create_dir_all(&self.dir).await?; 42 | 43 | let contents = serde_json::to_string(&value)?; 44 | let mut file = File::create(self.dir.join(name)).await?; 45 | file.write_all(contents.as_bytes()) 46 | .await 47 | .map_err(Into::into) 48 | } 49 | 50 | async fn load(&self, name: &str) -> Result, BoxError> { 51 | match File::open(self.dir.join(name)).await { 52 | Err(err) if err.kind() == ErrorKind::NotFound => Ok(None), 53 | Err(err) => Err(err.into()), 54 | Ok(mut file) => { 55 | let mut contents = String::new(); 56 | file.read_to_string(&mut contents).await?; 57 | Ok(Some(serde_json::from_str(&contents)?)) 58 | } 59 | } 60 | } 61 | } 62 | 63 | #[async_trait] 64 | impl Persist for Disk { 65 | async fn save_current(&self, value: &CurrentValues) -> Result<(), BoxError> { 66 | self.save("current.json", value).await 67 | } 68 | 69 | async fn load_current(&self) -> Result, BoxError> { 70 | self.load("current.json").await 71 | } 72 | 73 | async fn save_history(&self, key: &str, value: &ValueHistory) -> Result<(), BoxError> { 74 | self.save(&format!("history-{}.json", key), value).await 75 | } 76 | 77 | async fn load_history(&self, key: &str) -> Result, BoxError> { 78 | self.load(&format!("history-{}.json", key)).await 79 | } 80 | } 81 | 82 | #[cfg(test)] 83 | mod tests { 84 | use super::*; 85 | use crate::tests::test_persistence; 86 | 87 | #[tokio::test] 88 | async fn disk() { 89 | let dir = tempfile::TempDir::new().unwrap(); 90 | test_persistence(Disk::new(dir.path())).await; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /feattle-sync/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate is the implementation for some synchronization strategies for the feature flags 2 | //! (called "feattles", for short). 3 | //! 4 | //! The crate [`feattle_core`] provides the trait [`feattle_core::persist::Persist`] as the 5 | //! extension point to implementors of the persistence layer logic. This crates has some useful 6 | //! concrete implementations: [`Disk`] and [`S3`]. Please refer to the 7 | //! [main package - `feattle`](https://crates.io/crates/feattle) for more information. 8 | //! 9 | //! It also provides a simple way to poll the persistence layer for updates in [`BackgroundSync`]. 10 | //! 11 | //! # Optional features 12 | //! 13 | //! - **aws_sdk_s3**: provides [`S3`] to integrate with AWS' S3 using the crate `aws-sdk-s3` crate 14 | //! - **rusoto_s3**: provides [`RusotoS3`] to integrate with AWS' S3 using the crate `rusoto` crate 15 | 16 | #[cfg(feature = "aws_sdk_s3")] 17 | mod aws_sdk_s3; 18 | mod background_sync; 19 | mod disk; 20 | #[cfg(feature = "rusoto_s3")] 21 | mod rusoto_s3; 22 | 23 | #[cfg(feature = "aws_sdk_s3")] 24 | pub use aws_sdk_s3::*; 25 | pub use background_sync::*; 26 | pub use disk::*; 27 | #[cfg(feature = "rusoto_s3")] 28 | pub use rusoto_s3::*; 29 | 30 | #[cfg(test)] 31 | pub mod tests { 32 | use chrono::Utc; 33 | use serde_json::json; 34 | 35 | use feattle_core::persist::{CurrentValue, CurrentValues, HistoryEntry, Persist, ValueHistory}; 36 | 37 | pub async fn test_persistence(persistence: P) { 38 | // Empty state 39 | assert_eq!(persistence.load_current().await.unwrap(), None); 40 | assert_eq!(persistence.load_history("key").await.unwrap(), None); 41 | 42 | // Save new values and check if correctly saved 43 | let feattles = vec![( 44 | "key".to_string(), 45 | CurrentValue { 46 | modified_at: Utc::now(), 47 | modified_by: "someone".to_owned(), 48 | value: json!(17i32), 49 | }, 50 | )] 51 | .into_iter() 52 | .collect(); 53 | let current_values = CurrentValues { 54 | version: 17, 55 | date: Utc::now(), 56 | feattles, 57 | }; 58 | persistence.save_current(¤t_values).await.unwrap(); 59 | assert_eq!( 60 | persistence.load_current().await.unwrap(), 61 | Some(current_values) 62 | ); 63 | 64 | // Save history and check if correctly saved 65 | let history = ValueHistory { 66 | entries: vec![HistoryEntry { 67 | value: json!(17i32), 68 | value_overview: "overview".to_owned(), 69 | modified_at: Utc::now(), 70 | modified_by: "someone else".to_owned(), 71 | }], 72 | }; 73 | persistence.save_history("key", &history).await.unwrap(); 74 | assert_eq!( 75 | persistence.load_history("key").await.unwrap(), 76 | Some(history) 77 | ); 78 | assert_eq!(persistence.load_history("key2").await.unwrap(), None); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /feattle-sync/src/rusoto_s3.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use feattle_core::persist::{CurrentValues, Persist, ValueHistory}; 3 | use feattle_core::BoxError; 4 | use rusoto_core::RusotoError; 5 | use rusoto_s3::{GetObjectError, GetObjectRequest, PutObjectRequest, S3Client, S3}; 6 | use serde::de::DeserializeOwned; 7 | use serde::Serialize; 8 | use std::fmt; 9 | use std::time::Duration; 10 | use tokio::io::AsyncReadExt; 11 | use tokio::time; 12 | 13 | /// Persist the data in an [AWS S3](https://aws.amazon.com/s3/) bucket. 14 | /// 15 | /// To use it, make sure to activate the cargo feature `"rusoto_s3"` in your `Cargo.toml`. 16 | /// 17 | /// # Example 18 | /// ``` 19 | /// use std::sync::Arc; 20 | /// use std::time::Duration; 21 | /// use feattle_core::{feattles, Feattles}; 22 | /// use feattle_sync::RusotoS3; 23 | /// use rusoto_s3::S3Client; 24 | /// use rusoto_core::Region; 25 | /// 26 | /// feattles! { 27 | /// struct MyToggles { 28 | /// a: bool, 29 | /// } 30 | /// } 31 | /// 32 | /// // Create a S3 client, read more at the official documentation https://www.rusoto.org 33 | /// let s3_client = S3Client::new(Region::default()); 34 | /// 35 | /// let timeout = Duration::from_secs(10); 36 | /// let persistence = Arc::new(RusotoS3::new( 37 | /// s3_client, 38 | /// "my-bucket".to_owned(), 39 | /// "some/s3/prefix/".to_owned(), 40 | /// timeout, 41 | /// )); 42 | /// let my_toggles = MyToggles::new(persistence); 43 | /// ``` 44 | #[derive(Clone)] 45 | pub struct RusotoS3 { 46 | client: S3Client, 47 | bucket: String, 48 | prefix: String, 49 | timeout: Duration, 50 | } 51 | 52 | impl fmt::Debug for RusotoS3 { 53 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 54 | f.debug_struct("S3") 55 | .field("client", &"S3Client") 56 | .field("bucket", &self.bucket) 57 | .field("prefix", &self.prefix) 58 | .finish() 59 | } 60 | } 61 | 62 | impl RusotoS3 { 63 | pub fn new(client: S3Client, bucket: String, prefix: String, timeout: Duration) -> Self { 64 | RusotoS3 { 65 | client, 66 | bucket, 67 | prefix, 68 | timeout, 69 | } 70 | } 71 | 72 | async fn save(&self, name: &str, value: T) -> Result<(), BoxError> { 73 | let key = format!("{}{}", self.prefix, name); 74 | let contents = serde_json::to_string(&value)?; 75 | let put_future = self.client.put_object(PutObjectRequest { 76 | body: Some(contents.into_bytes().into()), 77 | bucket: self.bucket.clone(), 78 | key, 79 | ..Default::default() 80 | }); 81 | time::timeout(self.timeout, put_future).await??; 82 | 83 | Ok(()) 84 | } 85 | 86 | async fn load(&self, name: &str) -> Result, BoxError> { 87 | let key = format!("{}{}", self.prefix, name); 88 | let get_future = self.client.get_object(GetObjectRequest { 89 | bucket: self.bucket.clone(), 90 | key, 91 | ..Default::default() 92 | }); 93 | match time::timeout(self.timeout, get_future).await? { 94 | Err(RusotoError::Service(GetObjectError::NoSuchKey(_))) => Ok(None), 95 | Ok(response) => match response.body { 96 | None => Ok(None), 97 | Some(body) => { 98 | let mut contents = String::new(); 99 | body.into_async_read().read_to_string(&mut contents).await?; 100 | Ok(Some(serde_json::from_str(&contents)?)) 101 | } 102 | }, 103 | Err(error) => Err(error.into()), 104 | } 105 | } 106 | } 107 | 108 | #[async_trait] 109 | impl Persist for RusotoS3 { 110 | async fn save_current(&self, value: &CurrentValues) -> Result<(), BoxError> { 111 | self.save("current.json", value).await 112 | } 113 | 114 | async fn load_current(&self) -> Result, BoxError> { 115 | self.load("current.json").await 116 | } 117 | 118 | async fn save_history(&self, key: &str, value: &ValueHistory) -> Result<(), BoxError> { 119 | self.save(&format!("history-{}.json", key), value).await 120 | } 121 | 122 | async fn load_history(&self, key: &str) -> Result, BoxError> { 123 | self.load(&format!("history-{}.json", key)).await 124 | } 125 | } 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | use super::*; 130 | use crate::tests::test_persistence; 131 | 132 | #[tokio::test] 133 | async fn s3() { 134 | use rusoto_core::Region; 135 | use rusoto_s3::{ 136 | Delete, DeleteObjectsRequest, ListObjectsV2Request, ObjectIdentifier, S3Client, S3, 137 | }; 138 | use std::env; 139 | 140 | dotenv::dotenv().ok(); 141 | 142 | // Please set the environment variables AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, 143 | // AWS_REGION, S3_BUCKET and S3_KEY_PREFIX accordingly 144 | let client = S3Client::new(Region::default()); 145 | let bucket = env::var("S3_BUCKET").unwrap(); 146 | let prefix = format!("{}/rusoto-s3", env::var("S3_KEY_PREFIX").unwrap()); 147 | 148 | // Clear all previous objects 149 | let objects_to_delete = client 150 | .list_objects_v2(ListObjectsV2Request { 151 | bucket: bucket.clone(), 152 | prefix: Some(prefix.clone()), 153 | ..Default::default() 154 | }) 155 | .await 156 | .unwrap() 157 | .contents 158 | .unwrap_or_default(); 159 | let keys_to_delete: Vec<_> = objects_to_delete 160 | .into_iter() 161 | .filter_map(|o| o.key) 162 | .collect(); 163 | 164 | if !keys_to_delete.is_empty() { 165 | println!( 166 | "Will first clear previous objects in S3: {:?}", 167 | keys_to_delete 168 | ); 169 | client 170 | .delete_objects(DeleteObjectsRequest { 171 | bucket: bucket.clone(), 172 | delete: Delete { 173 | objects: keys_to_delete 174 | .into_iter() 175 | .map(|key| ObjectIdentifier { 176 | key, 177 | version_id: None, 178 | }) 179 | .collect(), 180 | ..Default::default() 181 | }, 182 | ..Default::default() 183 | }) 184 | .await 185 | .unwrap(); 186 | } 187 | 188 | let timeout = Duration::from_secs(10); 189 | test_persistence(RusotoS3::new(client, bucket, prefix, timeout)).await; 190 | } 191 | } 192 | -------------------------------------------------------------------------------- /feattle-ui/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "feattle-ui" 3 | version = "4.0.0" 4 | authors = ["Guilherme Souza "] 5 | edition = "2021" 6 | rust-version = "1.82.0" 7 | description = "Featture toggles for Rust, extensible and with background synchronization and administration UI" 8 | repository = "https://github.com/sitegui/feattle-rs" 9 | license = "MIT OR Apache-2.0" 10 | readme = "README.md" 11 | keywords = ["toggle", "feature", "flag", "flipper"] 12 | categories = ["config", "data-structures", "development-tools", "web-programming"] 13 | 14 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 15 | 16 | [dependencies] 17 | async-trait = "0.1.88" 18 | axum = { version = "0.8.1", optional = true, default-features = false, features = ["form", "json"] } 19 | chrono = { version = "0.4.15", features = ["serde"] } 20 | feattle-core = { path = "../feattle-core", version = "3.0.0" } 21 | handlebars = "6.3.1" 22 | log = "0.4.11" 23 | serde = { version = "1.0.115", features = ["derive"] } 24 | serde_json = "1.0.57" 25 | thiserror = "2.0.12" 26 | warp = { version = "0.3.0", optional = true } 27 | 28 | [dev-dependencies] 29 | axum = { version = "0.8.1", features = ["tokio"] } 30 | tokio = { version = "1.4.0", features = ["macros", "rt", "rt-multi-thread"] } 31 | 32 | [package.metadata.docs.rs] 33 | all-features = true 34 | -------------------------------------------------------------------------------- /feattle-ui/README.md: -------------------------------------------------------------------------------- 1 | # feattle-ui 2 | 3 | [![Crates.io](https://img.shields.io/crates/v/feattle-ui.svg)](https://crates.io/crates/feattle-ui) 4 | [![Docs.rs](https://docs.rs/feattle-ui/badge.svg)](https://docs.rs/feattle-ui) 5 | [![CI](https://github.com/sitegui/feattle-rs/workflows/Continuous%20Integration/badge.svg)](https://github.com/sitegui/feattle-rs/actions) 6 | [![Coverage Status](https://coveralls.io/repos/github/sitegui/feattle-rs/badge.svg?branch=master)](https://coveralls.io/github/sitegui/feattle-rs?branch=master) 7 | 8 | This crate implements an administration Web Interface for visualizing and modifying the feature 9 | flags (called "feattles", for short). 10 | 11 | It provides a web-framework-agnostic implementation in [`AdminPanel`] and ready-to-use bindings 12 | for `warp` and `axum`. Please refer to the 13 | [main package - `feattle`](https://crates.io/crates/feattle) for more information. 14 | 15 | Note that authentication is **not** provided out-of-the-box and you're the one responsible for 16 | controlling and protecting the access. 17 | 18 | ## Optional features 19 | 20 | - **axum**: provides [`axum_router`] for a read-to-use integration with [`axum`] 21 | - **warp**: provides [`run_warp_server`] for a read-to-use integration with [`warp`] 22 | 23 | ## License 24 | 25 | Licensed under either of 26 | 27 | * Apache License, Version 2.0 28 | ([LICENSE-APACHE](LICENSE-APACHE) or http://www.apache.org/licenses/LICENSE-2.0) 29 | * MIT license 30 | ([LICENSE-MIT](LICENSE-MIT) or http://opensource.org/licenses/MIT) 31 | 32 | at your option. 33 | 34 | ## Contribution 35 | 36 | Unless you explicitly state otherwise, any contribution intentionally submitted 37 | for inclusion in the work by you, as defined in the Apache-2.0 license, shall be 38 | dual licensed as above, without any additional terms or conditions. 39 | 40 | See [CONTRIBUTING.md](CONTRIBUTING.md). 41 | -------------------------------------------------------------------------------- /feattle-ui/src/api.rs: -------------------------------------------------------------------------------- 1 | //! Describes the schema of the JSON API 2 | use feattle_core::last_reload::LastReload; 3 | use feattle_core::persist::ValueHistory; 4 | use feattle_core::FeattleDefinition; 5 | use serde::{Deserialize, Serialize}; 6 | use serde_json::Value; 7 | 8 | /// The first version of the API. This is still unstable while this crate is in `0.x` 9 | pub mod v1 { 10 | use super::*; 11 | 12 | #[derive(Debug, Clone, Serialize)] 13 | pub struct ListFeattlesResponse { 14 | pub definitions: Vec, 15 | pub last_reload: LastReload, 16 | pub reload_failed: bool, 17 | } 18 | 19 | #[derive(Debug, Clone, Serialize)] 20 | pub struct ShowFeattleResponse { 21 | pub definition: FeattleDefinition, 22 | pub history: ValueHistory, 23 | pub last_reload: LastReload, 24 | pub reload_failed: bool, 25 | } 26 | 27 | #[derive(Debug, Clone, Deserialize)] 28 | pub struct EditFeattleRequest { 29 | pub value: Value, 30 | pub modified_by: String, 31 | } 32 | 33 | #[derive(Debug, Clone, Serialize)] 34 | pub struct EditFeattleResponse {} 35 | } 36 | -------------------------------------------------------------------------------- /feattle-ui/src/axum_ui.rs: -------------------------------------------------------------------------------- 1 | use crate::api::v1; 2 | use crate::{AdminPanel, RenderError, RenderedPage}; 3 | use async_trait::async_trait; 4 | use axum::extract::{Path, State}; 5 | use axum::http::{HeaderMap, StatusCode}; 6 | use axum::response::{IntoResponse, Redirect, Response}; 7 | use axum::{routing, Form, Json, Router}; 8 | use feattle_core::{Feattles, UpdateError}; 9 | use serde::Deserialize; 10 | use std::sync::Arc; 11 | 12 | /// A trait that can be used to extract information about the user that is modifying a feattle. 13 | /// 14 | /// If a `Response` is returned, the feattle will not be modified and the given response will be 15 | /// returned. 16 | /// 17 | /// For convenience, this trait is implemented for: 18 | /// - strings (`String` and `&'static str`) if simply want to label all modifications with a single 19 | /// name. 20 | /// - functions that take a [`HeaderMap`] and return `Result` if async is 21 | /// not necessary 22 | /// 23 | /// For example, to extract the username from a trusted header: 24 | /// ``` 25 | /// use axum::http::{HeaderMap, StatusCode}; 26 | /// 27 | /// fn get_user(headers: &HeaderMap) -> Result { 28 | /// headers 29 | /// .get("X-User") 30 | /// .and_then(|user| user.to_str().ok()) 31 | /// .map(|user| user.to_string()) 32 | /// .ok_or(StatusCode::UNAUTHORIZED) 33 | /// } 34 | /// ``` 35 | #[async_trait] 36 | pub trait ExtractModifiedBy: Send + Sync + 'static { 37 | async fn extract_modified_by(&self, headers: &HeaderMap) -> Result; 38 | } 39 | 40 | /// Return an [`axum`] router that serves the admin panel. 41 | /// 42 | /// To use it, make sure to activate the cargo feature `"axum"` in your `Cargo.toml`. 43 | /// 44 | /// The router will answer to the web UI under "/" and a JSON API under "/api/v1/" (see more at [`v1`]): 45 | /// - GET /api/v1/feattles 46 | /// - GET /api/v1/feattle/{key} 47 | /// - POST /api/v1/feattle/{key} 48 | /// 49 | /// # Example 50 | /// ```no_run 51 | /// # #[tokio::main] 52 | /// # async fn main() -> Result<(), Box> { 53 | /// use std::future::IntoFuture; 54 | /// use feattle_ui::{AdminPanel, axum_router}; 55 | /// use feattle_core::{feattles, Feattles}; 56 | /// use feattle_core::persist::NoPersistence; 57 | /// use std::sync::Arc; 58 | /// 59 | /// use tokio::net::TcpListener; 60 | /// 61 | /// feattles! { 62 | /// struct MyToggles { a: bool, b: i32 } 63 | /// } 64 | /// 65 | /// // `NoPersistence` here is just a mock for the sake of the example 66 | /// let my_toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence))); 67 | /// let admin_panel = Arc::new(AdminPanel::new(my_toggles, "Project Panda - DEV".to_owned())); 68 | /// 69 | /// let router = axum_router(admin_panel, "admin"); 70 | /// 71 | /// let listener = TcpListener::bind(("127.0.0.1", 3031)).await?; 72 | /// tokio::spawn(axum::serve(listener, router.into_make_service()).into_future()); 73 | /// 74 | /// # Ok(()) 75 | /// # } 76 | /// ``` 77 | pub fn axum_router( 78 | admin_panel: Arc>, 79 | extract_modified_by: impl ExtractModifiedBy, 80 | ) -> Router<()> 81 | where 82 | F: Feattles + Sync + Send + 'static, 83 | { 84 | async fn list_feattles( 85 | State(state): State>, 86 | ) -> impl IntoResponse { 87 | state.admin_panel.list_feattles().await 88 | } 89 | 90 | async fn list_feattles_api_v1( 91 | State(state): State>, 92 | ) -> impl IntoResponse { 93 | state.admin_panel.list_feattles_api_v1().await.map(Json) 94 | } 95 | 96 | async fn show_feattle( 97 | State(state): State>, 98 | Path(key): Path, 99 | ) -> impl IntoResponse { 100 | state.admin_panel.show_feattle(&key).await 101 | } 102 | 103 | async fn show_feattle_api_v1( 104 | State(state): State>, 105 | Path(key): Path, 106 | ) -> impl IntoResponse { 107 | state.admin_panel.show_feattle_api_v1(&key).await.map(Json) 108 | } 109 | 110 | async fn edit_feattle( 111 | State(state): State>, 112 | Path(key): Path, 113 | headers: HeaderMap, 114 | Form(form): Form, 115 | ) -> Response { 116 | let modified_by = match state 117 | .extract_modified_by 118 | .extract_modified_by(&headers) 119 | .await 120 | { 121 | Ok(modified_by) => modified_by, 122 | Err(response) => return response, 123 | }; 124 | 125 | state 126 | .admin_panel 127 | .edit_feattle(&key, &form.value_json, modified_by) 128 | .await 129 | .map(|_| Redirect::to("/")) 130 | .into_response() 131 | } 132 | 133 | async fn edit_feattle_api_v1( 134 | State(state): State>, 135 | Path(key): Path, 136 | Json(request): Json, 137 | ) -> impl IntoResponse { 138 | state 139 | .admin_panel 140 | .edit_feattle_api_v1(&key, request) 141 | .await 142 | .map(Json) 143 | } 144 | 145 | async fn render_public_file( 146 | State(state): State>, 147 | Path(file_name): Path, 148 | ) -> impl IntoResponse { 149 | state.admin_panel.render_public_file(&file_name) 150 | } 151 | 152 | let state = RouterState { 153 | admin_panel, 154 | extract_modified_by: Arc::new(extract_modified_by), 155 | }; 156 | 157 | Router::new() 158 | .route("/", routing::get(list_feattles)) 159 | .route("/api/v1/feattles", routing::get(list_feattles_api_v1)) 160 | .route("/feattle/{key}", routing::get(show_feattle)) 161 | .route("/api/v1/feattle/{key}", routing::get(show_feattle_api_v1)) 162 | .route("/feattle/{key}/edit", routing::post(edit_feattle)) 163 | .route("/api/v1/feattle/{key}", routing::post(edit_feattle_api_v1)) 164 | .route("/public/{file_name}", routing::get(render_public_file)) 165 | .with_state(state) 166 | } 167 | 168 | #[derive(Debug, Deserialize)] 169 | struct EditFeattleForm { 170 | value_json: String, 171 | } 172 | 173 | struct RouterState { 174 | admin_panel: Arc>, 175 | extract_modified_by: Arc, 176 | } 177 | 178 | impl IntoResponse for RenderedPage { 179 | fn into_response(self) -> Response { 180 | ([("Content-Type", self.content_type)], self.content).into_response() 181 | } 182 | } 183 | 184 | impl IntoResponse for RenderError { 185 | fn into_response(self) -> Response { 186 | match self { 187 | RenderError::NotFound | RenderError::Update(UpdateError::UnknownKey(_)) => { 188 | StatusCode::NOT_FOUND.into_response() 189 | } 190 | RenderError::Update(UpdateError::Parsing(err)) => ( 191 | StatusCode::BAD_REQUEST, 192 | format!("Failed to parse: {:?}", err), 193 | ) 194 | .into_response(), 195 | err => { 196 | log::error!("request failed with {:?}", err); 197 | (StatusCode::INTERNAL_SERVER_ERROR, format!("{:?}", err)).into_response() 198 | } 199 | } 200 | } 201 | } 202 | 203 | impl Clone for RouterState { 204 | fn clone(&self) -> Self { 205 | RouterState { 206 | admin_panel: self.admin_panel.clone(), 207 | extract_modified_by: self.extract_modified_by.clone(), 208 | } 209 | } 210 | } 211 | 212 | #[async_trait] 213 | impl ExtractModifiedBy for String { 214 | async fn extract_modified_by(&self, _headers: &HeaderMap) -> Result { 215 | Ok(self.clone()) 216 | } 217 | } 218 | 219 | #[async_trait] 220 | impl ExtractModifiedBy for &'static str { 221 | async fn extract_modified_by(&self, _headers: &HeaderMap) -> Result { 222 | Ok(self.to_string()) 223 | } 224 | } 225 | 226 | #[async_trait] 227 | impl ExtractModifiedBy for F 228 | where 229 | F: Fn(&HeaderMap) -> Result + Send + Sync + 'static, 230 | R: IntoResponse, 231 | { 232 | async fn extract_modified_by(&self, headers: &HeaderMap) -> Result { 233 | self(headers).map_err(|response| response.into_response()) 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /feattle-ui/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! This crate implements an administration Web Interface for visualizing and modifying the feature 2 | //! flags (called "feattles", for short). 3 | //! 4 | //! It provides a web-framework-agnostic implementation in [`AdminPanel`] and ready-to-use bindings 5 | //! for `warp` and `axum`. Please refer to the 6 | //! [main package - `feattle`](https://crates.io/crates/feattle) for more information. 7 | //! 8 | //! Note that authentication is **not** provided out-of-the-box and you're the one responsible for 9 | //! controlling and protecting the access. 10 | //! 11 | //! # Optional features 12 | //! 13 | //! - **axum**: provides [`axum_router`] for a read-to-use integration with [`axum`] 14 | //! - **warp**: provides [`run_warp_server`] for a read-to-use integration with [`warp`] 15 | 16 | pub mod api; 17 | #[cfg(feature = "axum")] 18 | mod axum_ui; 19 | mod pages; 20 | #[cfg(feature = "warp")] 21 | mod warp_ui; 22 | 23 | use crate::pages::{PageError, Pages}; 24 | use feattle_core::{BoxError, Feattles, HistoryError, UpdateError}; 25 | use serde_json::Value; 26 | use std::sync::Arc; 27 | 28 | use crate::api::v1; 29 | #[cfg(feature = "axum")] 30 | pub use axum_ui::axum_router; 31 | #[cfg(feature = "warp")] 32 | pub use warp_ui::run_warp_server; 33 | 34 | /// The administration panel, agnostic to the choice of web-framework. 35 | /// 36 | /// This type is designed to be easily integrated with Rust web-frameworks, by providing one method 37 | /// per page and form submission, each returning bytes with their "Content-Type". 38 | /// 39 | /// # Example 40 | /// ``` 41 | /// # #[tokio::main] 42 | /// # async fn main() -> Result<(), Box> { 43 | /// use feattle_ui::AdminPanel; 44 | /// use feattle_core::{feattles, Feattles}; 45 | /// use feattle_core::persist::NoPersistence; 46 | /// use std::sync::Arc; 47 | /// 48 | /// feattles! { 49 | /// struct MyToggles { a: bool, b: i32 } 50 | /// } 51 | /// 52 | /// // `NoPersistence` here is just a mock for the sake of the example 53 | /// let my_toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence))); 54 | /// let admin_panel = AdminPanel::new(my_toggles, "Project Panda - DEV".to_owned()); 55 | /// 56 | /// let home_content = admin_panel.list_feattles().await?; 57 | /// assert_eq!(home_content.content_type, "text/html; charset=utf-8"); 58 | /// assert!(home_content.content.len() > 0); 59 | /// # Ok(()) 60 | /// # } 61 | /// ``` 62 | pub struct AdminPanel { 63 | feattles: Arc, 64 | pages: Pages, 65 | } 66 | 67 | /// Represent a rendered page 68 | #[derive(Debug, Clone)] 69 | pub struct RenderedPage { 70 | /// The value for the `Content-Type` header 71 | pub content_type: String, 72 | /// The response body, as bytes 73 | pub content: Vec, 74 | } 75 | 76 | /// Represent what can go wrong while handling a request 77 | #[derive(Debug, thiserror::Error)] 78 | pub enum RenderError { 79 | /// The requested page does not exist 80 | #[error("the requested page does not exist")] 81 | NotFound, 82 | /// The template failed to render 83 | #[error("the template failed to render")] 84 | Template(#[from] handlebars::RenderError), 85 | /// Failed to serialize or deserialize JSON 86 | #[error("failed to serialize or deserialize JSON")] 87 | Serialization(#[from] serde_json::Error), 88 | /// Failed to recover history information 89 | #[error("failed to recover history information")] 90 | History(#[from] HistoryError), 91 | /// Failed to update value 92 | #[error("failed to update value")] 93 | Update(#[from] UpdateError), 94 | /// Failed to reload new version 95 | #[error("failed to reload new version")] 96 | Reload(#[source] BoxError), 97 | } 98 | 99 | impl From for RenderError { 100 | fn from(error: PageError) -> Self { 101 | match error { 102 | PageError::NotFound => RenderError::NotFound, 103 | PageError::Template(error) => RenderError::Template(error), 104 | PageError::Serialization(error) => RenderError::Serialization(error), 105 | } 106 | } 107 | } 108 | 109 | impl AdminPanel { 110 | /// Create a new UI provider for a given feattles and a user-visible label 111 | pub fn new(feattles: Arc, label: String) -> Self { 112 | AdminPanel { 113 | feattles, 114 | pages: Pages::new(label), 115 | } 116 | } 117 | 118 | /// Render the page that lists the current feattles values, together with navigation links to 119 | /// modify them. This page is somewhat the "home screen" of the UI. 120 | /// 121 | /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. 122 | pub async fn list_feattles(&self) -> Result { 123 | let data = self.list_feattles_api_v1().await?; 124 | Ok(self 125 | .pages 126 | .render_feattles(&data.definitions, data.last_reload, data.reload_failed)?) 127 | } 128 | 129 | /// The JSON-API equivalent of [`AdminPanel::list_feattles()`]. 130 | /// 131 | /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. 132 | pub async fn list_feattles_api_v1(&self) -> Result { 133 | let reload_failed = self.feattles.reload().await.is_err(); 134 | Ok(v1::ListFeattlesResponse { 135 | definitions: self.feattles.definitions(), 136 | last_reload: self.feattles.last_reload(), 137 | reload_failed, 138 | }) 139 | } 140 | 141 | /// Render the page that shows the current and historical values of a single feattle, together 142 | /// with the form to modify it. The generated form submits to "/feattle/{{ key }}/edit" with the 143 | /// POST method in url-encoded format with a single field called "value_json". 144 | /// 145 | /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. 146 | pub async fn show_feattle(&self, key: &str) -> Result { 147 | let data = self.show_feattle_api_v1(key).await?; 148 | Ok(self.pages.render_feattle( 149 | &data.definition, 150 | &data.history, 151 | data.last_reload, 152 | data.reload_failed, 153 | )?) 154 | } 155 | 156 | /// The JSON-API equivalent of [`AdminPanel::show_feattle()`]. 157 | /// 158 | /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. 159 | pub async fn show_feattle_api_v1( 160 | &self, 161 | key: &str, 162 | ) -> Result { 163 | let reload_failed = self.feattles.reload().await.is_err(); 164 | let definition = self.feattles.definition(key).ok_or(RenderError::NotFound)?; 165 | let history = self.feattles.history(key).await?; 166 | Ok(v1::ShowFeattleResponse { 167 | definition, 168 | history, 169 | last_reload: self.feattles.last_reload(), 170 | reload_failed, 171 | }) 172 | } 173 | 174 | /// Process a modification of a single feattle, given its key and the JSON representation of its 175 | /// future value. In case of success, the return is empty, so caller should usually redirect the 176 | /// user somewhere after. 177 | /// 178 | /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. Unlike the other pages, 179 | /// if the reload fails, this operation will fail. 180 | pub async fn edit_feattle( 181 | &self, 182 | key: &str, 183 | value_json: &str, 184 | modified_by: String, 185 | ) -> Result<(), RenderError> { 186 | let value: Value = serde_json::from_str(value_json)?; 187 | self.edit_feattle_api_v1(key, v1::EditFeattleRequest { value, modified_by }) 188 | .await?; 189 | Ok(()) 190 | } 191 | 192 | /// The JSON-API equivalent of [`AdminPanel::edit_feattle()`]. 193 | /// 194 | /// To ensure fresh data is displayed, [`Feattles::reload()`] is called. Unlike the other pages, 195 | /// if the reload fails, this operation will fail. 196 | pub async fn edit_feattle_api_v1( 197 | &self, 198 | key: &str, 199 | request: v1::EditFeattleRequest, 200 | ) -> Result { 201 | log::info!( 202 | "Received edit request for key {} with value {}", 203 | key, 204 | request.value 205 | ); 206 | self.feattles.reload().await.map_err(RenderError::Reload)?; 207 | self.feattles 208 | .update(key, request.value, request.modified_by) 209 | .await?; 210 | Ok(v1::EditFeattleResponse {}) 211 | } 212 | 213 | /// Renders a public file with the given path. The pages include public files like 214 | /// "/public/some/path.js", but this method should be called with only the "some/path.js" part. 215 | pub fn render_public_file(&self, path: &str) -> Result { 216 | Ok(self.pages.render_public_file(path)?) 217 | } 218 | } 219 | 220 | #[cfg(test)] 221 | mod tests { 222 | use super::*; 223 | use feattle_core::{feattles, Feattles}; 224 | 225 | feattles! { 226 | struct MyToggles { a: bool, b: i32 } 227 | } 228 | 229 | #[tokio::test] 230 | async fn test() { 231 | use feattle_core::persist::NoPersistence; 232 | 233 | // `NoPersistence` here is just a mock for the sake of the example 234 | let my_toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence))); 235 | my_toggles.reload().await.unwrap(); 236 | let admin_panel = Arc::new(AdminPanel::new( 237 | my_toggles, 238 | "Project Panda - DEV".to_owned(), 239 | )); 240 | 241 | // Just check the methods return 242 | admin_panel.list_feattles().await.unwrap(); 243 | admin_panel.show_feattle("a").await.unwrap(); 244 | admin_panel.show_feattle("non-existent").await.unwrap_err(); 245 | admin_panel.render_public_file("script.js").unwrap(); 246 | admin_panel.render_public_file("non-existent").unwrap_err(); 247 | admin_panel 248 | .edit_feattle("a", "true", "user".to_owned()) 249 | .await 250 | .unwrap(); 251 | admin_panel 252 | .edit_feattle("a", "17", "user".to_owned()) 253 | .await 254 | .unwrap_err(); 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /feattle-ui/src/pages.rs: -------------------------------------------------------------------------------- 1 | use crate::RenderedPage; 2 | use chrono::{DateTime, Utc}; 3 | use feattle_core::last_reload::LastReload; 4 | use feattle_core::persist::ValueHistory; 5 | use feattle_core::FeattleDefinition; 6 | use handlebars::Handlebars; 7 | use serde_json::json; 8 | use std::collections::BTreeMap; 9 | use std::sync::Arc; 10 | 11 | #[derive(Debug, Clone)] 12 | pub struct Pages { 13 | handlebars: Arc>, 14 | public_files: BTreeMap<&'static str, PublicFile>, 15 | label: String, 16 | } 17 | 18 | #[derive(Debug, thiserror::Error)] 19 | pub enum PageError { 20 | #[error("The requested page does not exist")] 21 | NotFound, 22 | #[error("The template failed to render")] 23 | Template(#[from] handlebars::RenderError), 24 | #[error("Failed to serialize or deserialize JSON")] 25 | Serialization(#[from] serde_json::Error), 26 | } 27 | 28 | #[derive(Debug, Clone)] 29 | struct PublicFile { 30 | content: &'static [u8], 31 | content_type: &'static str, 32 | } 33 | 34 | pub type PageResult = Result; 35 | 36 | impl Pages { 37 | pub fn new(label: String) -> Self { 38 | let mut handlebars = Handlebars::new(); 39 | macro_rules! register_template { 40 | ($name:expr) => { 41 | handlebars 42 | .register_template_string( 43 | $name, 44 | include_str!(concat!("../web/", $name, ".hbs")), 45 | ) 46 | .expect("The handlebars template should be valid"); 47 | }; 48 | } 49 | register_template!("layout"); 50 | register_template!("feattles"); 51 | register_template!("feattle"); 52 | 53 | let mut public_files = BTreeMap::new(); 54 | macro_rules! insert_public_file { 55 | ($name:expr, $content_type:expr) => { 56 | public_files.insert( 57 | $name, 58 | PublicFile { 59 | content: include_bytes!(concat!("../web/", $name)), 60 | content_type: $content_type, 61 | }, 62 | ); 63 | }; 64 | } 65 | insert_public_file!("script.js", "application/javascript"); 66 | insert_public_file!("style.css", "text/css"); 67 | insert_public_file!("favicon-32x32.png", "image/png"); 68 | 69 | Pages { 70 | handlebars: Arc::new(handlebars), 71 | public_files, 72 | label, 73 | } 74 | } 75 | 76 | pub fn render_public_file(&self, path: &str) -> PageResult { 77 | let file = self.public_files.get(path).ok_or(PageError::NotFound)?; 78 | Ok(RenderedPage { 79 | content_type: file.content_type.to_owned(), 80 | content: file.content.to_owned(), 81 | }) 82 | } 83 | 84 | pub fn render_feattles( 85 | &self, 86 | definitions: &[FeattleDefinition], 87 | last_reload: LastReload, 88 | reload_failed: bool, 89 | ) -> PageResult { 90 | let feattles: Vec<_> = definitions 91 | .iter() 92 | .map(|definition| { 93 | json!({ 94 | "key": definition.key, 95 | "format": definition.format.tag, 96 | "description": definition.description, 97 | "value_overview": definition.value_overview, 98 | "last_modification": last_modification(definition, last_reload), 99 | }) 100 | }) 101 | .collect(); 102 | let version = match last_reload { 103 | LastReload::Never | LastReload::NoData { .. } => "unknown".to_owned(), 104 | LastReload::Data { 105 | version, 106 | version_date, 107 | .. 108 | } => format!("{}, created at {}", version, date_string(version_date)), 109 | }; 110 | let last_reload_str = match last_reload.reload_date() { 111 | None => "never".to_owned(), 112 | Some(date) => date_string(date), 113 | }; 114 | 115 | Self::convert_html(self.handlebars.render( 116 | "feattles", 117 | &json!({ 118 | "feattles": feattles, 119 | "label": self.label, 120 | "last_reload": last_reload_str, 121 | "version": version, 122 | "reload_failed": reload_failed, 123 | }), 124 | )) 125 | } 126 | 127 | pub fn render_feattle( 128 | &self, 129 | definition: &FeattleDefinition, 130 | history: &ValueHistory, 131 | last_reload: LastReload, 132 | reload_failed: bool, 133 | ) -> PageResult { 134 | let history = history 135 | .entries 136 | .iter() 137 | .map(|entry| -> Result<_, PageError> { 138 | Ok(json!({ 139 | "modified_at": date_string(entry.modified_at), 140 | "modified_by": entry.modified_by, 141 | "value_overview": entry.value_overview, 142 | "value_json": serde_json::to_string(&entry.value)?, 143 | })) 144 | }) 145 | .collect::, _>>()?; 146 | 147 | Self::convert_html(self.handlebars.render( 148 | "feattle", 149 | &json!({ 150 | "key": definition.key, 151 | "format": definition.format.tag, 152 | "description": definition.description, 153 | "value_overview": definition.value_overview, 154 | "last_modification": last_modification(definition, last_reload), 155 | "format_json": serde_json::to_string(&definition.format.kind)?, 156 | "value_json": serde_json::to_string(&definition.value)?, 157 | "label": self.label, 158 | "history": history, 159 | "reload_failed": reload_failed, 160 | }), 161 | )) 162 | } 163 | 164 | fn convert_html(rendered: Result) -> PageResult { 165 | let content = rendered?; 166 | Ok(RenderedPage { 167 | content_type: "text/html; charset=utf-8".to_owned(), 168 | content: content.into_bytes(), 169 | }) 170 | } 171 | } 172 | 173 | fn last_modification(definition: &FeattleDefinition, last_reload: LastReload) -> String { 174 | match (last_reload, definition.modified_at, &definition.modified_by) { 175 | (LastReload::Never, _, _) => "unknown".to_owned(), 176 | (LastReload::NoData { .. }, _, _) 177 | | (LastReload::Data { .. }, None, _) 178 | | (LastReload::Data { .. }, _, None) => "never".to_owned(), 179 | (LastReload::Data { .. }, Some(at), Some(by)) => { 180 | format!("{} by {}", date_string(at), by) 181 | } 182 | } 183 | } 184 | 185 | fn date_string(datetime: DateTime) -> String { 186 | datetime.format("%Y-%m-%d %H:%M:%S %Z").to_string() 187 | } 188 | -------------------------------------------------------------------------------- /feattle-ui/src/warp_ui.rs: -------------------------------------------------------------------------------- 1 | use crate::api::v1; 2 | use crate::{AdminPanel, RenderError, RenderedPage}; 3 | use feattle_core::{Feattles, UpdateError}; 4 | use serde::{Deserialize, Serialize}; 5 | use std::net::SocketAddr; 6 | use std::sync::Arc; 7 | use warp::filters::path; 8 | use warp::http::{StatusCode, Uri}; 9 | use warp::reject::Reject; 10 | use warp::{reject, reply, Filter, Rejection, Reply}; 11 | 12 | #[derive(Debug)] 13 | #[allow(dead_code)] 14 | struct RequestError(RenderError); 15 | 16 | #[derive(Debug, Deserialize)] 17 | struct EditFeattleForm { 18 | value_json: String, 19 | } 20 | 21 | /// Run the given admin panel using [`warp`] framework. 22 | /// 23 | /// To use it, make sure to activate the cargo feature `"warp"` in your `Cargo.toml`. 24 | /// 25 | /// This will host the web UI under "/" and a JSON API under "/api/v1/" (see more at [`v1`]): 26 | /// - GET /api/v1/feattles 27 | /// - GET /api/v1/feattle/{key} 28 | /// - POST /api/v1/feattle/{key} 29 | /// 30 | /// # Example 31 | /// ```no_run 32 | /// # #[tokio::main] 33 | /// # async fn main() -> Result<(), Box> { 34 | /// use feattle_ui::{AdminPanel, run_warp_server}; 35 | /// use feattle_core::{feattles, Feattles}; 36 | /// use feattle_core::persist::NoPersistence; 37 | /// use std::sync::Arc; 38 | /// 39 | /// feattles! { 40 | /// struct MyToggles { a: bool, b: i32 } 41 | /// } 42 | /// 43 | /// // `NoPersistence` here is just a mock for the sake of the example 44 | /// let my_toggles = Arc::new(MyToggles::new(Arc::new(NoPersistence))); 45 | /// let admin_panel = Arc::new(AdminPanel::new(my_toggles, "Project Panda - DEV".to_owned())); 46 | /// 47 | /// run_warp_server(admin_panel, ([127, 0, 0, 1], 3030)).await; 48 | /// # Ok(()) 49 | /// # } 50 | /// ``` 51 | pub async fn run_warp_server( 52 | admin_panel: Arc>, 53 | addr: impl Into + 'static, 54 | ) where 55 | F: Feattles + Sync + Send + 'static, 56 | { 57 | let admin_panel = warp::any().map(move || admin_panel.clone()); 58 | 59 | let list_feattles = warp::path::end() 60 | .and(warp::get()) 61 | .and(admin_panel.clone()) 62 | .and_then(|admin_panel: Arc>| async move { 63 | admin_panel 64 | .list_feattles() 65 | .await 66 | .map_err(to_rejection) 67 | .map(to_reply) 68 | }); 69 | 70 | let list_feattles_api = warp::path!("feattles") 71 | .and(warp::get()) 72 | .and(admin_panel.clone()) 73 | .and_then(|admin_panel: Arc>| async move { 74 | to_json_result(admin_panel.list_feattles_api_v1().await) 75 | }); 76 | 77 | let show_feattle = warp::path!("feattle" / String) 78 | .and(warp::get()) 79 | .and(admin_panel.clone()) 80 | .and_then(|key: String, admin_panel: Arc>| async move { 81 | admin_panel 82 | .show_feattle(&key) 83 | .await 84 | .map_err(to_rejection) 85 | .map(to_reply) 86 | }); 87 | 88 | let show_feattle_api = warp::path!("feattle" / String) 89 | .and(warp::get()) 90 | .and(admin_panel.clone()) 91 | .and_then(|key: String, admin_panel: Arc>| async move { 92 | to_json_result(admin_panel.show_feattle_api_v1(&key).await) 93 | }); 94 | 95 | let edit_feattle = warp::path!("feattle" / String / "edit") 96 | .and(warp::post()) 97 | .and(admin_panel.clone()) 98 | .and(warp::body::form()) 99 | .and_then( 100 | |key: String, admin_panel: Arc>, form: EditFeattleForm| async move { 101 | admin_panel 102 | .edit_feattle(&key, &form.value_json, "admin".to_owned()) 103 | .await 104 | .map_err(to_rejection) 105 | .map(|_| warp::redirect(Uri::from_static("/"))) 106 | }, 107 | ); 108 | 109 | let edit_feattle_api = 110 | warp::path!("feattle" / String) 111 | .and(warp::post()) 112 | .and(admin_panel.clone()) 113 | .and(warp::body::json()) 114 | .and_then( 115 | |key: String, 116 | admin_panel: Arc>, 117 | request: v1::EditFeattleRequest| async move { 118 | to_json_result(admin_panel.edit_feattle_api_v1(&key, request).await) 119 | }, 120 | ); 121 | 122 | let public_files = warp::path!("public" / String) 123 | .and(warp::get()) 124 | .and(admin_panel.clone()) 125 | .and_then( 126 | |file_name: String, admin_panel: Arc>| async move { 127 | admin_panel 128 | .render_public_file(&file_name) 129 | .map_err(to_rejection) 130 | .map(to_reply) 131 | }, 132 | ); 133 | 134 | let api = path::path("api") 135 | .and(path::path("v1")) 136 | .and(list_feattles_api.or(show_feattle_api).or(edit_feattle_api)); 137 | 138 | warp::serve( 139 | list_feattles 140 | .or(show_feattle) 141 | .or(edit_feattle) 142 | .or(public_files) 143 | .or(api), 144 | ) 145 | .run(addr) 146 | .await; 147 | } 148 | 149 | impl Reject for RequestError {} 150 | 151 | fn to_reply(page: RenderedPage) -> impl Reply { 152 | reply::with_header(page.content, "Content-Type", page.content_type) 153 | } 154 | 155 | fn to_rejection(error: RenderError) -> Rejection { 156 | if let RenderError::NotFound = error { 157 | reject::not_found() 158 | } else { 159 | log::error!("request failed with {:?}", error); 160 | reject::custom(RequestError(error)) 161 | } 162 | } 163 | 164 | fn to_json_result( 165 | value: Result, 166 | ) -> Result, Rejection> { 167 | match value { 168 | Ok(ok) => Ok(Box::new(reply::json(&ok))), 169 | Err(RenderError::NotFound) | Err(RenderError::Update(UpdateError::UnknownKey(_))) => { 170 | Ok(Box::new(StatusCode::NOT_FOUND)) 171 | } 172 | Err(RenderError::Update(UpdateError::Parsing(err))) => Ok(Box::new(reply::with_status( 173 | format!("Failed to parse: {:?}", err), 174 | StatusCode::BAD_REQUEST, 175 | ))), 176 | Err(err) => Err(reject::custom(RequestError(err))), 177 | } 178 | } 179 | -------------------------------------------------------------------------------- /feattle-ui/web/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sitegui/feattle-rs/ccfdea3df8123a4062b23661c63617a880b6c58a/feattle-ui/web/favicon-32x32.png -------------------------------------------------------------------------------- /feattle-ui/web/feattle.hbs: -------------------------------------------------------------------------------- 1 | {{#> layout}} 2 | {{#*inline "title"}} 3 | Feattle {{ key }} 4 | {{/inline}} 5 | {{#*inline "content"}} 6 |

{{ key }}

7 | 8 | {{#if reload_failed }} 9 |
10 |

Synchronization failed

11 | Failed to reload fresh data from the underlying persistence source.
12 | The values shown in this page reflect the state in memory of this instance, 13 | that may or may not be the desired state. 14 |
15 | {{/if}} 16 | 17 |

{{ description }}

18 |

19 | Type: {{ format }}
20 | Last modification: {{ last_modification }}
21 | Current value: {{ value_overview }} 22 |

23 | 24 | 33 | 34 | 40 |
41 | 42 |
44 |
45 | Go back 46 | 47 |
48 |
49 | 50 |

History

51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | {{#each history}} 62 | 63 | 64 | 65 | 66 | 67 | 68 | {{/each}} 69 | 70 |
Modified atModified byValue
{{ this.modified_at }}{{ this.modified_by }}{{ this.value_overview }}
71 | 72 | 103 | 106 | {{/inline}} 107 | {{/layout}} 108 | -------------------------------------------------------------------------------- /feattle-ui/web/feattles.hbs: -------------------------------------------------------------------------------- 1 | {{#> layout }} 2 | {{#*inline "title"}} 3 | Feattle - {{ label }} 4 | {{/inline}} 5 | {{#*inline "content"}} 6 |

Current Values

7 | 8 | {{#if reload_failed }} 9 |
10 |

Synchronization failed

11 | Failed to reload fresh data from the underlying persistence source.
12 | The values shown in this page reflect the state in memory of this instance, 13 | that may or may not be the desired state. 14 |
15 | {{/if}} 16 | 17 |
18 | /// Last reload: {{ last_reload }}
19 | /// Version: {{ version }}
20 | struct Feattles {
21 | 22 | {{#each feattles}} 23 | {{#if @index }}
{{/if}} 24 | {{#if this.description }} 25 | /// {{ this.description }}
26 | ///
27 | {{/if}} 28 | /// Last modification: {{ this.last_modification }} - edit
29 | {{ this.key }}: 30 | {{ this.format }} = 31 | {{ this.value_overview }},
32 | {{/each}} 33 |
34 | } 35 |
36 | {{/inline}} 37 | {{/layout}} 38 | 39 | -------------------------------------------------------------------------------- /feattle-ui/web/layout.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 | 18 | 21 | 22 | 23 | 24 | {{> title }} 25 | 26 | 27 | 28 | 36 | 37 |
38 | {{> content }} 39 |
40 | 41 | 44 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /feattle-ui/web/script.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | class FeattleEditor { 4 | constructor(editorEl) { 5 | this.editorEl = editorEl 6 | 7 | this.format = JSON.parse(editorEl.attr('data-format')) 8 | this.initialValue = JSON.parse(editorEl.attr('data-value')) 9 | 10 | this.sourceEl = null 11 | this.getValue = null 12 | 13 | if (this.format.tag === 'Bool') { 14 | this._prepareBool() 15 | } else if (this.format.tag === 'Integer') { 16 | this._prepareNumber(true) 17 | } else if (this.format.tag === 'Float') { 18 | this._prepareNumber(false) 19 | } else if (this.format.tag === 'String' && this.format.content.tag === 'Any') { 20 | this._prepareString() 21 | } else if (this.format.tag === 'String' && this.format.content.tag === 'Pattern') { 22 | this._prepareString(this.format.content.content) 23 | } else if (this.format.tag === 'String' && this.format.content.tag === 'Choices') { 24 | this._prepareChoices(this.format.content.content) 25 | } else if (this.format.tag === 'Optional') { 26 | this._prepareOptional(this.format.content) 27 | } else { 28 | this._prepareOther() 29 | } 30 | } 31 | 32 | _prepareBool() { 33 | this.sourceEl = this._newSwitch(this.editorEl, 'Value', this.initialValue) 34 | this.getValue = () => this.sourceEl.prop('checked') 35 | } 36 | 37 | _prepareNumber(isInteger) { 38 | this.sourceEl = $('', { 39 | 'class': 'form-control', 40 | type: 'number', 41 | step: isInteger ? '' : 'any', 42 | val: this.initialValue 43 | }) 44 | this.editorEl.append(this.sourceEl) 45 | this.getValue = () => Number(this.sourceEl.val()) 46 | } 47 | 48 | _prepareString(pattern) { 49 | this.sourceEl = $('', { 50 | 'class': 'form-control', 51 | pattern: pattern, 52 | val: this.initialValue 53 | }) 54 | this.editorEl.append(this.sourceEl) 55 | this.getValue = () => this.sourceEl.val() 56 | } 57 | 58 | _prepareChoices(choices) { 59 | let radioGroupId = String(Math.random()) 60 | this.sourceEl = $(choices.map(choice => { 61 | let radioId = String(Math.random()) 62 | return $('
', { 63 | 'class': 'custom-control custom-radio', 64 | append: [ 65 | $('', { 66 | type: 'radio', 67 | name: radioGroupId, 68 | id: radioId, 69 | 'class': 'custom-control-input', 70 | value: choice, 71 | checked: choice === this.initialValue 72 | }), 73 | $('