From 22267c51bbdb023444fedda5bac09adef5be8205 Mon Sep 17 00:00:00 2001 From: Jacob Phillips Date: Thu, 1 Oct 2026 00:12:44 +0000 Subject: [PATCH] update the framework a bit --- .config/nextest.toml | 2 +- .devcontainer/Dockerfile | 9 - .devcontainer/devcontainer.json | 28 - .devcontainer/docker-compose.yml | 28 - .devcontainer/post-create.sh | 12 - deploy/docker/.dockerignore => .dockerignore | 2 +- .env.example | 15 +- .github/workflows/deploy_lambda.yml | 57 +- .github/workflows/test.yml | 47 +- .gitignore | 3 - .vscode/settings.json | 50 +- .vscode/tasks.json | 31 + Cargo.lock | 738 +++++++++++------- Cargo.toml | 10 +- README.md | 140 +++- compose.yml | 30 + deploy/aws_sam/samconfig.toml | 35 - deploy/aws_sam/template.yaml | 70 -- deploy/docker/Dockerfile | 28 +- install.sh | 34 + justfile | 48 ++ ...25d7968e7c9fc1386c3cff8567080f6d8d5c5.json | 70 ++ ...13949babe0daf544c92dc3824345a47a8d8d2.json | 73 ++ ...16528384d3a1b5e09970045cfa5295dfdff0d.json | 70 ++ ...b8a0ff7bf95c805a1371be014e09fdd192517.json | 75 ++ ...ac683b75e246369328fe4a80cf7bfc431f746.json | 70 ++ ...5b8470645d6b2c8085881071413c972eab0eb.json | 70 ++ lib/Cargo.toml | 44 +- lib/build.rs | 5 + lib/migrations/0001_create_users.sql | 11 + lib/src/api/mod.rs | 2 +- lib/src/app.rs | 48 +- lib/src/auth/adapters/mod.rs | 3 + lib/src/auth/adapters/repo_jwt.rs | 97 +++ lib/src/auth/errors.rs | 32 + lib/src/auth/mod.rs | 6 +- lib/src/auth/models.rs | 65 +- lib/src/auth/repo.rs | 12 +- lib/src/auth/repo_jwt.rs | 12 - lib/src/auth/router.rs | 75 +- lib/src/auth/service.rs | 60 +- lib/src/config.rs | 34 + lib/src/database/mod.rs | 3 + lib/src/database/models.rs | 57 ++ lib/src/error.rs | 110 +-- lib/src/error_reporting/mod.rs | 3 + lib/src/error_reporting/service.rs | 30 + lib/src/init.rs | 110 ++- lib/src/lib.rs | 10 +- lib/src/postgres/mod.rs | 15 + lib/src/user/adapters/mod.rs | 5 + lib/src/user/adapters/repo_in_memory.rs | 122 +++ lib/src/user/adapters/repo_postgres.rs | 110 +++ lib/src/user/adapters/sql/create_user.sql | 3 + .../adapters/sql/delete_user_by_email.sql | 7 + .../user/adapters/sql/delete_user_by_id.sql | 7 + .../user/adapters/sql/get_user_by_email.sql | 4 + lib/src/user/adapters/sql/get_user_by_id.sql | 4 + lib/src/user/adapters/sql/update_user.sql | 9 + lib/src/user/errors.rs | 24 + lib/src/user/mod.rs | 10 +- lib/src/user/models.rs | 41 + lib/src/user/repo.rs | 119 ++- lib/src/user/repo_in_memory.rs | 52 -- lib/src/user/repo_postgres.rs | 24 - lib/src/user/service.rs | 38 +- lib/tests/add.rs | 4 - lib/tests/user.rs | 38 + macros/Cargo.toml | 8 - macros/src/lib.rs | 14 - railway.toml | 8 + rust-toolchain.toml | 2 + scripts/admin/deploy_lambda.sh | 17 - scripts/admin/log_tail_lambda.sh | 11 - scripts/admin/tmp_docker_build_run.sh | 15 - scripts/fix.sh | 12 - scripts/preview_sam.sh | 15 - scripts/run.sh | 10 - scripts/test-e2e.sh | 12 - scripts/test-unit.sh | 13 - scripts/test.sh | 11 - 81 files changed, 2449 insertions(+), 1009 deletions(-) delete mode 100644 .devcontainer/Dockerfile delete mode 100644 .devcontainer/devcontainer.json delete mode 100644 .devcontainer/docker-compose.yml delete mode 100755 .devcontainer/post-create.sh rename deploy/docker/.dockerignore => .dockerignore (55%) create mode 100644 .vscode/tasks.json create mode 100644 compose.yml delete mode 100644 deploy/aws_sam/samconfig.toml delete mode 100644 deploy/aws_sam/template.yaml create mode 100755 install.sh create mode 100644 justfile create mode 100644 lib/.sqlx/query-0b8296620d615a80744d456910925d7968e7c9fc1386c3cff8567080f6d8d5c5.json create mode 100644 lib/.sqlx/query-0f627e4b70eddc9bf63fd35988e13949babe0daf544c92dc3824345a47a8d8d2.json create mode 100644 lib/.sqlx/query-572ac4a90633aa913b833a1724116528384d3a1b5e09970045cfa5295dfdff0d.json create mode 100644 lib/.sqlx/query-7cbdf2f86d00f2f67474d86ebebb8a0ff7bf95c805a1371be014e09fdd192517.json create mode 100644 lib/.sqlx/query-c63a4ffa423c530f1c2346d1a40ac683b75e246369328fe4a80cf7bfc431f746.json create mode 100644 lib/.sqlx/query-f9f0b3fd968f50bf7146fd610385b8470645d6b2c8085881071413c972eab0eb.json create mode 100644 lib/build.rs create mode 100644 lib/migrations/0001_create_users.sql create mode 100644 lib/src/auth/adapters/mod.rs create mode 100644 lib/src/auth/adapters/repo_jwt.rs create mode 100644 lib/src/auth/errors.rs delete mode 100644 lib/src/auth/repo_jwt.rs create mode 100644 lib/src/database/mod.rs create mode 100644 lib/src/database/models.rs create mode 100644 lib/src/error_reporting/mod.rs create mode 100644 lib/src/error_reporting/service.rs create mode 100644 lib/src/postgres/mod.rs create mode 100644 lib/src/user/adapters/mod.rs create mode 100644 lib/src/user/adapters/repo_in_memory.rs create mode 100644 lib/src/user/adapters/repo_postgres.rs create mode 100644 lib/src/user/adapters/sql/create_user.sql create mode 100644 lib/src/user/adapters/sql/delete_user_by_email.sql create mode 100644 lib/src/user/adapters/sql/delete_user_by_id.sql create mode 100644 lib/src/user/adapters/sql/get_user_by_email.sql create mode 100644 lib/src/user/adapters/sql/get_user_by_id.sql create mode 100644 lib/src/user/adapters/sql/update_user.sql create mode 100644 lib/src/user/errors.rs delete mode 100644 lib/src/user/repo_in_memory.rs delete mode 100644 lib/src/user/repo_postgres.rs delete mode 100644 lib/tests/add.rs create mode 100644 lib/tests/user.rs delete mode 100644 macros/Cargo.toml delete mode 100644 macros/src/lib.rs create mode 100644 railway.toml create mode 100644 rust-toolchain.toml delete mode 100755 scripts/admin/deploy_lambda.sh delete mode 100755 scripts/admin/log_tail_lambda.sh delete mode 100755 scripts/admin/tmp_docker_build_run.sh delete mode 100755 scripts/fix.sh delete mode 100755 scripts/preview_sam.sh delete mode 100755 scripts/run.sh delete mode 100755 scripts/test-e2e.sh delete mode 100755 scripts/test-unit.sh delete mode 100755 scripts/test.sh diff --git a/.config/nextest.toml b/.config/nextest.toml index 29a37f1..0b84ce1 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -5,5 +5,5 @@ test-threads = 1 [profile.unit] default-filter = 'not kind(test)' -slow-timeout = { period = "20s" } +slow-timeout = { period = "5s" } fail-fast = false diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile deleted file mode 100644 index 9feb5cf..0000000 --- a/.devcontainer/Dockerfile +++ /dev/null @@ -1,9 +0,0 @@ -FROM jifalops/rust-lambda:latest - -# Install additional packages -RUN sudo apt-get update && sudo apt-get install -y --no-install-recommends \ - trash-cli - -RUN rustup default 1.86 && \ - rustup component add rustfmt && \ - rustup component add clippy \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json deleted file mode 100644 index f92cb68..0000000 --- a/.devcontainer/devcontainer.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "name": "API-Rust", - "dockerComposeFile": "./docker-compose.yml", - "service": "api-rust", - "remoteUser": "developer", - "workspaceFolder": "/api-rust", - "postCreateCommand": ".devcontainer/post-create.sh", - "postStartCommand": "sudo nohup dockerd &", - "customizations": { - "vscode": { - "settings": { - "lldb.launch.initCommands": [ - // Squelches warning when debugging in the container. - "settings set target.disable-aslr false" - ], - "dev.containers.dockerCredentialHelper": false - }, - "extensions": [ - "rust-lang.rust-analyzer", - "tamasfe.even-better-toml", - "fill-labs.dependi", - "EthanSK.restore-terminals", - "redhat.vscode-yaml", - "me-dutour-mathieu.vscode-github-actions" - ] - } - } -} \ No newline at end of file diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml deleted file mode 100644 index 8cc0690..0000000 --- a/.devcontainer/docker-compose.yml +++ /dev/null @@ -1,28 +0,0 @@ -services: - api-rust: - build: - context: .. - dockerfile: .devcontainer/Dockerfile - ports: - - "3000:3000" - - "3001:3001" - command: sleep infinity - volumes: - - ..:/api-rust:cached - environment: - TZ: ${TZ} # Timezone - GITHUB_TOKEN: ${GITHUB_TOKEN} # Github CLI - # See https://docs.rs/tracing-subscriber/latest/tracing_subscriber/filter/struct.EnvFilter.html#example-syntax - RUST_LOG: trace - POSTGRES_URI: postgresql://postgres:developer@postgres:5432/postgres - - postgres: - image: postgres:13 - environment: - POSTGRES_PASSWORD: developer - ports: - - "5432:5432" - volumes: - - postgres_data:/var/lib/postgresql/data -volumes: - postgres_data: diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh deleted file mode 100755 index a65bf67..0000000 --- a/.devcontainer/post-create.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -# Remember bash history on the local machine. -ln -s "$(pwd)/.devcontainer/.bash_history" ~/.bash_history - -# Install the user's dotfiles from GitHub. -gh repo clone dotfiles ~/.dotfiles && ~/.dotfiles/install.sh - -# Copy .env.example to .env. -if [ ! -f .env ]; then - cp .env.example .env -fi diff --git a/deploy/docker/.dockerignore b/.dockerignore similarity index 55% rename from deploy/docker/.dockerignore rename to .dockerignore index 7eab8eb..6db06ce 100644 --- a/deploy/docker/.dockerignore +++ b/.dockerignore @@ -1,2 +1,2 @@ **/target/ -**/tests/ \ No newline at end of file +.git/ diff --git a/.env.example b/.env.example index 1dfca32..c1926ca 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,5 @@ # Define development environment variables in a bash-compliant format. -# AWS config for local administration. -AWS_ACCESS_KEY_ID='' -AWS_SECRET_ACCESS_KEY='' -AWS_DEFAULT_REGION='' -AWS_DEFAULT_OUTPUT='json' - -# Lambda specific -PROD_LAMBDA_LOG_GROUP='api-rust-ApiFunction-XXXXXXXXXXXX' - -# Shared by deployment targets. -PROD_RUST_LOG='info' -PROD_POSTGRES_URI='postgres://postgres:password@host:5432/postgres' +DATABASE_URL='postgresql://postgres:developer@localhost:5432/postgres' +JWT_SECRET='dev-only-change-me' +PORT='3000' diff --git a/.github/workflows/deploy_lambda.yml b/.github/workflows/deploy_lambda.yml index 4a17530..79b7bac 100644 --- a/.github/workflows/deploy_lambda.yml +++ b/.github/workflows/deploy_lambda.yml @@ -1,42 +1,47 @@ -# AWS Example SAM Deploy: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/deploying-using-github.html -# Cargo Lambda example: https://www.cargo-lambda.info/guide/getting-started.html#github-actions +# Cargo Lambda in CI: https://www.cargo-lambda.info/guide/getting-started.html name: Deploy Lambda on: push: branches: - main paths: - - '**/src/**' - - '**/Cargo.toml' + - "**/src/**" + - "**/Cargo.toml" + - "Cargo.lock" + - "lib/migrations/**" + - "lib/.sqlx/**" jobs: - sam-deploy: + deploy: runs-on: ubuntu-latest if: ${{ vars.DEPLOY_LAMBDA == 'true' }} + env: + # Build the sqlx query macros from the committed lib/.sqlx cache; there is + # no database in this job. + SQLX_OFFLINE: "true" + RUST_LOG: ${{ vars.RUST_LOG }} + DATABASE_URL: ${{ secrets.DATABASE_URL }} + JWT_SECRET: ${{ secrets.JWT_SECRET }} steps: - - uses: actions/checkout@v3 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: mlugg/setup-zig@v1 with: - targets: aarch64-unknown-linux-gnu - - name: Run tests - run: cargo test --release --features lambda - - name: Install Zig toolchain - uses: korandoru/setup-zig@v1 + version: 0.14.0 + - uses: taiki-e/install-action@v2 with: - zig-version: 0.13.0 - - name: Install Cargo Lambda - uses: jaxxstorm/action-install-gh-release@v1.9.0 - with: - repo: cargo-lambda/cargo-lambda - platform: linux - arch: x86_64 - - run: cargo lambda build --release --features lambda --arm64 - - uses: aws-actions/setup-sam@v2 - - uses: aws-actions/configure-aws-credentials@v1 + tool: cargo-lambda + - uses: aws-actions/configure-aws-credentials@v4 with: aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} aws-region: ${{ vars.AWS_REGION }} - - run: | - cd deploy/aws_sam - sam deploy --no-confirm-changeset --no-fail-on-empty-changeset --parameter-overrides RustLog="${{ vars.RUST_LOG }}" PostgresUri="${{ secrets.POSTGRES_URI }}" + # Cross-compile for arm64 and deploy behind a public function URL, passing + # the job's env through to the function. + - run: cargo lambda build --release --features lambda --arm64 + - run: >- + cargo lambda deploy --enable-function-url + --env-var RUST_LOG="${RUST_LOG:-info}" + --env-var DATABASE_URL="$DATABASE_URL" + --env-var JWT_SECRET="$JWT_SECRET" + api-rust diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 807d735..3ec31c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,12 +6,47 @@ on: paths: - "**/src/**" - "**/Cargo.toml" + - "lib/migrations/**" + - "lib/.sqlx/**" jobs: - cargo-test: + # Lint and unit tests. The sqlx query macros compile against the committed + # lib/.sqlx cache, so no database is needed here. + unit: runs-on: ubuntu-latest + env: + SQLX_OFFLINE: "true" steps: - - uses: actions/checkout@v3 - - name: Install Rust toolchain - uses: dtolnay/rust-toolchain@stable - - name: Run tests - run: cargo test + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: just,nextest + - run: just check + + # Repository contract tests against a real Postgres. + e2e: + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + env: + SQLX_OFFLINE: "true" + DATABASE_URL: postgres://postgres:postgres@localhost:5432/postgres + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@nextest + - run: cargo nextest run --profile e2e --features testing diff --git a/.gitignore b/.gitignore index 9afbbc6..55509a8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ target/ -.aws-sam/ .env .env.* !.env.example -.bash_history -.Trash-* diff --git a/.vscode/settings.json b/.vscode/settings.json index d56d6d1..72d6d0a 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,52 +8,4 @@ "source.organizeImports": "explicit", "source.convertImportFormat": "explicit", }, - "github.copilot.chat.copilotDebugCommand.enabled": false, - "git.terminalAuthentication": false, - "restoreTerminals.terminals": [ - { - "splitTerminals": [ - { - "name": "dev", - "commands": [ - "./scripts/test-unit.sh && ./scripts/run.sh" - ] - }, - { - "name": "preview AWS SAM", - "commands": [ - "sleep 5; ./scripts/test-e2e.sh && ./scripts/preview_sam.sh" - ] - } - ] - }, - { - "splitTerminals": [ - { - "name": "bash", - "commands": [] - } - ] - } - ], - // CloudFormation intrinsic functions. - "yaml.customTags": [ - "!And", - "!If", - "!Not", - "!Equals", - "!Or", - "!FindInMap sequence", - "!Base64", - "!Cidr", - "!Ref", - "!Sub", - "!GetAtt", - "!GetAZs", - "!ImportValue", - "!Select", - "!Select sequence", - "!Split", - "!Join sequence" - ], -} \ No newline at end of file +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..14b9fef --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,31 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "dev", + "type": "shell", + "command": "just dev", + "isBackground": true, + "problemMatcher": [], + "presentation": { "group": "dev" }, + "runOptions": { "runOn": "folderOpen" } + }, + // Release builds, slow to start; run on demand via "Tasks: Run Task". + { + "label": "docker", + "type": "shell", + "command": "just docker", + "isBackground": true, + "problemMatcher": [], + "presentation": { "group": "dev" } + }, + { + "label": "lambda", + "type": "shell", + "command": "just lambda", + "isBackground": true, + "problemMatcher": [], + "presentation": { "group": "dev" } + } + ] +} diff --git a/Cargo.lock b/Cargo.lock index cce9603..ee5d299 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,18 +17,6 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "512761e0bb2578dd7380c6baaa0f4ce03e84f95e960231d1dec8bf4d7d6e2627" -[[package]] -name = "ahash" -version = "0.8.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" -dependencies = [ - "cfg-if", - "once_cell", - "version_check", - "zerocopy", -] - [[package]] name = "aho-corasick" version = "1.1.3" @@ -48,30 +36,25 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" name = "api-rust" version = "0.1.0" dependencies = [ - "api-rust-macros", "argon2", "async-trait", - "axum", - "dashmap", + "jsonwebtoken", "lambda_http 0.14.0", "poem", "poem-lambda", "poem-openapi", "serde", "serde_json", - "short-uuid", "sqlx", - "thiserror 2.0.4", + "thiserror", + "time", "tokio", "tracing", "tracing-subscriber", + "uuid", "validator", ] -[[package]] -name = "api-rust-macros" -version = "0.1.0" - [[package]] name = "argon2" version = "0.5.3" @@ -103,7 +86,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -114,7 +97,7 @@ checksum = "e539d3fca749fcee5236ab05e93a52867dd549cc157c8cb7f99595f3cedffdb5" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -170,72 +153,6 @@ dependencies = [ "serde_json", ] -[[package]] -name = "axum" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d6fd624c75e18b3b4c6b9caf42b1afe24437daaee904069137d8bab077be8b8" -dependencies = [ - "axum-core", - "axum-macros", - "bytes", - "form_urlencoded", - "futures-util", - "http", - "http-body", - "http-body-util", - "hyper", - "hyper-util", - "itoa", - "matchit", - "memchr", - "mime", - "percent-encoding", - "pin-project-lite", - "rustversion", - "serde", - "serde_json", - "serde_path_to_error", - "serde_urlencoded", - "sync_wrapper", - "tokio", - "tower 0.5.2", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-core" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df1362f362fd16024ae199c1970ce98f9661bf5ef94b9808fee734bc3698b733" -dependencies = [ - "bytes", - "futures-util", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "rustversion", - "sync_wrapper", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "axum-macros" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "backtrace" version = "0.3.74" @@ -271,11 +188,11 @@ checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" [[package]] name = "bitflags" -version = "2.6.0" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048fb63fd8b5923fc5aa7b340d8e156aec7ec02f0c78fa8a6ddc2613f6f71de" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" dependencies = [ - "serde", + "serde_core", ] [[package]] @@ -296,6 +213,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "byteorder" version = "1.5.0" @@ -313,10 +236,11 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.2" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ + "find-msvc-tools", "shlex", ] @@ -347,6 +271,22 @@ version = "0.9.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + [[package]] name = "cpufeatures" version = "0.2.16" @@ -417,7 +357,7 @@ dependencies = [ "proc-macro2", "quote", "strsim", - "syn", + "syn 2.0.89", ] [[package]] @@ -428,21 +368,7 @@ checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ "darling_core", "quote", - "syn", -] - -[[package]] -name = "dashmap" -version = "6.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5041cc499144891f3790297212f32a74fb938e5136a14943f338ef9e0ae276cf" -dependencies = [ - "cfg-if", - "crossbeam-utils", - "hashbrown 0.14.5", - "lock_api", - "once_cell", - "parking_lot_core", + "syn 2.0.89", ] [[package]] @@ -456,6 +382,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "serde_core", +] + [[package]] name = "derive_more" version = "1.0.0" @@ -473,7 +408,7 @@ checksum = "cb7330aeadfbe296029522e6c40f315320aba36fc43a5b3632f3795348f3bd22" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", "unicode-xid", ] @@ -497,7 +432,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -568,6 +503,12 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + [[package]] name = "flume" version = "0.11.1" @@ -585,6 +526,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "form_urlencoded" version = "1.2.1" @@ -661,7 +608,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -694,6 +641,19 @@ dependencies = [ "slab", ] +[[package]] +name = "futures_codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad004dd81205978a2bba6c566ed70535ccf88c0be34649e628186474603f43ca" +dependencies = [ + "bytes", + "futures-sink", + "futures-util", + "memchr", + "pin-project-lite", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -711,8 +671,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4567c8db10ae91089c99af84c68c38da3ec2f087c3f82960bcdbf3656b6f4d7" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", ] [[package]] @@ -742,27 +715,22 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" dependencies = [ - "ahash", "allocator-api2", + "equivalent", + "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.15.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" - [[package]] name = "hashlink" -version = "0.9.1" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.14.5", + "hashbrown", ] [[package]] @@ -836,12 +804,11 @@ dependencies = [ [[package]] name = "http" -version = "1.1.0" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21b9ddb458710bc376481b842f5da65cdf31522de232c1ca8146abce2a358258" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", - "fnv", "itoa", ] @@ -1045,7 +1012,7 @@ checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -1082,7 +1049,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62f822373a4fe84d4bb149bf54e584a7f4abec90e072ed49cda0edea5b95471f" dependencies = [ "equivalent", - "hashbrown 0.15.2", + "hashbrown", ] [[package]] @@ -1100,6 +1067,32 @@ version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "540654e97a3f4470a492cd30ff187bc95d89557a903a2bbf112e2fae98104ef2" +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonwebtoken" +version = "9.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" +dependencies = [ + "base64 0.22.1", + "js-sys", + "pem", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "lambda_http" version = "0.13.0" @@ -1177,7 +1170,7 @@ dependencies = [ "serde_path_to_error", "tokio", "tokio-stream", - "tower 0.4.13", + "tower", "tower-layer", "tracing", ] @@ -1197,7 +1190,7 @@ dependencies = [ "hyper", "hyper-util", "tokio", - "tower 0.4.13", + "tower", "tower-service", "tracing", "tracing-subscriber", @@ -1230,7 +1223,6 @@ version = "0.30.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" dependencies = [ - "cc", "pkg-config", "vcpkg", ] @@ -1272,12 +1264,6 @@ dependencies = [ "regex-automata 0.1.10", ] -[[package]] -name = "matchit" -version = "0.8.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" - [[package]] name = "md-5" version = "0.10.6" @@ -1300,12 +1286,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1357,16 +1337,6 @@ dependencies = [ "libc", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nu-ansi-term" version = "0.46.0" @@ -1377,6 +1347,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -1394,6 +1374,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + [[package]] name = "num-integer" version = "0.1.46" @@ -1439,6 +1425,12 @@ version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + [[package]] name = "overload" version = "0.1.1" @@ -1486,10 +1478,14 @@ dependencies = [ ] [[package]] -name = "paste" -version = "1.0.15" +name = "pem" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" +checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" +dependencies = [ + "base64 0.22.1", + "serde_core", +] [[package]] name = "pem-rfc7468" @@ -1523,7 +1519,7 @@ checksum = "3c0f5fad0874fc7abcd4d750e76917eaebbecaa2c20bde22e1dbeeba8beb758c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -1593,9 +1589,10 @@ dependencies = [ "serde_urlencoded", "serde_yaml", "smallvec", + "sse-codec", "sync_wrapper", "tempfile", - "thiserror 2.0.4", + "thiserror", "tokio", "tokio-stream", "tokio-util", @@ -1612,7 +1609,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -1647,7 +1644,8 @@ dependencies = [ "serde_json", "serde_urlencoded", "serde_yaml", - "thiserror 2.0.4", + "thiserror", + "time", "tokio", ] @@ -1665,10 +1663,16 @@ dependencies = [ "proc-macro2", "quote", "regex", - "syn", - "thiserror 2.0.4", + "syn 2.0.89", + "thiserror", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1706,7 +1710,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -1748,6 +1752,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.5" @@ -1775,7 +1785,7 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" dependencies = [ - "getrandom", + "getrandom 0.2.15", ] [[package]] @@ -1840,6 +1850,20 @@ dependencies = [ "uncased", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.15", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsa" version = "0.9.7" @@ -1879,6 +1903,52 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.23.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.18" @@ -1891,30 +1961,72 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "serde" -version = "1.0.215" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6513c1ad0b11a9376da888e3e0baa0077f1aed55c17f50e7b2397136129fb88f" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.215" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad1e866f866923f252f05c889987993144fb74e722403468a4ebd70c3cd756c0" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.6", ] [[package]] @@ -1997,18 +2109,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - -[[package]] -name = "short-uuid" -version = "0.2.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "58bea582e8f851430ff43e07edfc4c30b6a418f2ec4b6fed3f8496537e41a5df" -dependencies = [ - "uuid", -] +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2029,6 +2132,18 @@ dependencies = [ "rand_core", ] +[[package]] +name = "simple_asn1" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" +dependencies = [ + "num-bigint", + "num-traits", + "thiserror", + "time", +] + [[package]] name = "slab" version = "0.4.9" @@ -2076,21 +2191,11 @@ dependencies = [ "der", ] -[[package]] -name = "sqlformat" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" -dependencies = [ - "nom", - "unicode_categories", -] - [[package]] name = "sqlx" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93334716a037193fac19df402f8571269c84a00852f6a7066b5d2616dcd64d3e" +checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ "sqlx-core", "sqlx-macros", @@ -2101,59 +2206,59 @@ dependencies = [ [[package]] name = "sqlx-core" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4d8060b456358185f7d50c55d9b5066ad956956fddec42ee2e8567134a8936e" +checksum = "ee6798b1838b6a0f69c007c133b8df5866302197e404e8b6ee8ed3e3a5e68dc6" dependencies = [ - "atoi", - "byteorder", + "base64 0.22.1", "bytes", "crc", "crossbeam-queue", "either", "event-listener", - "futures-channel", "futures-core", "futures-intrusive", "futures-io", "futures-util", - "hashbrown 0.14.5", + "hashbrown", "hashlink", - "hex", "indexmap", "log", "memchr", "once_cell", - "paste", "percent-encoding", + "rustls", + "rustls-native-certs", "serde", "serde_json", "sha2", "smallvec", - "sqlformat", - "thiserror 1.0.69", + "thiserror", + "time", + "tokio", + "tokio-stream", "tracing", "url", ] [[package]] name = "sqlx-macros" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cac0692bcc9de3b073e8d747391827297e075c7710ff6276d9f7a1f3d58c6657" +checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", "sqlx-core", "sqlx-macros-core", - "syn", + "syn 2.0.89", ] [[package]] name = "sqlx-macros-core" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1804e8a7c7865599c9c79be146dc8a9fd8cc86935fa641d3ea58e5f0688abaa5" +checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", @@ -2169,16 +2274,16 @@ dependencies = [ "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn", - "tempfile", + "syn 2.0.89", + "tokio", "url", ] [[package]] name = "sqlx-mysql" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bb4714269afa44aef2755150a0fc19d756fb580a67db8885608cf02f47d06a" +checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", @@ -2211,16 +2316,17 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 1.0.69", + "thiserror", + "time", "tracing", "whoami", ] [[package]] name = "sqlx-postgres" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fa91a732d854c5d7726349bb4bb879bb9478993ceb764247660aee25f67c2f8" +checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", @@ -2231,7 +2337,6 @@ dependencies = [ "etcetera", "futures-channel", "futures-core", - "futures-io", "futures-util", "hex", "hkdf", @@ -2249,16 +2354,17 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 1.0.69", + "thiserror", + "time", "tracing", "whoami", ] [[package]] name = "sqlx-sqlite" -version = "0.8.2" +version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" +checksum = "c2d12fe70b2c1b4401038055f90f151b78208de1f9f89a7dbfd41587a10c3eea" dependencies = [ "atoi", "flume", @@ -2273,10 +2379,23 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", + "thiserror", + "time", "tracing", "url", ] +[[package]] +name = "sse-codec" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a395a858c7ff5c4b42aeab0501e07c978ac5e1ae5059f301884dab3fa405f47" +dependencies = [ + "futures-io", + "futures_codec", + "memchr", +] + [[package]] name = "stable_deref_trait" version = "1.2.0" @@ -2317,6 +2436,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -2334,7 +2464,7 @@ checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -2350,33 +2480,13 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "thiserror" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" -dependencies = [ - "thiserror-impl 1.0.69", -] - [[package]] name = "thiserror" version = "2.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2f49a1853cf82743e3b7950f77e0f4d622ca36cf4317cba00c767838bac8d490" dependencies = [ - "thiserror-impl 2.0.4", -] - -[[package]] -name = "thiserror-impl" -version = "1.0.69" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -2387,7 +2497,7 @@ checksum = "8381894bb3efe0c4acac3ded651301ceee58a15d47c2e34885ed1908ad667061" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -2400,6 +2510,36 @@ dependencies = [ "once_cell", ] +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "tinystr" version = "0.7.6" @@ -2451,7 +2591,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -2473,6 +2613,7 @@ checksum = "6b9590b93e6fcc1739458317cccd391ad3955e2bde8913edf6f95f9e65a8f034" dependencies = [ "bytes", "futures-core", + "futures-io", "futures-sink", "pin-project-lite", "tokio", @@ -2510,22 +2651,6 @@ dependencies = [ "tracing", ] -[[package]] -name = "tower" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" -dependencies = [ - "futures-core", - "futures-util", - "pin-project-lite", - "sync_wrapper", - "tokio", - "tower-layer", - "tower-service", - "tracing", -] - [[package]] name = "tower-layer" version = "0.3.3" @@ -2558,7 +2683,7 @@ checksum = "395ae124c09f9e6918a2310af6038fba074bcf474ac352496d5910dd59a2226d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -2667,18 +2792,18 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unicode_categories" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" - [[package]] name = "unsafe-libyaml" version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.4" @@ -2704,11 +2829,14 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.11.0" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ - "getrandom", + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", ] [[package]] @@ -2738,7 +2866,7 @@ dependencies = [ "proc-macro-error2", "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -2780,6 +2908,51 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "whoami" version = "1.5.2" @@ -2818,6 +2991,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + [[package]] name = "windows-sys" version = "0.48.0" @@ -2836,6 +3015,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-targets" version = "0.48.5" @@ -2998,7 +3186,7 @@ checksum = "2380878cad4ac9aac1e2435f3eb4020e8374b5f13c296cb75b4620ff8e229154" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", "synstructure", ] @@ -3020,7 +3208,7 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] [[package]] @@ -3040,7 +3228,7 @@ checksum = "595eed982f7d355beb85837f651fa22e90b3c044842dc7f2c2842c086f295808" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", "synstructure", ] @@ -3069,5 +3257,5 @@ checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.89", ] diff --git a/Cargo.toml b/Cargo.toml index 945ddc1..784daf2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,4 +1,12 @@ [workspace] resolver = "2" -members = ["lib", "macros"] +members = ["lib"] + +# Compile-time query checking runs inside sqlx-macros, and argon2 hashing runs in +# every auth test; both are much faster optimized, even in dev builds. +[profile.dev.package.sqlx-macros] +opt-level = 3 + +[profile.dev.package.argon2] +opt-level = 3 diff --git a/README.md b/README.md index c3078df..8c27742 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,147 @@ # API Rust +[![.github/workflows/test.yml](https://github.com/jifalops/api-rust/actions/workflows/test.yml/badge.svg)](https://github.com/jifalops/api-rust/actions/workflows/test.yml) [![.github/workflows/deploy_lambda.yml](https://github.com/jifalops/api-rust/actions/workflows/deploy_lambda.yml/badge.svg)](https://github.com/jifalops/api-rust/actions/workflows/deploy_lambda.yml) -A template repo for building a Rust/Axum API using a dev-container. +A template repo for building a Rust API with [poem][poem] and +[poem-openapi][poem-openapi], backed by Postgres via [sqlx][sqlx], and deployable to AWS Lambda, Docker, or Railway. + +[poem]: https://github.com/poem-web/poem +[poem-openapi]: https://docs.rs/poem-openapi +[sqlx]: https://github.com/launchbadge/sqlx + +## Getting started + +Requires Docker, which runs Postgres via `compose.yml`. `./install.sh` installs +the Rust toolchain and the cargo tools the recipes below need, and creates `.env`. + +```sh +./install.sh +just dev # start Postgres; rerun unit tests and restart the API on change +just test-e2e # the Postgres integration tests +just --list # everything else +``` + +The API serves: + +| Path | What | +| --------- | ----------------------------------------------- | +| `/health` | Liveness probe, returns `ok` | +| `/` | Stoplight Elements API browser | +| `/spec` | OpenAPI spec as JSON | +| `/api` | The API itself (`/api/auth/sign_up`, `…/me`, …) | + +## Environment + +Local values live in `.env`, created from `.env.example` by `install.sh`. Required at runtime: + +| Variable | Notes | +| ---------------- | ------------------------------------------------------------ | +| `DATABASE_URL` | Postgres connection string. Also what `sqlx-cli` reads. | +| `JWT_SECRET` | Signs and verifies tokens. Must be set. | +| `PORT` | Optional, defaults to `3000`. | +| `TOKEN_TTL_HOURS`| Optional, defaults to `24`. | + +Missing required variables panic on boot rather than failing on the first request +that needs them — see `lib/src/config.rs`. + +## Structure + +``` +lib/src/ + api/ OpenAPI tags + auth/ example domain: sign-up, sign-in, bearer-token verification + user/ example domain: CRUD over a users table + config.rs environment, validated at startup + database/ Created/Updated/Deleted envelopes, shared by repositories + error.rs AppError, InfraError, and their HTTP status mapping + error_reporting/ one place to hook up an error-tracking backend + init.rs startup: infra, routers, middleware, graceful shutdown + postgres/ pool + migrations +lib/migrations/ schema, applied automatically on connect +``` + +Each domain follows the same shape, so `user/` is the one to copy when adding one: + +``` +/ + mod.rs re-exports only + models.rs domain types + errors.rs the domain's error enum + its HTTP status mapping + repo.rs the repository trait, its row DTO, and its contract tests + service.rs business logic over the trait + router.rs poem-openapi handlers + adapters/ + repo_in_memory.rs no database; also where the contract tests run as unit tests + repo_postgres.rs sqlx, queries loaded from adapters/sql/*.sql + sql/*.sql one file per query +``` + +Adding a domain means: write `errors.rs` and add a transparent `AppError` variant +for it, write the repo trait plus its `testing` module, implement both adapters, +and wire the service into `init.rs`. + +### Errors + +Handlers return `AppResult` and use `?`. Each domain error implements poem's +`ResponseError` to map its variants to status codes, and `AppError` delegates to +whichever domain it wraps. `AppError` also implements `ApiResponse` with an empty +`meta()`, which keeps every possible error status out of the generated OpenAPI +spec — operations document their success response only. + +### Database and the offline query cache + +Queries use sqlx's compile-time-checked `query_file_as!`, so building normally +needs a live database. The verified query metadata is committed to `lib/.sqlx`, +and `SQLX_OFFLINE=true` builds against that instead — which is how CI and the +Docker images build with no Postgres available. + +**Re-run `just sqlx-cache` whenever you change a `.sql` file, a migration, or a row +struct, and commit the resulting `lib/.sqlx` changes.** Stale cache entries fail +the build with a confusing macro error. + +### Tests + +Repository tests are written once, against the trait, in each domain's +`repo.rs::testing` module. Both adapters run them: + +- the in-memory adapter runs them as unit tests, with no database — + `cargo nextest run --profile unit`, which `just dev` reruns on every change; +- the Postgres adapter runs them from `lib/tests/`, where `#[sqlx::test]` creates + a migrated scratch database per test — `just test-e2e`, gated behind the + `testing` feature. ## Deploy ### AWS Lambda -It follows the [official guide][1] for building Rust Lambda functions. +Built and deployed with [Cargo Lambda][1]. The `lambda` feature swaps the server +for `poem-lambda`. `just lambda` runs a release build in Cargo Lambda's local +emulator. Deploys run only in CI: on pushes to `main`, when the `DEPLOY_LAMBDA` +variable is `true`, `.github/workflows/deploy_lambda.yml` cross-compiles for +arm64 and deploys the function behind a public function URL. It needs the +`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `DATABASE_URL`, and `JWT_SECRET` +secrets and the `AWS_REGION` and (optional) `RUST_LOG` variables. +Memory and timeout live under `[package.metadata.lambda.deploy]` in +`lib/Cargo.toml`. + +[1]: https://www.cargo-lambda.info/ + +### Docker / Railway + +`deploy/docker/Dockerfile` is a multi-stage build producing a distroless image. +`just docker` builds and runs it locally on port 3010; `railway.toml` points +Railway at the same file with `/health` as its healthcheck. + +## Auth, and what to replace + +The included auth is a self-contained example: passwords hashed with argon2 and +stored on the `users` row, tokens signed as HS256 JWTs by `AuthRepoJwt`. It is a +starting point, not a finished identity system — there is no email verification, +password reset, refresh token, or revocation. To use a managed provider instead, +implement `AuthRepo` against it and swap the adapter in `init.rs`; nothing outside +`auth/adapters/` needs to change. + +## License -[1]: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/building-rust.html +Zero-Clause BSD. See [LICENSE](LICENSE). diff --git a/compose.yml b/compose.yml new file mode 100644 index 0000000..d1bdc30 --- /dev/null +++ b/compose.yml @@ -0,0 +1,30 @@ +services: + postgres: + image: postgres:16 + environment: + POSTGRES_PASSWORD: developer + ports: + - "5432:5432" + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: pg_isready -U postgres + interval: 1s + + # The release image, run by `just docker`. + app: + profiles: [docker] + build: + context: . + dockerfile: deploy/docker/Dockerfile + ports: + - "3010:3000" + environment: + RUST_LOG: ${RUST_LOG:-info} + DATABASE_URL: postgresql://postgres:developer@postgres:5432/postgres + JWT_SECRET: ${JWT_SECRET} + depends_on: + postgres: + condition: service_healthy +volumes: + postgres_data: diff --git a/deploy/aws_sam/samconfig.toml b/deploy/aws_sam/samconfig.toml deleted file mode 100644 index 199fb46..0000000 --- a/deploy/aws_sam/samconfig.toml +++ /dev/null @@ -1,35 +0,0 @@ -# https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html -version = 0.1 - -[default] -[default.global] -[default.global.parameters] -stack_name = "api-rust" - -[default.build.parameters] -cached = true -parallel = true -beta_features = true - -[default.validate.parameters] -lint = true - -[default.deploy.parameters] -capabilities = "CAPABILITY_IAM" -confirm_changeset = true -resolve_s3 = true - -[default.sync.parameters] -watch = true -beta_features = true - -[default.local_start_api.parameters] -warm_containers = "EAGER" - -[default.local_start_lambda.parameters] -warm_containers = "EAGER" - -[prod] -[prod.sync] -[prod.sync.parameters] -watch = false diff --git a/deploy/aws_sam/template.yaml b/deploy/aws_sam/template.yaml deleted file mode 100644 index 611a477..0000000 --- a/deploy/aws_sam/template.yaml +++ /dev/null @@ -1,70 +0,0 @@ -# Template sections: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/sam-specification-template-anatomy.html -# Rust specific: https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/building-rust.html -Transform: AWS::Serverless-2016-10-31 - -Parameters: - Arch: - Type: String - Default: arm64 - AllowedValues: - - arm64 - - x86_64 - PostgresUri: - Type: String - RustLog: - Type: String - -Resources: - # https://github.com/aws/serverless-application-model/blob/master/versions/2016-10-31.md#httpapi - ApiGateway: - Type: AWS::Serverless::HttpApi - Properties: - CorsConfiguration: - AllowMethods: - - GET - - POST - - PUT - - DELETE - - OPTIONS - AllowHeaders: - - Content-Type - - Authorization - AllowOrigins: - - "*" - - # https://github.com/aws/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction - ApiFunction: - Type: AWS::Serverless::Function - Metadata: - BuildMethod: rust-cargolambda - Properties: - Timeout: 10 - MemorySize: 128 - CodeUri: ../../target/lambda/api-rust - Runtime: provided.al2023 - Handler: bootstrap - Architectures: - - !Ref Arch - Environment: - Variables: - RUST_BACKTRACE: 1 - POSTGRES_URI: !Ref PostgresUri - RUST_LOG: !Ref RustLog - Events: - RootRoute: - Type: HttpApi - Properties: - ApiId: !Ref ApiGateway - Path: / - Method: ANY - ApiEvent: - Type: HttpApi - Properties: - ApiId: !Ref ApiGateway - Path: /{proxy+} - Method: ANY - -Outputs: - ApiEndpoint: - Description: HTTP API Gateway endpoint URL - Value: !Sub https://${ApiGateway}.execute-api.${AWS::Region}.amazonaws.com diff --git a/deploy/docker/Dockerfile b/deploy/docker/Dockerfile index 02326bf..48e0d3e 100644 --- a/deploy/docker/Dockerfile +++ b/deploy/docker/Dockerfile @@ -1,28 +1,32 @@ -# Build +# Build from the repository root: +# docker build -t api-rust -f deploy/docker/Dockerfile . FROM rust:slim-bookworm AS builder +# Compile the sqlx query macros against the committed lib/.sqlx cache instead of +# a live database, so the image builds with no Postgres available. +ENV SQLX_OFFLINE=true + WORKDIR /usr/src/app -COPY ../../Cargo.toml ../../Cargo.lock ./ -COPY ../../lib ./lib -COPY ../../macros ./macros +COPY Cargo.toml Cargo.lock ./ +COPY lib ./lib -RUN cargo build --release --workspace +# Cache mounts keep the registry and compiled dependencies between builds. The +# target dir isn't part of the image, so the binary is copied out of it. +RUN --mount=type=cache,target=/usr/local/cargo/registry \ + --mount=type=cache,target=/usr/src/app/target \ + cargo build --release && cp target/release/api-rust /usr/local/bin/ # Production FROM gcr.io/distroless/cc WORKDIR /app -# Copy the compiled binaries from builder -COPY --from=builder /usr/src/app/target/release/* /app/ +COPY --from=builder /usr/local/bin/api-rust /app/ -# Set environment variables -ENV RUST_LOG=debug -ENV HOST=0.0.0.0 +ENV RUST_LOG=info ENV PORT=3000 EXPOSE 3000 -# Update with your API binary name -CMD ["/app/api-rust"] \ No newline at end of file +CMD ["/app/api-rust"] diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..da2f9dd --- /dev/null +++ b/install.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Installs the tooling the justfile recipes expect. Docker is assumed to be +# installed already. +set -euo pipefail + +cd "$(dirname "$0")" + +if ! command -v rustup >/dev/null; then + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain none + source "$HOME/.cargo/env" +fi + +# Installs the toolchain and components pinned in rust-toolchain.toml. +rustup toolchain install + +# Prebuilt binaries where available, instead of compiling each tool from source. +if ! command -v cargo-binstall >/dev/null; then + curl -L --proto '=https' --tlsv1.2 -sSf \ + https://raw.githubusercontent.com/cargo-bins/cargo-binstall/main/install-from-binstall-release.sh | bash +fi +cargo binstall --no-confirm just cargo-watch cargo-nextest cargo-sort + +# Built from source to get only the Postgres driver and rustls. +cargo install --locked sqlx-cli --no-default-features --features postgres,rustls + +# Bundles Zig, which `just lambda` uses to cross-compile for arm64. +if ! command -v cargo-lambda >/dev/null; then + curl -fsSL https://cargo-lambda.info/install.sh | sh +fi + +if [ ! -f .env ]; then + cp .env.example .env + echo "Created .env from .env.example" +fi diff --git a/justfile b/justfile new file mode 100644 index 0000000..774bd01 --- /dev/null +++ b/justfile @@ -0,0 +1,48 @@ +# Task runner for this repo. Run `just` to list recipes. +# Recipes assume the tooling install.sh sets up. + +set dotenv-load := true + +default: + @just --list + +# Start Postgres, then rerun the unit tests and restart the API on every change. +dev: postgres + cargo watch -x 'nextest run --profile unit' -x run + +# Build the release image and run it on port 3010 against the local Postgres. +docker: + docker compose up --build app + +# Run a release build in the local Lambda emulator, served on port 9000 at +# /lambda-url/api-rust/. +lambda: postgres + cargo lambda watch --release --features lambda + +# Lint and unit tests; what CI runs on every pull request. +check: + cargo fmt --check + cargo clippy --workspace --all-targets --features testing -- -D warnings + cargo test --doc --no-fail-fast + cargo nextest run --profile unit + +# Integration tests. Needs Postgres; each test gets its own scratch database. +test-e2e: postgres + cargo nextest run --profile e2e --features testing + +# Refresh the committed sqlx offline query cache after changing SQL or migrations. +sqlx-cache: postgres + cd lib && cargo sqlx prepare + +# Format, sort manifests, then apply the compiler's and clippy's own suggestions. +fix: + cargo fmt + cargo sort --workspace + cargo fix --allow-dirty + cargo clippy --fix --allow-dirty + +# Start the local Postgres container and apply migrations. The app migrates on +# connect too, but the sqlx query macros need the schema before it can compile. +postgres: + docker compose up -d --wait postgres + sqlx migrate run --source lib/migrations diff --git a/lib/.sqlx/query-0b8296620d615a80744d456910925d7968e7c9fc1386c3cff8567080f6d8d5c5.json b/lib/.sqlx/query-0b8296620d615a80744d456910925d7968e7c9fc1386c3cff8567080f6d8d5c5.json new file mode 100644 index 0000000..c99f139 --- /dev/null +++ b/lib/.sqlx/query-0b8296620d615a80744d456910925d7968e7c9fc1386c3cff8567080f6d8d5c5.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users\nSET\n deleted_at = now(),\n updated_at = now()\nWHERE email = $1\n AND deleted_at IS NULL\nRETURNING *;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "photo_url", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "0b8296620d615a80744d456910925d7968e7c9fc1386c3cff8567080f6d8d5c5" +} diff --git a/lib/.sqlx/query-0f627e4b70eddc9bf63fd35988e13949babe0daf544c92dc3824345a47a8d8d2.json b/lib/.sqlx/query-0f627e4b70eddc9bf63fd35988e13949babe0daf544c92dc3824345a47a8d8d2.json new file mode 100644 index 0000000..6bd868c --- /dev/null +++ b/lib/.sqlx/query-0f627e4b70eddc9bf63fd35988e13949babe0daf544c92dc3824345a47a8d8d2.json @@ -0,0 +1,73 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users\nSET\n email_verified = $2,\n name = COALESCE($3, name),\n photo_url = COALESCE($4, photo_url),\n updated_at = now()\nWHERE id = $1\n AND deleted_at IS NULL\nRETURNING *;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "photo_url", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Bool", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "0f627e4b70eddc9bf63fd35988e13949babe0daf544c92dc3824345a47a8d8d2" +} diff --git a/lib/.sqlx/query-572ac4a90633aa913b833a1724116528384d3a1b5e09970045cfa5295dfdff0d.json b/lib/.sqlx/query-572ac4a90633aa913b833a1724116528384d3a1b5e09970045cfa5295dfdff0d.json new file mode 100644 index 0000000..2f8f3fb --- /dev/null +++ b/lib/.sqlx/query-572ac4a90633aa913b833a1724116528384d3a1b5e09970045cfa5295dfdff0d.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT *\nFROM users\nWHERE email = $1\n AND deleted_at IS NULL;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "photo_url", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "572ac4a90633aa913b833a1724116528384d3a1b5e09970045cfa5295dfdff0d" +} diff --git a/lib/.sqlx/query-7cbdf2f86d00f2f67474d86ebebb8a0ff7bf95c805a1371be014e09fdd192517.json b/lib/.sqlx/query-7cbdf2f86d00f2f67474d86ebebb8a0ff7bf95c805a1371be014e09fdd192517.json new file mode 100644 index 0000000..ffdaff7 --- /dev/null +++ b/lib/.sqlx/query-7cbdf2f86d00f2f67474d86ebebb8a0ff7bf95c805a1371be014e09fdd192517.json @@ -0,0 +1,75 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users (id, email, email_verified, password_hash, name, photo_url)\nVALUES ($1, $2, $3, $4, $5, $6)\nRETURNING *;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "photo_url", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Text", + "Bool", + "Text", + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "7cbdf2f86d00f2f67474d86ebebb8a0ff7bf95c805a1371be014e09fdd192517" +} diff --git a/lib/.sqlx/query-c63a4ffa423c530f1c2346d1a40ac683b75e246369328fe4a80cf7bfc431f746.json b/lib/.sqlx/query-c63a4ffa423c530f1c2346d1a40ac683b75e246369328fe4a80cf7bfc431f746.json new file mode 100644 index 0000000..686e0eb --- /dev/null +++ b/lib/.sqlx/query-c63a4ffa423c530f1c2346d1a40ac683b75e246369328fe4a80cf7bfc431f746.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT *\nFROM users\nWHERE id = $1\n AND deleted_at IS NULL;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "photo_url", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "c63a4ffa423c530f1c2346d1a40ac683b75e246369328fe4a80cf7bfc431f746" +} diff --git a/lib/.sqlx/query-f9f0b3fd968f50bf7146fd610385b8470645d6b2c8085881071413c972eab0eb.json b/lib/.sqlx/query-f9f0b3fd968f50bf7146fd610385b8470645d6b2c8085881071413c972eab0eb.json new file mode 100644 index 0000000..f6dcaac --- /dev/null +++ b/lib/.sqlx/query-f9f0b3fd968f50bf7146fd610385b8470645d6b2c8085881071413c972eab0eb.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users\nSET\n deleted_at = now(),\n updated_at = now()\nWHERE id = $1\n AND deleted_at IS NULL\nRETURNING *;\n", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "password_hash", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "photo_url", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "deleted_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + false, + false, + true + ] + }, + "hash": "f9f0b3fd968f50bf7146fd610385b8470645d6b2c8085881071413c972eab0eb" +} diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 86b05cd..406744b 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -5,30 +5,54 @@ name = "api-rust" version = "0.1.0" edition = "2024" +# https://www.cargo-lambda.info/commands/deploy.html +[package.metadata.lambda.deploy] +memory = 128 +timeout = 10 + +[[bin]] +name = "api-rust" +path = "src/main.rs" + [features] default = [] -lambda = ["dep:lambda_http"] +lambda = ["dep:lambda_http", "dep:poem-lambda"] testing = [] [dependencies] -api-rust-macros = { version = "*", path = "../macros" } argon2 = "0.5.3" async-trait = "0.1.83" -axum = { version = "0.8.1", features = ["macros"] } -dashmap = "6.1.0" +jsonwebtoken = "9.3.1" lambda_http = { version = "0.14.0", optional = true, features = ["apigw_http"] } poem = "3.1.6" -poem-lambda = "5.1.2" -poem-openapi = { version = "5.1.12", features = ["stoplight-elements"] } +poem-lambda = { version = "5.1.2", optional = true } +poem-openapi = { version = "5.1.12", features = ["stoplight-elements", "time"] } serde = { version = "1.0.215", features = ["derive"] } serde_json = "1.0.133" -short-uuid = "0.2.0" -sqlx = { version = "0.8.2", features = ["postgres"] } +sqlx = { version = "0.8.2", features = [ + "derive", + "migrate", + "postgres", + "runtime-tokio", + "time", + "tls-rustls-ring-native-roots", +] } thiserror = "2.0.4" +time = { version = "0.3.41", features = [ + "formatting", + "macros", + "parsing", + "serde", +] } tokio = { version = "1", features = ["full"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } +uuid = { version = "1.17.0", features = ["serde", "v4"] } validator = { version = "0.20.0", features = ["derive"] } -# https://www.cargo-lambda.info/commands/build.html#build-configuration-in-cargo-s-metadata -# [package.metadata.lambda.build] +[dev-dependencies] +poem = { version = "3.1.6", features = ["test"] } + +[[test]] +name = "user" +required-features = ["testing"] diff --git a/lib/build.rs b/lib/build.rs new file mode 100644 index 0000000..d506869 --- /dev/null +++ b/lib/build.rs @@ -0,0 +1,5 @@ +// generated by `sqlx migrate build-script` +fn main() { + // trigger recompilation when a new migration is added + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/lib/migrations/0001_create_users.sql b/lib/migrations/0001_create_users.sql new file mode 100644 index 0000000..4a1e8da --- /dev/null +++ b/lib/migrations/0001_create_users.sql @@ -0,0 +1,11 @@ +CREATE TABLE users ( + id TEXT PRIMARY KEY, + email TEXT NOT NULL UNIQUE, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + password_hash TEXT NOT NULL, + name TEXT, + photo_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + deleted_at TIMESTAMPTZ +); diff --git a/lib/src/api/mod.rs b/lib/src/api/mod.rs index c0e245b..164ec25 100644 --- a/lib/src/api/mod.rs +++ b/lib/src/api/mod.rs @@ -1,3 +1,3 @@ mod models; -pub use models::*; \ No newline at end of file +pub use models::*; diff --git a/lib/src/app.rs b/lib/src/app.rs index 5b89b83..8a198b5 100644 --- a/lib/src/app.rs +++ b/lib/src/app.rs @@ -1,45 +1,25 @@ -use crate::{ - auth::{AuthRepo, AuthService}, - user::{UserRepo, UserService}, -}; +use std::sync::Arc; -pub trait Service: Sync + Send + 'static {} -impl Service for T {} +use crate::{auth::AuthService, user::UserService}; -pub trait Repo: Sync + Send + 'static {} -impl Repo for T {} - -pub trait App: Sync + Send + 'static { - type Auth: AuthRepo; - type User: UserRepo; - - fn auth(&self) -> &AuthService; - - fn user(&self) -> &UserService; +/// The composed application: one service per domain, already wired to its +/// adapters. Services are concrete rather than generic over their repository — +/// adapter choice happens once, in [`crate::init`], behind `Box`. +pub struct App { + auth: Arc, + user: Arc, } -pub struct NewApp -where - Auth: AuthRepo, - User: UserRepo, -{ - pub auth: AuthService, - pub user: UserService, -} - -impl App for NewApp -where - Auth: AuthRepo, - User: UserRepo, -{ - type Auth = Auth; - type User = User; +impl App { + pub fn new(auth: Arc, user: Arc) -> Self { + Self { auth, user } + } - fn auth(&self) -> &AuthService { + pub fn auth(&self) -> &Arc { &self.auth } - fn user(&self) -> &UserService { + pub fn user(&self) -> &Arc { &self.user } } diff --git a/lib/src/auth/adapters/mod.rs b/lib/src/auth/adapters/mod.rs new file mode 100644 index 0000000..0b6f58e --- /dev/null +++ b/lib/src/auth/adapters/mod.rs @@ -0,0 +1,3 @@ +mod repo_jwt; + +pub use repo_jwt::AuthRepoJwt; diff --git a/lib/src/auth/adapters/repo_jwt.rs b/lib/src/auth/adapters/repo_jwt.rs new file mode 100644 index 0000000..3eff0fe --- /dev/null +++ b/lib/src/auth/adapters/repo_jwt.rs @@ -0,0 +1,97 @@ +use jsonwebtoken::{ + Algorithm, DecodingKey, EncodingKey, Header, TokenData, Validation, decode, encode, +}; +use time::{Duration, OffsetDateTime}; + +use crate::{ + AppResult, + auth::{AuthError, AuthRepo, Token, TokenUser}, + user::User, +}; + +/// Signs and verifies JWTs with a shared secret. +pub struct AuthRepoJwt { + secret: String, + algorithm: Algorithm, + ttl: Duration, +} + +impl AuthRepoJwt { + pub fn new(secret: &str, algorithm: Algorithm, ttl: Duration) -> Self { + Self { + secret: secret.to_string(), + algorithm, + ttl, + } + } +} + +#[async_trait::async_trait] +impl AuthRepo for AuthRepoJwt { + async fn create_token(&self, user: &User) -> AppResult { + let issued_at = OffsetDateTime::now_utc(); + let claims = TokenUser { + id: user.id.clone(), + email: user.email.clone(), + issued_at, + expires_at: issued_at + self.ttl, + }; + let encoded = encode( + &Header::new(self.algorithm), + &claims, + &EncodingKey::from_secret(self.secret.as_ref()), + ) + .map_err(|e| AuthError::Internal(format!("Failed to encode token: {e}")))?; + Ok(Token::from(encoded)) + } + + fn verify(&self, token: &Token) -> AppResult { + let decoded: TokenData = decode( + token.as_ref(), + &DecodingKey::from_secret(self.secret.as_ref()), + &Validation::new(self.algorithm), + ) + .map_err(|e| AuthError::InvalidToken(format!("Failed to decode token: {e}")))?; + Ok(decoded.claims) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::user::testing::new_user; + + fn repo(ttl: Duration) -> AuthRepoJwt { + AuthRepoJwt::new("test-secret", Algorithm::HS256, ttl) + } + + #[tokio::test] + async fn round_trips_a_token() { + let repo = repo(Duration::hours(1)); + let user = new_user("round@example.com"); + let token = repo.create_token(&user).await.unwrap(); + let claims = repo.verify(&token).unwrap(); + assert_eq!(claims.id, user.id); + assert_eq!(claims.email, user.email); + } + + #[tokio::test] + async fn rejects_an_expired_token() { + let repo = repo(Duration::hours(-1)); + let token = repo + .create_token(&new_user("expired@example.com")) + .await + .unwrap(); + assert!(repo.verify(&token).is_err()); + } + + #[tokio::test] + async fn rejects_a_token_signed_with_another_secret() { + let token = repo(Duration::hours(1)) + .create_token(&new_user("forged@example.com")) + .await + .unwrap(); + let other = AuthRepoJwt::new("different-secret", Algorithm::HS256, Duration::hours(1)); + assert!(other.verify(&token).is_err()); + } +} diff --git a/lib/src/auth/errors.rs b/lib/src/auth/errors.rs new file mode 100644 index 0000000..1f48256 --- /dev/null +++ b/lib/src/auth/errors.rs @@ -0,0 +1,32 @@ +use poem::error::ResponseError; +use poem::http::StatusCode; + +#[derive(Debug, thiserror::Error, Clone)] +pub enum AuthError { + #[error("Invalid credential")] + InvalidCredential, + + #[error("Invalid user token: {0}")] + InvalidToken(String), + + #[error("Unauthorized access: {0}")] + Unauthorized(String), + + #[error("Invalid sign-up data: {0}")] + Validation(String), + + #[error("Internal error: {0}")] + Internal(String), +} + +impl ResponseError for AuthError { + fn status(&self) -> StatusCode { + match self { + AuthError::InvalidCredential => StatusCode::UNAUTHORIZED, + AuthError::InvalidToken(_) => StatusCode::UNAUTHORIZED, + AuthError::Unauthorized(_) => StatusCode::FORBIDDEN, + AuthError::Validation(_) => StatusCode::BAD_REQUEST, + AuthError::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + } + } +} diff --git a/lib/src/auth/mod.rs b/lib/src/auth/mod.rs index 79c5db6..f28874a 100644 --- a/lib/src/auth/mod.rs +++ b/lib/src/auth/mod.rs @@ -1,11 +1,13 @@ +mod adapters; +mod errors; mod models; mod repo; -mod repo_jwt; mod router; mod service; +pub use adapters::*; +pub use errors::*; pub use models::*; pub use repo::AuthRepo; -pub use repo_jwt::AuthRepoJwt; pub use router::AuthRouter; pub use service::*; diff --git a/lib/src/auth/models.rs b/lib/src/auth/models.rs index b3ec304..0737f9d 100644 --- a/lib/src/auth/models.rs +++ b/lib/src/auth/models.rs @@ -1,25 +1,66 @@ -use poem_openapi::Object; +use std::{fmt::Display, sync::Arc}; + +use poem::Request; +use poem_openapi::{NewType, Object, SecurityScheme, auth::Bearer}; use serde::{Deserialize, Serialize}; +use time::OffsetDateTime; use validator::Validate; -#[derive(Debug, thiserror::Error)] -pub enum AuthError { - #[error("Invalid credential")] - InvalidCredential, +use super::AuthService; + +/// An encoded and signed token. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash, NewType)] +pub struct Token(String); + +impl Display for Token { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Object)] -pub struct Token { - pub user_id: String, - pub token: String, - pub expires: u64, +impl AsRef for Token { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl From for Token { + fn from(s: String) -> Self { + Self(s) + } +} + +/// Claims decoded from a token. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct TokenUser { + #[serde(rename = "sub")] + pub id: String, + pub email: String, + #[serde(rename = "iat", with = "time::serde::timestamp")] + pub issued_at: OffsetDateTime, + #[serde(rename = "exp", with = "time::serde::timestamp")] + pub expires_at: OffsetDateTime, +} + +/// Bearer token authentication for OpenAPI. Handlers take this as an argument to +/// require a valid token; the decoded claims come back as `BearerAuth(user)`. +#[derive(SecurityScheme)] +#[oai(ty = "bearer", checker = "check_bearer_token")] +pub struct BearerAuth(pub TokenUser); + +async fn check_bearer_token(req: &Request, bearer: Bearer) -> Option { + if bearer.token.is_empty() { + return None; + } + let auth = req.data::>()?; + auth.verify_token(&Token(bearer.token.to_owned())).ok() } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Validate, Object)] pub struct SignUpData { #[validate(email)] pub email: String, - #[validate(length(min = 4, max = 64))] + #[validate(length(min = 8, max = 64))] pub password: String, #[validate(length(min = 1, max = 64))] pub name: Option, @@ -31,6 +72,6 @@ pub struct SignUpData { pub struct SignInData { #[validate(email)] pub email: String, - #[validate(length(min = 4, max = 64))] + #[validate(length(min = 8, max = 64))] pub password: String, } diff --git a/lib/src/auth/repo.rs b/lib/src/auth/repo.rs index 45449a0..5f63db6 100644 --- a/lib/src/auth/repo.rs +++ b/lib/src/auth/repo.rs @@ -1,8 +1,12 @@ -use crate::{app::Repo, user::User, AppError}; +use crate::{AppResult, user::User}; -use super::Token; +use super::{Token, TokenUser}; #[async_trait::async_trait] -pub trait AuthRepo: Repo { - async fn create_token(&self, user: &User) -> Result; +pub trait AuthRepo: Send + Sync + 'static { + /// Issues a signed token for a user. + async fn create_token(&self, user: &User) -> AppResult; + + /// Verifies a token's signature and expiry, returning its claims. + fn verify(&self, token: &Token) -> AppResult; } diff --git a/lib/src/auth/repo_jwt.rs b/lib/src/auth/repo_jwt.rs deleted file mode 100644 index e40fe0b..0000000 --- a/lib/src/auth/repo_jwt.rs +++ /dev/null @@ -1,12 +0,0 @@ -use crate::{user::User, AppError}; - -use super::{repo::AuthRepo, Token}; - -pub struct AuthRepoJwt; - -#[async_trait::async_trait] -impl AuthRepo for AuthRepoJwt { - async fn create_token(&self, user: &User) -> Result { - todo!() - } -} diff --git a/lib/src/auth/router.rs b/lib/src/auth/router.rs index 1206415..d6cb48f 100644 --- a/lib/src/auth/router.rs +++ b/lib/src/auth/router.rs @@ -1,52 +1,59 @@ use std::sync::Arc; -use poem_openapi::{ApiResponse, OpenApi, payload::Json}; +use poem_openapi::{OpenApi, payload::Json}; -use crate::{api::ApiTags, app::App}; +use crate::{ + App, AppResult, + api::ApiTags, + user::{UserIdentifier, UserProfile}, +}; -use super::{SignInData, SignUpData, Token}; +use super::{BearerAuth, SignInData, SignUpData, Token}; -pub struct AuthRouter { - pub app: Arc, +pub struct AuthRouter { + app: Arc, } -#[derive(ApiResponse)] -enum AuthResponse { - #[oai(status = 200)] - Success(Json), - #[oai(status = 401)] - Unauthorized(Json), +impl AuthRouter { + pub fn new(app: Arc) -> Self { + Self { app } + } } #[OpenApi(tag = "ApiTags::Auth", prefix_path = "/auth")] -impl AuthRouter { +impl AuthRouter { /// Sign up / Register #[oai(path = "/sign_up", method = "post")] - async fn sign_up(&self, Json(data): Json) -> AuthResponse { - match self.app.auth().sign_up(data, self.app.as_ref()).await { - Ok(token) => { - tracing::info!("User signed up successfully: {:?}", token); - AuthResponse::Success(Json(token)) - } - Err(e) => { - tracing::error!("Sign up error: {:?}", e); - AuthResponse::Unauthorized(Json(e.to_string())) - } - } + async fn sign_up(&self, Json(data): Json) -> AppResult> { + let token = self + .app + .auth() + .sign_up(data, self.app.user()) + .await + .inspect_err(|e| tracing::warn!("Sign up failed: {e}"))?; + Ok(Json(token)) } /// Sign in / Login #[oai(path = "/sign_in", method = "post")] - async fn sign_in(&self, Json(data): Json) -> AuthResponse { - match self.app.auth().sign_in(data, self.app.as_ref()).await { - Ok(token) => { - tracing::info!("User signed in successfully: {:?}", token); - AuthResponse::Success(Json(token)) - } - Err(e) => { - tracing::error!("Sign in error: {:?}", e); - AuthResponse::Unauthorized(Json(e.to_string())) - } - } + async fn sign_in(&self, Json(data): Json) -> AppResult> { + let token = self + .app + .auth() + .sign_in(data, self.app.user()) + .await + .inspect_err(|e| tracing::warn!("Sign in failed: {e}"))?; + Ok(Json(token)) + } + + /// The signed-in user's own profile. + #[oai(path = "/me", method = "get")] + async fn me(&self, BearerAuth(claims): BearerAuth) -> AppResult> { + let user = self + .app + .user() + .get_user(UserIdentifier::Id(claims.id)) + .await?; + Ok(Json(UserProfile::from(&user.model))) } } diff --git a/lib/src/auth/service.rs b/lib/src/auth/service.rs index d93a5bb..0fd0e6d 100644 --- a/lib/src/auth/service.rs +++ b/lib/src/auth/service.rs @@ -5,57 +5,67 @@ use argon2::{ use validator::Validate; use crate::{ - App, AppError, - user::{NewUser, UserIdentifier, UserRepo}, + AppResult, + user::{NewUser, UserIdentifier, UserService}, }; -use super::{AuthError, AuthRepo, SignInData, SignUpData, Token}; +use super::{AuthError, AuthRepo, SignInData, SignUpData, Token, TokenUser}; -pub struct AuthService { - repo: R, +pub struct AuthService { + repo: Box, } -impl AuthService { - pub fn new(repo: R) -> Self { - Self { repo } +impl AuthService { + pub fn new(repo: impl AuthRepo) -> Self { + Self { + repo: Box::new(repo), + } } - pub async fn sign_up(&self, data: SignUpData, app: &A) -> Result { + + pub async fn sign_up(&self, data: SignUpData, users: &UserService) -> AppResult { data.validate() - .map_err(|e| AppError::Validation(e.to_string()))?; + .map_err(|e| AuthError::Validation(e.to_string()))?; let new_user = NewUser { email: data.email, - password_hash: self.hash_password(data.password)?, + password_hash: self.hash_password(&data.password)?, name: data.name, photo_url: data.photo_url, }; - let user = app.user().create_user(new_user).await?; - let token = self.repo.create_token(&user).await?; - Ok(token) + let created = users.create_user(new_user).await?; + self.repo.create_token(&created.model).await } - pub async fn sign_in(&self, data: SignInData, app: &A) -> Result { + pub async fn sign_in(&self, data: SignInData, users: &UserService) -> AppResult { data.validate() - .map_err(|e| AppError::Validation(e.to_string()))?; - let user = app - .user() + .map_err(|e| AuthError::Validation(e.to_string()))?; + let user = users .get_user(UserIdentifier::Email(data.email)) - .await?; - self.verify_password(data.password, &user.password_hash)?; + .await + // Don't disclose whether the address is registered. + .map_err(|_| AuthError::InvalidCredential)? + .model; + self.verify_password(&data.password, &user.password_hash)?; self.repo.create_token(&user).await } - fn hash_password(&self, password: String) -> Result { + pub fn verify_token(&self, token: &Token) -> AppResult { + self.repo.verify(token) + } + + fn hash_password(&self, password: &str) -> AppResult { let salt = SaltString::generate(&mut OsRng); let hashed = Argon2::default() .hash_password(password.as_bytes(), &salt) - .map_err(|e| AppError::Internal(e.to_string()))? + .map_err(|e| AuthError::Internal(e.to_string()))? .to_string(); Ok(hashed) } - fn verify_password(&self, password: String, hash: &str) -> Result<(), AppError> { - let parsed = PasswordHash::new(hash).map_err(|e| AppError::Internal(e.to_string()))?; + + fn verify_password(&self, password: &str, hash: &str) -> AppResult<()> { + let parsed = PasswordHash::new(hash).map_err(|e| AuthError::Internal(e.to_string()))?; Argon2::default() .verify_password(password.as_bytes(), &parsed) - .map_err(|_| AuthError::InvalidCredential.into()) + .map_err(|_| AuthError::InvalidCredential)?; + Ok(()) } } diff --git a/lib/src/config.rs b/lib/src/config.rs index e69de29..7bc8d73 100644 --- a/lib/src/config.rs +++ b/lib/src/config.rs @@ -0,0 +1,34 @@ +//! Environment configuration, validated once at startup. +//! +//! Required variables are resolved eagerly so a misconfigured deployment fails +//! on boot rather than on the first request that needs them. + +use std::sync::LazyLock; + +/// Postgres connection string. `DATABASE_URL` is the name `sqlx-cli`, +/// `sqlx::migrate!`, and `#[sqlx::test]` all expect, so the app reads the +/// same variable rather than introducing a second name. +pub static DATABASE_URL: LazyLock = + LazyLock::new(|| std::env::var("DATABASE_URL").expect("DATABASE_URL must be set")); + +/// Secret used to sign and verify JWTs. +pub static JWT_SECRET: LazyLock = + LazyLock::new(|| std::env::var("JWT_SECRET").expect("JWT_SECRET must be set")); + +/// Port to bind the server to. Defaults to 3000. +pub fn port() -> u16 { + std::env::var("PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(3000) +} + +/// How long an issued token stays valid. +pub fn token_ttl() -> time::Duration { + time::Duration::hours( + std::env::var("TOKEN_TTL_HOURS") + .ok() + .and_then(|h| h.parse().ok()) + .unwrap_or(24), + ) +} diff --git a/lib/src/database/mod.rs b/lib/src/database/mod.rs new file mode 100644 index 0000000..164ec25 --- /dev/null +++ b/lib/src/database/mod.rs @@ -0,0 +1,3 @@ +mod models; + +pub use models::*; diff --git a/lib/src/database/models.rs b/lib/src/database/models.rs new file mode 100644 index 0000000..b787ad5 --- /dev/null +++ b/lib/src/database/models.rs @@ -0,0 +1,57 @@ +use serde::{Deserialize, Serialize}; +use time::UtcDateTime; + +/// Postgres SQLSTATE for a unique constraint violation. +pub const UNIQUE_VIOLATION: &str = "23505"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct WithId { + pub model: T, + pub id: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Created { + pub model: T, + pub created_at: UtcDateTime, + pub updated_at: Option, + pub deleted_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Updated { + pub model: T, + pub created_at: UtcDateTime, + pub updated_at: UtcDateTime, + pub deleted_at: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Deleted { + pub model: T, + pub deleted_at: UtcDateTime, + pub created_at: UtcDateTime, + pub updated_at: Option, +} + +impl From> for Created { + fn from(updated: Updated) -> Self { + Created { + model: updated.model, + created_at: updated.created_at, + updated_at: Some(updated.updated_at), + deleted_at: updated.deleted_at, + } + } +} + +impl From> for Deleted { + fn from(updated: Updated) -> Self { + Deleted { + model: updated.model, + deleted_at: updated.updated_at, + created_at: updated.created_at, + updated_at: Some(updated.updated_at), + } + } +} diff --git a/lib/src/error.rs b/lib/src/error.rs index 00a3b6f..c3b0ff1 100644 --- a/lib/src/error.rs +++ b/lib/src/error.rs @@ -1,59 +1,79 @@ -use axum::{ - Json, - body::Body, - http::{Response, StatusCode}, - response::IntoResponse, +use poem::error::ResponseError; +use poem::http::StatusCode; +use poem_openapi::{ + ApiResponse, + registry::{MetaResponses, Registry}, }; -use serde_json::json; -use crate::auth::AuthError; +use crate::{auth::AuthError, user::UserError}; -#[derive(Debug, thiserror::Error)] +pub type AppResult = std::result::Result; +pub type InfraResult = std::result::Result; + +/// Failures in the infrastructure layer, which belong to no business domain. +#[derive(Debug, thiserror::Error, Clone)] +pub enum InfraError { + #[error("Invalid configuration: {0}")] + Config(String), + + #[error("Postgres error: {0}")] + Postgres(String), + + #[error("IO error: {0}")] + Io(String), + + #[error("Network error: {0}")] + Network(String), +} + +impl ResponseError for InfraError { + fn status(&self) -> StatusCode { + match self { + InfraError::Config(_) => StatusCode::INTERNAL_SERVER_ERROR, + InfraError::Postgres(_) => StatusCode::INTERNAL_SERVER_ERROR, + InfraError::Io(_) => StatusCode::INTERNAL_SERVER_ERROR, + InfraError::Network(_) => StatusCode::SERVICE_UNAVAILABLE, + } + } +} + +/// Aggregates every domain's error type. Add one transparent variant per module +/// so handlers can return `AppResult` and use `?` on any domain error. +#[derive(Debug, thiserror::Error, Clone)] pub enum AppError { - #[error("Authentication failed: {0}")] + #[error(transparent)] Auth(#[from] AuthError), - #[error("Database error: {0}")] - Database(#[from] sqlx::Error), + #[error(transparent)] + User(#[from] UserError), - #[error("Not found")] - NotFound, + #[error(transparent)] + Infra(#[from] InfraError), +} - #[error("Validation failed: {0}")] - Validation(String), +impl ResponseError for AppError { + fn status(&self) -> StatusCode { + match self { + AppError::Auth(err) => err.status(), + AppError::User(err) => err.status(), + AppError::Infra(err) => err.status(), + } + } +} - #[error("Internal error: {0}")] - Internal(String), +// Prevents OpenAPI docs from including every error possibility in the response codes. +impl ApiResponse for AppError { + fn meta() -> MetaResponses { + MetaResponses { + responses: Vec::with_capacity(0), + } + } - #[error("Internal error: {0}")] - MalformedData(#[from] serde_json::Error), + fn register(_: &mut Registry) {} } -impl IntoResponse for AppError { - fn into_response(self) -> Response { - let (status, error_message) = match self { - AppError::Auth(_) => (StatusCode::UNAUTHORIZED, self.to_string()), - AppError::Database(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), - ), - AppError::NotFound => (StatusCode::NOT_FOUND, self.to_string()), - AppError::Validation(msg) => (StatusCode::BAD_REQUEST, msg), - AppError::Internal(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error".to_string(), - ), - AppError::MalformedData(_) => ( - StatusCode::INTERNAL_SERVER_ERROR, - "Internal server error: Malformed data.".to_string(), - ), - }; - - let body = Json(json!({ - "error": error_message, - "code": status.as_u16() - })); - - (status, body).into_response() +impl From for InfraError { + fn from(err: sqlx::Error) -> Self { + InfraError::Postgres(err.to_string()) } } diff --git a/lib/src/error_reporting/mod.rs b/lib/src/error_reporting/mod.rs new file mode 100644 index 0000000..9d0e401 --- /dev/null +++ b/lib/src/error_reporting/mod.rs @@ -0,0 +1,3 @@ +mod service; + +pub use service::*; diff --git a/lib/src/error_reporting/service.rs b/lib/src/error_reporting/service.rs new file mode 100644 index 0000000..b9220b0 --- /dev/null +++ b/lib/src/error_reporting/service.rs @@ -0,0 +1,30 @@ +use std::error::Error; + +use tracing::error; + +use crate::AppError; + +/// Central seam for reporting errors. Currently logs through `tracing`; swap the +/// bodies for a real error-tracking backend (Sentry, Honeycomb, …) without +/// touching any call site. +pub struct ErrorReportingService {} + +impl Default for ErrorReportingService { + fn default() -> Self { + Self::new() + } +} + +impl ErrorReportingService { + pub fn new() -> Self { + Self {} + } + + pub fn report_app_error(&self, err: &AppError) { + error!("Reporting error: {}", err); + } + + pub fn report_unknown_error(&self, err: &dyn Error) { + error!("Reporting unknown error: {}", err); + } +} diff --git a/lib/src/init.rs b/lib/src/init.rs index 26a2d35..004fec6 100644 --- a/lib/src/init.rs +++ b/lib/src/init.rs @@ -1,41 +1,123 @@ use std::sync::Arc; -use poem::{Route, listener::TcpListener}; +use jsonwebtoken::Algorithm; +#[cfg(not(feature = "lambda"))] +use poem::listener::TcpListener; +use poem::{ + EndpointExt, Response, Route, + error::ResponseError, + get, handler, + middleware::{AddData, Cors, Tracing}, +}; use poem_openapi::OpenApiService; +#[cfg(not(feature = "lambda"))] +use tracing::info; use crate::{ - app::NewApp, + App, AppError, InfraResult, auth::{AuthRepoJwt, AuthRouter, AuthService}, + config, + error_reporting::ErrorReportingService, + postgres, user::{UserRepoPostgres, UserService}, }; +#[handler] +fn health() -> &'static str { + "ok" +} + +/// Cross-cutting infrastructure, built once at startup. +struct Infra { + postgres_pool: sqlx::Pool, +} + +async fn setup_infra() -> InfraResult { + Ok(Infra { + postgres_pool: postgres::connect().await?, + }) +} + pub async fn initialize() { setup_tracing(); - let app = Arc::new(NewApp { - auth: AuthService::new(AuthRepoJwt), - user: UserService::new(UserRepoPostgres), - }); + let error_reporting = Arc::new(ErrorReportingService::new()); - let routers = AuthRouter { app: app.clone() }; - let api = OpenApiService::new(routers, "API", "1.0"); + let infra = match setup_infra().await { + Ok(infra) => infra, + Err(e) => { + error_reporting.report_app_error(&AppError::from(e.clone())); + panic!("Failed to set up infrastructure: {e}"); + } + }; + + let auth = Arc::new(AuthService::new(AuthRepoJwt::new( + &config::JWT_SECRET, + Algorithm::HS256, + config::token_ttl(), + ))); + let user = Arc::new(UserService::new(UserRepoPostgres::new( + infra.postgres_pool.clone(), + ))); + let app = Arc::new(App::new(auth.clone(), user)); + + let api = OpenApiService::new(AuthRouter::new(app), "API", "1.0"); let ui = api.stoplight_elements(); let spec = api.spec_endpoint(); + + let error_reporting_catch = error_reporting.clone(); let routes = Route::new() + .at("/health", get(health)) .nest("/", ui) - .nest("/api", api) - .nest("/spec", spec); + .nest( + "/api", + api.with(AddData::new(auth)) + .with(AddData::new(error_reporting)), + ) + .nest("/spec", spec) + .catch_all_error(move |err| { + let service = error_reporting_catch.clone(); + async move { + if let Some(app_err) = err.downcast_ref::() { + service.report_app_error(app_err); + Response::builder().status(app_err.status()).finish() + } else { + service.report_unknown_error(&err); + Response::builder().status(err.status()).finish() + } + } + }) + .with(Cors::new()) + .with(Tracing); #[cfg(not(feature = "lambda"))] { - let _ = poem::Server::new(TcpListener::bind("0.0.0.0:3000")) - .run(routes) - .await; + let addr = format!("0.0.0.0:{}", config::port()); + info!("Listening on {addr}"); + poem::Server::new(TcpListener::bind(addr)) + .run_with_graceful_shutdown(routes, shutdown_signal(), None) + .await + .expect("Server failed"); } #[cfg(feature = "lambda")] { - let _ = poem_lambda::run(routes); + poem_lambda::run(routes) + .await + .expect("Lambda runtime failed"); + } +} + +#[cfg(not(feature = "lambda"))] +async fn shutdown_signal() { + use tokio::signal; + let ctrl_c = signal::ctrl_c(); + let mut sigterm = + signal::unix::signal(signal::unix::SignalKind::terminate()).expect("SIGTERM handler"); + tokio::select! { + _ = ctrl_c => {} + _ = sigterm.recv() => {} } + info!("Shutdown signal received"); } fn setup_tracing() { diff --git a/lib/src/lib.rs b/lib/src/lib.rs index 4f45808..30a36ae 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -1,9 +1,13 @@ pub mod api; -pub mod app; +mod app; pub mod auth; +pub mod config; +pub mod database; mod error; +pub mod error_reporting; pub mod init; +pub mod postgres; pub mod user; -pub use app::App; -pub use error::AppError; +pub use app::*; +pub use error::*; diff --git a/lib/src/postgres/mod.rs b/lib/src/postgres/mod.rs new file mode 100644 index 0000000..2d083cc --- /dev/null +++ b/lib/src/postgres/mod.rs @@ -0,0 +1,15 @@ +use sqlx::{PgPool, Pool, Postgres}; + +use crate::{InfraError, InfraResult, config::DATABASE_URL}; + +/// Connects to Postgres and applies any pending migrations. +pub async fn connect() -> InfraResult> { + let pool = PgPool::connect(&DATABASE_URL) + .await + .map_err(|e| InfraError::Postgres(format!("Failed to connect to Postgres: {e}")))?; + sqlx::migrate!("./migrations") + .run(&pool) + .await + .map_err(|e| InfraError::Postgres(format!("Failed to run migrations: {e}")))?; + Ok(pool) +} diff --git a/lib/src/user/adapters/mod.rs b/lib/src/user/adapters/mod.rs new file mode 100644 index 0000000..dc91cb8 --- /dev/null +++ b/lib/src/user/adapters/mod.rs @@ -0,0 +1,5 @@ +mod repo_in_memory; +mod repo_postgres; + +pub use repo_in_memory::UserRepoInMemory; +pub use repo_postgres::UserRepoPostgres; diff --git a/lib/src/user/adapters/repo_in_memory.rs b/lib/src/user/adapters/repo_in_memory.rs new file mode 100644 index 0000000..ae2af24 --- /dev/null +++ b/lib/src/user/adapters/repo_in_memory.rs @@ -0,0 +1,122 @@ +use std::{collections::HashMap, sync::RwLock}; + +use time::UtcDateTime; + +use crate::{ + AppResult, + database::{Created, Deleted, Updated}, + user::{User, UserError, UserIdentifier, UserRepo}, +}; + +/// In-memory [`UserRepo`], for tests and for running the API without a database. +#[derive(Default)] +pub struct UserRepoInMemory { + users: RwLock>>, +} + +impl UserRepoInMemory { + pub fn new() -> Self { + Self::default() + } + + /// Looks up a live (not soft-deleted) record's id. + fn find_id(&self, ident: &UserIdentifier) -> Option { + let users = self.users.read().unwrap(); + users + .values() + .filter(|u| u.deleted_at.is_none()) + .find(|u| match ident { + UserIdentifier::Id(id) => &u.model.id == id, + UserIdentifier::Email(email) => &u.model.email == email, + }) + .map(|u| u.model.id.clone()) + } +} + +#[async_trait::async_trait] +impl UserRepo for UserRepoInMemory { + async fn create_user(&self, user: User) -> AppResult> { + for ident in user.identifiers() { + if self.find_id(&ident).is_some() { + Err(UserError::UserAlreadyExists(ident.to_string()))? + } + } + let now = UtcDateTime::now(); + let record = Updated { + model: user, + created_at: now, + updated_at: now, + deleted_at: None, + }; + let mut users = self.users.write().unwrap(); + users.insert(record.model.id.clone(), record.clone()); + Ok(record.into()) + } + + async fn get_user(&self, ident: UserIdentifier) -> AppResult> { + let id = self + .find_id(&ident) + .ok_or_else(|| UserError::UserNotFound(ident.to_string()))?; + let users = self.users.read().unwrap(); + Ok(users.get(&id).cloned().unwrap()) + } + + async fn update_user(&self, user: User) -> AppResult> { + let ident = UserIdentifier::Id(user.id.clone()); + let id = self + .find_id(&ident) + .ok_or_else(|| UserError::UserNotFound(ident.to_string()))?; + let mut users = self.users.write().unwrap(); + let record = users.get_mut(&id).unwrap(); + record.model = user; + record.updated_at = UtcDateTime::now(); + Ok(record.clone()) + } + + async fn delete_user(&self, ident: UserIdentifier) -> AppResult> { + let id = self + .find_id(&ident) + .ok_or_else(|| UserError::UserNotFound(ident.to_string()))?; + let mut users = self.users.write().unwrap(); + let record = users.get_mut(&id).unwrap(); + record.updated_at = UtcDateTime::now(); + record.deleted_at = Some(record.updated_at); + Ok(record.clone().into()) + } +} + +#[cfg(test)] +mod tests { + use super::UserRepoInMemory; + use crate::user::testing; + + #[tokio::test] + async fn test_create_user() { + testing::test_create_user(UserRepoInMemory::new()).await; + } + + #[tokio::test] + async fn test_create_user_rejects_duplicate_email() { + testing::test_create_user_rejects_duplicate_email(UserRepoInMemory::new()).await; + } + + #[tokio::test] + async fn test_get_user() { + testing::test_get_user(UserRepoInMemory::new()).await; + } + + #[tokio::test] + async fn test_get_user_not_found() { + testing::test_get_user_not_found(UserRepoInMemory::new()).await; + } + + #[tokio::test] + async fn test_update_user() { + testing::test_update_user(UserRepoInMemory::new()).await; + } + + #[tokio::test] + async fn test_delete_user() { + testing::test_delete_user(UserRepoInMemory::new()).await; + } +} diff --git a/lib/src/user/adapters/repo_postgres.rs b/lib/src/user/adapters/repo_postgres.rs new file mode 100644 index 0000000..69da5a5 --- /dev/null +++ b/lib/src/user/adapters/repo_postgres.rs @@ -0,0 +1,110 @@ +use sqlx::{PgPool, query_file_as}; + +use crate::{ + AppError, AppResult, InfraError, + database::{Created, Deleted, UNIQUE_VIOLATION, Updated}, + user::{User, UserError, UserIdentifier, UserRepo, repo::UserDto}, +}; + +pub struct UserRepoPostgres { + pool: PgPool, +} + +impl UserRepoPostgres { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait::async_trait] +impl UserRepo for UserRepoPostgres { + async fn create_user(&self, user: User) -> AppResult> { + let dto = query_file_as!( + UserDto, + "src/user/adapters/sql/create_user.sql", + user.id, + user.email, + user.email_verified, + user.password_hash, + user.name, + user.photo_url, + ) + .fetch_one(&self.pool) + .await + .map_err(|e| match e { + sqlx::Error::Database(db_err) if db_err.code().as_deref() == Some(UNIQUE_VIOLATION) => { + UserError::UserAlreadyExists(user.email.clone()).into() + } + _ => AppError::from(InfraError::Postgres(format!("Error creating user: {e}"))), + })?; + Ok(Updated::::from(dto).into()) + } + + async fn get_user(&self, ident: UserIdentifier) -> AppResult> { + let id_value = ident.to_string(); + let result = match &ident { + UserIdentifier::Id(id) => { + query_file_as!(UserDto, "src/user/adapters/sql/get_user_by_id.sql", id) + .fetch_one(&self.pool) + .await + } + UserIdentifier::Email(email) => { + query_file_as!( + UserDto, + "src/user/adapters/sql/get_user_by_email.sql", + email + ) + .fetch_one(&self.pool) + .await + } + }; + let dto = result.map_err(|e| match e { + sqlx::Error::RowNotFound => UserError::UserNotFound(id_value).into(), + _ => AppError::from(InfraError::Postgres(format!("Error getting user: {e}"))), + })?; + Ok(dto.into()) + } + + async fn update_user(&self, user: User) -> AppResult> { + let dto = query_file_as!( + UserDto, + "src/user/adapters/sql/update_user.sql", + user.id, + user.email_verified, + user.name, + user.photo_url, + ) + .fetch_one(&self.pool) + .await + .map_err(|e| match e { + sqlx::Error::RowNotFound => UserError::UserNotFound(user.id.clone()).into(), + _ => AppError::from(InfraError::Postgres(format!("Error updating user: {e}"))), + })?; + Ok(dto.into()) + } + + async fn delete_user(&self, ident: UserIdentifier) -> AppResult> { + let id_value = ident.to_string(); + let result = match &ident { + UserIdentifier::Id(id) => { + query_file_as!(UserDto, "src/user/adapters/sql/delete_user_by_id.sql", id) + .fetch_one(&self.pool) + .await + } + UserIdentifier::Email(email) => { + query_file_as!( + UserDto, + "src/user/adapters/sql/delete_user_by_email.sql", + email + ) + .fetch_one(&self.pool) + .await + } + }; + let dto = result.map_err(|e| match e { + sqlx::Error::RowNotFound => UserError::UserNotFound(id_value).into(), + _ => AppError::from(InfraError::Postgres(format!("Error deleting user: {e}"))), + })?; + Ok(Updated::::from(dto).into()) + } +} diff --git a/lib/src/user/adapters/sql/create_user.sql b/lib/src/user/adapters/sql/create_user.sql new file mode 100644 index 0000000..8842e8b --- /dev/null +++ b/lib/src/user/adapters/sql/create_user.sql @@ -0,0 +1,3 @@ +INSERT INTO users (id, email, email_verified, password_hash, name, photo_url) +VALUES ($1, $2, $3, $4, $5, $6) +RETURNING *; diff --git a/lib/src/user/adapters/sql/delete_user_by_email.sql b/lib/src/user/adapters/sql/delete_user_by_email.sql new file mode 100644 index 0000000..2c36b71 --- /dev/null +++ b/lib/src/user/adapters/sql/delete_user_by_email.sql @@ -0,0 +1,7 @@ +UPDATE users +SET + deleted_at = now(), + updated_at = now() +WHERE email = $1 + AND deleted_at IS NULL +RETURNING *; diff --git a/lib/src/user/adapters/sql/delete_user_by_id.sql b/lib/src/user/adapters/sql/delete_user_by_id.sql new file mode 100644 index 0000000..453ac37 --- /dev/null +++ b/lib/src/user/adapters/sql/delete_user_by_id.sql @@ -0,0 +1,7 @@ +UPDATE users +SET + deleted_at = now(), + updated_at = now() +WHERE id = $1 + AND deleted_at IS NULL +RETURNING *; diff --git a/lib/src/user/adapters/sql/get_user_by_email.sql b/lib/src/user/adapters/sql/get_user_by_email.sql new file mode 100644 index 0000000..5c5e94a --- /dev/null +++ b/lib/src/user/adapters/sql/get_user_by_email.sql @@ -0,0 +1,4 @@ +SELECT * +FROM users +WHERE email = $1 + AND deleted_at IS NULL; diff --git a/lib/src/user/adapters/sql/get_user_by_id.sql b/lib/src/user/adapters/sql/get_user_by_id.sql new file mode 100644 index 0000000..3e09d84 --- /dev/null +++ b/lib/src/user/adapters/sql/get_user_by_id.sql @@ -0,0 +1,4 @@ +SELECT * +FROM users +WHERE id = $1 + AND deleted_at IS NULL; diff --git a/lib/src/user/adapters/sql/update_user.sql b/lib/src/user/adapters/sql/update_user.sql new file mode 100644 index 0000000..eca767d --- /dev/null +++ b/lib/src/user/adapters/sql/update_user.sql @@ -0,0 +1,9 @@ +UPDATE users +SET + email_verified = $2, + name = COALESCE($3, name), + photo_url = COALESCE($4, photo_url), + updated_at = now() +WHERE id = $1 + AND deleted_at IS NULL +RETURNING *; diff --git a/lib/src/user/errors.rs b/lib/src/user/errors.rs new file mode 100644 index 0000000..25d4993 --- /dev/null +++ b/lib/src/user/errors.rs @@ -0,0 +1,24 @@ +use poem::error::ResponseError; +use poem::http::StatusCode; + +#[derive(Debug, thiserror::Error, Clone)] +pub enum UserError { + #[error("User already exists: {0}")] + UserAlreadyExists(String), + + #[error("User not found: {0}")] + UserNotFound(String), + + #[error("Invalid user data: {0}")] + Invalid(String), +} + +impl ResponseError for UserError { + fn status(&self) -> StatusCode { + match self { + UserError::UserAlreadyExists(_) => StatusCode::CONFLICT, + UserError::UserNotFound(_) => StatusCode::NOT_FOUND, + UserError::Invalid(_) => StatusCode::BAD_REQUEST, + } + } +} diff --git a/lib/src/user/mod.rs b/lib/src/user/mod.rs index 63fe9ad..8c430a3 100644 --- a/lib/src/user/mod.rs +++ b/lib/src/user/mod.rs @@ -1,11 +1,13 @@ +mod adapters; +mod errors; mod models; mod repo; -mod repo_in_memory; -mod repo_postgres; mod service; +pub use adapters::*; +pub use errors::*; pub use models::*; pub use repo::UserRepo; -pub use repo_in_memory::UserRepoInMemory; -pub use repo_postgres::UserRepoPostgres; +#[cfg(any(test, feature = "testing"))] +pub use repo::testing; pub use service::*; diff --git a/lib/src/user/models.rs b/lib/src/user/models.rs index 20acf1a..d818ed5 100644 --- a/lib/src/user/models.rs +++ b/lib/src/user/models.rs @@ -1,5 +1,6 @@ use std::fmt::Display; +use poem_openapi::Object; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] @@ -29,6 +30,28 @@ impl User { } } +/// The publicly exposable view of a [`User`] — notably without the password hash. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Object)] +pub struct UserProfile { + pub id: String, + pub email: String, + pub email_verified: bool, + pub name: Option, + pub photo_url: Option, +} + +impl From<&User> for UserProfile { + fn from(user: &User) -> Self { + Self { + id: user.id.clone(), + email: user.email.clone(), + email_verified: user.email_verified, + name: user.name.clone(), + photo_url: user.photo_url.clone(), + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] pub enum UserIdentifier { Id(String), @@ -44,3 +67,21 @@ impl Display for UserIdentifier { write!(f, "{}", s) } } + +#[cfg(any(test, feature = "testing"))] +pub mod testing { + use super::User; + + /// A valid user fixture. The id is unique per call so repeated use against a + /// shared database doesn't collide. + pub fn new_user(email: &str) -> User { + User { + id: format!("user_{}", uuid::Uuid::new_v4()), + email: email.to_string(), + email_verified: false, + password_hash: "not-a-real-hash".to_string(), + name: None, + photo_url: None, + } + } +} diff --git a/lib/src/user/repo.rs b/lib/src/user/repo.rs index d47fc9e..9ec2146 100644 --- a/lib/src/user/repo.rs +++ b/lib/src/user/repo.rs @@ -1,14 +1,121 @@ -use crate::{app::Repo, AppError}; +use serde::{Deserialize, Serialize}; +use sqlx::prelude::FromRow; +use time::OffsetDateTime; + +use crate::{ + AppResult, + database::{Created, Deleted, Updated}, +}; use super::{User, UserIdentifier}; #[async_trait::async_trait] -pub trait UserRepo: Repo { - async fn create_user(&self, user: &User) -> Result<(), AppError>; +pub trait UserRepo: Send + Sync + 'static { + async fn create_user(&self, user: User) -> AppResult>; + + async fn get_user(&self, ident: UserIdentifier) -> AppResult>; + + async fn update_user(&self, user: User) -> AppResult>; + + async fn delete_user(&self, ident: UserIdentifier) -> AppResult>; +} + +/// Row shape of the `users` table. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, FromRow)] +pub struct UserDto { + pub id: String, + pub email: String, + pub email_verified: bool, + pub password_hash: String, + pub name: Option, + pub photo_url: Option, + pub created_at: OffsetDateTime, + pub updated_at: OffsetDateTime, + pub deleted_at: Option, +} + +impl From for Updated { + fn from(dto: UserDto) -> Self { + Updated { + model: User { + id: dto.id, + email: dto.email, + email_verified: dto.email_verified, + password_hash: dto.password_hash, + name: dto.name, + photo_url: dto.photo_url, + }, + created_at: dto.created_at.into(), + updated_at: dto.updated_at.into(), + deleted_at: dto.deleted_at.map(Into::into), + } + } +} + +/// Adapter-agnostic repository tests. Each adapter calls these so every backend +/// is held to the same contract — see `adapters/repo_in_memory.rs` (no database) +/// and `lib/tests/user.rs` (Postgres). +#[cfg(any(test, feature = "testing"))] +pub mod testing { + pub use super::super::models::testing::new_user; + + use super::UserRepo; + use crate::user::UserIdentifier; + + pub async fn test_create_user(repo: impl UserRepo) { + let user = new_user("create@example.com"); + let created = repo.create_user(user.clone()).await.unwrap(); + assert_eq!(created.model, user); + } + + pub async fn test_create_user_rejects_duplicate_email(repo: impl UserRepo) { + let user = new_user("duplicate@example.com"); + repo.create_user(user.clone()).await.unwrap(); + let mut other = new_user("duplicate@example.com"); + other.id = format!("{}_other", user.id); + assert!(repo.create_user(other).await.is_err()); + } + + pub async fn test_get_user(repo: impl UserRepo) { + let user = new_user("get@example.com"); + repo.create_user(user.clone()).await.unwrap(); + let by_id = repo + .get_user(UserIdentifier::Id(user.id.clone())) + .await + .unwrap(); + assert_eq!(by_id.model, user); + let by_email = repo + .get_user(UserIdentifier::Email(user.email.clone())) + .await + .unwrap(); + assert_eq!(by_email.model, user); + } - async fn get_user(&self, ident: UserIdentifier) -> Result; + pub async fn test_get_user_not_found(repo: impl UserRepo) { + let result = repo + .get_user(UserIdentifier::Email("nobody@example.com".to_string())) + .await; + assert!(result.is_err()); + } - async fn update_user(&self, user: &User) -> Result<(), AppError>; + pub async fn test_update_user(repo: impl UserRepo) { + let user = new_user("update@example.com"); + repo.create_user(user.clone()).await.unwrap(); + let mut updated_user = user.clone(); + updated_user.name = Some("New Name".to_string()); + let result = repo.update_user(updated_user.clone()).await.unwrap(); + assert_eq!(result.model, updated_user); + } - async fn delete_user(&self, ident: UserIdentifier) -> Result<(), AppError>; + pub async fn test_delete_user(repo: impl UserRepo) { + let user = new_user("delete@example.com"); + repo.create_user(user.clone()).await.unwrap(); + let deleted = repo + .delete_user(UserIdentifier::Id(user.id.clone())) + .await + .unwrap(); + assert_eq!(deleted.model, user); + // Soft-deleted users are no longer readable. + assert!(repo.get_user(UserIdentifier::Id(user.id)).await.is_err()); + } } diff --git a/lib/src/user/repo_in_memory.rs b/lib/src/user/repo_in_memory.rs deleted file mode 100644 index 3a13a6d..0000000 --- a/lib/src/user/repo_in_memory.rs +++ /dev/null @@ -1,52 +0,0 @@ -use dashmap::DashMap; - -use crate::AppError; - -use super::{repo::UserRepo, User, UserIdentifier}; - -pub struct UserRepoInMemory { - data: DashMap>, -} - -#[async_trait::async_trait] -impl UserRepo for UserRepoInMemory { - async fn create_user(&self, user: &User) -> Result<(), AppError> { - for ident in user.identifiers() { - if self.data.contains_key(&ident) { - return Err(AppError::Validation("User already exists".to_string())); - } - } - for ident in user.identifiers() { - let map = DashMap::new(); - map.insert(ident.to_string(), user.clone()); - self.data.insert(ident, map); - } - Ok(()) - } - - async fn get_user(&self, ident: super::UserIdentifier) -> Result { - if let Some(map) = self.data.get(&ident) { - if let Some(user) = map.get(&ident.to_string()) { - return Ok(user.clone()); - } - } - Err(AppError::NotFound) - } - - async fn update_user(&self, user: &User) -> Result<(), AppError> { - for ident in user.identifiers() { - if let Some(map) = self.data.get(&ident) { - map.insert(ident.to_string(), user.clone()); - } - } - Ok(()) - } - - async fn delete_user(&self, ident: UserIdentifier) -> Result<(), AppError> { - let user = self.get_user(ident.clone()).await?; - for ident in user.identifiers() { - self.data.remove(&ident); - } - Ok(()) - } -} diff --git a/lib/src/user/repo_postgres.rs b/lib/src/user/repo_postgres.rs deleted file mode 100644 index 31735b8..0000000 --- a/lib/src/user/repo_postgres.rs +++ /dev/null @@ -1,24 +0,0 @@ -use crate::AppError; - -use super::{User, UserIdentifier, UserRepo}; - -pub struct UserRepoPostgres; - -#[async_trait::async_trait] -impl UserRepo for UserRepoPostgres { - async fn create_user(&self, user: &User) -> Result<(), AppError> { - todo!(); - } - - async fn get_user(&self, ident: UserIdentifier) -> Result { - todo!(); - } - - async fn update_user(&self, user: &User) -> Result<(), AppError> { - todo!(); - } - - async fn delete_user(&self, ident: UserIdentifier) -> Result<(), AppError> { - todo!(); - } -} diff --git a/lib/src/user/service.rs b/lib/src/user/service.rs index 065f7ba..0ff5d60 100644 --- a/lib/src/user/service.rs +++ b/lib/src/user/service.rs @@ -1,23 +1,26 @@ -use short_uuid::short; +use crate::{ + AppResult, + database::{Created, Deleted, Updated}, +}; -use crate::AppError; +use super::{NewUser, User, UserIdentifier, repo::UserRepo}; -use super::{repo::UserRepo, NewUser, User, UserIdentifier}; - -pub struct UserService { - repo: R, +pub struct UserService { + repo: Box, } -impl UserService { - pub fn new(repo: R) -> Self { - Self { repo } +impl UserService { + pub fn new(repo: impl UserRepo) -> Self { + Self { + repo: Box::new(repo), + } } fn generate_id(&self) -> String { - format!("user_{}", short!()) + format!("user_{}", uuid::Uuid::new_v4()) } - pub async fn create_user(&self, data: NewUser) -> Result { + pub async fn create_user(&self, data: NewUser) -> AppResult> { let user = User { id: self.generate_id(), email: data.email, @@ -26,11 +29,18 @@ impl UserService { name: data.name, photo_url: data.photo_url, }; - self.repo.create_user(&user).await?; - Ok(user) + self.repo.create_user(user).await } - pub async fn get_user(&self, ident: UserIdentifier) -> Result { + pub async fn get_user(&self, ident: UserIdentifier) -> AppResult> { self.repo.get_user(ident).await } + + pub async fn update_user(&self, user: User) -> AppResult> { + self.repo.update_user(user).await + } + + pub async fn delete_user(&self, ident: UserIdentifier) -> AppResult> { + self.repo.delete_user(ident).await + } } diff --git a/lib/tests/add.rs b/lib/tests/add.rs deleted file mode 100644 index 0bd8719..0000000 --- a/lib/tests/add.rs +++ /dev/null @@ -1,4 +0,0 @@ -#[test] -fn can_add() { - assert_eq!(2 + 2, 4); -} diff --git a/lib/tests/user.rs b/lib/tests/user.rs new file mode 100644 index 0000000..bd5da2e --- /dev/null +++ b/lib/tests/user.rs @@ -0,0 +1,38 @@ +//! Postgres-backed contract tests for `UserRepo`. +//! +//! These run the same assertions as the in-memory adapter's unit tests, against a +//! real database. `#[sqlx::test]` provisions an ephemeral, migrated database per +//! test, so they need `DATABASE_URL` and are gated behind the `testing` feature. + +use api_rust::user::{UserRepoPostgres, testing}; +use sqlx::PgPool; + +#[sqlx::test] +async fn test_create_user(pool: PgPool) { + testing::test_create_user(UserRepoPostgres::new(pool)).await; +} + +#[sqlx::test] +async fn test_create_user_rejects_duplicate_email(pool: PgPool) { + testing::test_create_user_rejects_duplicate_email(UserRepoPostgres::new(pool)).await; +} + +#[sqlx::test] +async fn test_get_user(pool: PgPool) { + testing::test_get_user(UserRepoPostgres::new(pool)).await; +} + +#[sqlx::test] +async fn test_get_user_not_found(pool: PgPool) { + testing::test_get_user_not_found(UserRepoPostgres::new(pool)).await; +} + +#[sqlx::test] +async fn test_update_user(pool: PgPool) { + testing::test_update_user(UserRepoPostgres::new(pool)).await; +} + +#[sqlx::test] +async fn test_delete_user(pool: PgPool) { + testing::test_delete_user(UserRepoPostgres::new(pool)).await; +} diff --git a/macros/Cargo.toml b/macros/Cargo.toml deleted file mode 100644 index 93481a2..0000000 --- a/macros/Cargo.toml +++ /dev/null @@ -1,8 +0,0 @@ -# https://doc.rust-lang.org/cargo/reference/manifest.html - -[package] -name = "api-rust-macros" -version = "0.1.0" -edition = "2024" - -[dependencies] diff --git a/macros/src/lib.rs b/macros/src/lib.rs deleted file mode 100644 index 7d12d9a..0000000 --- a/macros/src/lib.rs +++ /dev/null @@ -1,14 +0,0 @@ -pub fn add(left: usize, right: usize) -> usize { - left + right -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} diff --git a/railway.toml b/railway.toml new file mode 100644 index 0000000..2c92e96 --- /dev/null +++ b/railway.toml @@ -0,0 +1,8 @@ +# Railway deploys the same distroless image as `just docker`. +[build] +builder = "dockerfile" +dockerfilePath = "deploy/docker/Dockerfile" + +[deploy] +healthcheckPath = "/health" +healthcheckTimeout = 10 diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..292fe49 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,2 @@ +[toolchain] +channel = "stable" diff --git a/scripts/admin/deploy_lambda.sh b/scripts/admin/deploy_lambda.sh deleted file mode 100755 index 6f3a0a9..0000000 --- a/scripts/admin/deploy_lambda.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/usr/bin/env bash - -set -e - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -pushd "$script_dir/../.." > /dev/null -cargo lambda build --release --features lambda --arm64 -popd > /dev/null - -set -o allexport -source "$script_dir/../../.env" -set +o allexport - -pushd "$script_dir/../../deploy/aws_sam" > /dev/null -sam deploy --debug --parameter-overrides RustLog="$PROD_RUST_LOG" PostgresUri="$PROD_POSTGRES_URI" -popd > /dev/null diff --git a/scripts/admin/log_tail_lambda.sh b/scripts/admin/log_tail_lambda.sh deleted file mode 100755 index 0e29645..0000000 --- a/scripts/admin/log_tail_lambda.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -set -e - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -set -o allexport -source "$script_dir/../../.env" -set +o allexport - -aws logs tail "/aws/lambda/$PROD_LAMBDA_LOG_GROUP" --follow diff --git a/scripts/admin/tmp_docker_build_run.sh b/scripts/admin/tmp_docker_build_run.sh deleted file mode 100755 index d60cb05..0000000 --- a/scripts/admin/tmp_docker_build_run.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -e - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -set -o allexport -source "$script_dir/../../.env" -set +o allexport - -pushd "$script_dir/../../deploy/docker" > /dev/null -docker build -t api-rust -f Dockerfile ../.. -popd > /dev/null - -docker run -p "3010:3000" -e RUST_LOG=${PROD_RUST_LOG} -e POSTGRES_URI=${PROD_POSTGRES_URI} api-rust diff --git a/scripts/fix.sh b/scripts/fix.sh deleted file mode 100755 index 44363e7..0000000 --- a/scripts/fix.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -pushd "$script_dir/.." > /dev/null - -cargo sort lib -cargo sort macros -cargo fmt -cargo fix --allow-dirty -cargo clippy --fix --allow-dirty - -popd > /dev/null diff --git a/scripts/preview_sam.sh b/scripts/preview_sam.sh deleted file mode 100755 index 5fb657e..0000000 --- a/scripts/preview_sam.sh +++ /dev/null @@ -1,15 +0,0 @@ -#!/usr/bin/env bash - -set -e -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -# Use cargo lambda to build. -pushd "$script_dir/.." > /dev/null -cargo lambda build --release --features lambda -popd > /dev/null - -# Use SAM to start the API. -pushd "$script_dir/../deploy/aws_sam" > /dev/null -sam local start-api --debug --host 0.0.0.0 --port 3001 \ - --parameter-overrides Arch="$(uname -m)" RustLog="$RUST_LOG" PostgresUri="$POSTGRES_URI" -popd > /dev/null diff --git a/scripts/run.sh b/scripts/run.sh deleted file mode 100755 index 3d301d2..0000000 --- a/scripts/run.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -set -e - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -pushd "$script_dir/../lib" > /dev/null - -cargo watch -x run - -popd > /dev/null diff --git a/scripts/test-e2e.sh b/scripts/test-e2e.sh deleted file mode 100755 index 9dce39f..0000000 --- a/scripts/test-e2e.sh +++ /dev/null @@ -1,12 +0,0 @@ - -#!/usr/bin/env bash - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -pushd "$script_dir/.." > /dev/null - -cargo nextest run --profile e2e -exit_code=$? - -popd > /dev/null - -exit $exit_code diff --git a/scripts/test-unit.sh b/scripts/test-unit.sh deleted file mode 100755 index b5ceabe..0000000 --- a/scripts/test-unit.sh +++ /dev/null @@ -1,13 +0,0 @@ -#!/usr/bin/env bash - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" -pushd "$script_dir/.." > /dev/null - -cargo test --doc --no-fail-fast -exit_code=$? -cargo nextest run --profile unit -exit_code=$((exit_code + $?)) - -popd > /dev/null - -exit $exit_code diff --git a/scripts/test.sh b/scripts/test.sh deleted file mode 100755 index 87c2777..0000000 --- a/scripts/test.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env bash - -script_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" - -"$script_dir/test-unit.sh" -exit_code=$? - -"$script_dir/test-e2e.sh" -exit_code=$((exit_code + $?)) - -exit $exit_code