├── .github └── workflows │ └── test.yml ├── .gitignore ├── .pre-commit-config.yaml ├── .vscode ├── extensions.json └── settings.json ├── Cargo.toml ├── LICENSE ├── NOTICE ├── README.md ├── authn ├── Cargo.toml ├── functions │ ├── activate │ │ └── function.json │ ├── login │ │ └── function.json │ ├── logout │ │ └── function.json │ └── signup │ │ └── function.json └── src │ ├── db │ ├── mod.rs │ ├── postgres.sql │ ├── sqlite.sql │ └── tests.rs │ ├── driver │ ├── activate.rs │ ├── email.rs │ ├── login.rs │ ├── logout.rs │ ├── mod.rs │ ├── signup.rs │ └── testutils.rs │ ├── lib.rs │ ├── model │ ├── accesstoken.rs │ ├── mod.rs │ ├── passwords.rs │ ├── session.rs │ └── user.rs │ └── rest │ ├── api_activate_get.rs │ ├── api_login_post.rs │ ├── api_logout_post.rs │ ├── api_signup_post.rs │ ├── httputils.rs │ ├── mod.rs │ └── testutils.rs ├── config.env.tmpl ├── core ├── Cargo.toml └── src │ ├── clocks.rs │ ├── db.rs │ ├── db │ ├── postgres.rs │ └── sqlite.rs │ ├── driver.rs │ ├── env.rs │ ├── lib.rs │ ├── model │ ├── emailaddress.rs │ ├── mod.rs │ └── username.rs │ ├── rest.rs │ ├── rest │ └── base_urls.rs │ └── template.rs ├── example ├── .gitignore ├── Cargo.toml ├── Makefile ├── config.mk.tmpl ├── functions │ ├── .funcignore │ ├── .gitignore │ ├── host.json │ ├── keys │ │ └── function.json │ └── local.settings.json └── src │ ├── db │ ├── mod.rs │ ├── postgres.sql │ ├── sqlite.sql │ └── tests.rs │ ├── driver │ ├── key.rs │ ├── keys.rs │ ├── mod.rs │ └── testutils.rs │ ├── lib.rs │ ├── main.rs │ ├── model.rs │ └── rest │ ├── key_delete.rs │ ├── key_get.rs │ ├── key_put.rs │ ├── keys_get.rs │ ├── mod.rs │ └── testutils.rs ├── geo ├── Cargo.toml └── src │ ├── azure.rs │ ├── caching.rs │ ├── counter.rs │ ├── ipapi.rs │ ├── lib.rs │ └── mock.rs ├── lint.sh ├── queue ├── Cargo.toml ├── functions │ └── queue-loop │ │ └── function.json └── src │ ├── db │ ├── mod.rs │ ├── postgres.sql │ ├── sqlite.sql │ ├── status.rs │ └── tests.rs │ ├── driver │ ├── client.rs │ ├── mod.rs │ └── worker.rs │ ├── lib.rs │ ├── model.rs │ └── rest │ ├── mod.rs │ ├── queue_loop.rs │ └── testutils.rs ├── rustfmt.toml ├── smtp ├── Cargo.toml └── src │ ├── db │ ├── mod.rs │ ├── postgres.sql │ ├── sqlite.sql │ └── tests.rs │ ├── driver │ ├── mod.rs │ └── testutils.rs │ ├── lib.rs │ └── model.rs └── test.sh /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | # III-IV 2 | # Copyright 2023 Julio Merino 3 | 4 | name: Test 5 | 6 | on: [push, pull_request] 7 | 8 | jobs: 9 | lint: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 15 12 | env: 13 | RUSTC_WRAPPER: "sccache" 14 | SCCACHE_GHA_ENABLED: "true" 15 | steps: 16 | - uses: actions-rs/toolchain@v1 17 | with: 18 | profile: minimal 19 | # Use the latest stable Rust version for lint checks to 20 | # verify any new Clippy warnings that may appear. 21 | toolchain: stable 22 | default: true 23 | components: clippy, rustfmt 24 | - uses: actions/checkout@v4 25 | - uses: mozilla-actions/sccache-action@v0.0.9 26 | - run: sudo apt update 27 | - run: sudo apt install pre-commit 28 | - run: ./lint.sh 29 | 30 | test-individually: 31 | runs-on: ubuntu-latest 32 | timeout-minutes: 15 33 | env: 34 | AZURE_MAPS_KEY: ${{ secrets.AZURE_MAPS_KEY }} 35 | PGSQL_TEST_HOST: ${{ secrets.PGSQL_TEST_HOST }} 36 | PGSQL_TEST_PORT: ${{ secrets.PGSQL_TEST_PORT }} 37 | PGSQL_TEST_DATABASE: ${{ secrets.PGSQL_TEST_DATABASE }} 38 | PGSQL_TEST_USERNAME: ${{ secrets.PGSQL_TEST_USERNAME }} 39 | PGSQL_TEST_PASSWORD: ${{ secrets.PGSQL_TEST_PASSWORD }} 40 | RUSTC_WRAPPER: "sccache" 41 | SCCACHE_GHA_ENABLED: "true" 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: mozilla-actions/sccache-action@v0.0.9 45 | - run: ./test.sh all 46 | 47 | test-workspace: 48 | runs-on: ubuntu-latest 49 | timeout-minutes: 15 50 | env: 51 | AZURE_MAPS_KEY: ${{ secrets.AZURE_MAPS_KEY }} 52 | PGSQL_TEST_HOST: ${{ secrets.PGSQL_TEST_HOST }} 53 | PGSQL_TEST_PORT: ${{ secrets.PGSQL_TEST_PORT }} 54 | PGSQL_TEST_DATABASE: ${{ secrets.PGSQL_TEST_DATABASE }} 55 | PGSQL_TEST_USERNAME: ${{ secrets.PGSQL_TEST_USERNAME }} 56 | PGSQL_TEST_PASSWORD: ${{ secrets.PGSQL_TEST_PASSWORD }} 57 | RUSTC_WRAPPER: "sccache" 58 | SCCACHE_GHA_ENABLED: "true" 59 | steps: 60 | - uses: actions/checkout@v4 61 | - uses: mozilla-actions/sccache-action@v0.0.9 62 | - run: ./test.sh 63 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | config.env 3 | target 4 | vscode-target 5 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v4.4.0 4 | hooks: 5 | - id: check-case-conflict 6 | - id: check-executables-have-shebangs 7 | - id: check-merge-conflict 8 | - id: check-shebang-scripts-are-executable 9 | - id: check-yaml 10 | - id: detect-private-key 11 | - id: double-quote-string-fixer 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/jlebar/pre-commit-hooks.git 15 | rev: 62ca83ba4958da48ea44d9f24cd0aa58633376c7 16 | hooks: 17 | - id: do-not-submit 18 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "davidanson.vscode-markdownlint", 4 | "rust-lang.rust-analyzer", 5 | "stkb.rewrap", 6 | "streetsidesoftware.code-spell-checker", 7 | "tamasfe.even-better-toml" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.rulers": [100], 4 | "editor.tabSize": 4, 5 | "editor.wordWrapColumn": 100, 6 | 7 | "files.associations": { 8 | "Makefile.in": "makefile", 9 | "config.mk.tmpl": "makefile" 10 | }, 11 | "files.exclude": { 12 | "**/.DS_Store": true, 13 | "**/.git": true, 14 | "Cargo.lock": true, 15 | "**/config.mk": true, 16 | "target": true, 17 | "vscode-target": true 18 | }, 19 | "files.insertFinalNewline": true, 20 | "files.trimTrailingWhitespace": true, 21 | 22 | "[markdown]": { 23 | "editor.rulers": [80], 24 | "editor.quickSuggestions": { 25 | "comments": "off", 26 | "strings": "off", 27 | "other": "off" 28 | }, 29 | "editor.wordWrapColumn": 80 30 | }, 31 | 32 | "markdownlint.config": { 33 | "MD007": { 34 | "indent": 4 35 | }, 36 | "MD026": { 37 | "punctuation": ".,:;'" 38 | }, 39 | "MD030": { 40 | "ol_single": 2, 41 | "ol_multi": 2, 42 | "ul_single": 3, 43 | "ul_multi": 3 44 | }, 45 | "MD033": false 46 | }, 47 | 48 | "[rust]": { 49 | "editor.formatOnSave": true 50 | }, 51 | 52 | "[plaintext]": { 53 | "editor.rulers": [80], 54 | "editor.quickSuggestions": { 55 | "comments": "off", 56 | "strings": "off", 57 | "other": "off" 58 | }, 59 | "editor.wordWrapColumn": 80 60 | }, 61 | 62 | "cSpell.language": "en", 63 | "cSpell.words": [ 64 | "iii-iv", 65 | "jmmv", 66 | "lettre", 67 | "newtype", 68 | "oneshot", 69 | "sqlx", 70 | "testutils", 71 | "thiserror" 72 | ] 73 | } 74 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = [ 3 | "authn", 4 | "core", 5 | "example", 6 | "geo", 7 | "queue", 8 | "smtp", 9 | ] 10 | resolver = "2" 11 | 12 | [workspace.dependencies] 13 | async-session = "3" 14 | async-trait = "0.1" 15 | axum = "0.7" 16 | axum-server = "0.7" 17 | base64 = "0.22" 18 | bcrypt = "0.17" 19 | bytes = "1.10" 20 | derivative = "2.2" 21 | derive-getters = "0.5.0" 22 | derive_more = { version = "2.0.1", default-features = false } 23 | env_logger = "0.11" 24 | futures = "0.3" 25 | http = "1.3.1" 26 | http-body = "1.0" 27 | hyper = { version = "1.6", features = ["full"] } 28 | lettre = { version = "0.11.15", default-features = false } 29 | log = "0.4" 30 | lru_time_cache = "0.11" 31 | mime = "0.3" 32 | paste = "1.0" 33 | quoted_printable = "0.5" 34 | rand = "0.9" 35 | regex = "1" 36 | reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } 37 | serde_json = "1" 38 | serde_test = "1" 39 | serde_urlencoded = "0.7" 40 | serde = { version = "1", features = ["derive"] } 41 | sqlx = "0.8" 42 | temp-env = "0.3.6" 43 | thiserror = "2.0" 44 | time = "0.3" 45 | tokio = "1" 46 | tower = "0.5" 47 | tower-http = { version = "0.6", features = ["cors"] } 48 | url = "2.5" 49 | uuid = { version = "1.16", default-features = false, features = ["serde", "std", "v4"] } 50 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | III-IV 2 | Copyright 2023 Julio Merino 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # III-IV: Opinionated framework for web services 2 | 3 | III-IV is a rudimentary and _very_ opinionated framework to build web services 4 | in Rust. This framework is a thin layer over other well-known crates such as 5 | `axum` and `sqlx`. As such, it mostly provides boilerplate code necessary to 6 | tie everything together, but it also forces code to be structured in a way 7 | that permits fast unit testing. 8 | 9 | At the moment, all of the functionality and structure provided here are geared 10 | towards the way **I, @jmmv**, have been building these web services. The vast 11 | majority of the code in this repository comes from those services verbatim and 12 | is quite ad-hoc. So... take this as a disclaimer: _I don't think that the 13 | code in here will be readily usable for your use case. This is why there are 14 | no formal releases nor plans to make them, and there are zero promises about 15 | backwards API compatibility: there will be churn._ 16 | 17 | That said, if you find this useful for any reason and want to use portions of 18 | the code anyway, great! You will have to pull the code from Git, read the doc 19 | comments attached to the various crates and modules, and I strongly recommend 20 | that you pin your usage to a specific commit. I'll be happy to consider 21 | contributions if you have any. 22 | 23 | ## Key characteristics 24 | 25 | * High-level transaction-based database abstraction, which provides a 26 | mechanism to implement the exact same service logic against PostgreSQL and 27 | SQLite. 28 | * Use of PostgreSQL in deployment builds and SQLite during testing, thanks to 29 | the prior point. 30 | * Proven foundations: `sqlx` for database access, `axum` as the web framework, 31 | and `tokio` as the async runtime. 32 | * Configuration via environment variables. 33 | * Optional deployment to Azure functions. 34 | 35 | ## What's in the name? 36 | 37 | The name III-IV refers to the number of layers that services using this 38 | framework need to implement. The 3 is about the `rest`, `driver`, and `db` 39 | layers, and the 4 is about the cross-layer data `model` module. You can read 40 | the name as "three-four". 41 | 42 | ## Installation 43 | 44 | As mentioned in the introduction above, there are no formal releases of this 45 | framework and there are no plans to make them. You will have to depend on this 46 | code straight from this Git repository. 47 | 48 | The following can get you started. Make sure to pick the latest commit 49 | available in this repository to pin your dependencies to. Do _not_ rely on 50 | the `main` branch. 51 | 52 | ```toml 53 | [dependencies.iii-iv-core] 54 | git = "https://github.com/jmmv/iii-iv.git" 55 | rev = "git commit you based your work off" 56 | features = ["postgres"] 57 | 58 | [dev-dependencies.iii-iv-core] 59 | git = "https://github.com/jmmv/iii-iv.git" 60 | rev = "git commit you based your work off" 61 | features = ["sqlite", "testutils"] 62 | ``` 63 | 64 | ## Example 65 | 66 | The `example` directory contains a full application built using this framework. 67 | The application implements a simple REST interface for a key/value store. The 68 | server is backed by PostgreSQL in the binary build, but tests are backed by 69 | SQLite. This allows tests to run at lightning speeds and with zero setup, which 70 | is a primary goal of this framework. 71 | 72 | The code of the application is overly verbose: you will notice that there are 73 | many small files. This is to make room for tests at every layer (which you will 74 | also find in the template), because the tests tend to grow up very large. 75 | 76 | This example is meant to be usable as a template for new services. You can 77 | copy/paste it into a new crate, delete all of the key/value store logic, and 78 | add your own. 79 | -------------------------------------------------------------------------------- /authn/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iii-iv-authn" 3 | version = "0.0.0" 4 | description = "III-IV: Simple authentication support" 5 | authors = ["Julio Merino "] 6 | edition = "2024" 7 | publish = false 8 | 9 | [features] 10 | default = ["postgres"] 11 | postgres = ["iii-iv-core/postgres", "sqlx/postgres"] 12 | sqlite = ["iii-iv-core/sqlite", "sqlx/sqlite"] 13 | testutils = ["dep:url", "iii-iv-core/sqlite", "iii-iv-core/testutils", "iii-iv-smtp/testutils"] 14 | 15 | [dependencies] 16 | async-trait = { workspace = true } 17 | axum = { workspace = true } 18 | base64 = { workspace = true } 19 | bcrypt = { workspace = true } 20 | derivative = { workspace = true } 21 | futures = { workspace = true } 22 | http = { workspace = true } 23 | iii-iv-core = { path = "../core" } 24 | iii-iv-smtp = { path = "../smtp" } 25 | log = { workspace = true } 26 | lru_time_cache = { workspace = true } 27 | rand = { workspace = true } 28 | serde_urlencoded = { workspace = true } 29 | serde = { workspace = true } 30 | time = { workspace = true } 31 | url = { workspace = true, optional = true } 32 | 33 | [dependencies.sqlx] 34 | workspace = true 35 | optional = true 36 | features = ["runtime-tokio-rustls", "time"] 37 | 38 | [dev-dependencies] 39 | futures = { workspace = true } 40 | iii-iv-core = { path = "../core", features = ["sqlite", "testutils"] } 41 | iii-iv-smtp = { path = "../smtp", features = ["testutils"] } 42 | temp-env = { workspace = true } 43 | tokio = { workspace = true, features = ["rt", "macros", "rt-multi-thread"] } 44 | url = { workspace = true } 45 | 46 | [dev-dependencies.sqlx] 47 | workspace = true 48 | features = ["runtime-tokio-rustls", "sqlite", "time"] 49 | -------------------------------------------------------------------------------- /authn/functions/activate/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "get" 10 | ], 11 | "route": "users/{user:regex(^[^/]+$)}/activate" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /authn/functions/login/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "post" 10 | ], 11 | "route": "login" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /authn/functions/logout/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "post" 10 | ], 11 | "route": "users/{user:regex(^[^/]+$)}/logout" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /authn/functions/signup/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": [ 9 | "post" 10 | ], 11 | "route": "signup" 12 | }, 13 | { 14 | "type": "http", 15 | "direction": "out", 16 | "name": "res" 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /authn/src/db/postgres.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | CREATE TABLE IF NOT EXISTS users ( 17 | -- The user's chosen username. 18 | username VARCHAR(32) PRIMARY KEY NOT NULL, 19 | 20 | -- The user's hashed password using the bcrypt algorithm. 21 | -- May be null, in which case the user is denied login. 22 | password VARCHAR(60), 23 | 24 | -- The user's email address. 25 | email VARCHAR(64) UNIQUE NOT NULL, 26 | 27 | -- Activation code. If present, the account has not been activated yet. 28 | -- 29 | -- Note that this is supposed to be an u64 so it can show up as negative when persisted 30 | -- in the database. 31 | activation_code BIGINT, 32 | 33 | -- The user's last successful login timestamp. 34 | last_login TIMESTAMPTZ 35 | ); 36 | 37 | CREATE TABLE IF NOT EXISTS sessions ( 38 | access_token CHAR(256) PRIMARY KEY NOT NULL, 39 | 40 | username VARCHAR(32) NOT NULL REFERENCES users (username), 41 | 42 | login_time TIMESTAMPTZ NOT NULL, 43 | 44 | -- Logout time, if known. Sessions have a maximum validity time as enforced by the driver 45 | -- but users can also explicitly log out. 46 | logout_time TIMESTAMPTZ 47 | ); 48 | -------------------------------------------------------------------------------- /authn/src/db/sqlite.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | PRAGMA foreign_keys = ON; 17 | 18 | CREATE TABLE IF NOT EXISTS users ( 19 | username TEXT PRIMARY KEY NOT NULL, 20 | password TEXT, 21 | email TEXT UNIQUE NOT NULL, 22 | activation_code INTEGER, 23 | last_login_secs INTEGER, 24 | last_login_nsecs INTEGER, 25 | CHECK ((last_login_secs IS NULL AND last_login_nsecs IS NULL) 26 | OR (last_login_secs IS NOT NULL AND last_login_nsecs IS NOT NULL)) 27 | ); 28 | 29 | CREATE TABLE IF NOT EXISTS sessions ( 30 | access_token TEXT PRIMARY KEY NOT NULL, 31 | username TEXT NOT NULL REFERENCES users (username), 32 | login_time_secs INTEGER NOT NULL, 33 | login_time_nsecs INTEGER NOT NULL, 34 | logout_time_secs INTEGER, 35 | logout_time_nsecs INTEGER 36 | ); 37 | -------------------------------------------------------------------------------- /authn/src/driver/activate.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Extends the driver with the `activate` method. 17 | 18 | use crate::db; 19 | use crate::driver::AuthnDriver; 20 | use iii_iv_core::driver::{DriverError, DriverResult}; 21 | use iii_iv_core::model::Username; 22 | 23 | impl AuthnDriver { 24 | /// Marks a used as active based on a confirmation code. 25 | pub(crate) async fn activate(self, username: Username, code: u64) -> DriverResult<()> { 26 | let mut tx = self.db.begin().await?; 27 | 28 | let user = db::get_user_by_username(tx.ex(), username).await?; 29 | match user.activation_code() { 30 | Some(exp_code) => { 31 | if exp_code != code { 32 | return Err(DriverError::InvalidInput("Invalid activation code".to_owned())); 33 | } 34 | } 35 | None => return Err(DriverError::InvalidInput("User is already active".to_owned())), 36 | } 37 | 38 | db::set_user_activation_code(tx.ex(), user, None).await?; 39 | tx.commit().await?; 40 | 41 | Ok(()) 42 | } 43 | } 44 | 45 | #[cfg(test)] 46 | mod tests { 47 | use super::*; 48 | use crate::driver::AuthnOptions; 49 | use crate::driver::testutils::*; 50 | use iii_iv_core::db::Executor; 51 | use iii_iv_core::model::EmailAddress; 52 | 53 | /// Creates a test user with an optional activation `code` and returns its username. 54 | async fn create_test_user(ex: &mut Executor, code: Option) -> Username { 55 | let username = Username::from("some-username"); 56 | 57 | let user = db::create_user(ex, username.clone(), None, EmailAddress::from("a@example.com")) 58 | .await 59 | .unwrap(); 60 | db::set_user_activation_code(ex, user, code).await.unwrap(); 61 | 62 | username 63 | } 64 | 65 | #[tokio::test] 66 | async fn test_activate_ok() { 67 | let context = TestContext::setup(AuthnOptions::default()).await; 68 | 69 | let username = create_test_user(&mut context.ex().await, Some(42)).await; 70 | 71 | context.driver().activate(username.clone(), 42).await.unwrap(); 72 | 73 | let user = db::get_user_by_username(&mut context.ex().await, username).await.unwrap(); 74 | assert!(user.activation_code().is_none()); 75 | } 76 | 77 | #[tokio::test] 78 | async fn test_activate_bad_code() { 79 | let context = TestContext::setup(AuthnOptions::default()).await; 80 | 81 | let username = create_test_user(&mut context.ex().await, Some(42)).await; 82 | 83 | match context.driver().activate(username.clone(), 41).await { 84 | Err(DriverError::InvalidInput(e)) => assert!(e.contains("Invalid activation code")), 85 | e => panic!("{:?}", e), 86 | } 87 | 88 | let user = db::get_user_by_username(&mut context.ex().await, username).await.unwrap(); 89 | assert!(user.activation_code().is_some()); 90 | } 91 | 92 | #[tokio::test] 93 | async fn test_activate_already_active() { 94 | let context = TestContext::setup(AuthnOptions::default()).await; 95 | 96 | let username = create_test_user(&mut context.ex().await, None).await; 97 | 98 | match context.driver().activate(username.clone(), 1234).await { 99 | Err(DriverError::InvalidInput(e)) => assert!(e.contains("already active")), 100 | e => panic!("{:?}", e), 101 | } 102 | 103 | let user = db::get_user_by_username(&mut context.ex().await, username).await.unwrap(); 104 | assert!(user.activation_code().is_none()); 105 | } 106 | 107 | #[tokio::test] 108 | async fn test_user_not_found() { 109 | let context = TestContext::setup(AuthnOptions::default()).await; 110 | 111 | let username = Username::from("unknown"); 112 | 113 | match context.driver().activate(username.clone(), 1234).await { 114 | Err(DriverError::NotFound(_)) => (), 115 | e => panic!("{:?}", e), 116 | } 117 | 118 | db::get_user_by_username(&mut context.ex().await, username).await.unwrap_err(); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /authn/src/driver/email.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Utilities to send canned messages to users over email. 17 | 18 | use crate::driver::DriverResult; 19 | use iii_iv_core::model::{EmailAddress, Username}; 20 | use iii_iv_core::rest::BaseUrls; 21 | use iii_iv_smtp::driver::SmtpMailer; 22 | use iii_iv_smtp::model::EmailTemplate; 23 | 24 | /// Sends the activation code `code` for `username` to the given `email` address. 25 | /// 26 | /// The email contents are constructed from the `template` and are sent via `mailer`. 27 | /// `base_urls` is used to compute the address to the account activation endpoint. 28 | pub(super) async fn send_activation_code( 29 | mailer: &(dyn SmtpMailer + Send + Sync), 30 | template: &EmailTemplate, 31 | base_urls: &BaseUrls, 32 | username: &Username, 33 | email: &EmailAddress, 34 | code: u64, 35 | ) -> DriverResult<()> { 36 | // TODO(jmmv): This doesn't really belong here because it's leaking details about the REST 37 | // router into the driver. 38 | let activate_url = base_urls.make_backend_url(&format!( 39 | "api/users/{}/activate?code={}", 40 | username.as_str(), 41 | code 42 | )); 43 | 44 | let replacements = [("activate_url", activate_url.as_str()), ("username", username.as_str())]; 45 | let message = template.apply(email, &replacements)?; 46 | 47 | mailer.send(message).await 48 | } 49 | 50 | #[cfg(any(test, feature = "testutils"))] 51 | pub(crate) mod testutils { 52 | //! Utilities to help testing services that integrate with the `authn` features. 53 | 54 | use super::*; 55 | use iii_iv_smtp::driver::testutils::RecorderSmtpMailer; 56 | use iii_iv_smtp::model::testutils::parse_message; 57 | use url::Url; 58 | 59 | /// Creates an email activation template to capture activation codes during tests. 60 | pub(crate) fn make_test_activation_template() -> EmailTemplate { 61 | let from = "from@example.com".parse().unwrap(); 62 | EmailTemplate { from, subject_template: "Test activation", body_template: "%activate_url%" } 63 | } 64 | 65 | /// Gets the latest activation URL sent to `to` which, if any, should be for the username 66 | /// given in `exp_username`. 67 | pub(crate) async fn get_latest_activation_url( 68 | mailer: &RecorderSmtpMailer, 69 | to: &EmailAddress, 70 | exp_username: &Username, 71 | ) -> Option { 72 | let inboxes = mailer.inboxes.lock().await; 73 | match inboxes.get(to) { 74 | Some(inbox) => { 75 | let message = inbox.last().expect("Must have received at least one message"); 76 | let (headers, body) = parse_message(message); 77 | let bad_message = "Email was not built by make_test_activation_template"; 78 | assert_eq!("Test activation", headers.get("Subject").expect(bad_message)); 79 | let url = Url::parse(&body).expect(bad_message); 80 | assert!(url.as_str().contains(&format!("api/users/{}/", exp_username.as_str()))); 81 | Some(url) 82 | } 83 | None => None, 84 | } 85 | } 86 | 87 | /// Gets the latest activation code sent to `to` which, if any, should be for the username 88 | /// given in `exp_username`. 89 | pub(crate) async fn get_latest_activation_code( 90 | mailer: &RecorderSmtpMailer, 91 | to: &EmailAddress, 92 | exp_username: &Username, 93 | ) -> Option { 94 | let activation_url = get_latest_activation_url(mailer, to, exp_username).await; 95 | activation_url.map(|url| { 96 | url.as_str() 97 | .split_once('=') 98 | .map(|(_, code)| { 99 | str::parse(code).expect("Want only one numerical parameter in query string") 100 | }) 101 | .expect("No parameter found in query string") 102 | }) 103 | } 104 | } 105 | 106 | #[cfg(test)] 107 | mod tests { 108 | use super::testutils::*; 109 | use super::*; 110 | use iii_iv_smtp::driver::testutils::RecorderSmtpMailer; 111 | use iii_iv_smtp::model::testutils::parse_message; 112 | 113 | #[tokio::test] 114 | async fn test_send_activation_code() { 115 | let mailer = RecorderSmtpMailer::default(); 116 | 117 | let to = EmailAddress::from("user@example.com"); 118 | send_activation_code( 119 | &mailer, 120 | &make_test_activation_template(), 121 | &BaseUrls::from_strs( 122 | "https://test.example.com:1234/", 123 | Some("https://no-frontend.example.com"), 124 | ), 125 | &Username::from("user-123"), 126 | &to, 127 | 7654, 128 | ) 129 | .await 130 | .unwrap(); 131 | 132 | let message = mailer.expect_one_message(&to).await; 133 | let (headers, body) = parse_message(&message); 134 | assert_eq!(to.as_str(), headers.get("To").unwrap()); 135 | assert_eq!("https://test.example.com:1234/api/users/user-123/activate?code=7654", body); 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /authn/src/driver/logout.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Extends the driver with the `logout` method. 17 | 18 | use crate::db; 19 | use crate::driver::AuthnDriver; 20 | use crate::model::AccessToken; 21 | use iii_iv_core::driver::{DriverError, DriverResult}; 22 | use iii_iv_core::model::Username; 23 | 24 | impl AuthnDriver { 25 | /// Marks a session as deleted. 26 | pub(crate) async fn logout(self, token: AccessToken, username: Username) -> DriverResult<()> { 27 | let mut tx = self.db.begin().await?; 28 | let now = self.clock.now_utc(); 29 | 30 | let session = db::get_session(tx.ex(), &token).await?; 31 | if session.username() != &username { 32 | return Err(DriverError::NotFound("Entity not found".to_owned())); 33 | } 34 | db::delete_session(tx.ex(), session, now).await?; 35 | 36 | tx.commit().await?; 37 | 38 | // Removing the session from the cache is only a best-effort operation. If we end up with 39 | // multiple instances of a frontend running at once, there is no easy way to perform cache 40 | // invalidation across all of them. But we don't know how this code is consumed (maybe it 41 | // is part of a single-instance server instead of a lambda-style deployment), so let's try 42 | // to do the right thing. 43 | let mut cache = self.sessions_cache.lock().await; 44 | let _previous = cache.remove(&token); 45 | 46 | Ok(()) 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | use crate::driver::AuthnOptions; 54 | use crate::driver::testutils::*; 55 | use iii_iv_core::db::DbError; 56 | use std::time::Duration; 57 | 58 | #[tokio::test] 59 | async fn test_ok() { 60 | let context = TestContext::setup(AuthnOptions::default()).await; 61 | 62 | let username = Username::from("test"); 63 | 64 | let token = context.do_test_login(username.clone()).await; 65 | context.driver().logout(token.clone(), username).await.unwrap(); 66 | 67 | match db::get_session(&mut context.ex().await, &token).await { 68 | Err(DbError::NotFound) => (), 69 | e => panic!("{:?}", e), 70 | } 71 | } 72 | 73 | #[tokio::test] 74 | async fn test_not_found() { 75 | let context = TestContext::setup(AuthnOptions::default()).await; 76 | 77 | let username1 = Username::from("test1"); 78 | 79 | let token1 = context.do_test_login(username1.clone()).await; 80 | let token2 = context.do_test_login(Username::from("test2")).await; 81 | context.driver().logout(token1.clone(), username1).await.unwrap(); 82 | 83 | db::get_session(&mut context.ex().await, &token1).await.unwrap_err(); 84 | db::get_session(&mut context.ex().await, &token2).await.unwrap(); 85 | } 86 | 87 | #[tokio::test] 88 | async fn test_invalid_user_error() { 89 | let context = TestContext::setup(AuthnOptions::default()).await; 90 | 91 | let username1 = Username::from("test1"); 92 | let username2 = Username::from("test2"); 93 | 94 | let token1 = context.do_test_login(username1.clone()).await; 95 | let err1 = context.driver().logout(token1.clone(), username2).await.unwrap_err(); 96 | context.driver().logout(token1.clone(), username1.clone()).await.unwrap(); 97 | let err2 = context.driver().logout(token1.clone(), username1).await.unwrap_err(); 98 | 99 | assert_eq!(err1, err2); 100 | } 101 | 102 | #[tokio::test] 103 | async fn test_remove_from_sessions_cache() { 104 | // Configure a cache with just one entry and "infinite" duration so that we can precisely 105 | // control when entries get evicted. 106 | let opts = AuthnOptions { 107 | sessions_cache_capacity: 1, 108 | sessions_cache_ttl: Duration::from_secs(900), 109 | ..Default::default() 110 | }; 111 | let context = TestContext::setup(opts).await; 112 | 113 | let username = Username::from("test"); 114 | 115 | assert_eq!(0, context.driver().sessions_cache.lock().await.len()); 116 | let token = context.do_test_login(username.clone()).await; 117 | 118 | let mut tx = context.db().begin().await.unwrap(); 119 | let _user = context 120 | .driver() 121 | .get_session(&mut tx, context.driver().now_utc(), token.clone()) 122 | .await 123 | .unwrap(); 124 | tx.commit().await.unwrap(); 125 | assert_eq!(1, context.driver().sessions_cache.lock().await.len()); 126 | 127 | context.driver().logout(token.clone(), username).await.unwrap(); 128 | assert_eq!(0, context.driver().sessions_cache.lock().await.len()); 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /authn/src/driver/signup.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Extends the driver with the `signup` method. 17 | 18 | use crate::db; 19 | use crate::driver::AuthnDriver; 20 | use crate::driver::email::send_activation_code; 21 | use crate::model::Password; 22 | use iii_iv_core::db::DbError; 23 | use iii_iv_core::driver::{DriverError, DriverResult}; 24 | use iii_iv_core::model::{EmailAddress, Username}; 25 | 26 | /// Verifies that a password is sufficiently complex. 27 | // TODO(jmmv): This should be hidden via a trait and the user of this crate should be able to 28 | // choose or supply their own validation rules. 29 | fn password_validator(s: &str) -> Option<&'static str> { 30 | if s.len() < 8 { 31 | return Some("Too short"); 32 | } 33 | 34 | let mut alphabetic = false; 35 | let mut numeric = false; 36 | for ch in s.chars() { 37 | if ch.is_alphabetic() { 38 | alphabetic = true; 39 | } 40 | if ch.is_numeric() { 41 | numeric = true; 42 | } 43 | } 44 | if !alphabetic || !numeric { 45 | return Some("Must contain letters and numbers"); 46 | } 47 | 48 | None 49 | } 50 | 51 | impl AuthnDriver { 52 | /// Creates a new account for a user. 53 | pub(crate) async fn signup( 54 | self, 55 | username: Username, 56 | password: Password, 57 | email: EmailAddress, 58 | ) -> DriverResult<()> { 59 | let mut tx = self.db.begin().await?; 60 | 61 | let password = password.validate_and_hash(password_validator)?; 62 | 63 | let user = match db::create_user(tx.ex(), username, Some(password), email).await { 64 | Ok(user) => user, 65 | Err(DbError::AlreadyExists) => { 66 | return Err(DriverError::AlreadyExists( 67 | "Username or email address are already registered".to_owned(), 68 | )); 69 | } 70 | Err(e) => return Err(e.into()), 71 | }; 72 | 73 | let activation_code = rand::random::(); 74 | let user = db::set_user_activation_code(tx.ex(), user, Some(activation_code)).await?; 75 | 76 | // TODO(jmmv): This should leverage the queue somehow, but we need to figure out how that 77 | // can be done while also supporting service-specific tasks. 78 | send_activation_code( 79 | self.mailer.as_ref(), 80 | &self.activation_template, 81 | &self.base_urls, 82 | user.username(), 83 | user.email(), 84 | activation_code, 85 | ) 86 | .await?; 87 | 88 | tx.commit().await?; 89 | Ok(()) 90 | } 91 | } 92 | 93 | #[cfg(test)] 94 | mod tests { 95 | use super::*; 96 | use crate::driver::AuthnOptions; 97 | use crate::driver::testutils::*; 98 | 99 | #[tokio::test] 100 | async fn test_signup_ok() { 101 | let context = TestContext::setup(AuthnOptions::default()).await; 102 | 103 | let username = Username::from("hello"); 104 | let password = Password::from("sufficiently0complex"); 105 | let email = EmailAddress::from("foo@example.com"); 106 | 107 | assert_eq!( 108 | DbError::NotFound, 109 | db::get_user_by_username(&mut context.ex().await, username.clone()).await.unwrap_err() 110 | ); 111 | 112 | context.driver().signup(username.clone(), password, email).await.unwrap(); 113 | 114 | let user = 115 | db::get_user_by_username(&mut context.ex().await, username.clone()).await.unwrap(); 116 | assert!(user.activation_code().is_some()); 117 | assert_eq!( 118 | user.activation_code(), 119 | context.get_latest_activation_code(user.email(), &username).await 120 | ); 121 | } 122 | 123 | #[tokio::test] 124 | async fn test_signup_username_already_exists() { 125 | let context = TestContext::setup(AuthnOptions::default()).await; 126 | 127 | let username = Username::from("hello"); 128 | let email = EmailAddress::from("other@example.com"); 129 | 130 | db::create_user(&mut context.ex().await, username.clone(), None, email.clone()) 131 | .await 132 | .unwrap(); 133 | 134 | match context 135 | .driver() 136 | .signup(username.clone(), Password::from("the1password"), email.clone()) 137 | .await 138 | { 139 | Err(DriverError::AlreadyExists(msg)) => assert!(msg.contains("already registered")), 140 | e => panic!("{:?}", e), 141 | } 142 | 143 | assert!(context.get_latest_activation_code(&email, &username).await.is_none()); 144 | } 145 | 146 | #[tokio::test] 147 | async fn test_signup_email_already_exists() { 148 | let context = TestContext::setup(AuthnOptions::default()).await; 149 | 150 | let email = EmailAddress::from("foo@example.com"); 151 | 152 | db::create_user(&mut context.ex().await, Username::from("some"), None, email.clone()) 153 | .await 154 | .unwrap(); 155 | 156 | match context 157 | .driver() 158 | .signup(Username::from("other"), Password::from("the1password"), email.clone()) 159 | .await 160 | { 161 | Err(DriverError::AlreadyExists(msg)) => assert!(msg.contains("already registered")), 162 | e => panic!("{:?}", e), 163 | } 164 | 165 | assert!(context.get_latest_activation_code(&email, &Username::from("x")).await.is_none()); 166 | } 167 | 168 | #[tokio::test] 169 | async fn test_signup_weak_password() { 170 | let context = TestContext::setup(AuthnOptions::default()).await; 171 | 172 | let username = Username::from("hello"); 173 | let email = EmailAddress::from("other@example.com"); 174 | 175 | for (password, error) in [ 176 | ("a", "Too short"), 177 | ("abcdefg", "Too short"), 178 | ("long enough", "letters and numbers"), 179 | ("1234567890", "letters and numbers"), 180 | ] { 181 | match context 182 | .driver() 183 | .signup(username.clone(), Password::new(password).unwrap(), email.clone()) 184 | .await 185 | { 186 | Err(DriverError::InvalidInput(msg)) => { 187 | assert!(msg.contains("Weak password")); 188 | assert!(msg.contains(error)); 189 | } 190 | e => panic!("{:?}", e), 191 | } 192 | 193 | assert!(context.get_latest_activation_code(&email, &username).await.is_none()); 194 | } 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /authn/src/driver/testutils.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Utilities to help testing services that integrate with the `authn` features. 17 | 18 | use crate::db; 19 | use crate::driver::email::testutils::{get_latest_activation_code, make_test_activation_template}; 20 | use crate::driver::{AuthnDriver, AuthnOptions}; 21 | use crate::model::{AccessToken, Password}; 22 | use iii_iv_core::clocks::Clock; 23 | use iii_iv_core::db::Db; 24 | use iii_iv_core::model::EmailAddress; 25 | use iii_iv_core::model::Username; 26 | use iii_iv_core::rest::BaseUrls; 27 | use iii_iv_smtp::driver::testutils::RecorderSmtpMailer; 28 | use std::sync::Arc; 29 | #[cfg(test)] 30 | use { 31 | iii_iv_core::clocks::testutils::SettableClock, iii_iv_core::db::Executor, std::time::Duration, 32 | time::OffsetDateTime, time::macros::datetime, 33 | }; 34 | 35 | /// State of a running test. 36 | pub struct TestContext { 37 | /// The clock used by the test. 38 | #[cfg(test)] 39 | pub(super) clock: Arc, 40 | 41 | /// The SMTP mailer to capture authentication flow request messages. 42 | mailer: Arc, 43 | 44 | /// The driver to handle authentication flows. 45 | driver: AuthnDriver, 46 | } 47 | 48 | impl TestContext { 49 | /// Initializes the driver using an in-memory database, a monotonic clock and a mock 50 | /// messenger that captures outgoing notifications. 51 | #[cfg(test)] 52 | pub(crate) async fn setup(opts: AuthnOptions) -> Self { 53 | let db = Arc::from(iii_iv_core::db::sqlite::testutils::setup().await); 54 | let clock = Arc::from(SettableClock::new(datetime!(2023-12-01 05:50:00 UTC))); 55 | Self::setup_with(opts, db, clock, "the-realm").await 56 | } 57 | 58 | /// Initializes the test context using the given already-initialized objects. 59 | pub async fn setup_with( 60 | opts: AuthnOptions, 61 | db: Arc, 62 | clock: Arc, 63 | realm: &'static str, 64 | ) -> Self { 65 | db::init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 66 | let mailer = Arc::from(RecorderSmtpMailer::default()); 67 | let base_urls = Arc::from(BaseUrls::from_strs( 68 | "http://localhost:1234/", 69 | Some("http://no-frontend.example.com"), 70 | )); 71 | let driver = AuthnDriver::new( 72 | db, 73 | clock.clone(), 74 | mailer.clone(), 75 | make_test_activation_template(), 76 | base_urls, 77 | realm, 78 | opts, 79 | ); 80 | 81 | #[cfg(not(test))] 82 | let context = TestContext { mailer, driver }; 83 | #[cfg(test)] 84 | let context = TestContext { clock, mailer, driver }; 85 | context 86 | } 87 | 88 | /// Syntactic sugar to create a user ifor testing purposes. 89 | pub async fn create_active_user(&self, username: &Username) { 90 | let password = Password::from("test0password"); 91 | 92 | let email = EmailAddress::new(format!("{}@example.com", username.as_str())).unwrap(); 93 | self.driver 94 | .clone() 95 | .signup(username.clone(), password.clone(), email.clone()) 96 | .await 97 | .unwrap(); 98 | let activation_code = 99 | get_latest_activation_code(&self.mailer, &email, username).await.unwrap(); 100 | self.driver.clone().activate(username.clone(), activation_code).await.unwrap(); 101 | } 102 | 103 | /// Syntactic sugar to create and log a user in for testing purposes. 104 | pub async fn do_test_login(&self, username: Username) -> AccessToken { 105 | let password = Password::from("test0password"); 106 | self.create_active_user(&username).await; 107 | 108 | let response = self.driver.clone().login(username, password).await.unwrap(); 109 | response.take_access_token() 110 | } 111 | 112 | /// Gets access to the database used by this test context. 113 | #[cfg(test)] 114 | pub(crate) fn db(&self) -> &dyn Db { 115 | self.driver.db.as_ref() 116 | } 117 | 118 | /// Gets a direct executor against the database. 119 | #[cfg(test)] 120 | pub(crate) async fn ex(&self) -> Executor { 121 | self.driver.db.ex().await.unwrap() 122 | } 123 | 124 | /// Gets a copy of the driver in this test context. 125 | pub fn driver(&self) -> AuthnDriver { 126 | self.driver.clone() 127 | } 128 | 129 | /// Gets the latest activation code sent to `email` which, if any, should be for the username 130 | /// given in `exp_username`. 131 | #[cfg(test)] 132 | pub(crate) async fn get_latest_activation_code( 133 | &self, 134 | email: &EmailAddress, 135 | exp_username: &Username, 136 | ) -> Option { 137 | get_latest_activation_code(&self.mailer, email, exp_username).await 138 | } 139 | 140 | /// Returns "now" with an offset in seconds, which can be positive or negative. 141 | #[cfg(test)] 142 | pub(crate) fn now_delta(&self, secs: i64) -> OffsetDateTime { 143 | if secs > 0 { 144 | self.clock.now_utc() + Duration::from_secs(secs as u64) 145 | } else { 146 | self.clock.now_utc() - Duration::from_secs((-secs) as u64) 147 | } 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /authn/src/lib.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Common utilities to implement custom authentication. 17 | 18 | // Keep these in sync with other top-level files. 19 | #![warn(anonymous_parameters, bad_style, clippy::missing_docs_in_private_items, missing_docs)] 20 | #![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)] 21 | #![warn(unsafe_code)] 22 | 23 | pub mod db; 24 | pub mod driver; 25 | pub mod model; 26 | pub mod rest; 27 | -------------------------------------------------------------------------------- /authn/src/model/accesstoken.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! The `AccessToken` data type. 17 | 18 | use iii_iv_core::model::{ModelError, ModelResult}; 19 | use rand::Rng; 20 | use serde::{Deserialize, Serialize}; 21 | use std::fmt; 22 | 23 | /// Length of our binary tokens, in bytes. 24 | /// 25 | /// This is not customizable because this size is replicated in the database schema and we cannot 26 | /// simply change what it is at runtime. 27 | const TOKEN_LENGTH: usize = 256; 28 | 29 | /// An opaque type representing a user's access token. 30 | /// 31 | /// Access tokens are user-readable character sequences of a fixed size. 32 | #[derive(Clone, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] 33 | #[serde(transparent)] 34 | pub struct AccessToken(String); 35 | 36 | impl AccessToken { 37 | /// Creates a new access token. 38 | pub fn new>(token: S) -> ModelResult { 39 | let token = token.into(); 40 | if token.len() != TOKEN_LENGTH { 41 | return Err(ModelError("Invalid access token".to_owned())); 42 | } 43 | for ch in token.chars() { 44 | if !ch.is_ascii_alphanumeric() { 45 | return Err(ModelError("Invalid access token".to_owned())); 46 | } 47 | } 48 | Ok(Self(token)) 49 | } 50 | 51 | /// Generates a new access token. 52 | pub fn generate() -> Self { 53 | let mut rng = rand::rng(); 54 | let mut token = String::with_capacity(TOKEN_LENGTH); 55 | for _ in 0..TOKEN_LENGTH { 56 | let i = rng.random_range(0..(10 + 26 + 26)); 57 | let ch = if i < 10 { 58 | (b'0' + i) as char 59 | } else if i < 10 + 26 { 60 | (b'a' + (i - 10)) as char 61 | } else { 62 | (b'A' + (i - 10 - 26)) as char 63 | }; 64 | assert!(ch.is_alphanumeric()); 65 | token.push(ch); 66 | } 67 | Self::new(token).expect("Auto-generated tokens must be valid") 68 | } 69 | 70 | /// Returns the string representation of the token. 71 | pub fn as_str(&self) -> &str { 72 | &self.0 73 | } 74 | } 75 | 76 | impl fmt::Debug for AccessToken { 77 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 78 | f.write_str("scrubbed access token") 79 | } 80 | } 81 | 82 | #[cfg(test)] 83 | mod tests { 84 | use super::*; 85 | use std::collections::HashSet; 86 | 87 | #[test] 88 | fn test_accesstoken_ok() { 89 | let mut raw_token = String::new(); 90 | for _ in 0..TOKEN_LENGTH { 91 | raw_token.push('a'); 92 | } 93 | let token = AccessToken::new(&raw_token).unwrap(); 94 | assert_eq!(&raw_token, token.as_str()); 95 | } 96 | 97 | #[test] 98 | fn test_accesstoken_error_too_short() { 99 | AccessToken::new("abcde").unwrap_err(); 100 | } 101 | 102 | #[test] 103 | fn test_accesstoken_error_invalid_character() { 104 | let raw_token = "!".repeat(TOKEN_LENGTH); 105 | AccessToken::new(raw_token).unwrap_err(); 106 | } 107 | 108 | #[test] 109 | fn test_accesstoken_error_too_long() { 110 | let mut raw_token = "b".repeat(TOKEN_LENGTH); 111 | AccessToken::new(raw_token.clone()).unwrap(); 112 | raw_token.push('b'); 113 | AccessToken::new(raw_token).unwrap_err(); 114 | } 115 | 116 | #[test] 117 | fn test_accesstoken_generate_unique() { 118 | let mut raw_tokens = HashSet::::default(); 119 | for _ in 0..1000 { 120 | raw_tokens.insert(AccessToken::generate().as_str().to_owned()); 121 | } 122 | assert_eq!(1000, raw_tokens.len()); 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /authn/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Generic data types for authentication. 17 | 18 | mod accesstoken; 19 | pub use accesstoken::AccessToken; 20 | mod passwords; 21 | pub use passwords::{HashedPassword, Password}; 22 | mod session; 23 | pub use session::Session; 24 | mod user; 25 | pub use user::User; 26 | -------------------------------------------------------------------------------- /authn/src/model/passwords.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! The `Password` and `HashedPassword` data types. 17 | 18 | use iii_iv_core::model::{ModelError, ModelResult}; 19 | use serde::{Deserialize, Serialize}; 20 | use std::fmt; 21 | 22 | /// An opaque type to hold a password, protecting it from leaking into logs. 23 | #[derive(Deserialize, PartialEq, Serialize)] 24 | #[serde(transparent)] 25 | #[cfg_attr(any(test, feature = "testutils"), derive(Clone))] 26 | pub struct Password(String); 27 | 28 | impl Password { 29 | /// Creates a new password from a literal string. 30 | pub fn new>(s: S) -> ModelResult { 31 | let s = s.into(); 32 | if s.len() > 56 { 33 | return Err(ModelError("Password is too long".to_owned())); 34 | } 35 | Ok(Password(s)) 36 | } 37 | 38 | /// Returns a string view of the password. 39 | #[cfg(any(test, feature = "testutils"))] 40 | pub fn as_str(&self) -> &str { 41 | &self.0 42 | } 43 | 44 | /// Hashes the password after validating that it is sufficiently complex via the `validator` 45 | /// hook. Consumes the password because there is no context in which keeping the password 46 | /// alive once we have generated its hash is correct. 47 | pub fn validate_and_hash( 48 | self, 49 | validator: fn(&str) -> Option<&'static str>, 50 | ) -> ModelResult { 51 | if let Some(error) = validator(&self.0) { 52 | return Err(ModelError(format!("Weak password: {}", error))); 53 | } 54 | let hashed = 55 | bcrypt::hash(self.0, 10).map_err(|e| ModelError(format!("Password error: {}", e)))?; 56 | Ok(HashedPassword::new(hashed)) 57 | } 58 | 59 | /// Verifies if this password matches a given `hash`. 60 | pub fn verify(self, hash: &HashedPassword) -> ModelResult { 61 | bcrypt::verify(self.0, hash.as_str()) 62 | .map_err(|e| ModelError(format!("Password error: {}", e))) 63 | } 64 | } 65 | 66 | #[cfg(any(test, feature = "testutils"))] 67 | impl From<&'static str> for Password { 68 | /// Creates a new password from a hardcoded string, which must be valid. 69 | fn from(s: &'static str) -> Self { 70 | Password::new(s).expect("Hardcoded passwords must be valid") 71 | } 72 | } 73 | 74 | impl fmt::Debug for Password { 75 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 76 | f.write_str("scrubbed password") 77 | } 78 | } 79 | 80 | /// An opaque type to hold a hashed password, protecting it from leaking into logs. 81 | #[derive(PartialEq)] 82 | #[cfg_attr(any(test, feature = "testutils"), derive(Clone))] 83 | pub struct HashedPassword(String); 84 | 85 | impl HashedPassword { 86 | /// Creates a new hashed password from a literal string. 87 | pub fn new>(s: S) -> Self { 88 | HashedPassword(s.into()) 89 | } 90 | 91 | /// Returns a string view of the hash. 92 | pub fn as_str(&self) -> &str { 93 | &self.0 94 | } 95 | } 96 | 97 | impl fmt::Debug for HashedPassword { 98 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 99 | f.write_str("scrubbed hash") 100 | } 101 | } 102 | 103 | #[cfg(test)] 104 | mod tests { 105 | use super::*; 106 | 107 | #[test] 108 | fn test_password_ok() { 109 | assert_eq!(Password::from("foo"), Password::new("foo").unwrap()); 110 | assert_eq!("bar", Password::new("bar").unwrap().as_str()); 111 | } 112 | 113 | #[test] 114 | fn test_password_error() { 115 | assert!( 116 | Password::new( 117 | "this password is way too long to be valid because of bcrypt restrictions" 118 | ) 119 | .is_err() 120 | ); 121 | } 122 | 123 | #[test] 124 | fn test_password_validate_and_hash() { 125 | let password = Password::from("abcd"); 126 | password.clone().validate_and_hash(|_| None).unwrap(); 127 | match password.validate_and_hash(|_| Some("the error")) { 128 | Err(e) => assert_eq!("Weak password: the error", e.0), 129 | e => panic!("{:?}", e), 130 | } 131 | } 132 | 133 | #[test] 134 | fn test_password_hash_and_verify() { 135 | let password1 = Password::from("first password"); 136 | let password2 = Password::from("second password"); 137 | let hash1 = password1.clone().validate_and_hash(|_| None).unwrap(); 138 | let hash2 = password2.clone().validate_and_hash(|_| None).unwrap(); 139 | 140 | assert!(hash1.as_str().starts_with("$2b$10$")); 141 | assert!(hash2.as_str().starts_with("$2b$10$")); 142 | assert!(hash1 != hash2); 143 | 144 | assert!(password1.clone().verify(&hash1).unwrap()); 145 | assert!(!password2.clone().verify(&hash1).unwrap()); 146 | assert!(!password1.verify(&hash2).unwrap()); 147 | assert!(password2.verify(&hash2).unwrap()); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /authn/src/model/session.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! The `Session` data type. 17 | 18 | use crate::model::AccessToken; 19 | use iii_iv_core::model::Username; 20 | use time::OffsetDateTime; 21 | 22 | /// Represents a user session. 23 | #[cfg_attr(test, derive(Clone, Debug, PartialEq))] 24 | pub struct Session { 25 | /// The access token for the session, which acts as its identifier. 26 | access_token: AccessToken, 27 | 28 | /// The username for this session. 29 | username: Username, 30 | 31 | /// Timestamp to represent when the session was initiated. 32 | login_time: OffsetDateTime, 33 | } 34 | 35 | impl Session { 36 | /// Creates a new session from its parts. 37 | pub(crate) fn new( 38 | access_token: AccessToken, 39 | username: Username, 40 | login_time: OffsetDateTime, 41 | ) -> Self { 42 | Self { access_token, username, login_time } 43 | } 44 | 45 | /// Returns the session's access token. 46 | pub fn access_token(&self) -> &AccessToken { 47 | &self.access_token 48 | } 49 | 50 | /// Returns the session's username. 51 | pub fn username(&self) -> &Username { 52 | &self.username 53 | } 54 | 55 | /// Returns the session's login time. 56 | pub fn login_time(&self) -> OffsetDateTime { 57 | self.login_time 58 | } 59 | 60 | /// Consumes the session and extracts its access token. 61 | pub(crate) fn take_access_token(self) -> AccessToken { 62 | self.access_token 63 | } 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::*; 69 | use time::macros::datetime; 70 | 71 | #[test] 72 | fn test_session() { 73 | let token = AccessToken::generate(); 74 | let username = Username::new("foo").unwrap(); 75 | let login_time = datetime!(2022-05-17 06:46:53 UTC); 76 | let session = Session::new(token.clone(), username.clone(), login_time); 77 | assert_eq!(&token, session.access_token()); 78 | assert_eq!(&username, session.username()); 79 | assert_eq!(login_time, session.login_time()); 80 | assert_eq!(token, session.take_access_token()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /authn/src/model/user.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! The `User` data type. 17 | 18 | use crate::model::HashedPassword; 19 | use iii_iv_core::model::{EmailAddress, Username}; 20 | use time::OffsetDateTime; 21 | 22 | /// Representation of a user's information. 23 | #[derive(Debug, PartialEq)] 24 | pub struct User { 25 | /// Name of the user. 26 | username: Username, 27 | 28 | /// Hashed password. None if the user is not allowed to log in. 29 | password: Option, 30 | 31 | /// Email of the user. 32 | email: EmailAddress, 33 | 34 | /// Token required to activate the user if not active yet. 35 | activation_code: Option, 36 | 37 | /// Time of last login of the user. None if the user has never logged in. 38 | last_login: Option, 39 | } 40 | 41 | impl User { 42 | /// Creates a new user with the given fields. 43 | pub(crate) fn new(username: Username, email: EmailAddress) -> Self { 44 | Self { username, password: None, email, activation_code: None, last_login: None } 45 | } 46 | 47 | /// Modifies a user to set or clear its activation code. 48 | pub(crate) fn with_activation_code(mut self, code: Option) -> Self { 49 | self.activation_code = code; 50 | self 51 | } 52 | 53 | /// Modifies a user to record their most recent login time. 54 | pub(crate) fn with_last_login(mut self, last_login: OffsetDateTime) -> Self { 55 | self.last_login = Some(last_login); 56 | self 57 | } 58 | 59 | /// Modifies a user to add a password. 60 | pub(crate) fn with_password(mut self, password: HashedPassword) -> Self { 61 | self.password = Some(password); 62 | self 63 | } 64 | 65 | /// Gets the user's username. 66 | pub fn username(&self) -> &Username { 67 | &self.username 68 | } 69 | 70 | /// Gets the user's password as a hash. 71 | pub fn password(&self) -> Option<&HashedPassword> { 72 | self.password.as_ref() 73 | } 74 | 75 | /// Gets the user's email address. 76 | pub fn email(&self) -> &EmailAddress { 77 | &self.email 78 | } 79 | 80 | /// Gets the user's activation code. 81 | pub fn activation_code(&self) -> Option { 82 | self.activation_code 83 | } 84 | 85 | /// Gets the user's last login timestamp, or `None` if the user has never logged in yet. 86 | pub fn last_login(&self) -> Option { 87 | self.last_login 88 | } 89 | } 90 | 91 | #[cfg(test)] 92 | mod tests { 93 | use super::*; 94 | use time::macros::datetime; 95 | 96 | #[test] 97 | fn test_user_getters() { 98 | let user = User::new(Username::from("foo"), EmailAddress::from("a@example.com")); 99 | assert_eq!(&Username::from("foo"), user.username()); 100 | assert!(user.password().is_none()); 101 | assert_eq!(&EmailAddress::from("a@example.com"), user.email()); 102 | assert!(user.activation_code().is_none()); 103 | assert!(user.last_login().is_none()); 104 | 105 | let user = user 106 | .with_activation_code(Some(123)) 107 | .with_last_login(datetime!(2022-04-02 05:38:00 UTC)) 108 | .with_password(HashedPassword::new("password-hash")); 109 | assert_eq!(Some(123), user.activation_code()); 110 | assert_eq!(Some(&HashedPassword::new("password-hash")), user.password()); 111 | assert_eq!(Some(datetime!(2022-04-02 05:38:00 UTC)), user.last_login()); 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /authn/src/rest/api_activate_get.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to activate a newly-created user account. 17 | 18 | use crate::driver::AuthnDriver; 19 | use axum::extract::{Path, Query, State}; 20 | use axum::response::Html; 21 | use iii_iv_core::model::Username; 22 | use iii_iv_core::rest::{EmptyBody, RestError}; 23 | use iii_iv_core::template; 24 | use serde::{Deserialize, Serialize}; 25 | 26 | /// Default HTML to return when an account is successfully activated. 27 | const DEFAULT_ACTIVATED_TEMPLATE: &str = r#" 28 | Account activated 29 | 30 | 31 |

Success!

32 | 33 |

%username%, your account has been successfully activated.

34 | 35 | 36 | 37 | "#; 38 | 39 | /// Message sent to the server to activate a user account. 40 | #[derive(Default, Deserialize, Serialize)] 41 | pub struct ActivateRequest { 42 | /// Activation code. 43 | pub code: u64, 44 | } 45 | 46 | /// GET handler for this API. 47 | #[allow(clippy::type_complexity)] 48 | pub(crate) async fn handler( 49 | State((driver, activated_template)): State<(AuthnDriver, Option<&'static str>)>, 50 | Path(user): Path, 51 | Query(request): Query, 52 | _: EmptyBody, 53 | ) -> Result, RestError> { 54 | let user = Username::new(user)?; 55 | 56 | driver.activate(user.clone(), request.code).await?; 57 | 58 | let body = template::apply( 59 | activated_template.unwrap_or(DEFAULT_ACTIVATED_TEMPLATE), 60 | &[("username", user.as_str())], 61 | ); 62 | 63 | Ok(Html(body)) 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::*; 69 | use crate::rest::testutils::*; 70 | use axum::http; 71 | use iii_iv_core::{rest::testutils::OneShotBuilder, test_payload_must_be_empty}; 72 | 73 | fn route(username: &str, query: ActivateRequest) -> (http::Method, String) { 74 | ( 75 | http::Method::GET, 76 | format!( 77 | "/api/test/users/{}/activate?{}", 78 | username, 79 | serde_urlencoded::to_string(query).unwrap() 80 | ), 81 | ) 82 | } 83 | 84 | #[tokio::test] 85 | async fn test_ok() { 86 | let mut context = TestContextBuilder::new().build().await; 87 | 88 | let user = context.create_inactive_whoami_user(8991).await; 89 | 90 | let request = ActivateRequest { code: 8991 }; 91 | let body = OneShotBuilder::new(context.app(), route(user.username().as_str(), request)) 92 | .send_empty() 93 | .await 94 | .take_body_as_text() 95 | .await; 96 | 97 | assert!(body.contains("Success")); 98 | assert!(body.contains(&format!("{}, your", context.whoami().as_str()))); 99 | 100 | assert!(context.user_is_active(user.username()).await); 101 | } 102 | 103 | #[tokio::test] 104 | async fn test_ok_custom_template() { 105 | let template = "All good, %username%!"; 106 | let mut context = TestContextBuilder::new().with_activated_template(template).build().await; 107 | 108 | let user = context.create_inactive_whoami_user(8991).await; 109 | 110 | let request = ActivateRequest { code: 8991 }; 111 | let body = OneShotBuilder::new(context.app(), route(user.username().as_str(), request)) 112 | .send_empty() 113 | .await 114 | .take_body_as_text() 115 | .await; 116 | 117 | assert_eq!(format!("All good, {}!", context.whoami().as_str()), body); 118 | 119 | assert!(context.user_is_active(user.username()).await); 120 | } 121 | 122 | #[tokio::test] 123 | async fn test_cannot_activate() { 124 | let mut context = TestContextBuilder::new().build().await; 125 | 126 | let user = context.create_inactive_whoami_user(8991).await; 127 | 128 | let request = ActivateRequest { code: 123 }; 129 | OneShotBuilder::new(context.app(), route(user.username().as_str(), request)) 130 | .send_empty() 131 | .await 132 | .expect_status(http::StatusCode::BAD_REQUEST) 133 | .expect_error("Invalid activation code") 134 | .await; 135 | 136 | assert!(!context.user_is_active(user.username()).await); 137 | } 138 | 139 | #[tokio::test] 140 | async fn test_bad_username() { 141 | let context = TestContextBuilder::new().build().await; 142 | 143 | let request = ActivateRequest { code: 1 }; 144 | OneShotBuilder::new(context.into_app(), route("not%20valid", request)) 145 | .send_empty() 146 | .await 147 | .expect_status(http::StatusCode::BAD_REQUEST) 148 | .expect_error("Unsupported character") 149 | .await; 150 | } 151 | 152 | test_payload_must_be_empty!( 153 | TestContextBuilder::new().build().await.into_app(), 154 | route("irrelevant", ActivateRequest { code: 0 }) 155 | ); 156 | } 157 | -------------------------------------------------------------------------------- /authn/src/rest/api_login_post.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to create a new session for an existing user. 17 | 18 | use crate::driver::AuthnDriver; 19 | use crate::model::AccessToken; 20 | use crate::rest::get_basic_auth; 21 | use axum::Json; 22 | use axum::extract::State; 23 | use axum::http::HeaderMap; 24 | use axum::response::IntoResponse; 25 | use iii_iv_core::rest::{EmptyBody, RestError}; 26 | use serde::{Deserialize, Serialize}; 27 | use std::time::Duration; 28 | 29 | /// Message returned by the server after a successful login attempt. 30 | #[derive(Debug, Deserialize, Serialize)] 31 | pub struct LoginResponse { 32 | /// Access token for this session. 33 | pub access_token: AccessToken, 34 | 35 | /// Maximum age of the created session. The client can use this to set up cookie expiration 36 | /// times to match. 37 | pub session_max_age: Duration, 38 | } 39 | 40 | /// POST handler for this API. 41 | pub(crate) async fn handler( 42 | State(driver): State, 43 | headers: HeaderMap, 44 | _: EmptyBody, 45 | ) -> Result { 46 | let (username, password) = get_basic_auth(&headers, driver.realm())?; 47 | 48 | // The maximum session age is a property of the server, not the session. This might lead to a 49 | // situation where this value changes in the server's configuration and the clients have session 50 | // cookies with expiration times that don't match. That's OK because the clients need to be 51 | // prepared to handle authentication problems and session revocation for any reason. But this 52 | // is just a choice. We could as well store this value along each session in the database. 53 | let session_max_age = driver.opts().session_max_age; 54 | 55 | let session = driver.login(username, password).await?; 56 | let response = LoginResponse { access_token: session.take_access_token(), session_max_age }; 57 | 58 | Ok(Json(response)) 59 | } 60 | 61 | #[cfg(test)] 62 | mod tests { 63 | use super::*; 64 | use crate::driver::AuthnOptions; 65 | use crate::rest::testutils::*; 66 | use axum::http; 67 | use iii_iv_core::rest::testutils::OneShotBuilder; 68 | use iii_iv_core::test_payload_must_be_empty; 69 | 70 | fn route() -> (http::Method, String) { 71 | (http::Method::POST, "/api/test/login".to_owned()) 72 | } 73 | 74 | #[tokio::test] 75 | async fn test_ok() { 76 | let opts = 77 | AuthnOptions { session_max_age: Duration::from_secs(4182), ..Default::default() }; 78 | let mut context = TestContextBuilder::new().with_opts(opts).build().await; 79 | 80 | context.create_whoami_user().await; 81 | 82 | let response = OneShotBuilder::new(context.app(), route()) 83 | .with_basic_auth(context.whoami().as_str(), context.whoami_password().as_str()) 84 | .send_empty() 85 | .await 86 | .expect_json::() 87 | .await; 88 | 89 | assert!(context.session_exists(&response.access_token).await); 90 | assert!(context.user_exists(&context.whoami()).await); 91 | assert_eq!(4182, response.session_max_age.as_secs()); 92 | } 93 | 94 | #[tokio::test] 95 | async fn test_unknown_user() { 96 | let context = TestContextBuilder::new().build().await; 97 | 98 | OneShotBuilder::new(context.app(), route()) 99 | .with_basic_auth(context.whoami().as_str(), "password") 100 | .send_empty() 101 | .await 102 | .expect_status(http::StatusCode::FORBIDDEN) 103 | .expect_error("Unknown user") 104 | .await; 105 | } 106 | 107 | #[tokio::test] 108 | async fn test_bad_whoami() { 109 | let context = TestContextBuilder::new().with_whoami("not%20valid").build().await; 110 | 111 | OneShotBuilder::new(context.into_app(), route()) 112 | .with_basic_auth("not valid", "password") 113 | .send_empty() 114 | .await 115 | .expect_status(http::StatusCode::BAD_REQUEST) 116 | .expect_error("Unsupported character") 117 | .await; 118 | } 119 | 120 | test_payload_must_be_empty!(TestContextBuilder::new().build().await.into_app(), route()); 121 | } 122 | -------------------------------------------------------------------------------- /authn/src/rest/api_logout_post.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to terminate an existing session. 17 | 18 | use crate::driver::AuthnDriver; 19 | use crate::rest::get_bearer_auth; 20 | use axum::extract::{Path, State}; 21 | use axum::http::HeaderMap; 22 | use iii_iv_core::model::Username; 23 | use iii_iv_core::rest::{EmptyBody, RestError}; 24 | 25 | /// POST handler for this API. 26 | pub(crate) async fn handler( 27 | State(driver): State, 28 | Path(user): Path, 29 | headers: HeaderMap, 30 | _: EmptyBody, 31 | ) -> Result<(), RestError> { 32 | let access_token = get_bearer_auth(&headers, driver.realm())?; 33 | driver.logout(access_token, user).await?; 34 | 35 | Ok(()) 36 | } 37 | 38 | #[cfg(test)] 39 | mod tests { 40 | use crate::model::AccessToken; 41 | use crate::rest::testutils::*; 42 | use axum::http; 43 | use iii_iv_core::rest::testutils::OneShotBuilder; 44 | use iii_iv_core::test_payload_must_be_empty; 45 | 46 | fn route(username: &str) -> (http::Method, String) { 47 | (http::Method::POST, format!("/api/test/users/{}/logout", username)) 48 | } 49 | 50 | #[tokio::test] 51 | async fn test_ok() { 52 | let mut context = TestContextBuilder::new().build().await; 53 | 54 | let user = context.create_whoami_user().await; 55 | let token = context.access_token().await; 56 | 57 | assert!(context.session_exists(&token).await); 58 | 59 | OneShotBuilder::new(context.app(), route(user.username().as_str())) 60 | .with_bearer_auth(token.as_str()) 61 | .send_empty() 62 | .await 63 | .expect_empty() 64 | .await; 65 | 66 | assert!(!context.session_exists(&token).await); 67 | } 68 | 69 | #[tokio::test] 70 | async fn test_not_found() { 71 | let mut context = TestContextBuilder::new().build().await; 72 | 73 | let user = context.create_whoami_user().await; 74 | let token = AccessToken::generate(); 75 | 76 | OneShotBuilder::new(context.app(), route(user.username().as_str())) 77 | .with_bearer_auth(token.as_str()) 78 | .send_empty() 79 | .await 80 | .expect_status(http::StatusCode::NOT_FOUND) 81 | .expect_error("Entity not found") 82 | .await; 83 | 84 | assert!(!context.session_exists(&token).await); 85 | } 86 | 87 | #[tokio::test] 88 | async fn test_bad_username() { 89 | let context = TestContextBuilder::new().build().await; 90 | 91 | OneShotBuilder::new(context.app(), route("not%20valid")) 92 | .send_empty() 93 | .await 94 | .expect_status(http::StatusCode::BAD_REQUEST) 95 | .expect_text("Unsupported character") 96 | .await; 97 | } 98 | 99 | test_payload_must_be_empty!( 100 | TestContextBuilder::new().build().await.into_app(), 101 | route("irrelevant") 102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /authn/src/rest/api_signup_post.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to create a new user account. 17 | 18 | use crate::driver::AuthnDriver; 19 | use crate::model::Password; 20 | use axum::Json; 21 | use axum::extract::State; 22 | use iii_iv_core::model::{EmailAddress, Username}; 23 | use iii_iv_core::rest::RestError; 24 | use serde::{Deserialize, Serialize}; 25 | 26 | /// Message sent to the server to create an account. 27 | #[derive(Deserialize, Serialize)] 28 | pub struct SignupRequest { 29 | /// Desired username. 30 | pub username: Username, 31 | 32 | /// Desired password. 33 | pub password: Password, 34 | 35 | /// Email address for the user, needed to validate their account signup process and to contact 36 | /// the user for service changes. 37 | pub email: EmailAddress, 38 | } 39 | 40 | /// POST handler for this API. 41 | pub(crate) async fn handler( 42 | State(driver): State, 43 | Json(request): Json, 44 | ) -> Result<(), RestError> { 45 | driver.signup(request.username, request.password, request.email).await?; 46 | Ok(()) 47 | } 48 | 49 | #[cfg(test)] 50 | mod tests { 51 | use super::*; 52 | use crate::rest::testutils::*; 53 | use axum::http; 54 | use iii_iv_core::{rest::testutils::OneShotBuilder, test_payload_must_be_json}; 55 | 56 | fn route() -> (http::Method, String) { 57 | (http::Method::POST, "/api/test/signup".to_owned()) 58 | } 59 | 60 | #[tokio::test] 61 | async fn test_ok() { 62 | let mut context = TestContextBuilder::new().build().await; 63 | 64 | let request = SignupRequest { 65 | username: "new".into(), 66 | password: "hello4World".into(), 67 | email: "new@example.com".into(), 68 | }; 69 | OneShotBuilder::new(context.app(), route()).send_json(request).await.expect_empty().await; 70 | 71 | assert!(context.user_exists(&Username::from("new")).await); 72 | assert!(!context.user_is_active(&Username::from("new")).await); 73 | } 74 | 75 | #[tokio::test] 76 | async fn test_already_exists() { 77 | let mut context = TestContextBuilder::new().build().await; 78 | 79 | context.create_whoami_user().await; 80 | 81 | let request = SignupRequest { 82 | username: context.whoami(), 83 | password: "hello0World".into(), 84 | email: "other@example.com".into(), 85 | }; 86 | OneShotBuilder::new(context.into_app(), route()) 87 | .send_json(request) 88 | .await 89 | .expect_status(http::StatusCode::BAD_REQUEST) 90 | .expect_error("already registered") 91 | .await; 92 | } 93 | 94 | #[tokio::test] 95 | async fn test_bad_username() { 96 | let context = TestContextBuilder::new().with_whoami("not valid").build().await; 97 | 98 | let request = SignupRequest { 99 | username: Username::new_invalid("not valid"), 100 | password: "hello".into(), 101 | email: "some@example.com".into(), 102 | }; 103 | OneShotBuilder::new(context.into_app(), route()) 104 | .send_json(request) 105 | .await 106 | .expect_status(http::StatusCode::UNPROCESSABLE_ENTITY) 107 | .expect_text("Unsupported character") 108 | .await; 109 | } 110 | 111 | #[tokio::test] 112 | async fn test_bad_email() { 113 | let context = TestContextBuilder::new().with_whoami("not valid").build().await; 114 | 115 | let request = SignupRequest { 116 | username: "valid".into(), 117 | password: "hello".into(), 118 | email: EmailAddress::new_invalid("some.example.com"), 119 | }; 120 | OneShotBuilder::new(context.into_app(), route()) 121 | .send_json(request) 122 | .await 123 | .expect_status(http::StatusCode::UNPROCESSABLE_ENTITY) 124 | .expect_text("Email.*valid address") 125 | .await; 126 | } 127 | 128 | test_payload_must_be_json!(TestContextBuilder::new().build().await.into_app(), route()); 129 | } 130 | -------------------------------------------------------------------------------- /authn/src/rest/mod.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! REST interface for a generic authentication service. 17 | 18 | use crate::driver::AuthnDriver; 19 | use axum::Router; 20 | 21 | mod api_activate_get; 22 | mod api_login_post; 23 | mod api_logout_post; 24 | mod api_signup_post; 25 | mod httputils; 26 | #[cfg(any(test, feature = "testutils"))] 27 | pub mod testutils; 28 | 29 | pub use api_activate_get::ActivateRequest; 30 | pub use api_login_post::LoginResponse; 31 | pub use api_signup_post::SignupRequest; 32 | pub use httputils::{get_basic_auth, get_bearer_auth, has_bearer_auth}; 33 | 34 | /// Creates the router for the authentication endpoints. 35 | /// 36 | /// The `driver` is a configured instance of the `AuthnDriver` to handle accounts. 37 | /// 38 | /// The `activated_template` HTML template is used when confirming the successful activation of 39 | /// a new account. 40 | pub fn app(driver: AuthnDriver, activated_template: Option<&'static str>) -> Router { 41 | use axum::routing::{get, post}; 42 | 43 | let activate_router = Router::new() 44 | .route("/users/:user/activate", get(api_activate_get::handler)) 45 | .with_state((driver.clone(), activated_template)); 46 | 47 | Router::new() 48 | .route("/login", post(api_login_post::handler)) 49 | .route("/users/:user/logout", post(api_logout_post::handler)) 50 | .route("/signup", post(api_signup_post::handler)) 51 | .with_state(driver) 52 | .merge(activate_router) 53 | } 54 | 55 | #[cfg(test)] 56 | mod tests { 57 | use super::api_activate_get::ActivateRequest; 58 | use super::api_login_post::LoginResponse; 59 | use super::api_signup_post::SignupRequest; 60 | use super::testutils::*; 61 | use http::{Method, StatusCode}; 62 | use iii_iv_core::model::{EmailAddress, Username}; 63 | use iii_iv_core::rest::testutils::*; 64 | 65 | #[tokio::test] 66 | async fn test_e2e_signup_flow() { 67 | let mut context = TestContextBuilder::new().with_whoami("the-user").build().await; 68 | 69 | let request = SignupRequest { 70 | username: "the-user".into(), 71 | password: "The1234Password".into(), 72 | email: "new@example.com".into(), 73 | }; 74 | OneShotBuilder::new(context.app(), (Method::POST, "/api/test/signup")) 75 | .send_json(request) 76 | .await 77 | .expect_empty() 78 | .await; 79 | 80 | OneShotBuilder::new(context.app(), (Method::POST, "/api/test/login")) 81 | .with_basic_auth("the-user", "the password") 82 | .send_empty() 83 | .await 84 | .expect_status(StatusCode::FORBIDDEN) 85 | .expect_error("Invalid password") 86 | .await; 87 | 88 | OneShotBuilder::new(context.app(), (Method::POST, "/api/test/login")) 89 | .with_basic_auth("the-user", "The1234Password") 90 | .send_empty() 91 | .await 92 | .expect_status(StatusCode::CONFLICT) 93 | .expect_error("Account.*not.*activated") 94 | .await; 95 | 96 | let request = ActivateRequest { 97 | code: context 98 | .get_latest_activation_code( 99 | &EmailAddress::from("new@example.com"), 100 | &Username::from("the-user"), 101 | ) 102 | .await 103 | .unwrap(), 104 | }; 105 | OneShotBuilder::new( 106 | context.app(), 107 | ( 108 | Method::GET, 109 | format!( 110 | "/api/test/users/the-user/activate?{}", 111 | serde_urlencoded::to_string(request).unwrap() 112 | ), 113 | ), 114 | ) 115 | .send_empty() 116 | .await 117 | .expect_text("successfully activated") 118 | .await; 119 | 120 | let response = OneShotBuilder::new(context.app(), (Method::POST, "/api/test/login")) 121 | .with_basic_auth("the-user", "The1234Password") 122 | .send_empty() 123 | .await 124 | .expect_json::() 125 | .await; 126 | let access_token1 = response.access_token; 127 | assert!(context.session_exists(&access_token1).await); 128 | 129 | let response = OneShotBuilder::new(context.app(), (Method::POST, "/api/test/login")) 130 | .with_basic_auth("the-user", "The1234Password") 131 | .send_empty() 132 | .await 133 | .expect_json::() 134 | .await; 135 | let access_token2 = response.access_token; 136 | assert!(context.session_exists(&access_token1).await); 137 | assert!(context.session_exists(&access_token2).await); 138 | 139 | assert_ne!(access_token1, access_token2); 140 | 141 | OneShotBuilder::new(context.app(), (Method::POST, "/api/test/users/the-user/logout")) 142 | .with_bearer_auth(access_token1.as_str()) 143 | .send_empty() 144 | .await 145 | .expect_empty() 146 | .await; 147 | assert!(!context.session_exists(&access_token1).await); 148 | assert!(context.session_exists(&access_token2).await); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /config.env.tmpl: -------------------------------------------------------------------------------- 1 | # III-IV 2 | # Copyright 2023 Julio Merino 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | # use this file except in compliance with the License. You may obtain a copy 6 | # of the License at: 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | # License for the specific language governing permissions and limitations 14 | # under the License. 15 | 16 | # User-configurable settings to run tests. 17 | # 18 | # To modify this file, first copy it to config.env and then edit that local copy. This file 19 | # (config.env.tmpl) must not contain secrets and the config.env file must never be checked in. 20 | 21 | # Azure Maps key to use to geolocate IPs. 22 | export AZURE_MAPS_KEY= 23 | 24 | # Settings to connect to the test database. 25 | export PGSQL_TEST_HOST= 26 | export PGSQL_TEST_PORT=5432 27 | export PGSQL_TEST_DATABASE=iii-iv-test 28 | export PGSQL_TEST_USERNAME= 29 | export PGSQL_TEST_PASSWORD= 30 | 31 | # Debugging. 32 | export RUST_LOG=debug 33 | -------------------------------------------------------------------------------- /core/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iii-iv-core" 3 | version = "0.0.0" 4 | description = "III-IV: Core abstractions and types" 5 | authors = ["Julio Merino "] 6 | edition = "2024" 7 | publish = false 8 | 9 | [features] 10 | default = [] 11 | postgres = [ 12 | "dep:futures", 13 | "dep:log", 14 | "dep:rand", 15 | "dep:regex", 16 | "sqlx/postgres", 17 | "sqlx/runtime-tokio-rustls", 18 | ] 19 | sqlite = [ 20 | "dep:futures", 21 | "dep:log", 22 | "sqlx/sqlite", 23 | "sqlx/runtime-tokio-rustls", 24 | ] 25 | testutils = [ 26 | "dep:base64", 27 | "dep:bytes", 28 | "dep:env_logger", 29 | "dep:http-body", 30 | "dep:hyper", 31 | "dep:mime", 32 | "dep:paste", 33 | "dep:rand", 34 | "dep:regex", 35 | "dep:serde_urlencoded", 36 | "dep:tower", 37 | ] 38 | 39 | [dependencies] 40 | async-trait = { workspace = true } 41 | axum = { workspace = true } 42 | base64 = { workspace = true, optional = true } 43 | bytes = { workspace = true, optional = true } 44 | derivative = { workspace = true } 45 | env_logger = { workspace = true, optional = true } 46 | futures = { workspace = true, optional = true } 47 | http-body = { workspace = true, optional = true } 48 | http = { workspace = true } 49 | hyper = { workspace = true, optional = true } 50 | log = { workspace = true, optional = true } 51 | mime = { workspace = true, optional = true } 52 | paste = { workspace = true, optional = true } 53 | rand = { workspace = true, optional = true } 54 | regex = { workspace = true, optional = true } 55 | serde_json = { workspace = true } 56 | serde_urlencoded = { workspace = true, optional = true } 57 | serde = { workspace = true } 58 | sqlx = { workspace = true } 59 | thiserror = { workspace = true } 60 | time = { workspace = true } 61 | tokio = { workspace = true } 62 | tower = { workspace = true, optional = true } 63 | url = { workspace = true } 64 | 65 | [dev-dependencies] 66 | env_logger = { workspace = true } 67 | paste = { workspace = true } 68 | rand = { workspace = true } 69 | serde_test = { workspace = true } 70 | temp-env = { workspace = true } 71 | time = { workspace = true, features = ["macros"] } 72 | tokio = { workspace = true, features = ["macros"] } 73 | -------------------------------------------------------------------------------- /core/src/clocks.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Collection of clock implementations. 17 | 18 | use async_trait::async_trait; 19 | use std::time::Duration; 20 | use time::OffsetDateTime; 21 | 22 | /// Generic definition of a clock. 23 | #[async_trait] 24 | pub trait Clock { 25 | /// Returns the current UTC time. 26 | fn now_utc(&self) -> OffsetDateTime; 27 | 28 | /// Pauses execution of the current task for `duration`. 29 | async fn sleep(&self, duration: Duration); 30 | } 31 | 32 | /// Clock implementation that uses the system clock. 33 | #[derive(Clone, Default)] 34 | pub struct SystemClock {} 35 | 36 | #[async_trait] 37 | impl Clock for SystemClock { 38 | fn now_utc(&self) -> OffsetDateTime { 39 | let nanos = OffsetDateTime::now_utc().unix_timestamp_nanos(); 40 | 41 | // Truncate the timestamp to microsecond resolution as this is the resolution supported by 42 | // timestamps in the PostgreSQL database. We could do this in the database instead, but 43 | // then we would get some strange behavior throughout the program. Better be consistent. 44 | let nanos = nanos / 1000 * 1000; 45 | 46 | OffsetDateTime::from_unix_timestamp_nanos(nanos) 47 | .expect("nanos must be in range because they come from the current timestamp") 48 | } 49 | 50 | async fn sleep(&self, duration: Duration) { 51 | tokio::time::sleep(duration).await 52 | } 53 | } 54 | 55 | /// Test utilities. 56 | #[cfg(feature = "testutils")] 57 | pub mod testutils { 58 | use super::*; 59 | use std::sync::atomic::{AtomicU64, Ordering}; 60 | use std::time::Duration; 61 | 62 | /// A clock that returns a preconfigured instant and that can be modified at will. 63 | /// 64 | /// Only supports microsecond-level precision. 65 | pub struct SettableClock { 66 | /// Current fake time in microseconds. 67 | now_us: AtomicU64, 68 | } 69 | 70 | impl SettableClock { 71 | /// Creates a new clock that returns `now` until reconfigured with `set`. 72 | pub fn new(now: OffsetDateTime) -> Self { 73 | let now_ns = now.unix_timestamp_nanos(); 74 | assert!(now_ns % 1000 == 0, "Nanosecond precision not supported"); 75 | let now_us = u64::try_from(now_ns / 1000).unwrap(); 76 | Self { now_us: AtomicU64::new(now_us) } 77 | } 78 | 79 | /// Sets the new value of `now` that the clock returns. 80 | pub fn set(&self, now: OffsetDateTime) { 81 | let now_ns = now.unix_timestamp_nanos(); 82 | assert!(now_ns % 1000 == 0, "Nanosecond precision not supported"); 83 | let now_us = u64::try_from(now_ns / 1000).unwrap(); 84 | self.now_us.store(now_us, Ordering::SeqCst); 85 | } 86 | 87 | /// Advances the current time by `delta`. 88 | pub fn advance(&self, delta: Duration) { 89 | let delta_ns = delta.as_nanos(); 90 | assert!(delta_ns % 1000 == 0, "Nanosecond precision not supported"); 91 | let delta_us = u64::try_from(delta_ns / 1000).unwrap(); 92 | self.now_us.fetch_add(delta_us, Ordering::SeqCst); 93 | } 94 | } 95 | 96 | #[async_trait] 97 | impl Clock for SettableClock { 98 | fn now_utc(&self) -> OffsetDateTime { 99 | let now_us = self.now_us.load(Ordering::SeqCst); 100 | OffsetDateTime::from_unix_timestamp_nanos(now_us as i128 * 1000).unwrap() 101 | } 102 | 103 | async fn sleep(&self, duration: Duration) { 104 | self.advance(duration); 105 | tokio::task::yield_now().await; 106 | } 107 | } 108 | 109 | #[cfg(test)] 110 | mod tests { 111 | use super::*; 112 | use std::panic::catch_unwind; 113 | use time::macros::datetime; 114 | 115 | #[test] 116 | fn test_settableclock_microsecond_precision_supported() { 117 | let now = datetime!(2023-12-01 10:15:00.123456 UTC); 118 | let clock = SettableClock::new(now); 119 | assert_eq!(now, clock.now_utc()); 120 | 121 | let now = datetime!(2023-12-01 10:15:00.987654 UTC); 122 | clock.set(now); 123 | assert_eq!(now, clock.now_utc()); 124 | 125 | let now = datetime!(2023-12-01 10:15:00.987655 UTC); 126 | clock.advance(Duration::from_nanos(1000)); 127 | assert_eq!(now, clock.now_utc()); 128 | } 129 | 130 | #[test] 131 | fn test_settableclock_nanosecond_precision_unsupported() { 132 | catch_unwind(|| { 133 | SettableClock::new(datetime!(2023-12-01 10:20:00.123456001 UTC)); 134 | }) 135 | .unwrap_err(); 136 | 137 | let clock = SettableClock::new(datetime!(2023-12-01 10:20:00 UTC)); 138 | catch_unwind(|| { 139 | clock.set(datetime!(2023-12-01 10:20:00.123456001 UTC)); 140 | }) 141 | .unwrap_err(); 142 | 143 | catch_unwind(|| { 144 | clock.advance(Duration::from_nanos(1)); 145 | }) 146 | .unwrap_err(); 147 | } 148 | 149 | #[tokio::test] 150 | async fn test_settableclock_sleep_advances_time() { 151 | let clock = SettableClock::new(datetime!(2023-12-01 10:40:00 UTC)); 152 | // Sleep for an unreasonable period to ensure we don't block for long. 153 | clock.sleep(Duration::from_secs(3600)).await; 154 | assert_eq!(datetime!(2023-12-01 11:40:00 UTC), clock.now_utc()); 155 | } 156 | } 157 | } 158 | 159 | #[cfg(test)] 160 | mod tests { 161 | use super::*; 162 | 163 | #[test] 164 | fn test_systemclock_trivial() { 165 | let clock = SystemClock::default(); 166 | let now1 = clock.now_utc(); 167 | assert!(now1.unix_timestamp_nanos() > 0); 168 | let now2 = clock.now_utc(); 169 | assert!(now2 >= now1); 170 | } 171 | 172 | #[test] 173 | fn test_systemclock_microsecond_resolution() { 174 | let clock = SystemClock::default(); 175 | let now = clock.now_utc(); 176 | assert!(now.unix_timestamp_nanos() > 0); 177 | assert_eq!(0, now.nanosecond() % 1000); 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /core/src/driver.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Generic business logic for any service. 17 | //! 18 | //! Every service should implement its own `Driver` type. 19 | //! 20 | //! Every operation implemented in the `Driver` should take consume `self` because this is the 21 | //! layer that coordinates multiple operations against the database inside a single transaction. 22 | //! Consuming `self` prevents the caller from easily issuing multiple operations against the driver, 23 | //! as this would require a clone and highlight an undesirable pattern. 24 | 25 | use crate::db::DbError; 26 | use crate::model::ModelError; 27 | 28 | /// Business logic errors. These errors encompass backend and logical errors. 29 | #[derive(Clone, Debug, PartialEq, thiserror::Error)] 30 | pub enum DriverError { 31 | /// Indicates that a request to create an entry failed because it already exists. 32 | #[error("{0}")] 33 | AlreadyExists(String), 34 | 35 | /// Catch-all error type for unexpected database errors. 36 | #[error("{0}")] 37 | BackendError(String), 38 | 39 | /// Indicates an error in the input data. 40 | #[error("{0}")] 41 | InvalidInput(String), 42 | 43 | /// Indicates insufficient disk quota to perform the requested write operation. 44 | #[error("{0}")] 45 | NoSpace(String), 46 | 47 | /// Indicates that login cannot succeed because the account is not yet activated. 48 | #[error("Account has not been activated yet")] 49 | NotActivated, 50 | 51 | /// Indicates that a requested entry does not exist. 52 | #[error("{0}")] 53 | NotFound(String), 54 | 55 | /// Indicates that the calling user is not allowed to perform a read or write operation. 56 | #[error("{0}")] 57 | Unauthorized(String), 58 | } 59 | 60 | impl From for DriverError { 61 | fn from(e: DbError) -> Self { 62 | match e { 63 | DbError::AlreadyExists => DriverError::AlreadyExists(e.to_string()), 64 | DbError::BackendError(_) => DriverError::BackendError(e.to_string()), 65 | DbError::DataIntegrityError(_) => DriverError::BackendError(e.to_string()), 66 | DbError::NotFound => DriverError::NotFound(e.to_string()), 67 | DbError::Unavailable => DriverError::BackendError(e.to_string()), 68 | } 69 | } 70 | } 71 | 72 | impl From for DriverError { 73 | fn from(e: ModelError) -> Self { 74 | DriverError::InvalidInput(e.to_string()) 75 | } 76 | } 77 | 78 | /// Result type for this module. 79 | pub type DriverResult = Result; 80 | -------------------------------------------------------------------------------- /core/src/lib.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Rudimentary framework to build web services. 17 | //! 18 | //! Services built using this framework adhere to the following layered architecture, and they 19 | //! should structure their code to have these modules as well: 20 | //! 21 | //! 1. `model`: This is the base layer, providing high-level data types that represent concepts in 22 | //! the domain of the application. There should be no logic in here. Extensive use of the 23 | //! newtype and builder patterns is strongly encouraged. 24 | //! 25 | //! 1. `db`: This is the persistence layer. Services extend the `BareTx` trait with a `Tx` type 26 | //! that provides domain-specific operations. 27 | //! 28 | //! 1. `driver`: This is the business logic layer. Services provide their own `Driver` type to 29 | //! encapsulates all of the in-memory state required by the app and to coordinate access to the 30 | //! database. 31 | //! 32 | //! 1. `rest`: This is the HTTP layer, offering the REST APIs. Services should provide their own 33 | //! `axum::Router` implementation and back every API with a data object of type `Driver`. 34 | //! 35 | //! 1. `main`: This is the app launcher. It sole purpose is to gather configuration data from 36 | //! environment variables and call the `crate::serve` function to start the application. 37 | //! 38 | //! There are result and error types in every layer, such as `DbResult` and `DbError`. Errors can 39 | //! transparently float to the top of the app using the `?` operator, being translated to HTTP 40 | //! status codes once returned from the REST layer. 41 | //! 42 | //! This crate provides the basic structure and is modeled after the layers presented above. Every 43 | //! service implementation should define the same modules. For more details on how to implement 44 | //! each module, refer to the module-level docstring of the modules in this crate. 45 | //! 46 | //! This crate does not have any heavy dependencies except those that are required for all services. 47 | //! Heavy dependencies are introduced by depending on sibling crates. 48 | 49 | // Keep these in sync with other top-level files. 50 | #![warn(anonymous_parameters, bad_style, clippy::missing_docs_in_private_items, missing_docs)] 51 | #![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)] 52 | #![warn(unsafe_code)] 53 | 54 | pub mod clocks; 55 | pub mod db; 56 | pub mod driver; 57 | pub mod env; 58 | pub mod model; 59 | pub mod rest; 60 | pub mod template; 61 | -------------------------------------------------------------------------------- /core/src/model/emailaddress.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! The `EmailAddress` data type. 17 | 18 | use crate::model::{ModelError, ModelResult}; 19 | use serde::de::Visitor; 20 | use serde::{Deserialize, Serialize}; 21 | 22 | /// Maximum length of email addresses per the schema. 23 | pub(crate) const MAX_EMAIL_LENGTH: usize = 64; 24 | 25 | /// Represents a correctly-formatted email address. 26 | /// 27 | /// According to the standard, the local part of an email address may be case sensitive but the 28 | /// domain part is case insensitive. Given that we only persist email addresses for tracing 29 | /// purposes, this treats them as case sensitive overall. 30 | #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] 31 | #[serde(transparent)] 32 | pub struct EmailAddress(String); 33 | 34 | impl EmailAddress { 35 | /// Creates a new email address from an untrusted string `s`, making sure it is valid. 36 | pub fn new>(s: S) -> ModelResult { 37 | let s = s.into(); 38 | 39 | if s.trim().is_empty() { 40 | return Err(ModelError("Email address cannot be empty".to_owned())); 41 | } 42 | if s.len() > MAX_EMAIL_LENGTH { 43 | return Err(ModelError("Email address is too long".to_owned())); 44 | } 45 | 46 | // Email addresses can have many formats, and attempting to validate them is futile. Given 47 | // that they come from Azure AAD and thus they have been used to verify the account, we'll 48 | // trust that they are valid. But we do some tiny validation anyway to make sure we at 49 | // least pass data around correctly. 50 | if !s.contains('@') || s.contains(' ') { 51 | return Err(ModelError(format!("Email does not look like a valid address '{}'", s))); 52 | } 53 | 54 | Ok(Self(s)) 55 | } 56 | 57 | /// Creates a new email address from an untrusted string `s`, without validation. Useful for 58 | /// testing purposes only. 59 | #[cfg(any(test, feature = "testutils"))] 60 | pub fn new_invalid>(s: S) -> Self { 61 | Self(s.into()) 62 | } 63 | 64 | /// Returns a string view of the email address. 65 | pub fn as_str(&self) -> &str { 66 | self.0.as_str() 67 | } 68 | } 69 | 70 | #[cfg(feature = "testutils")] 71 | impl From<&str> for EmailAddress { 72 | fn from(raw_email: &str) -> Self { 73 | Self::new(raw_email).expect("Hardcoded email addresses for testing must be valid") 74 | } 75 | } 76 | 77 | /// Visitor to deserialize an `EmailAddress` from a string. 78 | struct EmailAddressVisitor; 79 | 80 | impl Visitor<'_> for EmailAddressVisitor { 81 | type Value = EmailAddress; 82 | 83 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 84 | formatter.write_str(r#"a two-letter country ISO code"#) 85 | } 86 | 87 | fn visit_str(self, v: &str) -> Result 88 | where 89 | E: serde::de::Error, 90 | { 91 | match EmailAddress::new(v) { 92 | Ok(code) => Ok(code), 93 | Err(e) => Err(E::custom(format!("{}", e))), 94 | } 95 | } 96 | 97 | fn visit_string(self, v: String) -> Result 98 | where 99 | E: serde::de::Error, 100 | { 101 | match EmailAddress::new(v) { 102 | Ok(code) => Ok(code), 103 | Err(e) => Err(E::custom(format!("{}", e))), 104 | } 105 | } 106 | } 107 | 108 | impl<'de> Deserialize<'de> for EmailAddress { 109 | fn deserialize(deserializer: D) -> Result 110 | where 111 | D: serde::Deserializer<'de>, 112 | { 113 | deserializer.deserialize_string(EmailAddressVisitor) 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use super::*; 120 | use serde_test::{Token, assert_de_tokens_error, assert_tokens}; 121 | 122 | #[test] 123 | fn test_emailaddress_ok() { 124 | assert_eq!("simple@example.com", EmailAddress::new("simple@example.com").unwrap().as_str()); 125 | assert_eq!("a!b@c", EmailAddress::new("a!b@c").unwrap().as_str()); 126 | } 127 | 128 | #[cfg(feature = "testutils")] 129 | #[test] 130 | fn test_emailaddress_into() { 131 | assert_eq!(EmailAddress::new("a@example.com").unwrap(), "a@example.com".into()); 132 | } 133 | 134 | #[test] 135 | fn test_emailaddress_error() { 136 | assert!(EmailAddress::new("").is_err()); 137 | assert!(EmailAddress::new("foo").is_err()); 138 | assert!(EmailAddress::new("foo!bar").is_err()); 139 | 140 | let mut long_string = 141 | "@234567890123456789012345678901234567890123456789012345678901234".to_owned(); 142 | assert!(EmailAddress::new(&long_string).is_ok()); 143 | long_string.push('x'); 144 | assert!(EmailAddress::new(&long_string).is_err()); 145 | } 146 | 147 | #[test] 148 | fn test_emailaddress_invalid() { 149 | assert!(EmailAddress::new(EmailAddress::new_invalid("a").as_str()).is_err()); 150 | } 151 | 152 | #[test] 153 | fn test_emailaddress_case_sensitive() { 154 | assert_ne!( 155 | EmailAddress::new("foo@example.com").unwrap(), 156 | EmailAddress::new("Foo@example.com").unwrap() 157 | ); 158 | assert_ne!( 159 | EmailAddress::new("foo@example.com").unwrap(), 160 | EmailAddress::new("foo@Example.Com").unwrap() 161 | ); 162 | } 163 | 164 | #[test] 165 | fn test_emailaddress_ser_de_ok() { 166 | let code = EmailAddress::new("HelloWorld@example.com").unwrap(); 167 | assert_tokens(&code, &[Token::String("HelloWorld@example.com")]); 168 | } 169 | 170 | #[test] 171 | fn test_emailaddress_de_error() { 172 | assert_de_tokens_error::( 173 | &[Token::String("HelloWorld")], 174 | "Email does not look like a valid address 'HelloWorld'", 175 | ); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /core/src/model/mod.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Generic data types often useful in REST services. 17 | 18 | mod emailaddress; 19 | pub use emailaddress::EmailAddress; 20 | mod username; 21 | pub use username::Username; 22 | 23 | /// Data model errors. 24 | #[derive(Debug, PartialEq, thiserror::Error)] 25 | #[error("{0}")] 26 | pub struct ModelError(pub String); 27 | 28 | /// Result type for this module. 29 | pub type ModelResult = Result; 30 | -------------------------------------------------------------------------------- /core/src/model/username.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! The `Username` data type. 17 | 18 | use crate::model::{ModelError, ModelResult}; 19 | use serde::{Deserialize, Serialize, de::Visitor}; 20 | 21 | /// Maximum length of a username as specified in the schema. 22 | pub(crate) const USERS_MAX_USERNAME_LENGTH: usize = 32; 23 | 24 | /// Represents a correctly-formatted (but maybe non-existent) username. 25 | /// 26 | /// Usernames are case-insensitive and, for simplicity reasons, we force them to be all in 27 | /// lowercase. 28 | #[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Serialize)] 29 | #[serde(transparent)] 30 | pub struct Username(String); 31 | 32 | impl Username { 33 | /// Creates a new username from an untrusted string `s`, making sure it is valid. 34 | pub fn new>(s: S) -> ModelResult { 35 | let s = s.into(); 36 | 37 | if s.is_empty() { 38 | return Err(ModelError("Username cannot be empty".to_owned())); 39 | } 40 | if s.len() > USERS_MAX_USERNAME_LENGTH { 41 | return Err(ModelError("Username is too long".to_owned())); 42 | } 43 | 44 | for ch in s.chars() { 45 | if !(ch.is_ascii_alphanumeric() || ".-_".find(ch).is_some()) { 46 | return Err(ModelError(format!( 47 | "Unsupported character '{}' in username '{}'", 48 | ch, s 49 | ))); 50 | } 51 | } 52 | 53 | Ok(Self(s.to_lowercase())) 54 | } 55 | 56 | /// Creates a new username from an untrusted string `s`, without validation. Useful for testing 57 | /// purposes only. 58 | #[cfg(any(test, feature = "testutils"))] 59 | pub fn new_invalid>(s: S) -> Self { 60 | Self(s.into()) 61 | } 62 | 63 | /// Returns a string view of the username. 64 | pub fn as_str(&self) -> &str { 65 | self.0.as_str() 66 | } 67 | } 68 | 69 | #[cfg(any(test, feature = "testutils"))] 70 | impl From<&'static str> for Username { 71 | /// Creates a new username from a hardcoded string, which must be valid. 72 | fn from(name: &'static str) -> Self { 73 | assert_eq!(name, name.to_lowercase(), "Hardcoded usernames must be lowercase"); 74 | Username::new(name).expect("Hardcoded usernames must be valid") 75 | } 76 | } 77 | 78 | /// A deserialization visitor for a `Username`. 79 | struct UsernameVisitor; 80 | 81 | impl Visitor<'_> for UsernameVisitor { 82 | type Value = Username; 83 | 84 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 85 | formatter.write_str("a string") 86 | } 87 | 88 | fn visit_str(self, v: &str) -> Result 89 | where 90 | E: serde::de::Error, 91 | { 92 | Username::new(v).map_err(|e| E::custom(e.to_string())) 93 | } 94 | 95 | fn visit_string(self, v: String) -> Result 96 | where 97 | E: serde::de::Error, 98 | { 99 | Username::new(v).map_err(|e| E::custom(e.to_string())) 100 | } 101 | } 102 | 103 | impl<'de> Deserialize<'de> for Username { 104 | fn deserialize(deserializer: D) -> Result 105 | where 106 | D: serde::Deserializer<'de>, 107 | { 108 | deserializer.deserialize_string(UsernameVisitor) 109 | } 110 | } 111 | 112 | #[cfg(test)] 113 | mod tests { 114 | use super::*; 115 | use serde_test::{Token, assert_de_tokens_error, assert_tokens}; 116 | 117 | #[test] 118 | fn test_username_ok() { 119 | assert_eq!(Username::from("simple"), Username::new("simple").unwrap()); 120 | assert_eq!(Username::from("bar_baz93.xyz-2"), Username::new("bar_Baz93.xyz-2").unwrap()); 121 | } 122 | 123 | #[test] 124 | fn test_username_error() { 125 | assert!(Username::new("").is_err()); 126 | assert!(Username::new("foo bar").is_err()); 127 | assert!(Username::new("foo@example.com").is_err()); 128 | assert!(Username::new("foo\u{00e9}bar").is_err()); 129 | assert!(Username::new("name1,name2").is_err()); 130 | assert!(Username::new("name1:name2").is_err()); 131 | 132 | let mut long_string = "12345678901234567890123456789012".to_owned(); 133 | assert!(Username::new(&long_string).is_ok()); 134 | long_string.push('x'); 135 | assert!(Username::new(&long_string).is_err()); 136 | } 137 | 138 | #[test] 139 | fn test_username_invalid() { 140 | assert!(Username::new(Username::new_invalid("a b").as_str()).is_err()); 141 | } 142 | 143 | #[test] 144 | fn test_username_case_insensitive_lowercase() { 145 | assert_eq!(Username::from("foo"), Username::new("Foo").unwrap()); 146 | assert_ne!(Username::from("foo"), Username::new("fo").unwrap()); 147 | 148 | assert_eq!("someusername", Username::new("SomeUsername").unwrap().as_str()); 149 | } 150 | 151 | #[test] 152 | fn test_username_ser_de_ok() { 153 | let code = Username::new("HelloWorld").unwrap(); 154 | assert_tokens(&code, &[Token::String("helloworld")]); 155 | } 156 | 157 | #[test] 158 | fn test_username_de_error() { 159 | assert_de_tokens_error::( 160 | &[Token::String("hello world")], 161 | "Unsupported character ' ' in username 'hello world'", 162 | ); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /core/src/template.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Trivial templating engine. 17 | 18 | /// Performs various named string replacements in `input` based on `replacements`. 19 | /// 20 | /// The `input` string can have `%key%` strings in it where `key` must appear in `replacements` and 21 | /// which will be replaced by its corresponding value. Raw `%` characters can be escaped via `%%` 22 | /// and nested expansions are not supported. 23 | pub fn apply(input: &'static str, replacements: &[(&'static str, &str)]) -> String { 24 | let mut output = String::with_capacity(input.len()); 25 | let mut partial_key: Option = None; 26 | for ch in input.chars() { 27 | if ch == '%' { 28 | match partial_key { 29 | Some(key) if key.is_empty() => { 30 | output.push('%'); 31 | partial_key = None; 32 | } 33 | Some(key) => { 34 | let mut found = false; 35 | for (candidate_key, value) in replacements { 36 | if *candidate_key == key { 37 | assert!(!found, "Found two values for replacement {}", key); 38 | output.push_str(value); 39 | found = true; 40 | // We could "break" here but we don't because we want to check for 41 | // duplicates. 42 | } 43 | } 44 | assert!(found, "No replacement for {} but it must have been defined", key); 45 | partial_key = None; 46 | } 47 | None => partial_key = Some(String::new()), 48 | } 49 | } else { 50 | match partial_key.as_mut() { 51 | Some(k) => k.push(ch), 52 | None => output.push(ch), 53 | } 54 | } 55 | } 56 | output 57 | } 58 | 59 | #[cfg(test)] 60 | mod tests { 61 | use super::*; 62 | 63 | #[test] 64 | fn test_apply_empty() { 65 | assert_eq!("", apply("", &[])); 66 | } 67 | 68 | #[test] 69 | fn test_apply_none() { 70 | assert_eq!("this is % some text %%", apply("this is %% some text %%%%", &[])); 71 | } 72 | 73 | #[test] 74 | fn test_apply_some() { 75 | let replacements = &[("a", "single letter"), ("foo", "many letters")]; 76 | assert_eq!("single lettermany letters", apply("%a%%foo%", replacements)); 77 | assert_eq!(" single letter many letters ", apply(" %a% %foo% ", replacements)); 78 | assert_eq!( 79 | "some single letter foo text many letters a", 80 | apply("some %a% foo text %foo% a", replacements) 81 | ); 82 | } 83 | 84 | #[test] 85 | fn test_apply_no_nested_replacements() { 86 | let replacements = &[("a", "%nested% chunk")]; 87 | assert_eq!("the %nested% chunk output", apply("the %a% output", replacements)); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | Cargo.lock 2 | build.stamp 3 | config.mk 4 | target/ 5 | vscode-target/ 6 | -------------------------------------------------------------------------------- /example/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iii-iv-example" 3 | version = "0.0.0" 4 | description = "III-IV: Sample service and template" 5 | authors = ["Julio Merino "] 6 | edition = "2024" 7 | publish = false 8 | 9 | [dependencies] 10 | async-session = { workspace = true } 11 | async-trait = { workspace = true } 12 | axum = { workspace = true } 13 | axum-server = { workspace = true } 14 | derive-getters = { workspace = true } 15 | env_logger = { workspace = true } 16 | futures = { workspace = true } 17 | hyper = { workspace = true } 18 | log = { workspace = true } 19 | serde_json = { workspace = true } 20 | serde = { workspace = true } 21 | sqlx = { workspace = true, features = ["runtime-tokio-rustls", "sqlite"] } 22 | thiserror = { workspace = true } 23 | tokio = { workspace = true, features = ["rt", "macros", "rt-multi-thread"] } 24 | tower-http = { workspace = true } 25 | url = { workspace = true, features = ["serde"] } 26 | 27 | [dependencies.iii-iv-core] 28 | path = "../core" 29 | features = ["postgres"] 30 | 31 | [dependencies.derive_more] 32 | workspace = true 33 | features = ["as_ref", "constructor"] 34 | 35 | [dev-dependencies.iii-iv-core] 36 | path = "../core" 37 | features = ["sqlite", "testutils"] 38 | -------------------------------------------------------------------------------- /example/Makefile: -------------------------------------------------------------------------------- 1 | # III-IV 2 | # Copyright 2023 Julio Merino 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | # use this file except in compliance with the License. You may obtain a copy 6 | # of the License at: 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | # License for the specific language governing permissions and limitations 14 | # under the License. 15 | 16 | sinclude config.mk 17 | 18 | PROD_ENV += PGSQL_PROD_HOST="$(PGSQL_PROD_HOST)" 19 | PROD_ENV += PGSQL_PROD_PORT="$(PGSQL_PROD_PORT)" 20 | PROD_ENV += PGSQL_PROD_DATABASE="$(PGSQL_PROD_DATABASE)" 21 | PROD_ENV += PGSQL_PROD_USERNAME="$(PGSQL_PROD_USERNAME)" 22 | PROD_ENV += PGSQL_PROD_PASSWORD="$(PGSQL_PROD_PASSWORD)" 23 | PROD_ENV += RUST_LOG=debug 24 | 25 | TEST_ENV += PGSQL_TEST_HOST="$(PGSQL_TEST_HOST)" 26 | TEST_ENV += PGSQL_TEST_PORT="$(PGSQL_TEST_PORT)" 27 | TEST_ENV += PGSQL_TEST_DATABASE="$(PGSQL_TEST_DATABASE)" 28 | TEST_ENV += PGSQL_TEST_USERNAME="$(PGSQL_TEST_USERNAME)" 29 | TEST_ENV += PGSQL_TEST_PASSWORD="$(PGSQL_TEST_PASSWORD)" 30 | TEST_ENV += RUST_LOG=debug 31 | 32 | CROSS_TARGET = x86_64-unknown-linux-musl 33 | 34 | .PHONY: default 35 | default: Makefile serve 36 | 37 | .PHONY: serve 38 | serve: 39 | cargo build 40 | cp ../target/debug/iii-iv-example functions/ 41 | @cd functions && $(PROD_ENV) func start 42 | 43 | .PHONY: test 44 | test: 45 | @$(TEST_ENV) cargo test $(TEST_ARGS) -- --include-ignored 46 | 47 | .PHONY: functions/iii-iv-example 48 | functions/iii-iv-example: 49 | cargo build --release --target=$(CROSS_TARGET) 50 | cp ../target/$(CROSS_TARGET)/release/iii-iv-example functions/ 51 | 52 | CLEANFILES += deploy.zip deploy.zip.dir 53 | .PHONY: deploy.zip 54 | deploy.zip: functions/iii-iv-example 55 | @$(TEST_PROD) cargo test --release --target=$(CROSS_TARGET) -- --include-ignored 56 | rm -rf deploy.zip.dir 57 | cp -r functions deploy.zip.dir 58 | for f in $$(cat deploy.zip.dir/.funcignore); do rm "deploy.zip.dir/$$f"; done 59 | rm deploy.zip.dir/.funcignore 60 | ( cd deploy.zip.dir && zip -9 -r ../deploy.zip . ) 61 | -------------------------------------------------------------------------------- /example/config.mk.tmpl: -------------------------------------------------------------------------------- 1 | # III-IV 2 | # Copyright 2023 Julio Merino 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | # use this file except in compliance with the License. You may obtain a copy 6 | # of the License at: 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | # License for the specific language governing permissions and limitations 14 | # under the License. 15 | 16 | # User-configurable settings to run tests. 17 | # 18 | # To modify this file, first copy it to config.mk and then edit that local copy. This file 19 | # (config.mk.tmpl) must not contain secrets and the config.mk file must never be checked in. 20 | 21 | # Settings to connect to the prod database. 22 | PGSQL_PROD_HOST= 23 | PGSQL_PROD_PORT=5432 24 | PGSQL_PROD_DATABASE=iii-iv-prod 25 | PGSQL_PROD_USERNAME= 26 | PGSQL_PROD_PASSWORD= 27 | 28 | # Settings to connect to the test database. 29 | PGSQL_TEST_HOST= 30 | PGSQL_TEST_PORT=5432 31 | PGSQL_TEST_DATABASE=iii-iv-test 32 | PGSQL_TEST_USERNAME= 33 | PGSQL_TEST_PASSWORD= 34 | -------------------------------------------------------------------------------- /example/functions/.funcignore: -------------------------------------------------------------------------------- 1 | .gitignore 2 | local.settings.json 3 | -------------------------------------------------------------------------------- /example/functions/.gitignore: -------------------------------------------------------------------------------- 1 | iii-iv-example 2 | -------------------------------------------------------------------------------- /example/functions/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "logging": { 4 | "applicationInsights": { 5 | "samplingSettings": { 6 | "isEnabled": true, 7 | "excludedTypes": "Request" 8 | } 9 | } 10 | }, 11 | "extensionBundle": { 12 | "id": "Microsoft.Azure.Functions.ExtensionBundle", 13 | "version": "[1.*, 2.6.2)" 14 | }, 15 | "customHandler": { 16 | "description": { 17 | "defaultExecutablePath": "./iii-iv-example", 18 | "workingDirectory": "", 19 | "arguments": [] 20 | }, 21 | "enableForwardingHttpRequest": true 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /example/functions/keys/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "authLevel": "anonymous", 5 | "type": "httpTrigger", 6 | "direction": "in", 7 | "name": "req", 8 | "methods": ["delete", "get", "put"], 9 | "route": "v1/keys/{key:regex(^.+$)?}" 10 | }, 11 | { 12 | "type": "http", 13 | "direction": "out", 14 | "name": "res" 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /example/functions/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "AzureWebJobsStorage": "", 5 | "FUNCTIONS_WORKER_RUNTIME": "custom", 6 | "AzureWebJobs.microsoft-identity-association.json.Disabled": "true" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /example/src/db/postgres.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | CREATE TABLE IF NOT EXISTS store ( 17 | key TEXT PRIMARY KEY, 18 | value TEXT NOT NULL, 19 | version INT4 NOT NULL 20 | ); 21 | -------------------------------------------------------------------------------- /example/src/db/sqlite.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | PRAGMA foreign_keys = ON; 17 | 18 | CREATE TABLE IF NOT EXISTS store ( 19 | key TEXT PRIMARY KEY, 20 | value TEXT NOT NULL, 21 | version INTEGER NOT NULL 22 | ); 23 | -------------------------------------------------------------------------------- /example/src/db/tests.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Database tests shared by all implementations. 17 | 18 | use crate::db::*; 19 | use iii_iv_core::db::DbError; 20 | 21 | async fn test_sequence_one(ex: &mut Executor) { 22 | let key = Key::new("the-key".to_owned()); 23 | 24 | assert_eq!(DbError::NotFound, get_key(ex, &key).await.unwrap_err()); 25 | assert_eq!(None, get_key_version(ex, &key).await.unwrap()); 26 | 27 | let entry = Entry::new("insert".to_owned(), Version::from_u32(1).unwrap()); 28 | set_key(ex, &key, &entry).await.unwrap(); 29 | assert_eq!(entry, get_key(ex, &key).await.unwrap()); 30 | assert_eq!(Some(entry.version()), get_key_version(ex, &key).await.unwrap().as_ref()); 31 | 32 | let entry = Entry::new("upsert".to_owned(), Version::from_u32(0).unwrap()); 33 | set_key(ex, &key, &entry).await.unwrap(); 34 | assert_eq!(entry, get_key(ex, &key).await.unwrap()); 35 | assert_eq!(Some(entry.version()), get_key_version(ex, &key).await.unwrap().as_ref()); 36 | 37 | delete_key(ex, &key).await.unwrap(); 38 | 39 | assert_eq!(DbError::NotFound, get_key(ex, &key).await.unwrap_err()); 40 | assert_eq!(None, get_key_version(ex, &key).await.unwrap()); 41 | } 42 | 43 | async fn test_multiple_keys(ex: &mut Executor) { 44 | let key1 = Key::new("key 1".to_owned()); 45 | let key2 = Key::new("key 2".to_owned()); 46 | let entry = Entry::new("same value".to_owned(), Version::from_u32(123).unwrap()); 47 | 48 | assert_eq!(DbError::NotFound, get_key(ex, &key1).await.unwrap_err()); 49 | assert_eq!(DbError::NotFound, get_key(ex, &key2).await.unwrap_err()); 50 | 51 | set_key(ex, &key1, &entry).await.unwrap(); 52 | 53 | assert_eq!(entry, get_key(ex, &key1).await.unwrap()); 54 | assert_eq!(DbError::NotFound, get_key(ex, &key2).await.unwrap_err()); 55 | 56 | assert_eq!(DbError::NotFound, delete_key(ex, &key2).await.unwrap_err()); 57 | 58 | assert_eq!(entry, get_key(ex, &key1).await.unwrap()); 59 | assert_eq!(DbError::NotFound, get_key(ex, &key2).await.unwrap_err()); 60 | 61 | set_key(ex, &key2, &entry).await.unwrap(); 62 | 63 | assert_eq!(entry, get_key(ex, &key1).await.unwrap()); 64 | assert_eq!(entry, get_key(ex, &key2).await.unwrap()); 65 | } 66 | 67 | /// Instantiates the database tests for this module. 68 | #[macro_export] 69 | macro_rules! generate_db_tests [ 70 | ( $setup:expr $(, #[$extra:meta])? ) => { 71 | iii_iv_core::db::testutils::generate_tests!( 72 | $( #[$extra], )? 73 | $setup, 74 | $crate::db::tests, 75 | test_sequence_one, 76 | test_multiple_keys 77 | ); 78 | } 79 | ]; 80 | 81 | use generate_db_tests; 82 | 83 | mod postgres { 84 | use super::*; 85 | use crate::db::init_schema; 86 | use iii_iv_core::db::Db; 87 | use iii_iv_core::db::postgres::PostgresDb; 88 | use std::sync::Arc; 89 | 90 | async fn setup() -> PostgresDb { 91 | let db = iii_iv_core::db::postgres::testutils::setup().await; 92 | init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 93 | db 94 | } 95 | 96 | generate_db_tests!( 97 | { 98 | let db = Arc::from(setup().await); 99 | (db.clone(), &mut db.ex().await.unwrap()) 100 | }, 101 | #[ignore = "Requires environment configuration and is expensive"] 102 | ); 103 | } 104 | 105 | mod sqlite { 106 | use super::*; 107 | use crate::db::init_schema; 108 | use iii_iv_core::db::Db; 109 | use iii_iv_core::db::sqlite::SqliteDb; 110 | use std::sync::Arc; 111 | 112 | async fn setup() -> SqliteDb { 113 | let db = iii_iv_core::db::sqlite::testutils::setup().await; 114 | init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 115 | db 116 | } 117 | 118 | generate_db_tests!({ 119 | let db = Arc::from(setup().await); 120 | (db.clone(), &mut db.ex().await.unwrap()) 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /example/src/driver/key.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Operations on one key. 17 | 18 | use crate::db; 19 | use crate::driver::Driver; 20 | use crate::model::*; 21 | use iii_iv_core::driver::DriverResult; 22 | 23 | impl Driver { 24 | /// Deletes an existing `key`. 25 | pub(crate) async fn delete_key(self, key: &Key) -> DriverResult<()> { 26 | db::delete_key(&mut self.db.ex().await?, key).await?; 27 | Ok(()) 28 | } 29 | 30 | /// Gets the current value of the given `key`. 31 | pub(crate) async fn get_key(self, key: &Key) -> DriverResult { 32 | let value = db::get_key(&mut self.db.ex().await?, key).await?; 33 | Ok(value) 34 | } 35 | 36 | /// Sets `key` to `value`, incrementing its version. 37 | pub(crate) async fn set_key(self, key: &Key, value: String) -> DriverResult { 38 | let mut tx = self.db.begin().await?; 39 | let version = db::get_key_version(tx.ex(), key) 40 | .await? 41 | .map(Version::next) 42 | .unwrap_or_else(Version::initial); 43 | let entry = Entry::new(value, version); 44 | db::set_key(tx.ex(), key, &entry).await?; 45 | tx.commit().await?; 46 | Ok(entry) 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | use crate::db; 54 | use crate::driver::testutils::*; 55 | use iii_iv_core::db::DbError; 56 | use iii_iv_core::driver::DriverError; 57 | 58 | #[tokio::test] 59 | async fn test_delete_key_ok() { 60 | let context = TestContext::setup().await; 61 | 62 | let key = Key::new("test".to_owned()); 63 | let entry = Entry::new("the value".to_owned(), Version::initial()); 64 | 65 | db::set_key(&mut context.ex().await, &key, &entry).await.unwrap(); 66 | 67 | context.driver().delete_key(&key).await.unwrap(); 68 | 69 | assert_eq!( 70 | DbError::NotFound, 71 | db::get_key(&mut context.ex().await, &key).await.unwrap_err() 72 | ); 73 | } 74 | 75 | #[tokio::test] 76 | async fn test_delete_key_not_found() { 77 | let context = TestContext::setup().await; 78 | 79 | let key = Key::new("test".to_owned()); 80 | 81 | assert_eq!( 82 | DriverError::NotFound("Entity not found".to_owned()), 83 | context.driver().delete_key(&key).await.unwrap_err() 84 | ); 85 | } 86 | 87 | #[tokio::test] 88 | async fn test_get_key_ok() { 89 | let context = TestContext::setup().await; 90 | 91 | let key = Key::new("test".to_owned()); 92 | let exp_entry = Entry::new("the value".to_owned(), Version::initial()); 93 | 94 | db::set_key(&mut context.ex().await, &key, &exp_entry).await.unwrap(); 95 | 96 | let entry = context.driver().get_key(&key).await.unwrap(); 97 | assert_eq!(exp_entry, entry); 98 | } 99 | 100 | #[tokio::test] 101 | async fn test_get_key_not_found() { 102 | let context = TestContext::setup().await; 103 | 104 | let key = Key::new("test".to_owned()); 105 | 106 | assert_eq!( 107 | DriverError::NotFound("Entity not found".to_owned()), 108 | context.driver().get_key(&key).await.unwrap_err() 109 | ); 110 | } 111 | 112 | #[tokio::test] 113 | async fn test_set_key_new() { 114 | let context = TestContext::setup().await; 115 | 116 | let key = Key::new("test".to_owned()); 117 | 118 | context.driver().set_key(&key, "first value".to_owned()).await.unwrap(); 119 | 120 | let entry = db::get_key(&mut context.ex().await, &key).await.unwrap(); 121 | assert_eq!(Entry::new("first value".to_owned(), Version::initial()), entry); 122 | } 123 | 124 | #[tokio::test] 125 | async fn test_set_key_update_existing() { 126 | let context = TestContext::setup().await; 127 | 128 | let key = Key::new("test".to_owned()); 129 | 130 | context.driver().set_key(&key, "first value".to_owned()).await.unwrap(); 131 | context.driver().set_key(&key, "second value".to_owned()).await.unwrap(); 132 | 133 | let entry = db::get_key(&mut context.ex().await, &key).await.unwrap(); 134 | assert_eq!(Entry::new("second value".to_owned(), Version::initial().next()), entry); 135 | } 136 | } 137 | -------------------------------------------------------------------------------- /example/src/driver/keys.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Operations on a collection of keys. 17 | 18 | use crate::db; 19 | use crate::driver::Driver; 20 | use crate::model::*; 21 | use iii_iv_core::driver::DriverResult; 22 | use std::collections::BTreeSet; 23 | 24 | impl Driver { 25 | /// Gets a list of all existing keys. 26 | pub(crate) async fn get_keys(self) -> DriverResult> { 27 | let mut tx = self.db.begin().await?; 28 | let keys = db::get_keys(tx.ex()).await?; 29 | tx.commit().await?; 30 | Ok(keys) 31 | } 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use super::*; 37 | use crate::db; 38 | use crate::driver::testutils::*; 39 | 40 | #[tokio::test] 41 | async fn test_get_keys_none() { 42 | let context = TestContext::setup().await; 43 | 44 | let keys = context.driver().get_keys().await.unwrap(); 45 | assert!(keys.is_empty()); 46 | } 47 | 48 | #[tokio::test] 49 | async fn test_get_keys_some() { 50 | let context = TestContext::setup().await; 51 | 52 | let key1 = Key::new("1".to_owned()); 53 | let key2 = Key::new("2".to_owned()); 54 | let key3 = Key::new("3".to_owned()); 55 | let entry = Entry::new("the value".to_owned(), Version::initial()); 56 | 57 | db::set_key(&mut context.ex().await, &key1, &entry).await.unwrap(); 58 | db::set_key(&mut context.ex().await, &key3, &entry).await.unwrap(); 59 | db::set_key(&mut context.ex().await, &key2, &entry).await.unwrap(); 60 | 61 | let keys = context.driver().get_keys().await.unwrap(); 62 | assert_eq!(vec![key1, key2, key3], keys.into_iter().collect::>()); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /example/src/driver/mod.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Business logic for the service. 17 | 18 | use iii_iv_core::db::Db; 19 | use std::sync::Arc; 20 | 21 | mod key; 22 | mod keys; 23 | #[cfg(test)] 24 | mod testutils; 25 | 26 | /// Business logic. 27 | /// 28 | /// The public operations exposed by the driver are all "one shot": they start and commit a 29 | /// transaction, so it's incorrect for the caller to use two separate calls. For this reason, 30 | /// these operations consume the driver in an attempt to minimize the possibility of executing 31 | /// two operations. 32 | #[derive(Clone)] 33 | pub(crate) struct Driver { 34 | /// The database that the driver uses for persistence. 35 | db: Arc, 36 | } 37 | 38 | impl Driver { 39 | /// Creates a new driver backed by the given injected components. 40 | pub(crate) fn new(db: Arc) -> Self { 41 | Self { db } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /example/src/driver/testutils.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Test utilities for the business layer. 17 | 18 | use crate::db; 19 | use crate::driver::Driver; 20 | use iii_iv_core::db::{Db, Executor}; 21 | use std::sync::Arc; 22 | 23 | pub(crate) struct TestContext { 24 | db: Arc, 25 | driver: Driver, 26 | } 27 | 28 | impl TestContext { 29 | pub(crate) async fn setup() -> Self { 30 | let db = Arc::from(iii_iv_core::db::sqlite::connect(":memory:").await.unwrap()); 31 | db::init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 32 | let driver = Driver::new(db.clone()); 33 | Self { db, driver } 34 | } 35 | 36 | pub(crate) async fn ex(&self) -> Executor { 37 | self.db.ex().await.unwrap() 38 | } 39 | 40 | pub(crate) fn driver(&self) -> Driver { 41 | self.driver.clone() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /example/src/lib.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Sample REST service that implements a key/value store. 17 | 18 | // Keep these in sync with other top-level files. 19 | #![warn(anonymous_parameters, bad_style, clippy::missing_docs_in_private_items, missing_docs)] 20 | #![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)] 21 | #![warn(unsafe_code)] 22 | 23 | use iii_iv_core::db::Db; 24 | use std::error::Error; 25 | use std::net::SocketAddr; 26 | use std::sync::Arc; 27 | 28 | pub mod db; 29 | pub mod driver; 30 | use driver::Driver; 31 | pub(crate) mod model; 32 | mod rest; 33 | use rest::app; 34 | 35 | /// Instantiates all resources to serve the application on `bind_addr`. 36 | /// 37 | /// While it'd be nice to push this responsibility to `main`, doing so would force us to expose many 38 | /// crate-internal types to the public, which in turn would make dead code detection harder. 39 | pub async fn serve( 40 | bind_addr: impl Into, 41 | db: Arc, 42 | ) -> Result<(), Box> { 43 | let driver = Driver::new(db); 44 | let app = app(driver); 45 | 46 | axum_server::bind(bind_addr.into()).serve(app.into_make_service()).await?; 47 | Ok(()) 48 | } 49 | -------------------------------------------------------------------------------- /example/src/main.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Entry point to the sample service. 17 | 18 | // Keep these in sync with other top-level files. 19 | #![warn(anonymous_parameters, bad_style, clippy::missing_docs_in_private_items, missing_docs)] 20 | #![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)] 21 | #![warn(unsafe_code)] 22 | 23 | use iii_iv_core::db::Db; 24 | use iii_iv_core::db::postgres::{PostgresDb, PostgresOptions}; 25 | use iii_iv_example::db::init_schema; 26 | use iii_iv_example::serve; 27 | use std::env; 28 | use std::net::Ipv4Addr; 29 | use std::sync::Arc; 30 | 31 | #[tokio::main] 32 | async fn main() { 33 | env_logger::init(); 34 | 35 | let port: u16 = match env::var("FUNCTIONS_CUSTOMHANDLER_PORT") { 36 | Ok(val) => val.parse().expect("Custom handler port has to be a number"), 37 | Err(_) => 3000, 38 | }; 39 | let addr = (Ipv4Addr::LOCALHOST, port); 40 | 41 | let db_opts = PostgresOptions::from_env("PGSQL_PROD").unwrap(); 42 | let db = Arc::from(PostgresDb::connect(db_opts).unwrap()); 43 | init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 44 | 45 | serve(addr, db).await.unwrap() 46 | } 47 | -------------------------------------------------------------------------------- /example/src/model.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! High-level data types. 17 | 18 | use derive_getters::Getters; 19 | use derive_more::{AsRef, Constructor}; 20 | use iii_iv_core::model::{ModelError, ModelResult}; 21 | use serde::{Deserialize, Serialize}; 22 | 23 | /// Newtype pattern for the keys of our key/value store. 24 | #[derive(AsRef, Constructor, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)] 25 | #[cfg_attr(test, derive(Debug))] 26 | pub(crate) struct Key(String); 27 | 28 | /// A key's current version number. We store this as an u32 but guarantee that it is 29 | /// usable in an i32 context because the PostgreSQL database backend needs it. 30 | #[derive(PartialEq, Serialize)] 31 | #[cfg_attr(test, derive(Debug, Deserialize))] 32 | pub(crate) struct Version(u32); 33 | 34 | impl Version { 35 | /// Returns the initial version assigned to new keys. 36 | pub(crate) fn initial() -> Version { 37 | Version(1) 38 | } 39 | 40 | /// Returns the next version to assign to an existing key. 41 | pub(crate) fn next(self) -> Version { 42 | Version(self.0 + 1) 43 | } 44 | 45 | /// Creates a version from an `i32` with range validation. 46 | pub(crate) fn from_i32(version: i32) -> ModelResult { 47 | match u32::try_from(version) { 48 | Ok(version) => Ok(Version(version)), 49 | Err(e) => Err(ModelError(format!("Version cannot be represented: {}", e))), 50 | } 51 | } 52 | 53 | /// Creates a version from a `u32` with range validation. 54 | #[cfg(test)] 55 | pub(crate) fn from_u32(version: u32) -> ModelResult { 56 | match i32::try_from(version) { 57 | Ok(_) => Ok(Version(version)), 58 | Err(e) => Err(ModelError(format!("Version cannot be represented: {}", e))), 59 | } 60 | } 61 | 62 | /// Returns the version as an `i32`. 63 | pub(crate) fn as_i32(&self) -> i32 { 64 | i32::try_from(self.0).expect("i32 compatibility validated at construction time") 65 | } 66 | 67 | /// Returns the version as a `u32`. 68 | #[cfg(test)] 69 | pub(crate) fn as_u32(&self) -> u32 { 70 | self.0 71 | } 72 | } 73 | 74 | /// Content of the keys stored in our key/value store. 75 | #[derive(Constructor, Getters, Serialize)] 76 | #[cfg_attr(test, derive(Debug, Deserialize, PartialEq))] 77 | pub(crate) struct Entry { 78 | /// The key's raw value. 79 | value: String, 80 | 81 | /// The key's current version number. 82 | version: Version, 83 | } 84 | -------------------------------------------------------------------------------- /example/src/rest/key_delete.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to delete a key. 17 | 18 | use crate::driver::Driver; 19 | use crate::model::Key; 20 | use axum::extract::{Path, State}; 21 | use axum::response::IntoResponse; 22 | use iii_iv_core::rest::{EmptyBody, RestError}; 23 | 24 | /// API handler. 25 | pub(crate) async fn handler( 26 | State(driver): State, 27 | Path(key): Path, 28 | _: EmptyBody, 29 | ) -> Result { 30 | driver.delete_key(&key).await?; 31 | 32 | Ok(()) 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use crate::rest::testutils::*; 38 | use axum::http; 39 | use iii_iv_core::rest::testutils::*; 40 | 41 | fn route(key: &str) -> (http::Method, String) { 42 | (http::Method::DELETE, format!("/api/v1/keys/{}", key)) 43 | } 44 | 45 | #[tokio::test] 46 | async fn test_ok() { 47 | let mut context = TestContext::setup().await; 48 | 49 | context.set_key("first", "value", 0).await; 50 | context.set_key("first", "value2", 1).await; 51 | context.set_key("second", "value", 0).await; 52 | 53 | OneShotBuilder::new(context.app(), route("first")).send_empty().await.expect_empty().await; 54 | 55 | assert!(!context.has_key("first").await); 56 | assert!(context.has_key("second").await); 57 | } 58 | 59 | #[tokio::test] 60 | async fn test_missing() { 61 | let mut context = TestContext::setup().await; 62 | 63 | context.set_key("first", "value", 0).await; 64 | 65 | OneShotBuilder::new(context.app(), route("second")) 66 | .send_empty() 67 | .await 68 | .expect_status(http::StatusCode::NOT_FOUND) 69 | .expect_error("not found") 70 | .await; 71 | } 72 | 73 | test_payload_must_be_empty!(TestContext::setup().await.into_app(), route("irrelevant")); 74 | } 75 | -------------------------------------------------------------------------------- /example/src/rest/key_get.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to get the latest version of a key. 17 | 18 | use crate::driver::Driver; 19 | use crate::model::Key; 20 | use axum::Json; 21 | use axum::extract::{Path, State}; 22 | use axum::response::IntoResponse; 23 | use iii_iv_core::rest::{EmptyBody, RestError}; 24 | 25 | /// API handler. 26 | pub(crate) async fn handler( 27 | State(driver): State, 28 | Path(key): Path, 29 | _: EmptyBody, 30 | ) -> Result { 31 | let entry = driver.get_key(&key).await?; 32 | Ok(Json(entry)) 33 | } 34 | 35 | #[cfg(test)] 36 | mod tests { 37 | use crate::model::*; 38 | use crate::rest::testutils::*; 39 | use axum::http; 40 | use iii_iv_core::rest::testutils::*; 41 | 42 | fn route(key: &str) -> (http::Method, String) { 43 | (http::Method::GET, format!("/api/v1/keys/{}", key)) 44 | } 45 | 46 | #[tokio::test] 47 | async fn test_ok() { 48 | let mut context = TestContext::setup().await; 49 | 50 | context.set_key("first", "value", 0).await; 51 | context.set_key("first", "value2", 1).await; 52 | context.set_key("second", "value", 0).await; 53 | 54 | let response = OneShotBuilder::new(context.into_app(), route("first")) 55 | .send_empty() 56 | .await 57 | .expect_json::() 58 | .await; 59 | let exp_response = Entry::new("value2".to_owned(), Version::from_u32(1).unwrap()); 60 | assert_eq!(exp_response, response); 61 | } 62 | 63 | #[tokio::test] 64 | async fn test_missing() { 65 | let mut context = TestContext::setup().await; 66 | 67 | context.set_key("first", "value", 0).await; 68 | 69 | OneShotBuilder::new(context.into_app(), route("second")) 70 | .send_empty() 71 | .await 72 | .expect_status(http::StatusCode::NOT_FOUND) 73 | .expect_error("not found") 74 | .await; 75 | } 76 | 77 | test_payload_must_be_empty!(TestContext::setup().await.into_app(), route("irrelevant")); 78 | } 79 | -------------------------------------------------------------------------------- /example/src/rest/key_put.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to create or update a key. 17 | 18 | use crate::driver::Driver; 19 | use crate::model::{Key, Version}; 20 | use axum::extract::{Path, State}; 21 | use axum::response::IntoResponse; 22 | use axum::{Json, http}; 23 | use iii_iv_core::rest::RestError; 24 | 25 | /// API handler. 26 | pub(crate) async fn handler( 27 | State(driver): State, 28 | Path(key): Path, 29 | body: String, 30 | ) -> Result<(http::StatusCode, impl IntoResponse), RestError> { 31 | let value = driver.set_key(&key, body).await?; 32 | let code = if *value.version() == Version::initial() { 33 | http::StatusCode::CREATED 34 | } else { 35 | http::StatusCode::OK 36 | }; 37 | Ok((code, Json(value))) 38 | } 39 | 40 | #[cfg(test)] 41 | mod tests { 42 | use super::*; 43 | use crate::model::*; 44 | use crate::rest::testutils::*; 45 | use iii_iv_core::rest::testutils::*; 46 | 47 | fn route(key: &str) -> (http::Method, String) { 48 | (http::Method::PUT, format!("/api/v1/keys/{}", key)) 49 | } 50 | 51 | #[tokio::test] 52 | async fn test_create() { 53 | let context = TestContext::setup().await; 54 | 55 | let response = OneShotBuilder::new(context.app(), route("first")) 56 | .send_text("new value") 57 | .await 58 | .expect_status(http::StatusCode::CREATED) 59 | .expect_json::() 60 | .await; 61 | let exp_response = Entry::new("new value".to_owned(), Version::initial()); 62 | assert_eq!(exp_response, response); 63 | 64 | assert_eq!(exp_response, context.get_key("first").await); 65 | } 66 | 67 | #[tokio::test] 68 | async fn test_update() { 69 | let mut context = TestContext::setup().await; 70 | 71 | context.set_key("first", "old value", 123).await; 72 | 73 | let response = OneShotBuilder::new(context.app(), route("first")) 74 | .send_text("new value") 75 | .await 76 | .expect_json::() 77 | .await; 78 | let exp_response = Entry::new("new value".to_owned(), Version::from_u32(124).unwrap()); 79 | assert_eq!(exp_response, response); 80 | 81 | assert_eq!(exp_response, context.get_key("first").await); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /example/src/rest/keys_get.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to get all existing keys. 17 | 18 | use crate::driver::Driver; 19 | use axum::Json; 20 | use axum::extract::State; 21 | use axum::response::IntoResponse; 22 | use iii_iv_core::rest::{EmptyBody, RestError}; 23 | 24 | /// API handler. 25 | pub(crate) async fn handler( 26 | State(driver): State, 27 | _: EmptyBody, 28 | ) -> Result { 29 | let keys = driver.get_keys().await?; 30 | 31 | Ok(Json(keys)) 32 | } 33 | 34 | #[cfg(test)] 35 | mod tests { 36 | use crate::rest::testutils::*; 37 | use axum::http; 38 | use iii_iv_core::rest::testutils::*; 39 | 40 | fn route() -> (http::Method, String) { 41 | (http::Method::GET, "/api/v1/keys".to_owned()) 42 | } 43 | 44 | #[tokio::test] 45 | async fn test_ok() { 46 | let mut context = TestContext::setup().await; 47 | 48 | context.set_key("second", "value", 0).await; 49 | context.set_key("first", "value", 0).await; 50 | context.set_key("first", "value2", 1).await; 51 | 52 | let response = OneShotBuilder::new(context.app(), route()) 53 | .send_empty() 54 | .await 55 | .expect_json::>() 56 | .await; 57 | let exp_response = vec!["first".to_owned(), "second".to_owned()]; 58 | assert_eq!(exp_response, response); 59 | } 60 | 61 | test_payload_must_be_empty!(TestContext::setup().await.into_app(), route()); 62 | } 63 | -------------------------------------------------------------------------------- /example/src/rest/mod.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Entry point to the REST server. 17 | 18 | use crate::driver::Driver; 19 | use axum::Router; 20 | 21 | mod key_delete; 22 | mod key_get; 23 | mod key_put; 24 | mod keys_get; 25 | #[cfg(test)] 26 | mod testutils; 27 | 28 | /// Creates the router for the application. 29 | pub(crate) fn app(driver: Driver) -> Router { 30 | use axum::routing::get; 31 | Router::new() 32 | .route( 33 | "/api/v1/keys/:key", 34 | get(key_get::handler).put(key_put::handler).delete(key_delete::handler), 35 | ) 36 | .route("/api/v1/keys", get(keys_get::handler)) 37 | .with_state(driver) 38 | } 39 | -------------------------------------------------------------------------------- /example/src/rest/testutils.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Test utilities for the REST API. 17 | 18 | use crate::db; 19 | use crate::driver::Driver; 20 | use crate::model::*; 21 | use crate::rest::app; 22 | use axum::Router; 23 | use iii_iv_core::db::Db; 24 | use std::sync::Arc; 25 | 26 | pub(crate) struct TestContext { 27 | db: Arc, 28 | app: Router, 29 | } 30 | 31 | impl TestContext { 32 | pub(crate) async fn setup() -> Self { 33 | let db = Arc::from(iii_iv_core::db::sqlite::connect(":memory:").await.unwrap()); 34 | db::init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 35 | let driver = Driver::new(db.clone()); 36 | let app = app(driver); 37 | Self { db, app } 38 | } 39 | 40 | pub(crate) fn app(&self) -> Router { 41 | self.app.clone() 42 | } 43 | 44 | pub(crate) fn into_app(self) -> Router { 45 | self.app 46 | } 47 | 48 | pub(crate) async fn set_key, V: Into>( 49 | &mut self, 50 | key: K, 51 | value: V, 52 | version: u32, 53 | ) { 54 | db::set_key( 55 | &mut self.db.ex().await.unwrap(), 56 | &Key::new(key.into()), 57 | &Entry::new(value.into(), Version::from_u32(version).unwrap()), 58 | ) 59 | .await 60 | .unwrap(); 61 | } 62 | 63 | pub(crate) async fn has_key>(&self, key: K) -> bool { 64 | db::get_key_version(&mut self.db.ex().await.unwrap(), &Key::new(key.into())) 65 | .await 66 | .unwrap() 67 | .is_some() 68 | } 69 | 70 | pub(crate) async fn get_key>(&self, key: K) -> Entry { 71 | db::get_key(&mut self.db.ex().await.unwrap(), &Key::new(key.into())).await.unwrap() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /geo/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iii-iv-geo" 3 | version = "0.0.0" 4 | description = "III-IV: Geolocation features" 5 | authors = ["Julio Merino "] 6 | edition = "2024" 7 | publish = false 8 | 9 | [features] 10 | default = [] 11 | testutils = [] 12 | 13 | [dependencies] 14 | async-trait = { workspace = true } 15 | bytes = { workspace = true } 16 | derivative = { workspace = true } 17 | futures = { workspace = true } 18 | iii-iv-core = { path = "../core" } 19 | log = { workspace = true } 20 | lru_time_cache = { workspace = true } 21 | reqwest = { workspace = true } 22 | serde_json = { workspace = true } 23 | serde = { workspace = true } 24 | time = { workspace = true } 25 | 26 | [dev-dependencies] 27 | iii-iv-core = { path = "../core", features = ["testutils"] } 28 | serde_test = { workspace = true } 29 | temp-env = { workspace = true } 30 | time = { workspace = true, features = ["macros"] } 31 | tokio = { workspace = true, features = ["rt", "macros", "rt-multi-thread"] } 32 | -------------------------------------------------------------------------------- /geo/src/counter.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Counter of requests for a period of time. 17 | 18 | use iii_iv_core::clocks::Clock; 19 | use std::{sync::Arc, time::Duration}; 20 | 21 | /// Counts the number of requests over the last minute with second resolution. 22 | pub(crate) struct RequestCounter { 23 | /// Clock to obtain the current time from. 24 | clock: Arc, 25 | 26 | /// Tracker of per-second counts within a minute. 27 | /// 28 | /// Each pair contains the timestamp of the ith second in the array and 29 | /// the counter of requests at that second. 30 | counts: [(i64, u16); 60], 31 | } 32 | 33 | impl RequestCounter { 34 | /// Creates a new request counter backed by `clock`. 35 | pub(crate) fn new(clock: Arc) -> Self { 36 | Self { clock, counts: [(0, 0); 60] } 37 | } 38 | 39 | /// Adds a request to the counter at the current time. 40 | pub(crate) fn account(&mut self) { 41 | let now = self.clock.now_utc(); 42 | let i = usize::from(now.second()) % 60; 43 | let (ts, count) = self.counts[i]; 44 | if ts == now.unix_timestamp() { 45 | self.counts[i] = (ts, count + 1); 46 | } else { 47 | self.counts[i] = (now.unix_timestamp(), 1); 48 | } 49 | } 50 | 51 | /// Counts the number of requests during the last minute. 52 | pub(crate) fn last_minute(&self) -> usize { 53 | let now = self.clock.now_utc(); 54 | let since = (now - Duration::from_secs(60)).unix_timestamp(); 55 | 56 | let mut total = 0; 57 | for (ts, count) in self.counts { 58 | if ts > since { 59 | total += usize::from(count); 60 | } 61 | } 62 | total 63 | } 64 | } 65 | 66 | #[cfg(test)] 67 | mod tests { 68 | use super::*; 69 | use iii_iv_core::clocks::testutils::SettableClock; 70 | use std::time::Duration; 71 | use time::macros::datetime; 72 | 73 | #[test] 74 | fn test_continuous() { 75 | let clock = Arc::from(SettableClock::new(datetime!(2023-09-26 18:20:15 UTC))); 76 | let mut counter = RequestCounter::new(clock.clone()); 77 | 78 | assert_eq!(0, counter.last_minute()); 79 | for i in 0..60 { 80 | clock.advance(Duration::from_secs(1)); 81 | counter.account(); 82 | counter.account(); 83 | assert_eq!((i + 1) * 2, counter.last_minute()); 84 | } 85 | assert_eq!(120, counter.last_minute()); 86 | for i in 0..60 { 87 | clock.advance(Duration::from_secs(1)); 88 | counter.account(); 89 | assert_eq!(120 - (i + 1), counter.last_minute()); 90 | } 91 | assert_eq!(60, counter.last_minute()); 92 | for i in 0..60 { 93 | clock.advance(Duration::from_secs(1)); 94 | assert_eq!(60 - (i + 1), counter.last_minute()); 95 | } 96 | assert_eq!(0, counter.last_minute()); 97 | } 98 | 99 | #[test] 100 | fn test_gaps() { 101 | let clock = Arc::from(SettableClock::new(datetime!(2023-09-26 17:20:56 UTC))); 102 | let mut counter = RequestCounter::new(clock.clone()); 103 | 104 | assert_eq!(0, counter.last_minute()); 105 | for _ in 0..1000 { 106 | counter.account(); 107 | } 108 | assert_eq!(1000, counter.last_minute()); 109 | 110 | clock.advance(Duration::from_secs(30)); 111 | counter.account(); 112 | assert_eq!(1001, counter.last_minute()); 113 | 114 | clock.advance(Duration::from_secs(29)); 115 | counter.account(); 116 | assert_eq!(1002, counter.last_minute()); 117 | 118 | clock.advance(Duration::from_secs(1)); 119 | counter.account(); 120 | assert_eq!(3, counter.last_minute()); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /geo/src/lib.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! APIs to access geo-location information. 17 | 18 | // Keep these in sync with other top-level files. 19 | #![warn(anonymous_parameters, bad_style, clippy::missing_docs_in_private_items, missing_docs)] 20 | #![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)] 21 | #![warn(unsafe_code)] 22 | 23 | use async_trait::async_trait; 24 | use serde::de::Visitor; 25 | use serde::{Deserialize, Serialize}; 26 | use std::io; 27 | use std::net::IpAddr; 28 | 29 | mod azure; 30 | pub use azure::{AzureGeoLocator, AzureGeoLocatorOptions}; 31 | mod caching; 32 | pub use caching::{CachingGeoLocator, CachingGeoLocatorOptions}; 33 | pub(crate) mod counter; 34 | mod ipapi; 35 | pub use ipapi::FreeIpApiGeoLocator; 36 | #[cfg(any(test, feature = "testutils"))] 37 | mod mock; 38 | #[cfg(any(test, feature = "testutils"))] 39 | pub use mock::MockGeoLocator; 40 | 41 | /// Result type for this module. 42 | type GeoResult = io::Result; 43 | 44 | /// Representation of a two-letter country ISO code. 45 | #[derive(Clone, Debug, Eq, Hash, PartialEq, Serialize)] 46 | #[serde(transparent)] 47 | pub struct CountryIsoCode(String); 48 | 49 | impl CountryIsoCode { 50 | /// Creates a new country ISO code after validating that it is OK. 51 | pub fn new>(code: S) -> GeoResult { 52 | let code = code.into(); 53 | if code.len() != 2 { 54 | return Err(io::Error::new( 55 | io::ErrorKind::InvalidData, 56 | format!("Country code {} does not have length 2", code), 57 | )); 58 | } 59 | Ok(Self(code.to_uppercase())) 60 | } 61 | 62 | /// Returns the country ISO code as a uppercase string. 63 | pub fn as_str(&self) -> &str { 64 | &self.0 65 | } 66 | } 67 | 68 | /// Visitor to deserialize a `CountryIsoCode` from a string. 69 | struct CountryIsoCodeVisitor; 70 | 71 | impl Visitor<'_> for CountryIsoCodeVisitor { 72 | type Value = CountryIsoCode; 73 | 74 | fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { 75 | formatter.write_str(r#"a two-letter country ISO code"#) 76 | } 77 | 78 | fn visit_str(self, v: &str) -> Result 79 | where 80 | E: serde::de::Error, 81 | { 82 | match CountryIsoCode::new(v) { 83 | Ok(code) => Ok(code), 84 | Err(e) => Err(E::custom(format!("{}", e))), 85 | } 86 | } 87 | 88 | fn visit_string(self, v: String) -> Result 89 | where 90 | E: serde::de::Error, 91 | { 92 | match CountryIsoCode::new(v) { 93 | Ok(code) => Ok(code), 94 | Err(e) => Err(E::custom(format!("{}", e))), 95 | } 96 | } 97 | } 98 | 99 | impl<'de> Deserialize<'de> for CountryIsoCode { 100 | fn deserialize(deserializer: D) -> Result 101 | where 102 | D: serde::Deserializer<'de>, 103 | { 104 | deserializer.deserialize_string(CountryIsoCodeVisitor) 105 | } 106 | } 107 | 108 | /// Interface to obtain geolocation information. 109 | #[async_trait] 110 | pub trait GeoLocator { 111 | /// Figures out which country `ip` is in, if possible. 112 | async fn locate(&self, ip: &IpAddr) -> GeoResult>; 113 | } 114 | 115 | #[cfg(test)] 116 | mod tests { 117 | use super::*; 118 | use serde_test::{Token, assert_de_tokens_error, assert_tokens}; 119 | 120 | #[test] 121 | fn test_country_iso_code_ser_de_ok() { 122 | let code = CountryIsoCode::new("ES").unwrap(); 123 | assert_tokens(&code, &[Token::String("ES")]); 124 | } 125 | 126 | #[test] 127 | fn test_country_iso_code_de_error() { 128 | assert_de_tokens_error::( 129 | &[Token::String("ESP")], 130 | "Country code ESP does not have length 2", 131 | ); 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /geo/src/mock.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Geolocation API implementation backed by an in-memory map for testing purposes. 17 | 18 | use crate::{CountryIsoCode, GeoLocator}; 19 | use async_trait::async_trait; 20 | use futures::lock::Mutex; 21 | use std::{collections::HashMap, io, net::IpAddr, sync::Arc}; 22 | 23 | /// Details of an entry in the mock geolocator. 24 | struct IpData { 25 | /// The country returned by this entry. 26 | country: Option, 27 | 28 | /// The number of times this entry was queried. 29 | query_count: usize, 30 | } 31 | 32 | /// Geolocator that uses an in-memory map of IPs to country codes. 33 | #[derive(Clone)] 34 | pub struct MockGeoLocator { 35 | /// Mapping of IPs to country codes. 36 | data: Arc>>, 37 | } 38 | 39 | impl MockGeoLocator { 40 | /// Mock country code that causes this geolocator to return an error for a query. 41 | pub const RETURN_ERROR: &'static str = ".."; 42 | 43 | /// Creates a new mock geolocator based on a list of `(ip, code)` pairs. 44 | /// 45 | /// If the `code` in a pair is `RETURN_ERROR`, the query for the `ip` will return an error. 46 | pub fn new(raw_data: &[(&'static str, &'static str)]) -> Self { 47 | let mut data = HashMap::with_capacity(raw_data.len()); 48 | for (ip, code) in raw_data { 49 | data.insert( 50 | (*ip).parse::().expect("Test IPs must be valid"), 51 | IpData { 52 | country: Some(CountryIsoCode::new(*code).expect("Invalid country code")), 53 | query_count: 0, 54 | }, 55 | ); 56 | } 57 | Self { data: Arc::from(Mutex::from(data)) } 58 | } 59 | 60 | /// Returns the number of times the geolocation data was queried for `ip`. 61 | pub async fn query_count(&self, ip: &IpAddr) -> usize { 62 | let data = self.data.lock().await; 63 | data.get(ip).map(|e| e.query_count).unwrap_or(0) 64 | } 65 | } 66 | 67 | #[async_trait] 68 | impl GeoLocator for MockGeoLocator { 69 | async fn locate(&self, ip: &IpAddr) -> super::GeoResult> { 70 | let mut data = self.data.lock().await; 71 | 72 | data.entry(*ip) 73 | .and_modify(|e| e.query_count += 1) 74 | .or_insert(IpData { country: None, query_count: 1 }); 75 | 76 | let country = data.get(ip).expect("Must be present").country.as_ref(); 77 | if let Some(country) = country { 78 | if country.as_str() == Self::RETURN_ERROR { 79 | return Err(io::Error::new( 80 | io::ErrorKind::Other, 81 | "This query is supposed to return an error", 82 | )); 83 | } 84 | } 85 | Ok(country.cloned()) 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /lint.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | # III-IV 3 | # Copyright 2023 Julio Merino 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not 6 | # use this file except in compliance with the License. You may obtain a copy 7 | # of the License at: 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | set -eu 18 | 19 | pre-commit run -a 20 | cargo clippy -- -D warnings 21 | cargo clippy --features=testutils -- -D warnings 22 | cargo clippy --all-features --all-targets -- -D warnings 23 | cargo fmt -- --check 24 | -------------------------------------------------------------------------------- /queue/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iii-iv-queue" 3 | version = "0.0.0" 4 | description = "III-IV: Database-backed queue for tasks" 5 | authors = ["Julio Merino "] 6 | edition = "2024" 7 | publish = false 8 | 9 | [features] 10 | default = ["postgres"] 11 | postgres = ["iii-iv-core/postgres", "sqlx/postgres", "sqlx/time", "sqlx/uuid"] 12 | sqlite = ["iii-iv-core/sqlite", "sqlx/sqlite", "sqlx/time", "sqlx/uuid"] 13 | testutils = [] 14 | 15 | [dependencies] 16 | async-trait = { workspace = true } 17 | axum = { workspace = true } 18 | derivative = { workspace = true } 19 | futures = { workspace = true } 20 | iii-iv-core = { path = "../core" } 21 | log = { workspace = true } 22 | serde_json = { workspace = true } 23 | serde = { workspace = true } 24 | time = { workspace = true } 25 | tokio = { workspace = true } 26 | uuid = { workspace = true } 27 | 28 | [dependencies.sqlx] 29 | workspace = true 30 | optional = true 31 | features = ["runtime-tokio-rustls", "time"] 32 | 33 | [dev-dependencies] 34 | iii-iv-core = { path = "../core", features = ["sqlite", "testutils"] } 35 | rand = { workspace = true } 36 | serde = { workspace = true, features = ["derive"] } 37 | time = { workspace = true, features = ["formatting"] } 38 | tokio = { workspace = true, features = ["rt", "macros", "rt-multi-thread"] } 39 | 40 | [dev-dependencies.sqlx] 41 | workspace = true 42 | features = ["runtime-tokio-rustls", "sqlite", "time", "uuid"] 43 | -------------------------------------------------------------------------------- /queue/functions/queue-loop/function.json: -------------------------------------------------------------------------------- 1 | { 2 | "bindings": [ 3 | { 4 | "type": "timerTrigger", 5 | "direction": "in", 6 | "name": "req", 7 | "schedule": "0 */10 * * * *" 8 | } 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /queue/src/db/postgres.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | CREATE TABLE IF NOT EXISTS tasks ( 17 | -- Unique identifier for this task. 18 | id UUID PRIMARY KEY NOT NULL, 19 | 20 | -- JSON-serialized contents of the task. 21 | json TEXT NOT NULL, 22 | 23 | -- Current status of the task. 24 | status_code SMALLINT NOT NULL, 25 | 26 | -- Reason explaining the current status of the task. May be NULL depending 27 | -- on the status_code. 28 | status_reason TEXT, 29 | 30 | -- Number of times the task attempted to run. 31 | runs SMALLINT NOT NULL, 32 | 33 | -- The time the task was created. Useful for debugging purposes. 34 | created TIMESTAMPTZ NOT NULL, 35 | 36 | -- The time the task was last updated. Must be initialized as "created" when 37 | -- the task is first inserted into the queue. 38 | updated TIMESTAMPTZ NOT NULL, 39 | 40 | -- The earliest time the task is allowed to run. 41 | only_after TIMESTAMPTZ 42 | ); 43 | 44 | CREATE INDEX IF NOT EXISTS tasks_by_runnable_state 45 | ON tasks (status_code, updated); 46 | -------------------------------------------------------------------------------- /queue/src/db/sqlite.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | PRAGMA foreign_keys = ON; 17 | 18 | CREATE TABLE IF NOT EXISTS tasks ( 19 | -- Unique identifier for this task. 20 | id UUID PRIMARY KEY NOT NULL, 21 | 22 | -- JSON-serialized contents of the task. 23 | json TEXT NOT NULL, 24 | 25 | -- Current status of the task. 26 | status_code INTEGER NOT NULL, 27 | 28 | -- Reason explaining the current status of the task. May be NULL depending 29 | -- on the status_code. 30 | status_reason TEXT, 31 | 32 | -- Number of times the task attempted to run. 33 | runs INTEGER NOT NULL, 34 | 35 | -- The time the task was created. Useful for debugging purposes. 36 | created_sec INTEGER NOT NULL, 37 | created_nsec INTEGER NOT NULL, 38 | 39 | -- The time the task was last updated. Must be initialized as "created" when 40 | -- the task is first inserted into the queue. 41 | updated_sec INTEGER NOT NULL, 42 | updated_nsec INTEGER NOT NULL, 43 | 44 | -- The earliest time the task is allowed to run. 45 | only_after_sec INTEGER, 46 | only_after_nsec INTEGER, 47 | 48 | CONSTRAINT only_after CHECK ( 49 | (only_after_sec IS NULL AND only_after_nsec IS NULL) 50 | OR (only_after_sec IS NOT NULL AND only_after_nsec IS NOT NULL)) 51 | ); 52 | 53 | CREATE INDEX IF NOT EXISTS tasks_by_runnable_state 54 | ON tasks (status_code, updated_sec, updated_nsec); 55 | -------------------------------------------------------------------------------- /queue/src/lib.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! A persistent task queue. 17 | //! 18 | //! This crate provides facilities to implement a persistent task queue backed 19 | //! by a database. 20 | //! 21 | //! The client offered by `driver::Client` enqueues new tasks and fetches details 22 | //! about their status by directly querying the database. There can be as many 23 | //! different clients as necessary accessing the tasks in this way. 24 | //! 25 | //! The worker offered by `driver::Worker` polls for tasks whenever it is poked 26 | //! by an external actor and executes those tasks. This is designed to work in 27 | //! the context of a serverless process, but could also be un in a long-lived 28 | //! process. There can also be multiple workers. 29 | 30 | // Keep these in sync with other top-level files. 31 | #![warn(anonymous_parameters, bad_style, clippy::missing_docs_in_private_items, missing_docs)] 32 | #![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)] 33 | #![warn(unsafe_code)] 34 | 35 | pub mod db; 36 | pub mod driver; 37 | pub mod model; 38 | pub mod rest; 39 | -------------------------------------------------------------------------------- /queue/src/rest/mod.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Common REST endpoints to interact with the queue. 17 | //! 18 | //! In order to use these endpoints, you must copy the contents of the `functions` 19 | //! directory supplied with this crate into the functions that your Azure Functions 20 | //! deployment provides. You will need a similar triggering mechanism for other 21 | //! runtimes. Note that the runtime must enforce a maximum execution time for the 22 | //! process to guarantee correctness. 23 | 24 | use crate::driver::Worker; 25 | use axum::Router; 26 | use futures::lock::Mutex; 27 | use std::sync::Arc; 28 | 29 | mod queue_loop; 30 | #[cfg(test)] 31 | mod testutils; 32 | 33 | /// Creates the router for the queue worker endpoints that are directly invoked 34 | /// by the Azure Functions runtime. These routes **must not** be nested under other 35 | /// paths. 36 | pub fn worker_cron_app(worker: Arc>>) -> Router 37 | where 38 | T: Send + Sync + 'static, 39 | { 40 | use axum::routing::post; 41 | Router::new().route("/queue-loop", post(queue_loop::cron_post_handler)).with_state(worker) 42 | } 43 | -------------------------------------------------------------------------------- /queue/src/rest/queue_loop.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! API to start a processing loop as invoked by the Azure Functions runtime via 17 | //! a `timerTrigger` scheduled trigger. 18 | 19 | use crate::driver::Worker; 20 | use axum::extract::State; 21 | use axum::response::IntoResponse; 22 | use axum::{Json, http}; 23 | use futures::lock::Mutex; 24 | use iii_iv_core::rest::RestError; 25 | use std::collections::HashMap; 26 | use std::sync::Arc; 27 | 28 | /// POST handler for this cron trigger. 29 | /// 30 | /// This handler **must** be installed at the root of the web server, not within the standard 31 | /// `/api` path, and its name must match the name of the corresponding `function.json` file. 32 | pub async fn cron_post_handler( 33 | State(worker): State>>>, 34 | // TODO(jmmv): Should deserialize the timer request and do something with it, but for now just 35 | // consume and ignore it. 36 | _body: String, 37 | ) -> Result 38 | where 39 | T: Send + Sync + 'static, 40 | { 41 | { 42 | let mut worker = worker.lock().await; 43 | worker.notify().await?; 44 | } 45 | 46 | // The empty JSON dictionary is necessary in the response to keep the Azure Functions runtime 47 | // happy. If we don't supply this in the response, the runtime thinks the function has not 48 | // terminated. And if we supply a different content type, the runtime raises an error. 49 | let result: HashMap = HashMap::default(); 50 | Ok((http::StatusCode::OK, Json(result))) 51 | } 52 | 53 | #[cfg(test)] 54 | mod tests { 55 | use crate::model::TaskResult; 56 | use crate::rest::testutils::*; 57 | use axum::http; 58 | use iii_iv_core::rest::testutils::*; 59 | use std::{collections::HashMap, time::Duration}; 60 | 61 | /// Constructs a URL to call the method/API under test. 62 | fn route() -> (http::Method, String) { 63 | (http::Method::POST, "/queue-loop".to_owned()) 64 | } 65 | 66 | #[tokio::test] 67 | async fn test_ok() { 68 | let mut context = TestContext::setup().await; 69 | 70 | let before = context.clock.now_utc(); 71 | 72 | let id1 = context 73 | .client 74 | .enqueue( 75 | &mut context.ex().await, 76 | &MockTask { result: Ok(Some("diagnostics".to_string())) }, 77 | ) 78 | .await 79 | .unwrap(); 80 | let id2 = context 81 | .client 82 | .enqueue(&mut context.ex().await, &MockTask { result: Err("the result".to_string()) }) 83 | .await 84 | .unwrap(); 85 | let ids = [id1, id2]; 86 | 87 | // Give some time to the worker to execute the tasks. We expect this to *not* happen 88 | // because the worker has not been notified that new tasks are ready for execution (the 89 | // client created by `TestContext::setup` is not connected to a worker. Obviously this 90 | // is racy and might not detect a real bug, but we should not get any false negatives. 91 | for _ in 0..10 { 92 | for id in ids { 93 | let result = context.client.poll(&mut context.ex().await, id).await.unwrap(); 94 | assert!( 95 | result.is_none(), 96 | "Task should not have completed because we didn't poll the worker yet" 97 | ); 98 | } 99 | context.clock.sleep(Duration::from_millis(1)).await; 100 | } 101 | 102 | let response = OneShotBuilder::new(context.app(), route()) 103 | .send_empty() 104 | .await 105 | .expect_json::>() 106 | .await; 107 | assert!(response.is_empty()); 108 | 109 | // Now that we poked the worker via the REST API, we can expect the tasks to complete. 110 | let results = context 111 | .client 112 | .wait_all(context.db.clone(), &ids, before, Duration::from_millis(1)) 113 | .await 114 | .unwrap(); 115 | assert_eq!(2, results.len()); 116 | assert_eq!(&TaskResult::Done(Some("diagnostics".to_string())), results.get(&id1).unwrap()); 117 | assert_eq!(&TaskResult::Failed("the result".to_string()), results.get(&id2).unwrap()); 118 | } 119 | } 120 | -------------------------------------------------------------------------------- /queue/src/rest/testutils.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | use crate::db; 17 | use crate::driver::{Client, Worker, WorkerOptions}; 18 | use crate::model::{ExecError, ExecResult}; 19 | use crate::rest::worker_cron_app; 20 | use axum::Router; 21 | use futures::lock::Mutex; 22 | use iii_iv_core::clocks::Clock; 23 | use iii_iv_core::clocks::testutils::SettableClock; 24 | use iii_iv_core::db::{Db, Executor}; 25 | use serde::{Deserialize, Serialize}; 26 | use std::sync::Arc; 27 | use time::macros::datetime; 28 | 29 | /// A task definition for testing purposes. 30 | #[derive(Deserialize, Serialize)] 31 | pub(super) struct MockTask { 32 | /// What the task will return upon execution. 33 | pub(super) result: Result, String>, 34 | } 35 | 36 | /// Executes `task`. 37 | async fn run_task(task: MockTask) -> ExecResult { 38 | task.result.map_err(ExecError::Failed) 39 | } 40 | 41 | /// State of a running test. 42 | pub(super) struct TestContext { 43 | /// Instance of the app under test. 44 | app: Router, 45 | 46 | /// Database backing the queue. 47 | pub(super) db: Arc, 48 | 49 | /// Queue client to interact with the tasks handled by `app`. 50 | pub(super) client: Client, 51 | 52 | /// Clock used during testing. 53 | pub(super) clock: Arc, 54 | } 55 | 56 | impl TestContext { 57 | /// Initializes a REST app using an in-memory datababase with an in-process worker and a 58 | /// client that is **not** connected to the worker. 59 | pub(super) async fn setup() -> TestContext { 60 | let db = Arc::from(iii_iv_core::db::sqlite::testutils::setup().await); 61 | db::init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 62 | let clock = Arc::from(SettableClock::new(datetime!(2023-12-01 05:50:00 UTC))); 63 | 64 | let worker = { 65 | let opts = WorkerOptions::default(); 66 | let worker = Worker::new(db.clone(), clock.clone(), opts, run_task); 67 | Arc::from(Mutex::from(worker)) 68 | }; 69 | 70 | // The client is not connected to the worker so that we can validate that the worker loop 71 | // isn't invoked until we ask for it. 72 | let client = Client::new(clock.clone()); 73 | 74 | let app = worker_cron_app(worker); 75 | 76 | TestContext { app, db, client, clock } 77 | } 78 | 79 | /// Gets a direct executor against the database. 80 | pub(crate) async fn ex(&self) -> Executor { 81 | self.db.ex().await.unwrap() 82 | } 83 | 84 | /// Gets a clone of the app router. 85 | pub(super) fn app(&self) -> Router { 86 | self.app.clone() 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2018" 2 | newline_style = "Unix" 3 | use_small_heuristics = "Max" 4 | -------------------------------------------------------------------------------- /smtp/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "iii-iv-smtp" 3 | version = "0.0.0" 4 | description = "III-IV: SMTP support" 5 | authors = ["Julio Merino "] 6 | edition = "2024" 7 | publish = false 8 | 9 | [features] 10 | default = ["postgres"] 11 | postgres = ["dep:sqlx", "iii-iv-core/postgres", "sqlx/postgres"] 12 | sqlite = ["dep:sqlx", "iii-iv-core/sqlite", "sqlx/sqlite"] 13 | testutils = ["dep:futures", "dep:env_logger", "dep:quoted_printable", "iii-iv-core/sqlite"] 14 | 15 | [dependencies] 16 | async-trait = { workspace = true } 17 | axum = { workspace = true } 18 | derivative = { workspace = true } 19 | env_logger = { workspace = true, optional = true } 20 | futures = { workspace = true, optional = true } 21 | http = { workspace = true } 22 | iii-iv-core = { path = "../core" } 23 | quoted_printable = { workspace = true, optional = true } 24 | serde_json = { workspace = true } 25 | thiserror = { workspace = true } 26 | time = { workspace = true } 27 | 28 | [dependencies.lettre] 29 | workspace = true 30 | features = ["builder", "hostname", "pool", "rustls-tls", "smtp-transport", "tokio1-rustls-tls"] 31 | 32 | [dependencies.sqlx] 33 | version = "0.8" 34 | optional = true 35 | features = ["runtime-tokio-rustls", "time"] 36 | 37 | [dev-dependencies] 38 | env_logger = { workspace = true } 39 | futures = { workspace = true } 40 | iii-iv-core = { path = "../core", features = ["sqlite", "testutils"] } 41 | quoted_printable = { workspace = true } 42 | temp-env = { workspace = true } 43 | time = { workspace = true, features = ["macros"] } 44 | tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } 45 | 46 | [dev-dependencies.sqlx] 47 | workspace = true 48 | features = ["runtime-tokio-rustls", "sqlite", "time"] 49 | -------------------------------------------------------------------------------- /smtp/src/db/postgres.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | CREATE TABLE IF NOT EXISTS email_log ( 17 | id SERIAL PRIMARY KEY, 18 | 19 | sent TIMESTAMPTZ NOT NULL, 20 | message BYTEA NOT NULL, 21 | result TEXT 22 | ); 23 | 24 | CREATE INDEX email_log_by_sent ON email_log (sent); 25 | -------------------------------------------------------------------------------- /smtp/src/db/sqlite.sql: -------------------------------------------------------------------------------- 1 | -- III-IV 2 | -- Copyright 2023 Julio Merino 3 | -- 4 | -- Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | -- use this file except in compliance with the License. You may obtain a copy 6 | -- of the License at: 7 | -- 8 | -- http://www.apache.org/licenses/LICENSE-2.0 9 | -- 10 | -- Unless required by applicable law or agreed to in writing, software 11 | -- distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | -- WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | -- License for the specific language governing permissions and limitations 14 | -- under the License. 15 | 16 | PRAGMA foreign_keys = ON; 17 | 18 | CREATE TABLE IF NOT EXISTS email_log ( 19 | id INTEGER PRIMARY KEY AUTOINCREMENT, 20 | 21 | sent_sec INTEGER NOT NULL, 22 | sent_nsec INTEGER NOT NULL, 23 | message BYTEA NOT NULL, 24 | result TEXT 25 | ); 26 | 27 | CREATE INDEX email_log_by_sent ON email_log (sent_sec, sent_nsec); 28 | -------------------------------------------------------------------------------- /smtp/src/db/tests.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Common tests for any database implementation. 17 | 18 | use crate::db::*; 19 | use iii_iv_core::db::Executor; 20 | use time::macros::{date, datetime}; 21 | 22 | async fn test_email_log(ex: &mut Executor) { 23 | // The message contents should be completely irrelevant for counting purposes, so keeping 24 | // them all identical helps assert that. 25 | let message = Message::builder() 26 | .from("from@example.com".parse().unwrap()) 27 | .to("to@example.com".parse().unwrap()) 28 | .subject("Foo") 29 | .body("Bar".to_owned()) 30 | .unwrap(); 31 | 32 | put_email_log(ex, &message, datetime!(2023-06-11 00:00:00.000000 UTC)).await.unwrap(); 33 | put_email_log(ex, &message, datetime!(2023-06-12 06:20:00.000001 UTC)).await.unwrap(); 34 | put_email_log(ex, &message, datetime!(2023-06-12 06:20:00.000002 UTC)).await.unwrap(); 35 | put_email_log(ex, &message, datetime!(2023-06-12 23:59:59.999999 UTC)).await.unwrap(); 36 | 37 | assert_eq!(0, count_email_log(ex, date!(2023 - 06 - 10)).await.unwrap()); 38 | assert_eq!(1, count_email_log(ex, date!(2023 - 06 - 11)).await.unwrap()); 39 | assert_eq!(3, count_email_log(ex, date!(2023 - 06 - 12)).await.unwrap()); 40 | assert_eq!(0, count_email_log(ex, date!(2023 - 06 - 13)).await.unwrap()); 41 | } 42 | 43 | macro_rules! generate_db_tests [ 44 | ( $setup:expr $(, #[$extra:meta] )? ) => { 45 | iii_iv_core::db::testutils::generate_tests!( 46 | $(#[$extra],)? 47 | $setup, 48 | $crate::db::tests, 49 | test_email_log 50 | ); 51 | } 52 | ]; 53 | 54 | use generate_db_tests; 55 | 56 | mod postgres { 57 | use super::*; 58 | use crate::db::init_schema; 59 | use iii_iv_core::db::Db; 60 | use iii_iv_core::db::postgres::PostgresDb; 61 | use std::sync::Arc; 62 | 63 | async fn setup() -> PostgresDb { 64 | let db = iii_iv_core::db::postgres::testutils::setup().await; 65 | init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 66 | db 67 | } 68 | 69 | generate_db_tests!( 70 | { 71 | let db = Arc::from(setup().await); 72 | (db.clone(), &mut db.ex().await.unwrap()) 73 | }, 74 | #[ignore = "Requires environment configuration and is expensive"] 75 | ); 76 | } 77 | 78 | mod sqlite { 79 | use super::*; 80 | use crate::db::init_schema; 81 | use iii_iv_core::db::Db; 82 | use iii_iv_core::db::sqlite::SqliteDb; 83 | use std::sync::Arc; 84 | 85 | async fn setup() -> SqliteDb { 86 | let db = iii_iv_core::db::sqlite::testutils::setup().await; 87 | init_schema(&mut db.ex().await.unwrap()).await.unwrap(); 88 | db 89 | } 90 | 91 | generate_db_tests!({ 92 | let db = Arc::from(setup().await); 93 | (db.clone(), &mut db.ex().await.unwrap()) 94 | }); 95 | } 96 | -------------------------------------------------------------------------------- /smtp/src/lib.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Utilities to send messages over email. 17 | 18 | // Keep these in sync with other top-level files. 19 | #![warn(anonymous_parameters, bad_style, clippy::missing_docs_in_private_items, missing_docs)] 20 | #![warn(unused, unused_extern_crates, unused_import_braces, unused_qualifications)] 21 | #![warn(unsafe_code)] 22 | 23 | pub mod db; 24 | pub mod driver; 25 | pub mod model; 26 | -------------------------------------------------------------------------------- /smtp/src/model.rs: -------------------------------------------------------------------------------- 1 | // III-IV 2 | // Copyright 2023 Julio Merino 3 | // 4 | // Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | // use this file except in compliance with the License. You may obtain a copy 6 | // of the License at: 7 | // 8 | // http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | // Unless required by applicable law or agreed to in writing, software 11 | // distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | // WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | // License for the specific language governing permissions and limitations 14 | // under the License. 15 | 16 | //! Data types to interact with email messages. 17 | 18 | use iii_iv_core::model::{EmailAddress, ModelError, ModelResult}; 19 | use iii_iv_core::template; 20 | use lettre::message::Body; 21 | use lettre::message::header::ContentTransferEncoding; 22 | pub use lettre::message::{Mailbox, Message}; 23 | 24 | /// A template for an email message. 25 | pub struct EmailTemplate { 26 | /// Who the message comes from. 27 | pub from: Mailbox, 28 | 29 | /// Subject of the message. 30 | pub subject_template: &'static str, 31 | 32 | /// Body of the message. 33 | pub body_template: &'static str, 34 | } 35 | 36 | impl EmailTemplate { 37 | /// Creates a message sent to `to` based on the template by applying the collection of 38 | /// `replacements` to it. 39 | /// 40 | /// The subject and body of the template are subject to string replacements per the rules 41 | /// described in `iii_iv_core::template::apply`. 42 | pub fn apply( 43 | &self, 44 | to: &EmailAddress, 45 | replacements: &[(&'static str, &str)], 46 | ) -> ModelResult { 47 | let to = to.as_str().parse().map_err(|e| { 48 | // TODO(jmmv): This should never happen... but there is no guarantee right now that we can 49 | // convert III-IV's `EmailAddress` into whatever Lettre expects. It'd be nice if we didn't 50 | // need this though. 51 | ModelError(format!("Cannot parse email address {}: {}", to.as_str(), e)) 52 | })?; 53 | 54 | let subject = template::apply(self.subject_template, replacements); 55 | 56 | let body = Body::new_with_encoding( 57 | template::apply(self.body_template, replacements), 58 | ContentTransferEncoding::QuotedPrintable, 59 | ) 60 | .map_err(|e| ModelError(format!("Failed to encode message: {:?}", e)))?; 61 | 62 | let message = Message::builder() 63 | .from(self.from.clone()) 64 | .to(to) 65 | .subject(subject) 66 | .body(body) 67 | .map_err(|e| ModelError(format!("Failed to encode message: {:?}", e)))?; 68 | Ok(message) 69 | } 70 | } 71 | 72 | /// Utilities to help testing email messages. 73 | #[cfg(any(test, feature = "testutils"))] 74 | pub mod testutils { 75 | use super::*; 76 | use std::collections::HashMap; 77 | 78 | /// Given an SMTP `message`, parses it and extracts its headers and body. 79 | pub fn parse_message(message: &Message) -> (HashMap, String) { 80 | let text = String::from_utf8(message.formatted()).unwrap(); 81 | let (raw_headers, encoded_body) = text 82 | .split_once("\r\n\r\n") 83 | .unwrap_or_else(|| panic!("Message seems to have the wrong format: {}", text)); 84 | 85 | let mut headers = HashMap::default(); 86 | for raw_header in raw_headers.split("\r\n") { 87 | let (key, value) = raw_header 88 | .split_once(": ") 89 | .unwrap_or_else(|| panic!("Header seems to have the wrong format: {}", raw_header)); 90 | let previous = headers.insert(key.to_owned(), value.to_owned()); 91 | assert!(previous.is_none(), "Duplicate header {}", raw_header); 92 | } 93 | 94 | let decoded_body = 95 | quoted_printable::decode(encoded_body, quoted_printable::ParseMode::Strict).unwrap(); 96 | let body = String::from_utf8(decoded_body).unwrap().replace("\r\n", "\n"); 97 | 98 | (headers, body) 99 | } 100 | } 101 | 102 | #[cfg(test)] 103 | mod tests { 104 | use super::testutils::*; 105 | use super::*; 106 | 107 | #[test] 108 | fn test_email_template() { 109 | let template = EmailTemplate { 110 | from: "Sender ".parse().unwrap(), 111 | subject_template: "The %s%", 112 | body_template: "The %b% with quoted printable =50 characters", 113 | }; 114 | 115 | let message = template 116 | .apply( 117 | &EmailAddress::from("recipient@example.com"), 118 | &[("s", "replaced subject"), ("b", "replaced body")], 119 | ) 120 | .unwrap(); 121 | let (headers, body) = parse_message(&message); 122 | 123 | let exp_message = Message::builder() 124 | .from(template.from) 125 | .to("recipient@example.com".parse().unwrap()) 126 | .subject("The replaced subject") 127 | .body( 128 | Body::new_with_encoding( 129 | "The replaced body with quoted printable =50 characters".to_owned(), 130 | ContentTransferEncoding::QuotedPrintable, 131 | ) 132 | .unwrap(), 133 | ) 134 | .unwrap(); 135 | let (exp_headers, exp_body) = parse_message(&exp_message); 136 | 137 | assert_eq!(exp_headers, headers); 138 | assert_eq!(exp_body, body); 139 | } 140 | 141 | #[test] 142 | fn test_parse_message() { 143 | let exp_body = " 144 | This is a sample message with a line that should be longer than 72 characters to test line wraps. 145 | 146 | There is also a second paragraph with = quoted printable characters. 147 | "; 148 | let message = Message::builder() 149 | .from("From someone ".parse().unwrap()) 150 | .to("to@example.com".parse().unwrap()) 151 | .subject("This: is the: subject line") 152 | .body(exp_body.to_owned()) 153 | .unwrap(); 154 | 155 | // Make sure the encoding of the message is quoted-printable. This isn't strictly required 156 | // because I suppose `parse_message` might succeed anyway, but it's good to encode our 157 | // assumption in a test. 158 | let text = String::from_utf8(message.formatted()).unwrap(); 159 | assert!(text.contains("=3D")); 160 | 161 | let (headers, body) = parse_message(&message); 162 | 163 | assert!(headers.len() >= 3); 164 | assert_eq!("\"From someone\" ", headers.get("From").unwrap()); 165 | assert_eq!("to@example.com", headers.get("To").unwrap()); 166 | assert_eq!("This: is the: subject line", headers.get("Subject").unwrap()); 167 | 168 | assert_eq!(exp_body, body); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #! /bin/sh 2 | # III-IV 3 | # Copyright 2023 Julio Merino 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not 6 | # use this file except in compliance with the License. You may obtain a copy 7 | # of the License at: 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | # License for the specific language governing permissions and limitations 15 | # under the License. 16 | 17 | set -eu 18 | 19 | readonly PROGNAME="${0##*/}" 20 | 21 | err() { 22 | echo "${PROGNAME}: E: ${*}" 1>&2 23 | exit 1 24 | } 25 | 26 | info() { 27 | echo "${PROGNAME}: I: ${*}" 1>&2 28 | } 29 | 30 | run_tests() { 31 | local dir="${1}"; shift 32 | local cargo_args="${1}"; shift 33 | local test_args="${1}"; shift 34 | local features="${1}"; shift 35 | 36 | ( 37 | if [ -e ./config.env ]; then 38 | info "Loading ./config.env" 39 | . ./config.env 40 | fi 41 | 42 | cd "${dir}" 43 | 44 | for feature in ${features}; do 45 | if [ "${feature}" = default ]; then 46 | info "Testing ${dir} with default features" 47 | cargo test ${cargo_args} -- --include-ignored ${test_args} 48 | else 49 | if [ "${dir}" = . ] || grep -q "^{feature} = \[" Cargo.toml; then 50 | info "Testing ${dir} with feature=${feature}" 51 | cargo test --features="${feature}" ${cargo_args} -- --include-ignored ${test_args} 52 | else 53 | info "Skipping ${dir} with feature=${feature}" 54 | fi 55 | fi 56 | done 57 | ) 58 | } 59 | 60 | usage() { 61 | cat <